Compare commits

...
32 Commits
Author SHA1 Message Date
nitesh d0536dbb11 Merge remote-tracking branch 'nexora/uate' into merge-uate-into-dev
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
# Conflicts:
#	src/app/core/shell/shell.component.html
#	src/app/tasks/tasks-routing.module.ts
#	src/environments/.env.ts
2026-07-23 17:15:52 +05:30
niteshandClaude Sonnet 5 97d0545f07 fix(chequebook,ctsclearing): surface global stock allocation in UI; fix decided-state check
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
Receive Stock form now displays the actual highest-allocated serial across
every series (not just silently prefilling the field), so the operator
can see and trust the auto-suggested range start rather than taking it
on faith.

Fixes a real bug where item.decision (which InwardClearingItem.java always
sets to the literal string "PENDING" at creation, never null) was checked
with a plain truthy test everywhere a "has this been decided yet" branch
was needed. Since "PENDING" is a non-empty string, that check was always
true — silently breaking the Decide/View button label, the collapsed
validation summary, the "Awaiting settlement" text, and the decision
dialog itself (which never showed the actual Pass/Return form, even for
still-pending items). Replaced with a proper isDecided() check everywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 17:08:10 +05:30
niteshandClaude Sonnet 5 ca85bae4fb chore(ctsclearing): add trailing period to comparison-details hint text
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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 18:39:27 +05:30
Jaanu423andClaude Sonnet 5 a22f8e7e45 feat(ctsclearing): validation comparison popup, decided-item read-only view, and dialog-close fixes
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
Adds a "Comparison Details" popup on the inward decision dialog showing
exactly what each automatic rule compared (amount figures vs words,
instrument date vs its valid-until window, balance vs amount, RBI
Positive Pay threshold, IQA sub-checks) instead of just the pass/fail
pill, mirroring InwardClearingValidationService.java's actual logic.
For an already-decided item, the stale/postdated "as of" date now shows
the decision date rather than today's live date.

Once a cheque is decided, the batch table's Validation column collapses
to a single outcome pill instead of permanently showing all 7 rule
badges, and the item-decision dialog itself switches to a read-only
decision summary (outcome, decisioned-on, note, settlement status)
instead of a re-openable Pass/Return form — the "Decide" action becomes
"View" for decided rows.

Also fixes two silently-broken directives: matTooltip and mat-dialog-close
were used throughout the decision dialogs without MatTooltipModule /
MatDialogClose being imported into the standalone components, so every
tooltip and every close button did nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 18:29:18 +05:30
Jaanu423andClaude Sonnet 5 0207cf85a3 feat(ctsclearing): single-panel signature verification for inward cheques
Publish Image in Docker Hub / build (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
Adds a rich decide dialog for clerks verifying inward CTS cheques: cheque
images, all automatic validation results, and a mandatory signature-check
gate built from the account's mode of operation and joint holders (backed
by a new SignatoryService reusing the existing signature/document fetch
pattern from the cashier module).

Also adds a fullscreen compare mode: the cheque opens large with a shelf
of every mandatory signer's stored signature, which can be dragged onto
the cheque, resized, made semi-transparent, and zoomed, for a real
side-by-side signature match instead of squinting at a thumbnail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 12:25:20 +05:30
Jaanu423andClaude Sonnet 5 6d205c0ed2 feat(clearing): add CTS/NACH clearing UI with real NPCI batch sessions
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
- CTS clearing module: inward batches (validate/pass/return), outward presentment
  capture, positive pay, settlement, reconciliation
- Outward Clearing merged into one screen: Pending Cheques + Batches tabs (a real
  clearing session per NPCI submission, either the 2-hourly job or manual send),
  drill into a batch to settle/dishonour its cheques
- Date-range, text, and quick (Today/Yesterday/All) filters with pagination across
  inward batches, outward presentments, outward batches, and batch detail
- NACH clearing module (mandates, batches, transaction decisioning)
- Shell/sidenav wiring for both new modules

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 15:57:45 +05:30
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
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
Jaanu423andClaude Sonnet 4.6 e952bc546f feat(election): committee meeting UI — schedule/edit drawer, online/offline mode toggle, soft delete
- committee-board: collapsible meetings section per card, Schedule Meeting button, meeting list with status chips and join links
- Meeting form drawer: ONLINE (platform, link, ID, password) vs OFFLINE (venue, address, city) mode toggle
- Actions: edit/complete/cancel for SCHEDULED; delete (soft) for all statuses on active committees
- election.service.ts: getMeetings, scheduleMeeting, updateMeeting, patchMeetingStatus, deleteMeeting
- election-audit-log: meeting action metas (SCHEDULE_MEETING, UPDATE_MEETING, CANCEL_MEETING, COMPLETE_MEETING, DELETE_MEETING + FAILEDs), COMMITTEE entity action list updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 10:18:00 +05:30
Jaanu423andClaude Sonnet 4.6 3e51eae4b5 feat(election): audit log — add DIRECTOR_APPOINTMENT and COMMITTEE entity types with full action coverage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 17:23:08 +05:30
Jaanu423andClaude Sonnet 4.6 83e9fc9e68 feat(election): committee management module with image upload, zoom viewer, and member profiles
- Add Committee Board component: create/edit committees with profile image upload
- Image viewer: redesigned zoom dialog with frosted glass toolbar, SVG icons, dark theme
- Committee profile image: circular upload ring, full-size zoom via ImagePreviewDialogComponent
- Member photos: clickable avatars open zoom viewer in both stacked and list views
- Backend: committee API service methods (CRUD, members, dissolve, current directors)
- Route and navigation link added to election dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 16:38:42 +05:30
Jaanu423andClaude Sonnet 4.6 c4387c5c79 feat(election): candidate lock UI, director board grouping, collapsible elections, and member photos
- Candidate board: lock Remove and Change Symbol buttons when voting is active (VOTING_OPEN/CLOSED/RESULT_DECLARED/CANCELLED)
- Director board: group directors by election with Current/Past badges, sorted newest first
- Past election groups collapsed by default; click header to expand/collapse with chevron animation
- Show first 6 directors, expand button for remaining members
- Load member photos in director cards via clientImage API (fallback to initials)
- 4-column responsive grid for director cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 13:44:05 +05:30
Jaanu423andClaude Sonnet 4.6 228df2f32c feat(election): election duration, director tenure auto-fill, and board UX improvements
- Add durationYears field to election config with Board Active Until display on dashboard
- Auto-fill director tenure start (from resultDate) and end (from durationEndDate) when election has duration set; both fields readonly with Auto badge
- Hide Status field on new appointment form (only shown on edit)
- Change Remove button from hard delete to soft delete (status REMOVED) with audit-safe confirm
- Fix isTenureExpired() template call replaced with precomputed isExpired property

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 12:24:50 +05:30
Jaanu423andClaude Sonnet 4.6 a44238774f feat(election): Board of Directors module — appoint, manage, and track elected directors
- New director-board component: 2-step drawer (pick WON candidate → fill appointment details)
- Pending appointments section: highlights WON candidates not yet given a designation
- mat-datepicker for all date fields; tenure end auto-fills to +5 years from start
- Frontend validation: required field indicators + inline mat-error on submit attempt
- Past dates disabled via [min]="today" on all date pickers
- mat-select for Designation and Status (CBS outline style)
- Election service: appointDirector, updateDirector, deleteDirector, getDirectors, getPendingDirectors
- Route added: /election/directors; nav link in election dashboard
- Error handler: suppress 404 toast for /election/directors (backend not yet deployed guard)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 12:47:33 +05:30
Jaanu423andClaude Sonnet 4.6 bc513e7c89 feat(election): modern results dashboard with donut chart, KPI panel, and symbol fix
- Full redesign: backdrop blobs, KPI metrics panel, split-layout card (donut + candidates list)
- Donut chart (CSS conic-gradient) per post showing vote share by candidate color
- Winner spotlight banner; unopposed candidate celebration card
- Fixed loadSymbols() timing bug: results iterated unconditionally, candidates use hasSymbol guard
- Added fetchAndCacheSymbol() helper; loadSymbols() called after declareResults()
- Fixed saveManualCounts -> saveManualVoteCounts method name mismatch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 18:34:27 +05:30
Jaanu423andClaude Sonnet 4.6 f2f2338c78 feat(election): remove election type field, simplify post to Director only, hide empty eligibility section
- Removed Election Type dropdown from config form (hardcoded DIRECTOR in payload)
- Post name options reduced to Director only, Add Post button removed
- Eligibility section in nomination-apply hidden when no criteria configured

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 16:50:52 +05:30
Jaanu423andClaude Sonnet 4.6 ed54db2e74 feat(election): manual vote entry, results declaration, remove electronic voting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 15:10:48 +05:30
Jaanu423andClaude Sonnet 4.6 4666a476fd feat(election): structured audit log details UI
Replaced raw JSON pre dump with readable key-value rows.
PASS/FAIL/WON values show as green badge, FAIL/REJECT/CANCELLED as red.
Manual vote counts shown as table with Candidate/Post/Votes/Previous columns.
Winner results shown as separate table with Post/Winner/Votes.
Added DETAIL_LABELS for 50+ field keys covering all audit action types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 15:01:48 +05:30
Jaanu423andClaude Sonnet 4.6 a915259ba1 feat(election): candidate analysis enhancements + overall shareholder analysis page
- candidate-analysis: add family voter influence donut chart (% of total shareholders)
- candidate-analysis: sub-caste breakdown by client lastname
- candidate-analysis: religion profile with candidate match (★) highlighting
- candidate-analysis: remove active savings holders metric, expand grid to 3 cols
- shareholder-analysis: new standalone page showing demographic analysis of all active shareholders (11 charts across gender, age, religion, caste, sub-caste, occupation, education, marital, share-holding, tenure, area)
- candidate-board: add 'Overall Analysis' button navigating to shareholder analysis page
- election-routing: lazy-load route for :electionId/shareholders/analysis
- election.service: add getOverallShareholderAnalysis() API call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 18:31:40 +05:30
Jaanu423andClaude Sonnet 4.6 78b87fb6ce feat(election): compact candidate analysis UI + savings card enhancements
- Reduce all spacing/padding/font sizes for denser layout (hero, KPI strip, section headers, cards, donuts, legends, influence metrics, loan stats)
- Fix Savings Account Depth card: remove empty space (justify-content flex-start), fix donut notch on single segment, add 3-stat grid (active/no-savings/penetration%) and insight note
- Remove Chart.js borderWidth/borderRadius on single-segment donuts to eliminate white gap artefact

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 12:29:17 +05:30
Jaanu423andClaude Sonnet 4.6 dac169ae15 feat(election): candidate voter analysis — Chart.js charts, cross-dimension insights
- New CandidateAnalysisComponent with Chart.js doughnut charts for all
  demographic/financial/social dimensions (gender, age, area, caste,
  sub-caste, religion, occupation, share-holding, tenure, loan, education,
  marital status, monthly income, savings account depth)
- Section 4: Multi-Dimension Voter Alignment card showing core base
  (caste + area cross-match), caste %, area %, and savings engagement
- Candidate board: added Analyse button linking to analysis page
- Hero header, KPI strip, 4 dashboard sections with hover charts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 11:53:28 +05:30
Jaanu423andClaude Sonnet 4.6 960e4e181b feat(election): audit log UI with server-side filter, pagination, entity-aware actions
- New ElectionAuditLogComponent: timeline view, expandable JSON details
- Server-side filtering: entityType, action, full-text search (350ms debounce)
- Pagination bar with page size selector (10/20/50 per page)
- Action dropdown filtered by selected entity (CANDIDATE shows only
  candidate actions, NOMINATION shows nomination actions, etc.)
- Entity change resets action filter automatically
- election.service.ts: getAuditLog() sends query params to backend
- Audit Log button added to every election card in dashboard
- Route: /election/:electionId/audit-log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 13:02:45 +05:30
Jaanu423andClaude Sonnet 4.6 f1b829a100 feat(election): candidate photos/symbols, voting interface, withdrawal, live vote counts
- Candidate board: show client profile photos and election symbols via CBS ContentStore
  (lazy-fetch base64 via GET /election/{id}/candidate/{id}/symbol), zoom/drag viewer
- Candidate board: live vote count badge per candidate (VOTING_OPEN/CLOSED only)
- Election dashboard: total voters/votes chips on VOTING_OPEN election cards
- Voting interface: fix candidates not loading (paged response .content extraction),
  lazy-load candidate photos and symbols, gold-bordered symbol on ballot card
- Nomination withdrawal: implement withdraw button in nomination-detail and nomination-list
- ImagePreviewDialogComponent: DomSanitizer support for data: URLs
- election.service: uploadCandidateSymbol (FormData), getCandidateSymbol, getClientImage,
  getVoteSummary methods
- main.scss: ipd-dialog / ipd-backdrop styles for image preview dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 18:38:11 +05:30
Jaanu423andClaude Sonnet 4.6 9e33392b01 feat(election): server-side pagination + CEO pre-check family loan UI
- election.service.ts: all 3 list APIs now accept page/size params and return PagedResponse
- election-dashboard: server-side paginated elections with statusCounts from backend
- nomination-list: server-side pagination; summary chips use backend totals (all pages)
- candidate-board: server-side pagination; post filter triggers new API call
- nomination-detail: full 8-check CEO pre-check display (director loan, relation accounts, KYC, family KYC, guarantor, AML, family loans)
- nomination-apply: guarantor status section added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 12:07:38 +05:30
Jaanu423andClaude Sonnet 4.6 71552d6875 feat(election): add guarantor status section to nomination-apply page
Shows whether the selected nominee is a guarantor on any loan, with loan health indicator, borrower details, savings account, guaranteed/released/remaining amounts, and loan timeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 13:12:08 +05:30
162 changed files with 37694 additions and 2444 deletions
+6
View File
@@ -36,6 +36,9 @@ import { ClientsModule } from './clients/clients.module';
import { GroupsModule } from './groups/groups.module';
import { CentersModule } from './centers/centers.module';
import { AccountingModule } from './accounting/accounting.module';
import { ChequeBookModule } from './chequebook/chequebook.module';
import { CtsClearingModule } from './ctsclearing/ctsclearing.module';
import { NachClearingModule } from './nachclearing/nachclearing.module';
import { SystemModule } from './system/system.module';
import { ProductsModule } from './products/products.module';
import { OrganizationModule } from './organization/organization.module';
@@ -144,6 +147,9 @@ export function HttpLoaderFactory(http: HttpClient) {
GroupsModule,
CentersModule,
AccountingModule,
ChequeBookModule,
CtsClearingModule,
NachClearingModule,
SystemModule,
ProductsModule,
OrganizationModule,
+12
View File
@@ -70,6 +70,18 @@ const routes: Routes = [
),
data: { title: 'Account Facilities', breadcrumb: 'Account Facilities' }
},
{
path: 'account-facilities/cheque-book',
loadComponent: () =>
import('../clerk/cheque-book/cheque-book.component').then((m) => m.ClerkChequeBookComponent),
data: { title: 'Cheque Book', breadcrumb: 'Cheque Book' }
},
{
path: 'account-facilities/cheque-book/:id',
loadComponent: () =>
import('../clerk/cheque-book/cheque-book-detail.component').then((m) => m.ClerkChequeBookDetailComponent),
data: { title: 'Cheque Book Detail', breadcrumb: 'Cheque Book Detail' }
},
{
path: 'photo-signature',
loadComponent: () =>
@@ -741,7 +741,7 @@
</div>
<div class="te-balance-row">
<span class="te-bal-num">4.</span><span class="te-bal-label">Freeze Amount</span>
<span class="te-bal-value">{{ currencySymbol }}{{ freezeAmount | number: '1.2-2' }}</span>
<span class="te-bal-value">{{ currencySymbol }}0.00</span>
</div>
<div class="te-balance-row">
<span class="te-bal-num">5.</span><span class="te-bal-label">Withdrawable Amt.</span>
@@ -806,7 +806,7 @@
</div>
<div class="te-bal-item">
<span class="te-bal-item__label">Transfer Hold</span>
<span class="te-bal-item__val">0.00</span>
<span class="te-bal-item__val">{{ freezeAmount | number: '1.2-2' }}</span>
</div>
<div class="te-bal-item">
<span class="te-bal-item__label">Clearing Hold</span>
@@ -922,7 +922,7 @@
</div>
<div class="te-acs-b">
<span class="te-acs-b-l">Tr. Hold</span>
<span class="te-acs-b-v">0.00</span>
<span class="te-acs-b-v">{{ freezeAmount | number: '1.2-2' }}</span>
</div>
<div class="te-acs-b">
<span class="te-acs-b-l">Clr. Hold</span>
@@ -8,6 +8,7 @@
import { Component, OnInit, OnDestroy, inject, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators';
@@ -426,16 +427,15 @@ export class JointHolderDialogComponent implements OnInit {
selector: 'mifosx-image-preview-dialog',
template: `
<div class="ipd-wrap">
<div class="ipd-header">
<div class="ipd-topbar">
<span class="ipd-title">{{ data.title }}</span>
<div class="ipd-controls">
<button class="ipd-btn" (click)="zoomOut()" title="Zoom Out">−</button>
<span class="ipd-zoom-label">{{ zoomPct }}%</span>
<button class="ipd-btn" (click)="zoomIn()" title="Zoom In">+</button>
<button class="ipd-btn" (click)="reset()" title="Reset">↺</button>
<button class="ipd-btn ipd-btn--close" (click)="close()">✕</button>
</div>
<button class="ipd-close-btn" (click)="close()" title="Close">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
</div>
<div
class="ipd-body"
(wheel)="onWheel($event)"
@@ -443,17 +443,47 @@ export class JointHolderDialogComponent implements OnInit {
(mousemove)="onMouseMove($event)"
(mouseup)="onMouseUp()"
(mouseleave)="onMouseUp()"
[style.cursor]="dragging ? 'grabbing' : zoom > 1 ? 'grab' : 'zoom-in'"
[style.cursor]="dragging ? 'grabbing' : zoom > 1 ? 'grab' : 'default'"
>
<img
[src]="data.url"
[src]="safeUrl"
[alt]="data.title"
class="ipd-img"
[style.transform]="imgTransform"
(dragstart)="$event.preventDefault()"
/>
</div>
<div class="ipd-hint">Scroll = Zoom &nbsp;·&nbsp; Drag = Pan</div>
<div class="ipd-footer">
<div class="ipd-toolbar">
<button class="ipd-tool" (click)="zoomOut()" [disabled]="zoom <= min" title="Zoom out">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5" />
<path d="M4 6h4M11 11l-2.5-2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
<span class="ipd-zoom-val">{{ zoomPct }}%</span>
<button class="ipd-tool" (click)="zoomIn()" [disabled]="zoom >= max" title="Zoom in">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5" />
<path d="M6 4v4M4 6h4M11 11l-2.5-2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
<div class="ipd-sep"></div>
<button class="ipd-tool" (click)="reset()" title="Reset zoom">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M2 7a5 5 0 1 0 1.2-3.2M2 3.5V7h3.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<span class="ipd-hint">Scroll to zoom &nbsp;·&nbsp; Drag to pan</span>
</div>
</div>
`,
styles: [
@@ -461,91 +491,141 @@ export class JointHolderDialogComponent implements OnInit {
.ipd-wrap {
display: flex;
flex-direction: column;
width: 85vw;
max-width: 960px;
border-radius: 10px;
width: min(88vw, 840px);
border-radius: 20px;
overflow: hidden;
background: #07080f;
box-shadow:
0 40px 100px rgba(0, 0, 0, 0.75),
0 0 0 1px rgba(255, 255, 255, 0.06);
}
.ipd-header {
/* ── Top bar ─────────────────────────── */
.ipd-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #1e293b;
color: #e2e8f0;
gap: 12px;
padding: 14px 18px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.ipd-title {
font-size: 12px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.07em;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.55);
}
.ipd-controls {
display: flex;
align-items: center;
gap: 6px;
}
.ipd-btn {
width: 32px;
height: 32px;
border-radius: 7px;
border: 1px solid #334155;
background: #0f172a;
color: #e2e8f0;
font-size: 17px;
font-weight: 700;
.ipd-close-btn {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s;
transition: all 0.15s;
padding: 0;
}
.ipd-btn:hover {
background: #1e40af;
border-color: #3b82f6;
}
.ipd-btn--close {
background: #450a0a;
border-color: #dc2626;
}
.ipd-btn--close:hover {
background: #dc2626;
}
.ipd-zoom-label {
font-size: 12px;
font-weight: 700;
color: #94a3b8;
min-width: 44px;
text-align: center;
.ipd-close-btn:hover {
background: rgba(239, 68, 68, 0.18);
border-color: rgba(239, 68, 68, 0.45);
color: #fca5a5;
}
/* ── Image area ──────────────────────── */
.ipd-body {
background: #0f172a;
background: #040508;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
height: 75vh;
height: 68vh;
user-select: none;
position: relative;
}
.ipd-img {
max-width: 80vw;
max-height: 70vh;
max-width: 82%;
max-height: 64vh;
object-fit: contain;
border-radius: 6px;
border-radius: 10px;
transform-origin: center center;
transition: transform 0.08s ease;
display: block;
transition: transform 0.07s ease;
pointer-events: none;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.55);
}
.ipd-hint {
background: #0f172a;
color: #475569;
font-size: 11px;
/* ── Footer ──────────────────────────── */
.ipd-footer {
background: rgba(255, 255, 255, 0.02);
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 12px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
/* ── Toolbar pill ────────────────────── */
.ipd-toolbar {
display: flex;
align-items: center;
gap: 2px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 50px;
padding: 4px 6px;
}
.ipd-tool {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
padding: 0;
flex-shrink: 0;
}
.ipd-tool:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.22);
color: #a5b4fc;
}
.ipd-tool:disabled {
opacity: 0.2;
cursor: not-allowed;
}
.ipd-zoom-val {
font-size: 12px;
font-weight: 700;
color: rgba(255, 255, 255, 0.85);
min-width: 44px;
text-align: center;
padding: 5px;
border-top: 1px solid #1e293b;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
padding: 0 2px;
}
.ipd-sep {
width: 1px;
height: 18px;
background: rgba(255, 255, 255, 0.1);
margin: 0 4px;
flex-shrink: 0;
}
/* ── Hint ────────────────────────────── */
.ipd-hint {
font-size: 10.5px;
color: rgba(255, 255, 255, 0.2);
letter-spacing: 0.03em;
}
`
],
@@ -555,6 +635,11 @@ export class JointHolderDialogComponent implements OnInit {
export class ImagePreviewDialogComponent {
dialogRef = inject<MatDialogRef<ImagePreviewDialogComponent>>(MatDialogRef);
data = inject<{ url: string; title: string }>(MAT_DIALOG_DATA);
private sanitizer = inject(DomSanitizer);
get safeUrl(): SafeResourceUrl {
return this.sanitizer.bypassSecurityTrustResourceUrl(this.data.url);
}
zoom = 1;
tx = 0;
@@ -2006,12 +2091,29 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
get freezeAmount(): number {
return this.accountDetails?.summary?.holdAmountBalance ?? 0;
// Savings exposes the lien/user hold as root-level `savingsAmountOnHold`; Current exposes its
// own (differently-sourced) hold as `onHoldFunds`. `summary.holdAmountBalance` never existed in
// either API response, so this always read 0 before.
const acTyp = this.selectedRow?.acTyp;
const isCurrent = acTyp === 'CA' || acTyp === 'CB';
if (isCurrent) {
return this.accountDetails?.onHoldFunds ?? this.accountDetails?.summary?.onHoldFunds ?? 0;
}
return this.accountDetails?.savingsAmountOnHold ?? 0;
}
get withdrawableAmount(): number {
const avail = this.accountDetails?.summary?.availableBalance ?? this.currentBalance;
return Math.max(0, avail - this.freezeAmount);
const acTyp = this.selectedRow?.acTyp;
const isCurrent = acTyp === 'CA' || acTyp === 'CB';
// Savings' availableBalance is already computed hold-adjusted on the backend (subtracts both
// guarantor and lien holds) — subtracting freezeAmount again here would double-count it.
// Current's availableBalance is just the raw balance and does NOT subtract its hold, so it
// still needs freezeAmount subtracted client-side.
if (isCurrent) {
return Math.max(0, avail - this.freezeAmount);
}
return Math.max(0, avail);
}
get currencySymbol(): string {
@@ -2772,30 +2874,38 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
if (isWithdrawal) {
// 3. Balance check
const withdrawable = this.withdrawableAmount;
if (amount > withdrawable) {
this.showAlert('error', 'Insufficient Balance', [
`Withdrawable Amount: ₹${withdrawable.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Requested Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'Please reduce the withdrawal amount.'
]);
return;
}
// Token-based cashier disbursement: the manager approval already validated the balance
// and posted the real withdrawal (money already left the account). Re-running the balance/
// min-balance checks here would compare the post-deduction balance against the same
// already-approved amount again, which fails for almost any real withdrawal.
const isTokenDisbursement = !this.isClerk && !!this.activeWithdrawalRequest;
// 4. Min balance
const enforceMin = !!this.accountDetails.enforceMinRequiredBalance;
const minBalance = Number(this.accountDetails.minRequiredBalance ?? 0);
if (enforceMin && minBalance > 0) {
const balanceAfter = this.currentBalance - amount;
if (balanceAfter < minBalance) {
this.showAlert('error', 'Minimum Balance Violation', [
`Required minimum balance: ₹${minBalance.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Balance after withdrawal: ₹${balanceAfter.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
if (!isTokenDisbursement) {
// 3. Balance check
const withdrawable = this.withdrawableAmount;
if (amount > withdrawable) {
this.showAlert('error', 'Insufficient Balance', [
`Withdrawable Amount: ₹${withdrawable.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Requested Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'Please reduce the withdrawal amount.'
]);
return;
}
// 4. Min balance
const enforceMin = !!this.accountDetails.enforceMinRequiredBalance;
const minBalance = Number(this.accountDetails.minRequiredBalance ?? 0);
if (enforceMin && minBalance > 0) {
const balanceAfter = this.currentBalance - amount;
if (balanceAfter < minBalance) {
this.showAlert('error', 'Minimum Balance Violation', [
`Required minimum balance: ₹${minBalance.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Balance after withdrawal: ₹${balanceAfter.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'Please reduce the withdrawal amount.'
]);
return;
}
}
}
// 5. Denomination overdraft — fire whenever denomination is entered for withdrawal
@@ -0,0 +1,150 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.inputs.Cheque Book' | translate }} #{{ book.id }}</h2>
<button mat-raised-button [routerLink]="['/chequebook', 'books']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
<mat-card class="info-card">
<mat-card-content>
<div class="info-grid">
<div>
<span class="k">{{ 'labels.inputs.Account' | translate }}</span>
<span>
{{ book.accountNo || book.accountId }}
@if (book.clientName) {
— {{ book.clientName }}
}
</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Account Type' | translate }}</span
><span>{{ book.accountType }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Office' | translate }}</span
><span>{{ book.officeName }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Series' | translate }}</span
><span>{{ book.seriesCode || '–' }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Leaf Range' | translate }}</span>
<span>{{ book.rangeStart ? book.rangeStart + ' – ' + book.rangeEnd : '–' }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Quantity' | translate }}</span
><span>{{ book.noOfBooks }} {{ 'labels.inputs.books' | translate }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Status' | translate }}</span
><span>{{ book.status }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Issue Date' | translate }}</span
><span>{{ book.issueDate ? (book.issueDate | dateFormat) : '–' }}</span>
</div>
</div>
</mat-card-content>
</mat-card>
@if (book.status === 'ISSUED') {
<div class="action-toolbar">
<button mat-stroked-button color="warn" (click)="startAction('stoppayment')">
{{ 'labels.buttons.Stop Payment' | translate }}
</button>
<button mat-stroked-button (click)="startAction('revokestoppayment')">
{{ 'labels.buttons.Revoke Stop Payment' | translate }}
</button>
<button mat-stroked-button color="warn" (click)="startAction('reportlost')">
{{ 'labels.buttons.Report Lost' | translate }}
</button>
<button mat-stroked-button (click)="startAction('cancelleaf')">
{{ 'labels.buttons.Cancel Leaf' | translate }}
</button>
<button mat-stroked-button color="primary" (click)="startAction('markused')">
{{ 'labels.buttons.Mark as Used' | translate }}
</button>
<button mat-stroked-button color="warn" (click)="startAction('return')">
{{ 'labels.buttons.Flag Returned' | translate }}
</button>
</div>
}
@if (activeAction) {
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="actionForm" (ngSubmit)="submitAction()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.From Leaf Seq' | translate }}</mat-label>
<input matInput type="number" formControlName="fromLeafSeq" />
<mat-hint>{{ 'labels.text.Leave blank for the whole book' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.To Leaf Seq' | translate }}</mat-label>
<input matInput type="number" formControlName="toLeafSeq" />
</mat-form-field>
@if (activeAction !== 'revokestoppayment') {
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
<input matInput formControlName="reason" [required]="activeAction !== 'markused'" />
@if (activeAction === 'markused') {
<mat-hint>{{
'labels.text.Optional — e.g. cleared per bank statement dated ...' | translate
}}</mat-hint>
}
</mat-form-field>
}
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="activeAction = null">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="warn" type="submit" [disabled]="actionForm.invalid || busy">
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
<div class="leaf-grid-wrapper mat-elevation-z1">
<div class="leaf-grid">
@for (l of leaves; track l.leafSerialNo) {
<button
type="button"
class="serial-circle"
[ngClass]="
l.status === 'ACTIVE'
? 'ok'
: l.status === 'USED'
? 'used'
: l.status === 'STOP_PAYMENT'
? 'warn'
: l.status === 'LOST'
? 'bad'
: l.status === 'RETURNED'
? 'returned'
: 'neutral'
"
(click)="selectLeaf(l)"
>
{{ l.leafSerialNo }}
</button>
}
</div>
@if (leaves.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
</div>
</div>
@@ -0,0 +1,131 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.info-card {
margin-bottom: 16px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px 24px;
div {
display: flex;
flex-direction: column;
}
.k {
font-size: 0.72rem;
text-transform: uppercase;
color: rgb(0 0 0 / 50%);
}
}
.action-toolbar {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.add-form {
margin-bottom: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px 20px;
}
mat-form-field {
width: 100%;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.leaf-grid-wrapper {
background: #fff;
padding: 16px;
}
.leaf-grid {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.serial-circle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
border: 2px solid currentcolor;
background: #fff;
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition:
transform 0.1s ease,
box-shadow 0.1s ease;
&:hover {
transform: scale(1.06);
box-shadow: 0 2px 6px rgb(0 0 0 / 15%);
}
&.ok {
color: #28c07a;
}
&.used {
color: #1976d2;
}
&.bad {
color: #d32f2f;
}
&.warn {
color: #a16207;
}
&.neutral {
color: #757575;
}
&.returned {
color: #9333ea;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -0,0 +1,137 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog } from '@angular/material/dialog';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Observable } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ChequeBookService } from '../chequebook.service';
import { LeafDetailDialogComponent } from './leaf-detail-dialog/leaf-detail-dialog.component';
type LeafAction = 'stoppayment' | 'revokestoppayment' | 'reportlost' | 'cancelleaf' | 'markused' | 'return';
/**
* One issued cheque book: its leaf register, and the six leaf-range actions (stop-payment, revoke,
* report-lost, cancel-leaf, mark-used, return) — each defaults to the whole book when no range is given.
* "Mark as used" is a manual record (this CBS has no clearing/presentment integration to detect it itself)
* — a branch sets it once it sees a leaf clear on the bank/clearing statement. "Return" flags a leaf as
* presented-and-dishonoured (insufficient funds, signature mismatch, etc.) — terminal, like report-lost.
*/
@Component({
selector: 'mifosx-cheque-book-detail',
templateUrl: './cheque-book-detail.component.html',
styleUrls: ['./cheque-book-detail.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatButtonModule,
MatSnackBarModule,
FaIconComponent
]
})
export class ChequeBookDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private formBuilder = inject(UntypedFormBuilder);
private chequeBookService = inject(ChequeBookService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
bookId!: string;
book: any = {};
leaves: any[] = [];
busy = false;
activeAction: LeafAction | null = null;
actionForm: UntypedFormGroup;
constructor() {
this.actionForm = this.formBuilder.group({
fromLeafSeq: [''],
toLeafSeq: [''],
reason: ['']
});
}
ngOnInit(): void {
this.bookId = this.route.snapshot.paramMap.get('id') as string;
this.loadAll();
}
loadAll(): void {
this.chequeBookService.getBook(this.bookId).subscribe((data: any) => (this.book = data || {}));
this.chequeBookService.getLeaves(this.bookId).subscribe((data: any[]) => {
this.leaves = data || [];
});
}
selectLeaf(leaf: any): void {
this.dialog.open(LeafDetailDialogComponent, { data: leaf, width: '420px' });
}
startAction(action: LeafAction): void {
this.activeAction = this.activeAction === action ? null : action;
this.actionForm.reset();
const reasonControl = this.actionForm.get('reason');
if (action === 'revokestoppayment' || action === 'markused') {
reasonControl?.clearValidators();
} else {
reasonControl?.setValidators(Validators.required);
}
reasonControl?.updateValueAndValidity();
}
submitAction(): void {
if (this.actionForm.invalid || this.busy || !this.activeAction) {
return;
}
this.busy = true;
const raw = this.actionForm.value;
const payload: any = { locale: 'en' };
if (raw.fromLeafSeq !== '' && raw.fromLeafSeq != null) payload.fromLeafSeq = raw.fromLeafSeq;
if (raw.toLeafSeq !== '' && raw.toLeafSeq != null) payload.toLeafSeq = raw.toLeafSeq;
if (raw.reason) payload.reason = raw.reason;
const actionCalls: Record<LeafAction, (bookId: string, payload: any) => Observable<any>> = {
stoppayment: (id, p) => this.chequeBookService.stopPayment(id, p),
revokestoppayment: (id, p) => this.chequeBookService.revokeStopPayment(id, p),
reportlost: (id, p) => this.chequeBookService.reportBookLost(id, p),
cancelleaf: (id, p) => this.chequeBookService.cancelLeaf(id, p),
markused: (id, p) => this.chequeBookService.markUsed(id, p),
return: (id, p) => this.chequeBookService.returnBook(id, p)
};
const call = actionCalls[this.activeAction](this.bookId, payload);
call.subscribe({
next: () => {
this.busy = false;
this.activeAction = null;
this.notify('Action applied');
this.loadAll();
},
error: () => {
this.busy = false;
this.notify(
'Action failed — check the leaf range and that the leaves are in a state that allows this action',
true
);
}
});
}
}
@@ -0,0 +1,72 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<h1 mat-dialog-title>
<span
class="serial-circle"
[ngClass]="
data.status === 'ACTIVE'
? 'ok'
: data.status === 'USED'
? 'used'
: data.status === 'STOP_PAYMENT'
? 'warn'
: data.status === 'LOST'
? 'bad'
: data.status === 'RETURNED'
? 'returned'
: 'neutral'
"
>{{ data.leafSerialNo }}</span
>
</h1>
<div mat-dialog-content>
<div class="info-grid">
<div>
<span class="k">{{ 'labels.inputs.Serial No' | translate }}</span
><span>{{ data.leafSerialNo }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Status' | translate }}</span
><span>{{ data.status }}</span>
</div>
@if (data.stopReason) {
<div>
<span class="k">{{ 'labels.inputs.Stop Reason' | translate }}</span
><span>{{ data.stopReason }}</span>
</div>
}
@if (data.lostReason) {
<div>
<span class="k">{{ 'labels.inputs.Lost Reason' | translate }}</span
><span>{{ data.lostReason }}</span>
</div>
}
@if (data.cancelledReason) {
<div>
<span class="k">{{ 'labels.inputs.Cancelled Reason' | translate }}</span
><span>{{ data.cancelledReason }}</span>
</div>
}
@if (data.usedReason) {
<div>
<span class="k">{{ 'labels.inputs.Used Reason' | translate }}</span
><span>{{ data.usedReason }}</span>
</div>
}
@if (data.returnReason) {
<div>
<span class="k">{{ 'labels.inputs.Return Reason' | translate }}</span
><span>{{ data.returnReason }}</span>
</div>
}
</div>
</div>
<mat-dialog-actions align="end">
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
</mat-dialog-actions>
@@ -0,0 +1,59 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px 24px;
min-width: 280px;
div {
display: flex;
flex-direction: column;
}
.k {
font-size: 0.72rem;
text-transform: uppercase;
color: rgb(0 0 0 / 50%);
}
}
.serial-circle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
border: 2px solid currentcolor;
background: #fff;
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
&.ok {
color: #28c07a;
}
&.used {
color: #1976d2;
}
&.bad {
color: #d32f2f;
}
&.warn {
color: #a16207;
}
&.neutral {
color: #757575;
}
}
@@ -0,0 +1,39 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import {
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Popup shown when a leaf circle in the cheque-book detail grid is clicked. */
@Component({
selector: 'mifosx-leaf-detail-dialog',
templateUrl: './leaf-detail-dialog.component.html',
styleUrls: ['./leaf-detail-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose
]
})
export class LeafDetailDialogComponent {
dialogRef = inject<MatDialogRef<LeafDetailDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
}
@@ -0,0 +1,239 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.inputs.Cheque Books' | translate }}</h2>
<div class="header-actions">
<button mat-raised-button color="primary" (click)="showRequestForm = !showRequestForm">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'labels.buttons.Request Cheque Book' | translate }}
</button>
<button mat-raised-button [routerLink]="['/chequebook']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
<div class="filter-row">
@for (s of ['REQUESTED', 'ISSUED', 'REJECTED', 'WITHDRAWN']; track s) {
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
}
</div>
@if (showRequestForm) {
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="requestForm" (ngSubmit)="submitRequest()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Account Type' | translate }}</mat-label>
<mat-select formControlName="accountType" required (selectionChange)="onAccountTypeChange()">
<mat-option value="SAVINGS">{{ 'labels.inputs.Savings' | translate }}</mat-option>
<mat-option value="CURRENT">{{ 'labels.inputs.Current' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Account Number' | translate }}</mat-label>
<input
matInput
formControlName="accountNo"
(input)="onAccountNoInput($any($event.target).value)"
required
/>
@if (searchingAccount) {
<mat-hint>{{ 'labels.text.Searching...' | translate }}</mat-hint>
} @else if (resolvedAccount) {
<mat-hint class="ok-hint">
&check; {{ resolvedAccount.accountNo }}
@if (resolvedAccount.clientName) {
— {{ resolvedAccount.clientName }}
}
@if (resolvedAccount.status) {
({{ resolvedAccount.status }})
}
</mat-hint>
} @else if (accountError) {
<mat-hint class="err-hint">{{ accountError | translate }}</mat-hint>
} @else {
<mat-hint>{{ 'labels.text.Type at least 4 digits of the account number' | translate }}</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
<mat-select formControlName="officeId" required>
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Series' | translate }}</mat-label>
<mat-select formControlName="seriesId" required>
@for (s of requestableSeries; track s.id) {
<mat-option [value]="s.id">
{{ s.seriesCode }} — {{ s.name }} ({{ s.leavesPerBook }}-{{ 'labels.inputs.leaf' | translate }},
{{ availableBooksForRequestSeries(s.id) }} {{ 'labels.inputs.books available' | translate }})
</mat-option>
}
</mat-select>
<mat-hint>{{ 'labels.text.Only series applicable to this account type are shown' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Number of Books' | translate }}</mat-label>
<input matInput type="number" formControlName="noOfBooks" required />
<mat-hint>{{ 'labels.text.Leaves handed over = books x the series book size' | translate }}</mat-hint>
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="showRequestForm = false">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" type="submit" [disabled]="requestForm.invalid || busy">
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="books">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.id }}</td>
</ng-container>
<ng-container matColumnDef="accountType">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account Type' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.accountType }}</td>
</ng-container>
<ng-container matColumnDef="account">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account' | translate }}</th>
<td mat-cell *matCellDef="let b">
{{ b.accountNo || b.accountId }}
@if (b.clientName) {
<span>({{ b.clientName }})</span>
}
</td>
</ng-container>
<ng-container matColumnDef="officeName">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Office' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.officeName }}</td>
</ng-container>
<ng-container matColumnDef="seriesCode">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Series' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.seriesCode }}</td>
</ng-container>
<ng-container matColumnDef="quantity">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Quantity' | translate }}</th>
<td mat-cell *matCellDef="let b" class="num">
{{ b.noOfBooks }} {{ 'labels.inputs.books' | translate }}
@if (b.totalLeaves) {
<span class="muted">({{ b.totalLeaves }} {{ 'labels.inputs.leaves' | translate }})</span>
}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let b">
<span
class="pill"
[ngClass]="
b.status === 'ISSUED' ? 'ok' : b.status === 'REJECTED' || b.status === 'WITHDRAWN' ? 'bad' : 'neutral'
"
>{{ b.status }}</span
>
</td>
</ng-container>
<ng-container matColumnDef="requestedDate">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Requested Date' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.requestedDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let b" (click)="$event.stopPropagation()">
@if (b.status === 'REQUESTED') {
<button mat-button color="primary" (click)="toggleIssue(b)">
{{ 'labels.buttons.Issue' | translate }}
</button>
<button mat-button color="warn" (click)="toggleReject(b)">{{ 'labels.buttons.Reject' | translate }}</button>
}
@if (b.status === 'ISSUED') {
<button mat-button (click)="openBook(b)">{{ 'labels.buttons.View' | translate }}</button>
<button mat-button color="warn" (click)="withdraw(b)">{{ 'labels.buttons.Withdraw' | translate }}</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns" (click)="openBook(row)" class="clickable-row"></tr>
</table>
@if (books.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (totalRecords > 0) {
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
@if (issueForBookId) {
<mat-card class="add-form">
<mat-card-content>
<h3>{{ 'labels.inputs.Issue Cheque Book' | translate }}</h3>
<p class="issue-need">
{{ 'labels.inputs.Requested' | translate }}: <strong>{{ issueForBook?.noOfBooks }}</strong>
{{ 'labels.inputs.books' | translate }} — {{ issueForBook?.seriesCode }}
<span [class.err-hint]="issueAvailableBooks < (issueForBook?.noOfBooks || 0)">
({{ issueAvailableBooks }} {{ 'labels.inputs.books available' | translate }})
</span>
</p>
<form [formGroup]="issueForm" (ngSubmit)="submitIssue(issueForBookId)">
<mat-checkbox formControlName="collectionAcknowledged">
{{ 'labels.inputs.Customer collected the book now' | translate }}
</mat-checkbox>
<div class="form-actions">
<button mat-raised-button type="button" (click)="issueForBookId = null; issueForBook = null">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" type="submit" [disabled]="issueForm.invalid || busy">
{{ 'labels.buttons.Issue' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
@if (rejectForBookId) {
<mat-card class="add-form">
<mat-card-content>
<h3>{{ 'labels.inputs.Reject Cheque Book Request' | translate }}</h3>
<form [formGroup]="rejectForm" (ngSubmit)="submitReject(rejectForBookId)">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Rejection Reason' | translate }}</mat-label>
<input matInput formControlName="rejectionReason" required />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="rejectForBookId = null">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="warn" type="submit" [disabled]="rejectForm.invalid || busy">
{{ 'labels.buttons.Reject' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
</div>
@@ -0,0 +1,128 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 12px;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.add-form {
margin-bottom: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
margin-bottom: 8px;
}
mat-form-field {
width: 100%;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
.clickable-row {
cursor: pointer;
&:hover {
background: rgb(0 0 0 / 3%);
}
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.muted {
color: rgb(0 0 0 / 50%);
font-size: 0.8rem;
margin-left: 4px;
}
.issue-need {
margin: 0 0 8px;
font-size: 0.9rem;
color: rgb(0 0 0 / 70%);
}
::ng-deep .ok-hint {
color: #28c07a;
font-weight: 600;
}
::ng-deep .err-hint {
color: #d32f2f;
font-weight: 600;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.bad {
background: #fde3e3;
color: #d32f2f;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -0,0 +1,366 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ChequeBookService } from '../chequebook.service';
/** Cheque book requisitions and issuance — request, issue (draws from available stock), reject, withdraw. */
@Component({
selector: 'mifosx-cheque-books',
templateUrl: './cheque-books.component.html',
styleUrls: ['./cheque-books.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class ChequeBooksComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private chequeBookService = inject(ChequeBookService);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
books: any[] = [];
totalRecords = 0;
pageSize = 10;
pageIndex = 0;
pageSizeOptions = [
10,
25,
50,
100
];
seriesList: any[] = [];
offices: any[] = [];
stockLots: any[] = [];
busy = false;
showRequestForm = false;
issueForBookId: number | null = null;
issueForBook: any = null;
rejectForBookId: number | null = null;
statusFilter = '';
requestForm: UntypedFormGroup;
issueForm: UntypedFormGroup;
rejectForm: UntypedFormGroup;
// account-number lookup for the request form
private accountSearch$ = new Subject<string>();
resolvedAccount: any = null;
searchingAccount = false;
accountError = '';
columns = [
'id',
'accountType',
'account',
'officeName',
'seriesCode',
'quantity',
'status',
'requestedDate',
'actions'
];
constructor() {
this.requestForm = this.formBuilder.group({
accountType: [
'SAVINGS',
Validators.required
],
accountNo: [
'',
Validators.required
],
accountId: [
null,
Validators.required
],
officeId: [
'',
Validators.required
],
seriesId: [
'',
Validators.required
],
noOfBooks: [
1,
Validators.required
]
});
this.issueForm = this.formBuilder.group({
collectionAcknowledged: [false]
});
this.rejectForm = this.formBuilder.group({
rejectionReason: [
'',
Validators.required
]
});
}
ngOnInit(): void {
this.loadBooks();
this.loadStock();
// populates a dropdown, so fetch every active series in one page rather than paging through it
this.chequeBookService
.getSeries('ACTIVE', 0, 500)
.subscribe((res: any) => (this.seriesList = (res?.pageItems ?? []).filter((s: any) => s.active)));
this.chequeBookService.getOffices().subscribe((data: any[]) => (this.offices = data || []));
// account-number lookup: debounce, only fire once at least 4 chars typed (mirrors the cashier module)
this.accountSearch$
.pipe(debounceTime(400), distinctUntilChanged())
.subscribe((accountNo) => this.doAccountSearch(accountNo));
}
/** Called on every keystroke in the account-number box; clears any prior match and (re)triggers the search. */
onAccountNoInput(value: string): void {
this.resolvedAccount = null;
this.accountError = '';
this.requestForm.patchValue({ accountId: null });
const trimmed = (value || '').trim();
if (trimmed.length >= 4) {
this.accountSearch$.next(trimmed);
}
}
/** Re-run the lookup against the other ledger when the account type toggles; the series choice narrows too. */
onAccountTypeChange(): void {
this.resolvedAccount = null;
this.accountError = '';
this.requestForm.patchValue({ accountId: null, seriesId: '' });
const accountNo = (this.requestForm.get('accountNo')?.value || '').trim();
if (accountNo.length >= 4) {
this.accountSearch$.next(accountNo);
}
}
/** Series applicable to whichever account type is currently selected in the request form — every series is
* tagged SAVINGS-only or CURRENT-only (never both), so this is a direct match. */
get requestableSeries(): any[] {
const type = this.requestForm.get('accountType')?.value;
return this.seriesList.filter((s) => s.applicableAccountType === type);
}
private doAccountSearch(accountNo: string): void {
const accountType = this.requestForm.get('accountType')?.value;
this.searchingAccount = true;
this.accountError = '';
this.chequeBookService.searchAccountByNo(accountType, accountNo).subscribe({
next: (res: any) => {
this.searchingAccount = false;
const items = res?.pageItems || (Array.isArray(res) ? res : []);
const exact = items.find((i: any) => i.accountNo === accountNo);
const match = exact || (items.length === 1 ? items[0] : null);
if (match) {
this.resolvedAccount = {
id: match.id,
accountNo: match.accountNo,
clientName: match.clientName || match.clientDisplayName || match.groupName || '',
status: match.status?.value || ''
};
this.requestForm.patchValue({ accountId: match.id });
} else {
this.accountError = 'No account found with that number';
}
},
error: () => {
this.searchingAccount = false;
this.accountError = 'Account lookup failed';
}
});
}
loadStock(): void {
// feeds availability calculations below, so fetch every available lot in one page rather than paging through it
this.chequeBookService
.getStockLots(undefined, undefined, 'AVAILABLE', 0, 500)
.subscribe((res: any) => (this.stockLots = res?.pageItems ?? []));
}
/** Whole books currently on hand for a series, optionally narrowed to one office. */
availableBooksForSeries(seriesId: number, officeId?: number): number {
return this.stockLots
.filter((l) => l.seriesId === seriesId && (!officeId || l.officeId === officeId) && l.status === 'AVAILABLE')
.reduce((sum, l) => sum + (l.remainingBooks || 0), 0);
}
/** Availability at whichever office is currently chosen in the request form. */
availableBooksForRequestSeries(seriesId: number): number {
return this.availableBooksForSeries(seriesId, this.requestForm.get('officeId')?.value);
}
loadBooks(): void {
this.chequeBookService
.getBooks(
undefined,
undefined,
undefined,
this.statusFilter || undefined,
this.pageIndex * this.pageSize,
this.pageSize
)
.subscribe((res: any) => {
this.books = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.totalRecords = Number(res?.totalFilteredRecords ?? this.books.length);
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.loadBooks();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadBooks();
}
openBook(book: any): void {
this.router.navigate([
'/chequebook',
'books',
book.id
]);
}
submitRequest(): void {
if (this.requestForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.requestForm.value;
const payload = {
accountType: raw.accountType,
accountId: raw.accountId,
officeId: raw.officeId,
seriesId: raw.seriesId,
noOfBooks: raw.noOfBooks,
locale: 'en'
};
this.chequeBookService.requestBook(payload).subscribe({
next: () => {
this.busy = false;
this.showRequestForm = false;
this.requestForm.reset({ accountType: 'SAVINGS', noOfBooks: 1 });
this.resolvedAccount = null;
this.accountError = '';
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
this.loadBooks();
},
error: (err: any) => {
this.busy = false;
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.developerMessage ||
'Request failed — check the account is active and the cheque-book facility is enabled';
this.notify(msg, true);
}
});
}
toggleIssue(book: any): void {
const opening = this.issueForBookId !== book.id;
this.issueForBookId = opening ? book.id : null;
this.issueForBook = opening ? book : null;
this.issueForm.reset({ collectionAcknowledged: false });
}
/** Books available right now for the series this book was already requested against. */
get issueAvailableBooks(): number {
return this.issueForBook?.seriesId
? this.availableBooksForSeries(this.issueForBook.seriesId, this.issueForBook.officeId)
: 0;
}
submitIssue(bookId: number): void {
if (this.issueForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.issueForm.value;
const payload = { collectionAcknowledged: !!raw.collectionAcknowledged, locale: 'en' };
this.chequeBookService.issueBook(bookId, payload).subscribe({
next: () => {
this.busy = false;
this.issueForBookId = null;
this.issueForBook = null;
this.notify('Cheque book issued');
this.loadBooks();
this.loadStock();
},
error: () => {
this.busy = false;
this.notify('Issue failed — check there is enough available stock for this series at the office', true);
}
});
}
toggleReject(book: any): void {
this.rejectForBookId = this.rejectForBookId === book.id ? null : book.id;
this.rejectForm.reset();
}
submitReject(bookId: number): void {
if (this.rejectForm.invalid || this.busy) {
return;
}
this.busy = true;
const payload = { rejectionReason: this.rejectForm.value.rejectionReason, locale: 'en' };
this.chequeBookService.rejectBook(bookId, payload).subscribe({
next: () => {
this.busy = false;
this.rejectForBookId = null;
this.notify('Cheque book request rejected');
this.loadBooks();
},
error: () => {
this.busy = false;
this.notify('Reject failed', true);
}
});
}
withdraw(book: any): void {
this.chequeBookService.withdrawBook(book.id).subscribe({
next: () => {
this.notify('Cheque book withdrawn');
this.loadBooks();
},
error: () => this.notify('Withdraw failed', true)
});
}
}
@@ -0,0 +1,136 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.inputs.Cheque Series' | translate }}</h2>
<div class="header-actions">
<button mat-raised-button color="primary" (click)="showForm = !showForm">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'labels.buttons.Add Cheque Series' | translate }}
</button>
<button mat-raised-button [routerLink]="['/chequebook']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
@if (showForm) {
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="seriesForm" (ngSubmit)="submit()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Series Code' | translate }}</mat-label>
<input matInput formControlName="seriesCode" placeholder="CTS-25" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Name' | translate }}</mat-label>
<input matInput formControlName="name" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Leaves Per Book' | translate }}</mat-label>
<input matInput type="number" formControlName="leavesPerBook" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Applicable Account Type' | translate }}</mat-label>
<mat-select formControlName="applicableAccountType" required>
<mat-option value="SAVINGS">{{ 'labels.inputs.Savings Only' | translate }}</mat-option>
<mat-option value="CURRENT">{{ 'labels.inputs.Current Only' | translate }}</mat-option>
</mat-select>
<mat-hint>{{ 'labels.text.SB and CA cheque stationery differ' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Reorder Level' | translate }}</mat-label>
<input matInput type="number" formControlName="reorderLevel" />
<mat-hint>{{ 'labels.text.Low-stock alert threshold (remaining leaves)' | translate }}</mat-hint>
</mat-form-field>
</div>
<mat-checkbox formControlName="ctsCompliant">{{
'labels.inputs.CTS-2010 Compliant' | translate
}}</mat-checkbox>
<div class="form-actions">
<button mat-raised-button type="button" (click)="showForm = false">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" type="submit" [disabled]="seriesForm.invalid || busy">
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
<div class="filter-row">
@for (s of ['ACTIVE', 'INACTIVE']; track s) {
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
}
</div>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="seriesList">
<ng-container matColumnDef="seriesCode">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Series Code' | translate }}</th>
<td mat-cell *matCellDef="let s">{{ s.seriesCode }}</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Name' | translate }}</th>
<td mat-cell *matCellDef="let s">{{ s.name }}</td>
</ng-container>
<ng-container matColumnDef="leavesPerBook">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Leaves Per Book' | translate }}</th>
<td mat-cell *matCellDef="let s" class="num">{{ s.leavesPerBook }}</td>
</ng-container>
<ng-container matColumnDef="applicableAccountType">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Applicable Account Type' | translate }}</th>
<td mat-cell *matCellDef="let s">
<span class="pill neutral">{{ s.applicableAccountType }}</span>
</td>
</ng-container>
<ng-container matColumnDef="ctsCompliant">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.CTS-2010 Compliant' | translate }}</th>
<td mat-cell *matCellDef="let s">
<span class="pill" [ngClass]="s.ctsCompliant ? 'ok' : 'neutral'">{{ s.ctsCompliant ? 'Yes' : 'No' }}</span>
</td>
</ng-container>
<ng-container matColumnDef="reorderLevel">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Reorder Level' | translate }}</th>
<td mat-cell *matCellDef="let s" class="num">{{ s.reorderLevel ?? '-' }}</td>
</ng-container>
<ng-container matColumnDef="active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let s">
<span class="pill" [ngClass]="s.active ? 'ok' : 'neutral'">{{ s.active ? 'Active' : 'Inactive' }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let s">
@if (s.active) {
<button mat-icon-button color="warn" (click)="deactivate(s)">
<mat-icon><fa-icon icon="trash"></fa-icon></mat-icon>
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
@if (seriesList.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (totalRecords > 0) {
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
</div>
@@ -0,0 +1,93 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 12px;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.add-form {
margin-bottom: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
margin-bottom: 8px;
}
mat-form-field {
width: 100%;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -0,0 +1,176 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog } from '@angular/material/dialog';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ChequeBookService } from '../chequebook.service';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
/** Cheque-leaf stationery master — leaves per book, CTS-2010 compliance, low-stock reorder threshold. */
@Component({
selector: 'mifosx-cheque-series',
templateUrl: './cheque-series.component.html',
styleUrls: ['./cheque-series.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class ChequeSeriesComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private chequeBookService = inject(ChequeBookService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
seriesList: any[] = [];
showForm = false;
busy = false;
statusFilter = '';
pageSize = 10;
pageIndex = 0;
totalRecords = 0;
pageSizeOptions = [
10,
25,
50,
100
];
seriesForm: UntypedFormGroup;
columns = [
'seriesCode',
'name',
'leavesPerBook',
'applicableAccountType',
'ctsCompliant',
'reorderLevel',
'active',
'actions'
];
constructor() {
this.seriesForm = this.formBuilder.group({
seriesCode: [
'',
Validators.required
],
name: [
'',
Validators.required
],
leavesPerBook: [
25,
Validators.required
],
applicableAccountType: [
'',
Validators.required
],
ctsCompliant: [true],
reorderLevel: [10]
});
}
ngOnInit(): void {
this.loadSeries();
}
loadSeries(): void {
this.chequeBookService
.getSeries(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize)
.subscribe((res: any) => {
this.seriesList = res?.pageItems ?? [];
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.loadSeries();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadSeries();
}
submit(): void {
if (this.seriesForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.seriesForm.value;
const payload: any = {
seriesCode: raw.seriesCode,
name: raw.name,
leavesPerBook: raw.leavesPerBook,
applicableAccountType: raw.applicableAccountType,
ctsCompliant: !!raw.ctsCompliant,
locale: 'en'
};
if (raw.reorderLevel !== '' && raw.reorderLevel != null) payload.reorderLevel = raw.reorderLevel;
this.chequeBookService.createSeries(payload).subscribe({
next: () => {
this.busy = false;
this.showForm = false;
this.seriesForm.reset({ leavesPerBook: 25, applicableAccountType: '', ctsCompliant: true, reorderLevel: 10 });
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
this.loadSeries();
},
error: () => {
this.busy = false;
this.notify('Failed to add cheque series', true);
}
});
}
deactivate(series: any): void {
const dialogRef = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `cheque series ${series.seriesCode}` }
});
dialogRef.afterClosed().subscribe((response: any) => {
if (!response?.delete) {
return;
}
this.chequeBookService.deactivateSeries(series.id).subscribe({
next: () => {
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
this.loadSeries();
},
error: () => this.notify('Failed to submit for approval', true)
});
});
}
}
@@ -0,0 +1,29 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.menus.Cheque Book Inventory' | translate }}</h2>
</div>
<div class="cards-grid">
@for (screen of screens; track screen.label) {
<mat-card class="cb-card" [routerLink]="screen.route">
<mat-card-content>
<div class="card-head">
<span class="card-icon">
<fa-icon [icon]="screen.icon"></fa-icon>
</span>
<span class="card-title">{{ screen.label | translate }}</span>
</div>
<p class="card-desc">{{ screen.description | translate }}</p>
</mat-card-content>
</mat-card>
}
</div>
</div>
@@ -0,0 +1,68 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.cb-card {
cursor: pointer;
transition:
box-shadow 0.2s ease,
transform 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
transform: translateY(-2px);
}
}
.card-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 10px;
flex: 0 0 auto;
background: rgb(25 118 210 / 10%);
color: #1976d2;
font-size: 1.15rem;
}
.card-title {
font-size: 1.05rem;
font-weight: 600;
line-height: 1.2;
}
.card-desc {
color: rgb(0 0 0 / 60%);
font-size: 0.85rem;
margin: 0;
}
@@ -0,0 +1,72 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatCardModule } from '@angular/material/card';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Cheque Book Inventory landing page — one card per screen (series master, stock, requests/issuance, and
* the teller quick-lookup).
*/
@Component({
selector: 'mifosx-chequebook-dashboard',
templateUrl: './chequebook-dashboard.component.html',
styleUrls: ['./chequebook-dashboard.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatIconModule,
MatCardModule,
FaIconComponent
]
})
export class ChequeBookDashboardComponent {
screens = [
{
route: [
'/chequebook',
'series'
],
label: 'Cheque Series',
icon: 'list',
description: 'Stationery master — leaves per book, CTS-2010 compliance, low-stock reorder level'
},
{
route: [
'/chequebook',
'stock'
],
label: 'Stock Lots & Transfers',
icon: 'boxes-stacked',
description: 'Receive bulk stock from the press, dispatch to branches, acknowledge receipt'
},
{
route: [
'/chequebook',
'books'
],
label: 'Requests & Issuance',
icon: 'book',
description: 'Customer requisitions, issuance, stop-payment, lost/stolen hotlisting'
},
{
route: [
'/chequebook',
'lookup'
],
label: 'Leaf Lookup',
icon: 'magnifying-glass',
description: 'Counter check — is this cheque number safe to honour?'
}
];
}
@@ -0,0 +1,69 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
/** Routing Imports */
import { Route } from '../core/route/route.service';
/** Custom Components */
import { ChequeBookDashboardComponent } from './chequebook-dashboard/chequebook-dashboard.component';
import { ChequeSeriesComponent } from './cheque-series/cheque-series.component';
import { StockManagementComponent } from './stock-management/stock-management.component';
import { ChequeBooksComponent } from './cheque-books/cheque-books.component';
import { ChequeBookDetailComponent } from './cheque-book-detail/cheque-book-detail.component';
import { LeafLookupComponent } from './leaf-lookup/leaf-lookup.component';
/** Cheque Book Inventory Routes */
const routes: Routes = [
Route.withShell([
{
path: 'chequebook',
data: { title: 'Cheque Book Inventory', breadcrumb: 'Cheque Book Inventory' },
children: [
{
path: '',
component: ChequeBookDashboardComponent
},
{
path: 'series',
component: ChequeSeriesComponent,
data: { title: 'Cheque Series', breadcrumb: 'Cheque Series' }
},
{
path: 'stock',
component: StockManagementComponent,
data: { title: 'Cheque Stock', breadcrumb: 'Cheque Stock' }
},
{
path: 'books',
component: ChequeBooksComponent,
data: { title: 'Cheque Books', breadcrumb: 'Cheque Books' }
},
{
path: 'books/:id',
component: ChequeBookDetailComponent,
data: { title: 'Cheque Book', routeParamBreadcrumb: 'id' }
},
{
path: 'lookup',
component: LeafLookupComponent,
data: { title: 'Leaf Lookup', breadcrumb: 'Leaf Lookup' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ChequeBookRoutingModule {}
+46
View File
@@ -0,0 +1,46 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { NgModule } from '@angular/core';
/** Custom Modules */
import { SharedModule } from '../shared/shared.module';
import { DirectivesModule } from '../directives/directives.module';
import { PipesModule } from 'app/pipes/pipes.module';
import { ChequeBookRoutingModule } from './chequebook-routing.module';
/** Custom Components (standalone) */
import { ChequeBookDashboardComponent } from './chequebook-dashboard/chequebook-dashboard.component';
import { ChequeSeriesComponent } from './cheque-series/cheque-series.component';
import { StockManagementComponent } from './stock-management/stock-management.component';
import { ChequeBooksComponent } from './cheque-books/cheque-books.component';
import { ChequeBookDetailComponent } from './cheque-book-detail/cheque-book-detail.component';
import { LeafLookupComponent } from './leaf-lookup/leaf-lookup.component';
/**
* Cheque Book Inventory Module.
*
* Stationery master, a unified stock ledger (HO receipt / branch transfer-in), account issuance with
* per-leaf serial tracking, stop-payment, and lost/stolen hotlisting for both issued and unissued stock.
*/
@NgModule({
imports: [
SharedModule,
DirectivesModule,
PipesModule,
ChequeBookRoutingModule,
ChequeBookDashboardComponent,
ChequeSeriesComponent,
StockManagementComponent,
ChequeBooksComponent,
ChequeBookDetailComponent,
LeafLookupComponent
]
})
export class ChequeBookModule {}
+226
View File
@@ -0,0 +1,226 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* Cheque Book Inventory — stationery master, a unified stock ledger (HO receipt / branch transfer-in share
* one drawdown-cursor concept), account issuance with per-leaf serial tracking, stop-payment and
* lost/stolen hotlisting (both issued and unissued stock).
*/
@Injectable({
providedIn: 'root'
})
export class ChequeBookService {
private http = inject(HttpClient);
// ----- Series -----
/** status: 'ACTIVE' | 'INACTIVE'. Server-side paginated — always returns { pageItems, totalFilteredRecords }. */
getSeries(status?: string, offset = 0, limit = 20): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
return this.http.get('/chequeseries', { params });
}
createSeries(payload: any): Observable<any> {
return this.http.post('/chequeseries', payload);
}
updateSeries(id: string | number, payload: any): Observable<any> {
return this.http.put(`/chequeseries/${id}`, payload);
}
deactivateSeries(id: string | number): Observable<any> {
return this.http.delete(`/chequeseries/${id}`);
}
// ----- Stock lots -----
/** Server-side paginated — always returns { pageItems, totalFilteredRecords }. */
getStockLots(
officeId?: string | number,
seriesId?: string | number,
status?: string,
offset = 0,
limit = 20
): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (officeId) params = params.set('officeId', officeId);
if (seriesId) params = params.set('seriesId', seriesId);
if (status) params = params.set('status', status);
return this.http.get('/chequestocklots', { params });
}
receiveStock(payload: any): Observable<any> {
return this.http.post('/chequestocklots', payload);
}
reportStockLotLost(lotId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequestocklots/${lotId}`, payload, {
params: new HttpParams().set('command', 'reportlost')
});
}
// ----- Stock transfers -----
/** Server-side paginated — always returns { pageItems, totalFilteredRecords }. */
getStockTransfers(status?: string, offset = 0, limit = 20): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
return this.http.get('/chequestocktransfers', { params });
}
dispatchStockTransfer(payload: any): Observable<any> {
return this.http.post('/chequestocktransfers', payload);
}
receiveStockTransfer(transferId: string | number): Observable<any> {
return this.http.post(
`/chequestocktransfers/${transferId}`,
{},
{ params: new HttpParams().set('command', 'receive') }
);
}
rejectStockTransfer(transferId: string | number): Observable<any> {
return this.http.post(
`/chequestocktransfers/${transferId}`,
{},
{ params: new HttpParams().set('command', 'reject') }
);
}
// ----- Cheque books -----
getBooks(
accountType?: string,
accountId?: string | number,
officeId?: string | number,
status?: string,
offset = 0,
limit = 20
): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (accountType) params = params.set('accountType', accountType);
if (accountId) params = params.set('accountId', accountId);
if (officeId) params = params.set('officeId', officeId);
if (status) params = params.set('status', status);
return this.http.get('/chequebooks', { params });
}
getBook(id: string | number): Observable<any> {
return this.http.get(`/chequebooks/${id}`);
}
getLeaves(bookId: string | number): Observable<any> {
return this.http.get(`/chequebooks/${bookId}/leaves`);
}
requestBook(payload: any): Observable<any> {
return this.http.post('/chequebooks', payload);
}
issueBook(bookId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'issue') });
}
rejectBook(bookId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'reject') });
}
withdrawBook(bookId: string | number): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, {}, { params: new HttpParams().set('command', 'withdraw') });
}
stopPayment(bookId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, payload, {
params: new HttpParams().set('command', 'stoppayment')
});
}
revokeStopPayment(bookId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, payload, {
params: new HttpParams().set('command', 'revokestoppayment')
});
}
reportBookLost(bookId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'reportlost') });
}
cancelLeaf(bookId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'cancelleaf') });
}
/** Manually record that leaves cleared, once the branch sees them on the bank/clearing statement. */
markUsed(bookId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'markused') });
}
/** Flag leaves as returned/dishonoured (insufficient funds, signature mismatch, etc.) — terminal, like reportlost. */
returnBook(bookId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'return') });
}
// ----- Pending requests (bespoke maker-checker for series/stock/book creation) -----
/**
* A different user than the maker must approve/reject — enforced server-side, not just in this UI.
* Server-side paginated and date-filtered, matching the clerk disbursement/withdrawal request queues.
*/
getPendingRequests(status?: string, fromDate?: string, toDate?: string, offset = 0, limit = 20): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
return this.http.get('/chequebook/pending-requests', { params });
}
getPendingRequest(id: string | number): Observable<any> {
return this.http.get(`/chequebook/pending-requests/${id}`);
}
approvePendingRequest(id: string | number): Observable<any> {
return this.http.post(
`/chequebook/pending-requests/${id}`,
{},
{ params: new HttpParams().set('command', 'approve') }
);
}
rejectPendingRequest(id: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebook/pending-requests/${id}`, payload, {
params: new HttpParams().set('command', 'reject')
});
}
// ----- Leaf lookup -----
lookupLeaf(serialNo: string | number): Observable<any> {
return this.http.get('/chequeleaves/lookup', { params: new HttpParams().set('serialNo', serialNo) });
}
// ----- Account lookup (by account number) -----
/** Search a Savings or Current account by its account number; returns a paginated list to match against. */
searchAccountByNo(accountType: string, accountNo: string): Observable<any> {
const base = accountType === 'CURRENT' ? '/currentaccounts' : '/savingsaccounts';
const params = new HttpParams().set('accountNo', accountNo).set('offset', '0').set('limit', '20');
return this.http.get(base, { params });
}
// ----- Shared template data -----
getOffices(): Observable<any> {
return this.http.get('/offices');
}
}
@@ -0,0 +1,136 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.inputs.Leaf Lookup' | translate }}</h2>
<button mat-raised-button [routerLink]="['/chequebook']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
<mat-card class="search-card">
<mat-card-content>
<form [formGroup]="searchForm" (ngSubmit)="search()" class="search-row">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Cheque Leaf Serial No' | translate }}</mat-label>
<input matInput type="number" formControlName="serialNo" required />
</mat-form-field>
<button mat-raised-button color="primary" type="submit" [disabled]="searchForm.invalid || busy">
<fa-icon icon="magnifying-glass"></fa-icon>&nbsp;{{ 'labels.buttons.Search' | translate }}
</button>
</form>
</mat-card-content>
</mat-card>
@if (results.length > 1) {
<div class="multi-match-note">
{{
'labels.text.More than one series has a leaf with this serial number — check the series code against the physical cheque.'
| translate
}}
</div>
}
@for (result of results; track result.seriesCode + '-' + result.leafSerialNo) {
<mat-card class="result-card">
<mat-card-content>
<div class="result-header">
@if (result.seriesCode) {
<span class="series-tag">{{ result.seriesCode }}</span>
}
<span
class="pill big"
[ngClass]="
result.status === 'ACTIVE'
? 'ok'
: result.status === 'USED'
? 'used'
: result.status === 'STOP_PAYMENT'
? 'warn'
: result.status === 'LOST'
? 'bad'
: result.status === 'RETURNED'
? 'returned'
: 'neutral'
"
>{{ result.status }}</span
>
</div>
<div class="info-grid">
<div>
<span class="k">{{ 'labels.inputs.Serial No' | translate }}</span
><span>{{ result.leafSerialNo }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Series' | translate }}</span
><span>{{ result.seriesCode || '–' }}</span>
</div>
@if (result.chequeBookId) {
<div>
<span class="k">{{ 'labels.inputs.Cheque Book' | translate }}</span
><span>#{{ result.chequeBookId }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Account' | translate }}</span
><span>{{ result.accountType }} {{ result.accountNo || result.accountId }}</span>
</div>
@if (result.clientName) {
<div>
<span class="k">{{ 'labels.inputs.Client' | translate }}</span
><span>{{ result.clientName }}</span>
</div>
}
} @else {
<div class="warn-note">
{{
'labels.text.This leaf was never issued to a customer — reported lost/stolen from stock.' | translate
}}
</div>
}
<div>
<span class="k">{{ 'labels.inputs.Office' | translate }}</span
><span>{{ result.officeName }}</span>
</div>
@if (result.status === 'STOP_PAYMENT') {
<div>
<span class="k">{{ 'labels.inputs.Stop Reason' | translate }}</span
><span>{{ result.stopReason }}</span>
</div>
}
@if (result.status === 'LOST') {
<div>
<span class="k">{{ 'labels.inputs.Lost Reason' | translate }}</span
><span>{{ result.lostReason }}</span>
</div>
}
@if (result.status === 'CANCELLED') {
<div>
<span class="k">{{ 'labels.inputs.Cancelled Reason' | translate }}</span
><span>{{ result.cancelledReason }}</span>
</div>
}
@if (result.status === 'USED' && result.usedReason) {
<div>
<span class="k">{{ 'labels.inputs.Used Reason' | translate }}</span
><span>{{ result.usedReason }}</span>
</div>
}
@if (result.status === 'RETURNED') {
<div>
<span class="k">{{ 'labels.inputs.Return Reason' | translate }}</span
><span>{{ result.returnReason }}</span>
</div>
}
</div>
</mat-card-content>
</mat-card>
} @empty {
@if (searched && !busy) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
}
</div>
@@ -0,0 +1,130 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
padding: 16px;
max-width: 700px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.search-card {
margin-bottom: 16px;
}
.search-row {
display: flex;
gap: 16px;
align-items: flex-start;
mat-form-field {
flex: 1;
}
}
.result-card {
margin-bottom: 16px;
}
.result-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.series-tag {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
color: rgb(0 0 0 / 60%);
}
.multi-match-note {
margin-bottom: 16px;
color: #a16207;
font-weight: 600;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px 24px;
div {
display: flex;
flex-direction: column;
}
.k {
font-size: 0.72rem;
text-transform: uppercase;
color: rgb(0 0 0 / 50%);
}
.warn-note {
grid-column: 1 / -1;
color: #a16207;
font-weight: 600;
}
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
&.big {
font-size: 14px;
padding: 6px 14px;
border-radius: 8px;
}
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.used {
background: #e3edfd;
color: #1976d2;
}
&.bad {
background: #fde3e3;
color: #d32f2f;
}
&.warn {
background: #fff3cd;
color: #a16207;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
&.returned {
background: #f3e8fd;
color: #9333ea;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -0,0 +1,81 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ChequeBookService } from '../chequebook.service';
/**
* The teller/counter quick-lookup: given a bare cheque-leaf serial number, is it active, stopped, lost, or
* was it ever issued at all — one code path covers every case (issued or unissued-but-stolen stock).
*/
@Component({
selector: 'mifosx-leaf-lookup',
templateUrl: './leaf-lookup.component.html',
styleUrls: ['./leaf-lookup.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatSnackBarModule,
FaIconComponent
]
})
export class LeafLookupComponent {
private formBuilder = inject(UntypedFormBuilder);
private chequeBookService = inject(ChequeBookService);
private snackBar = inject(MatSnackBar);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
searchForm: UntypedFormGroup;
// a bare serial number is only unique within its own series — more than one series can share a number,
// so the lookup can legitimately return more than one match
results: any[] = [];
searched = false;
busy = false;
constructor() {
this.searchForm = this.formBuilder.group({
serialNo: [
'',
Validators.required
]
});
}
search(): void {
if (this.searchForm.invalid || this.busy) {
return;
}
this.busy = true;
this.searched = true;
this.results = [];
this.chequeBookService.lookupLeaf(this.searchForm.value.serialNo).subscribe({
next: (data: any) => {
this.busy = false;
this.results = Array.isArray(data) ? data : [data];
},
error: () => {
this.busy = false;
this.results = [];
this.notify('No cheque leaf found with that serial number', true);
}
});
}
}
@@ -0,0 +1,47 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<h1 mat-dialog-title>{{ 'labels.inputs.Report Remaining Stock Lost/Stolen' | translate }}</h1>
<div mat-dialog-content>
<div class="rll-context">
<div class="rll-field">
<span class="k">{{ 'labels.inputs.Series' | translate }}</span>
<span>{{ data.seriesCode }}</span>
</div>
<div class="rll-field">
<span class="k">{{ 'labels.inputs.Office' | translate }}</span>
<span>{{ data.officeName }}</span>
</div>
<div class="rll-field">
<span class="k">{{ 'labels.inputs.Remaining Range' | translate }}</span>
<span>{{ data.nextSerialNo }} – {{ data.rangeEnd }}</span>
</div>
</div>
<form [formGroup]="reportLostForm" (ngSubmit)="submit()" class="rll-form">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.From Serial No' | translate }}</mat-label>
<input matInput type="number" formControlName="fromSerialNo" required />
<mat-hint>{{
'labels.text.Only a suffix of the remaining undrawn range can be reported' | translate
}}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
<input matInput formControlName="reason" required />
</mat-form-field>
</div>
</form>
</div>
<mat-dialog-actions align="end">
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
<button mat-raised-button color="warn" [disabled]="reportLostForm.invalid || busy" (click)="submit()">
{{ 'labels.buttons.Report Lost' | translate }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,34 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.rll-context {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px 20px;
min-width: 420px;
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px solid rgb(0 0 0 / 8%);
.rll-field {
display: flex;
flex-direction: column;
}
.k {
font-size: 0.7rem;
text-transform: uppercase;
color: rgb(0 0 0 / 50%);
}
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px 20px;
}
@@ -0,0 +1,90 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import {
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { MatSnackBar } from '@angular/material/snack-bar';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ChequeBookService } from '../../chequebook.service';
/** Popup shown when "Report Lost" is clicked on a stock lot row — reports the undrawn suffix as lost/stolen. */
@Component({
selector: 'mifosx-report-lot-lost-dialog',
templateUrl: './report-lot-lost-dialog.component.html',
styleUrls: ['./report-lot-lost-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose
]
})
export class ReportLotLostDialogComponent {
dialogRef = inject<MatDialogRef<ReportLotLostDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
private chequeBookService = inject(ChequeBookService);
private snackBar = inject(MatSnackBar);
private formBuilder = inject(UntypedFormBuilder);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
busy = false;
reportLostForm: UntypedFormGroup;
constructor() {
this.reportLostForm = this.formBuilder.group({
fromSerialNo: [
this.data.nextSerialNo,
Validators.required
],
reason: [
'',
Validators.required
]
});
}
submit(): void {
if (this.reportLostForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.reportLostForm.value;
const payload = { fromSerialNo: raw.fromSerialNo, reason: raw.reason, locale: 'en' };
this.chequeBookService.reportStockLotLost(this.data.id, payload).subscribe({
next: () => {
this.busy = false;
this.notify('Remaining stock reported lost/stolen');
this.dialogRef.close({ reported: true });
},
error: () => {
this.busy = false;
this.notify(
'Failed to report stock lost — check the serial number is within the remaining undrawn range',
true
);
}
});
}
}
@@ -0,0 +1,339 @@
<!--
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>
<div class="global-allocation-note">
{{ 'labels.text.Highest leaf serial allocated so far, across every series' | translate }}:
<strong>{{ globalHighestAllocated }}</strong>
— {{ 'labels.text.next available start' | translate }}:
<strong>{{ globalHighestAllocated + 1 }}</strong>
</div>
<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 across every series; editable' | translate }} ({{
globalHighestAllocated
}}
+ 1)</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,172 @@
/**
* 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;
}
.global-allocation-note {
margin-bottom: 14px;
padding: 8px 12px;
border-radius: 4px;
background: rgb(25 118 210 / 8%);
color: rgb(0 0 0 / 70%);
font-size: 0.85rem;
strong {
font-variant-numeric: tabular-nums;
}
}
.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,403 @@
/**
* 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;
}
/** Highest leaf-serial allocated so far, across every series/office — the actual number the "Range Start"
* suggestion is based on. Shown directly in the form (not just silently applied to the field) so the operator
* can see and trust the reasoning instead of taking a pre-filled number on faith. */
get globalHighestAllocated(): number {
return this.allStockLots.reduce((m, l) => Math.max(m, l.rangeEnd || 0), 0);
}
/**
* Next unused serial across EVERY series, not just the one being received — a real bank allocates leaf-serial
* numbers from one bank-wide numbering authority, so two different cheque designs never legitimately reuse the
* same range. Suggesting a start that only avoids the same series' own lots is exactly what let two series end
* up overlapping (and, once both got issued to the same account, an unresolvable serial collision) before the
* backend's own overlap check was widened to match (ChequeStockLotRepository.countOverlappingAnySeries).
*/
private nextFreeSerial(): number {
return this.globalHighestAllocated + 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)
});
}
}
@@ -0,0 +1,54 @@
/**
* 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/.
*/
/**
* Maps a clearing status/decision string to one of the three shared .pill classes
* (ok / bad / neutral). Shared by every CTS, NACH and settlement/reconciliation screen so the
* colour language stays consistent across the whole clearing feature.
*/
export function pillClass(status: string | null | undefined): 'ok' | 'bad' | 'neutral' {
const s = (status || '').toUpperCase();
const ok = [
'PASSED',
'PASS',
'POSTED',
'MATCHED',
'SETTLED',
'SENT',
'SENT_TO_CLEARING_HOUSE',
'PROCESSED',
'VALIDATED',
'ACTIVE',
'CLEARED',
'HONOURED',
'COMPLETED'
];
const bad = [
'RETURNED',
'RETURN',
'REJECTED',
'MISMATCH',
'DISHONOURED',
'CANCELLED',
'FAILED',
'FAIL',
'UNROUTABLE'
];
if (ok.includes(s)) {
return 'ok';
}
if (bad.includes(s)) {
return 'bad';
}
return 'neutral';
}
/** Extracts the most specific backend error message from a Fineract-style error response. */
export function clearingError(err: any, fallback = 'Request failed'): string {
return err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
}
@@ -0,0 +1,77 @@
/**
* 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';
/**
* Shared clearing services used across CTS and NACH — return-reason-code master, settlement-batch posting,
* and reconciliation.
*/
@Injectable({
providedIn: 'root'
})
export class ClearingService {
private http = inject(HttpClient);
// ----- Return reason codes -----
/** appliesTo: 'CTS' | 'NACH'. Returns a raw array of reason codes. */
getReturnReasonCodes(appliesTo: string): Observable<any> {
return this.http.get('/clearing/return-reason-codes', {
params: new HttpParams().set('appliesTo', appliesTo)
});
}
// ----- Settlement batches -----
/** Server-side paginated — returns { pageItems, totalFilteredRecords }. */
getSettlementBatches(clearingType?: string, status?: string, offset = 0, limit = 20): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (clearingType) params = params.set('clearingType', clearingType);
if (status) params = params.set('status', status);
return this.http.get('/clearing/settlement-batches', { params });
}
getSettlementBatch(id: string | number): Observable<any> {
return this.http.get(`/clearing/settlement-batches/${id}`);
}
/** payload: { clearingType, sourceBatchId }. */
prepareSettlement(payload: any): Observable<any> {
return this.http.post('/clearing/settlement-batches', payload);
}
postSettlement(id: string | number): Observable<any> {
return this.http.post(
`/clearing/settlement-batches/${id}`,
{},
{ params: new HttpParams().set('command', 'post') }
);
}
rejectSettlement(id: string | number, payload: any): Observable<any> {
return this.http.post(`/clearing/settlement-batches/${id}`, payload, {
params: new HttpParams().set('command', 'reject')
});
}
// ----- Reconciliations -----
getReconciliations(clearingType?: string, offset = 0, limit = 20): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (clearingType) params = params.set('clearingType', clearingType);
return this.http.get('/clearing/reconciliations', { params });
}
runReconciliation(payload: any): Observable<any> {
return this.http.post('/clearing/reconciliations', payload);
}
}
@@ -0,0 +1,88 @@
/**
* 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, of } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';
export interface JointHolder {
clientId: number;
cifNumber?: string;
displayName: string;
sharePercentage?: number;
signingAuthority?: string;
role?: string;
isPrimary?: boolean;
isMandatory?: boolean;
}
export interface AccountSignatoryInfo {
modeOfOperation: string | null;
primaryClientId: number | null;
primaryName: string | null;
jointHolders: JointHolder[];
availableBalance: number | null;
onHoldAmount: number | null;
}
/**
* Fetches account-holder/signatory data needed to verify a cheque's signature — reusing the exact APIs and
* conventions already established in the cashier module's joint-holder detail view
* (`transaction-entry.component.ts`), so this isn't a second, divergent implementation of the same lookup.
*/
@Injectable({
providedIn: 'root'
})
export class SignatoryService {
private http = inject(HttpClient);
/**
* Fetches the savings or current account (with joint holders and mode of operation embedded via
* `associations=all` — the same Fineract convention the cashier module already relies on).
*/
fetchAccountSignatories(accountType: string, accountId: number | string): Observable<AccountSignatoryInfo> {
const path =
accountType?.toUpperCase() === 'CURRENT' ? `/currentaccounts/${accountId}` : `/savingsaccounts/${accountId}`;
return this.http.get<any>(path, { params: new HttpParams().set('associations', 'all') }).pipe(
map((acc: any) => ({
modeOfOperation: acc?.modeOfOperation ?? null,
primaryClientId: acc?.clientId ?? null,
primaryName: acc?.clientName ?? acc?.name ?? null,
jointHolders: Array.isArray(acc?.jointHolders) ? acc.jointHolders : [],
availableBalance: acc?.summary?.availableBalance ?? null,
onHoldAmount: acc?.summary?.totalSavingsAmountOnHold ?? acc?.onHoldFundsDerived ?? null
}))
);
}
/** The client's stored signature (a document tagged name === 'clientSignature'), as a blob object URL, or null if none uploaded. */
fetchClientSignature(clientId: number | string): Observable<string | null> {
return this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(
switchMap((docs: any[]) => {
const sigDoc = (docs || []).find(
(d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature')
);
if (!sigDoc) {
return of(null);
}
return this.http.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' }).pipe(
map((blob: Blob) => URL.createObjectURL(blob)),
catchError(() => of(null))
);
}),
catchError(() => of(null))
);
}
/** The client's photo (already base64/text from this API), or null if none uploaded. */
fetchClientPhoto(clientId: number | string): Observable<string | null> {
return this.http.get(`/clients/${clientId}/images`, { responseType: 'text' }).pipe(catchError(() => of(null)));
}
}
@@ -11,116 +11,79 @@
}
.af-page {
background: #f8fafc;
min-height: calc(100vh - 68px);
background: #f0f4f8;
padding: 24px 28px;
display: flex;
flex-direction: column;
gap: 20px;
gap: 12px;
}
// ── Hero ──────────────────────────────────────────────────────────────────────
// ── Header ──────────────────────────────────────────────────────────────────────
.af-hero {
.af-header {
display: flex;
align-items: center;
gap: 18px;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 40%, #2563eb 100%);
border-radius: 16px;
padding: 22px 28px;
color: #fff;
box-shadow: 0 8px 32px rgb(37 99 235 / 35%);
position: relative;
overflow: hidden;
justify-content: space-between;
padding: 12px 20px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&::before {
content: '';
position: absolute;
top: -40px;
right: -40px;
width: 200px;
height: 200px;
background: rgb(255 255 255 / 5%);
border-radius: 50%;
}
&__icon-wrap {
width: 56px;
height: 56px;
background: rgb(255 255 255 / 15%);
border: 1px solid rgb(255 255 255 / 25%);
border-radius: 14px;
&__left {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
backdrop-filter: blur(8px);
gap: 10px;
}
&__text {
flex: 1;
&__icon {
color: #0f4f3c;
flex-shrink: 0;
}
&__title {
font-size: 22px;
font-weight: 800;
letter-spacing: -0.01em;
margin: 0 0 5px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
&__stats {
display: flex;
gap: 14px;
align-items: center;
&__sub {
font-size: 12px;
color: #64748b;
margin-top: 1px;
}
&__stat {
display: flex;
align-items: center;
gap: 5px;
font-size: 12.5px;
color: rgb(255 255 255 / 75%);
font-weight: 700;
strong {
color: #fff;
font-weight: 700;
&--green {
color: #16a34a;
}
&--green strong {
color: #86efac;
&--red {
color: #dc2626;
}
&--red strong {
color: #fca5a5;
}
}
&__sep {
width: 1px;
height: 14px;
background: rgb(255 255 255 / 25%);
}
}
.af-back-btn {
.af-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 10px 20px;
background: rgb(255 255 255 / 12%);
border: 1px solid rgb(255 255 255 / 25%);
border-radius: 10px;
color: #fff;
font-size: 12.5px;
gap: 6px;
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
backdrop-filter: blur(8px);
border: 1px solid #e2e8f0;
transition: background 0.15s;
height: 30px;
padding: 0 12px;
font-size: 12.5px;
background: #fff;
color: #0d1b2a;
&:hover {
background: rgb(255 255 255 / 22%);
transform: translateX(-2px);
background: #f0fdf4;
border-color: #0f4f3c;
color: #0f4f3c;
}
}
@@ -132,12 +95,12 @@
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 60px 20px;
gap: 10px;
padding: 40px 20px;
&__icon {
width: 88px;
height: 88px;
width: 64px;
height: 64px;
background: #e2e8f0;
border: 1px solid #cbd5e1;
border-radius: 50%;
@@ -147,80 +110,87 @@
}
&__title {
font-size: 18px;
font-size: 15px;
font-weight: 700;
color: #1e293b;
margin: 6px 0 0;
margin: 4px 0 0;
}
&__sub {
font-size: 13.5px;
font-size: 12.5px;
color: #64748b;
text-align: center;
margin: 0;
}
&__btn {
margin-top: 10px;
padding: 10px 24px;
background: #2563eb;
margin-top: 6px;
padding: 8px 20px;
background: #0f4f3c;
border: none;
border-radius: 10px;
border-radius: 8px;
color: #fff;
font-size: 13px;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #1d4ed8;
background: #0a3d2e;
}
}
}
// ── Body ────────────────────────────────────────────────────────────────────────
.af-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 20px 16px;
}
// ── Progress card ──────────────────────────────────────────────────────────────
.af-progress-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 18px 22px;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
border-radius: 10px;
padding: 12px 16px;
&__top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
margin-bottom: 8px;
}
&__label {
font-size: 11.5px;
font-weight: 600;
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.06em;
}
&__fraction {
font-size: 13px;
font-size: 12.5px;
font-weight: 700;
color: #16a34a;
color: #0f4f3c;
}
}
.af-progress-bar {
height: 10px;
height: 7px;
background: #e2e8f0;
border-radius: 999px;
overflow: hidden;
&__fill {
height: 100%;
background: linear-gradient(90deg, #16a34a, #22c55e);
background: #16a34a;
border-radius: 999px;
transition: width 0.5s ease;
box-shadow: 0 0 8px rgb(34 197 94 / 35%);
transition: width 0.4s ease;
}
}
@@ -229,65 +199,53 @@
.af-fac-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
&__header {
display: flex;
align-items: center;
gap: 9px;
padding: 12px 22px;
background: #1e293b;
color: #94a3b8;
font-size: 11.5px;
padding: 9px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
color: #64748b;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.06em;
}
}
.af-pills {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 20px 22px;
gap: 7px;
padding: 12px 16px;
}
.af-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 15px;
gap: 5px;
padding: 4px 11px;
border-radius: 999px;
font-size: 13px;
font-size: 12px;
font-weight: 600;
border: 1.5px solid;
transition:
transform 0.15s,
box-shadow 0.15s;
border: 1px solid;
cursor: default;
&__dot {
width: 7px;
height: 7px;
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
&--on {
background: #f0fdf4;
border-color: #22c55e;
color: #15803d;
border-color: #86efac;
color: #166534;
.af-pill__dot {
background: #22c55e;
box-shadow: 0 0 5px rgb(34 197 94 / 50%);
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgb(34 197 94 / 20%);
}
}
@@ -307,63 +265,46 @@
.af-stats-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
gap: 10px;
}
.af-stat-card {
border-radius: 14px;
padding: 20px 24px;
border-radius: 10px;
padding: 10px 14px;
display: flex;
align-items: center;
gap: 16px;
align-items: baseline;
gap: 7px;
border: 1px solid;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
font-size: 12px;
&__icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 20px;
font-size: 12px;
font-weight: 700;
}
&__num {
font-size: 30px;
font-size: 17px;
font-weight: 800;
line-height: 1;
display: block;
}
&__label {
font-size: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
display: block;
margin-top: 3px;
letter-spacing: 0.05em;
opacity: 0.75;
}
&--green {
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
background: #f0fdf4;
border-color: #86efac;
color: #15803d;
.af-stat-card__icon {
background: #bbf7d0;
}
color: #166534;
}
&--red {
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
border-color: #cbd5e1;
color: #475569;
.af-stat-card__icon {
background: #e2e8f0;
}
background: #f8fafc;
border-color: #e2e8f0;
color: #64748b;
}
}
@@ -18,34 +18,37 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
styleUrl: './account-facilities.component.scss',
template: `
<div class="af-page">
<!-- Hero -->
<div class="af-hero">
<div class="af-hero__icon-wrap">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<!-- Header -->
<div class="af-header">
<div class="af-header__left">
<svg
class="af-header__icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<div class="af-hero__text">
<h1 class="af-hero__title">Account Facilities</h1>
@if (accountLoaded) {
<div class="af-hero__stats">
<span class="af-hero__stat af-hero__stat--green">
<strong>{{ activeCount }}</strong> Active
</span>
<span class="af-hero__sep"></span>
<span class="af-hero__stat af-hero__stat--red">
<strong>{{ facilities.length - activeCount }}</strong> Inactive
</span>
<span class="af-hero__sep"></span>
<span class="af-hero__stat">
<strong>{{ facilities.length }}</strong> Total
</span>
</div>
}
<div>
<div class="af-header__title">Account Facilities</div>
@if (accountLoaded) {
<div class="af-header__sub">
<span class="af-header__stat af-header__stat--green">{{ activeCount }} active</span>
&middot;
<span class="af-header__stat af-header__stat--red">{{ facilities.length - activeCount }} inactive</span>
&middot; {{ facilities.length }} total
</div>
} @else {
<div class="af-header__sub">View all facilities enabled for the loaded account.</div>
}
</div>
</div>
@if (!embedded) {
<button class="af-back-btn" (click)="goBack()">
<button type="button" class="af-btn" (click)="goBack()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
@@ -58,7 +61,7 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
<!-- Empty state -->
<div class="af-empty">
<div class="af-empty__icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
@@ -70,48 +73,40 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
}
</div>
} @else {
<!-- Progress bar -->
<div class="af-progress-card">
<div class="af-progress-card__top">
<span class="af-progress-card__label">Facility Coverage</span>
<span class="af-progress-card__fraction">{{ activeCount }} / {{ facilities.length }}</span>
<div class="af-body">
<!-- Progress bar -->
<div class="af-progress-card">
<div class="af-progress-card__top">
<span class="af-progress-card__label">Facility Coverage</span>
<span class="af-progress-card__fraction">{{ activeCount }} / {{ facilities.length }}</span>
</div>
<div class="af-progress-bar">
<div class="af-progress-bar__fill" [style.width.%]="(activeCount / facilities.length) * 100"></div>
</div>
</div>
<div class="af-progress-bar">
<div class="af-progress-bar__fill" [style.width.%]="(activeCount / facilities.length) * 100"></div>
</div>
</div>
<!-- Facilities pills -->
<div class="af-fac-card">
<div class="af-fac-card__header">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
All Facilities
<!-- Facilities pills -->
<div class="af-fac-card">
<div class="af-fac-card__header">All Facilities</div>
<div class="af-pills">
@for (f of facilities; track f.label) {
<span class="af-pill" [class.af-pill--on]="f.active" [class.af-pill--off]="!f.active">
<span class="af-pill__dot"></span>
{{ f.label }}
</span>
}
</div>
</div>
<div class="af-pills">
@for (f of facilities; track f.label) {
<span class="af-pill" [class.af-pill--on]="f.active" [class.af-pill--off]="!f.active">
<span class="af-pill__dot"></span>
{{ f.label }}
</span>
}
</div>
</div>
<!-- Stats row -->
<div class="af-stats-row">
<div class="af-stat-card af-stat-card--green">
<div class="af-stat-card__icon">✓</div>
<div>
<!-- Stats row -->
<div class="af-stats-row">
<div class="af-stat-card af-stat-card--green">
<span class="af-stat-card__icon">&check;</span>
<span class="af-stat-card__num">{{ activeCount }}</span>
<span class="af-stat-card__label">Active</span>
</div>
</div>
<div class="af-stat-card af-stat-card--red">
<div class="af-stat-card__icon">✗</div>
<div>
<div class="af-stat-card af-stat-card--red">
<span class="af-stat-card__icon">&#10005;</span>
<span class="af-stat-card__num">{{ facilities.length - activeCount }}</span>
<span class="af-stat-card__label">Inactive</span>
</div>
@@ -0,0 +1,281 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
}
.cb-page {
background: #f8fafc;
min-height: calc(100vh - 68px);
display: flex;
flex-direction: column;
gap: 16px;
}
// ── Header ──────────────────────────────────────────────────────────────────────
.cb-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
color: #0f4f3c;
flex-shrink: 0;
}
&__title {
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
}
.cb-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
border: 1px solid #e2e8f0;
transition: background 0.15s;
height: 32px;
padding: 0 12px;
font-size: 13px;
background: #fff;
color: #0d1b2a;
&:hover {
background: #f0fdf4;
border-color: #0f4f3c;
color: #0f4f3c;
}
&--primary {
background: #0f4f3c;
border-color: #0f4f3c;
color: #fff;
&:hover {
background: #0a3d2e;
color: #fff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.cb-status-msg {
padding: 40px 20px;
text-align: center;
color: #64748b;
font-size: 13.5px;
}
// ── Body ────────────────────────────────────────────────────────────────────────
.cb-body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0 20px 20px;
}
.cb-info-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 18px 22px;
}
.cb-info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px 24px;
&__k {
display: block;
font-size: 11px;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 3px;
}
&__v {
display: block;
font-size: 13.5px;
font-weight: 600;
color: #0d1b2a;
}
}
.cb-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
&--ok {
background: #dcfce7;
color: #166534;
}
&--bad {
background: #fee2e2;
color: #991b1b;
}
&--neutral {
background: #fef3c7;
color: #92400e;
}
}
.cb-no-books {
padding: 30px 10px;
text-align: center;
color: #94a3b8;
font-size: 13px;
}
// ── Action toolbar ─────────────────────────────────────────────────────────────
.cb-action-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 20px;
}
.cb-action-btn {
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
border: 1px solid #e2e8f0;
transition: background 0.15s;
height: 32px;
padding: 0 14px;
font-size: 12.5px;
background: #fff;
color: #0d1b2a;
&:hover {
background: #f0fdf4;
border-color: #0f4f3c;
}
&--warn {
border-color: #fca5a5;
color: #dc2626;
&:hover {
background: #fef2f2;
border-color: #dc2626;
}
}
&--primary {
background: #0f4f3c;
border-color: #0f4f3c;
color: #fff;
&:hover {
background: #0a3d2e;
}
}
}
// ── Leaf grid ──────────────────────────────────────────────────────────────────
.cb-leaf-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.cb-leaf-circle {
width: 46px;
height: 46px;
border-radius: 50%;
border: 1.5px solid #cbd5e1;
background: #fff;
color: #64748b;
font-size: 12px;
font-weight: 700;
font-family: 'Courier New', monospace;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
transform 0.1s,
box-shadow 0.1s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgb(0 0 0 / 12%);
}
&--ok {
border-color: #22c55e;
color: #166534;
background: #f0fdf4;
}
&--used {
border-color: #cbd5e1;
color: #64748b;
background: #f8fafc;
}
&--warn {
border-color: #f59e0b;
color: #92400e;
background: #fffbeb;
}
&--bad {
border-color: #ef4444;
color: #991b1b;
background: #fef2f2;
}
&--returned {
border-color: #a855f7;
color: #6b21a8;
background: #faf5ff;
}
}
@@ -0,0 +1,207 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { ChequeBookService } from '../../chequebook/chequebook.service';
import { LeafDetailDialogComponent } from '../../chequebook/cheque-book-detail/leaf-detail-dialog/leaf-detail-dialog.component';
import { ClerkLeafActionDialogComponent, LeafAction } from './leaf-action-dialog/leaf-action-dialog.component';
/** Cheque-book detail for the Clerk Dashboard — a fullscreen clerk-styled view of one book, its leaf
* register, and the same maker-side leaf actions (stop-payment, revoke, report-lost, cancel-leaf,
* mark-used) as the back-office ChequeBookDetailComponent, so a clerk can act on a leaf without leaving
* the clerk shell/nav. Every action still goes through the existing maker-checker approval flow server-side.
* Each action opens as a popup (ClerkLeafActionDialogComponent) rather than an inline form so it never
* pushes the leaf grid around and gets the full dialog width to lay out its fields. */
@Component({
selector: 'mifosx-clerk-cheque-book-detail',
standalone: true,
imports: [CommonModule],
styleUrl: './cheque-book-detail.component.scss',
template: `
<div class="cb-page">
<!-- Header -->
<div class="cb-header">
<div class="cb-header__left">
<svg
class="cb-header__icon"
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
<div>
<div class="cb-header__title">Cheque Book #{{ book.id }}</div>
<div class="cb-header__sub">
{{ book.accountNo || book.accountId }}
@if (book.clientName) {
&nbsp;&middot;&nbsp;{{ book.clientName }}
}
</div>
</div>
</div>
<button type="button" class="cb-btn" (click)="goBack()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
Back
</button>
</div>
@if (loading) {
<div class="cb-status-msg">Loading cheque book&hellip;</div>
} @else {
<div class="cb-body">
<!-- Info card -->
<div class="cb-info-card">
<div class="cb-info-grid">
<div>
<span class="cb-info-grid__k">Account Type</span>
<span class="cb-info-grid__v">{{ book.accountType }}</span>
</div>
<div>
<span class="cb-info-grid__k">Office</span>
<span class="cb-info-grid__v">{{ book.officeName }}</span>
</div>
<div>
<span class="cb-info-grid__k">Series</span>
<span class="cb-info-grid__v">{{ book.seriesCode ?? '&#8212;' }}</span>
</div>
<div>
<span class="cb-info-grid__k">Leaf Range</span>
<span class="cb-info-grid__v">
{{ book.rangeStart ? book.rangeStart + ' – ' + book.rangeEnd : '&#8212;' }}
</span>
</div>
<div>
<span class="cb-info-grid__k">Quantity</span>
<span class="cb-info-grid__v">{{ book.noOfBooks }} book{{ book.noOfBooks === 1 ? '' : 's' }}</span>
</div>
<div>
<span class="cb-info-grid__k">Status</span>
<span
class="cb-pill"
[class.cb-pill--ok]="book.status === 'ISSUED'"
[class.cb-pill--bad]="book.status === 'REJECTED' || book.status === 'WITHDRAWN'"
[class.cb-pill--neutral]="
book.status !== 'ISSUED' && book.status !== 'REJECTED' && book.status !== 'WITHDRAWN'
"
>{{ book.status }}</span
>
</div>
<div>
<span class="cb-info-grid__k">Issue Date</span>
<span class="cb-info-grid__v">{{ book.issueDate ?? '&#8212;' }}</span>
</div>
</div>
</div>
<!-- Action toolbar — only for an issued book; each button pops up its own action dialog -->
@if (book.status === 'ISSUED') {
<div class="cb-action-toolbar">
<button type="button" class="cb-action-btn cb-action-btn--warn" (click)="openAction('stoppayment')">
Stop Payment
</button>
<button type="button" class="cb-action-btn" (click)="openAction('revokestoppayment')">
Revoke Stop Payment
</button>
<button type="button" class="cb-action-btn cb-action-btn--warn" (click)="openAction('reportlost')">
Report Lost
</button>
<button type="button" class="cb-action-btn" (click)="openAction('cancelleaf')">Cancel Leaf</button>
<button type="button" class="cb-action-btn cb-action-btn--primary" (click)="openAction('markused')">
Mark as Used
</button>
<button type="button" class="cb-action-btn cb-action-btn--warn" (click)="openAction('return')">
Flag Returned
</button>
</div>
}
<!-- Leaf grid -->
<div class="cb-info-card">
@if (leaves.length === 0) {
<div class="cb-no-books">No leaves to display</div>
} @else {
<div class="cb-leaf-grid">
@for (l of leaves; track l.leafSerialNo) {
<button
type="button"
class="cb-leaf-circle"
[class.cb-leaf-circle--ok]="l.status === 'ACTIVE'"
[class.cb-leaf-circle--used]="l.status === 'USED'"
[class.cb-leaf-circle--warn]="l.status === 'STOP_PAYMENT'"
[class.cb-leaf-circle--bad]="l.status === 'LOST'"
[class.cb-leaf-circle--returned]="l.status === 'RETURNED'"
(click)="selectLeaf(l)"
>
{{ l.leafSerialNo }}
</button>
}
</div>
}
</div>
</div>
}
</div>
`
})
export class ClerkChequeBookDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private chequeBookService = inject(ChequeBookService);
private dialog = inject(MatDialog);
bookId!: string;
book: any = {};
leaves: any[] = [];
loading = false;
ngOnInit(): void {
this.bookId = this.route.snapshot.paramMap.get('id') as string;
this.loadAll();
}
loadAll(): void {
this.loading = true;
this.chequeBookService.getBook(this.bookId).subscribe((data: any) => {
this.book = data || {};
this.loading = false;
});
this.chequeBookService.getLeaves(this.bookId).subscribe((data: any[]) => {
this.leaves = data || [];
});
}
selectLeaf(leaf: any): void {
this.dialog.open(LeafDetailDialogComponent, { data: leaf, width: '420px' });
}
openAction(action: LeafAction): void {
const ref = this.dialog.open(ClerkLeafActionDialogComponent, {
width: '480px',
data: { bookId: this.bookId, action }
});
ref.afterClosed().subscribe((result) => {
if (result?.success) {
this.loadAll();
}
});
}
goBack(): void {
this.router.navigate(['/clerk/account-facilities/cheque-book']);
}
}
@@ -0,0 +1,257 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
}
.cb-page {
background: #f8fafc;
min-height: calc(100vh - 68px);
display: flex;
flex-direction: column;
gap: 16px;
}
// ── Header ──────────────────────────────────────────────────────────────────────
.cb-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
color: #0f4f3c;
flex-shrink: 0;
}
&__title {
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
}
.cb-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
border: 1px solid #e2e8f0;
transition: background 0.15s;
height: 32px;
padding: 0 12px;
font-size: 13px;
background: #fff;
color: #0d1b2a;
&:hover {
background: #f0fdf4;
border-color: #0f4f3c;
color: #0f4f3c;
}
}
// ── Empty state ────────────────────────────────────────────────────────────────
.cb-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 60px 20px;
&__icon {
width: 88px;
height: 88px;
background: #e2e8f0;
border: 1px solid #cbd5e1;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
&__title {
font-size: 18px;
font-weight: 700;
color: #1e293b;
margin: 6px 0 0;
}
&__sub {
font-size: 13.5px;
color: #64748b;
text-align: center;
margin: 0;
}
&__btn {
margin-top: 10px;
padding: 10px 24px;
background: #0f4f3c;
border: none;
border-radius: 10px;
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #0a3d2e;
}
}
}
// ── Warning banner ─────────────────────────────────────────────────────────────
.cb-warn-banner {
margin: 0 20px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
font-size: 13px;
font-weight: 600;
padding: 12px 18px;
border-radius: 10px;
}
.cb-status-msg {
padding: 40px 20px;
text-align: center;
color: #64748b;
font-size: 13.5px;
&--error {
color: #dc2626;
}
}
.cb-no-books {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 40px 20px;
color: #94a3b8;
font-size: 13px;
}
// ── Table card ─────────────────────────────────────────────────────────────────
.cb-table-card {
margin: 0 20px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
}
.cb-table-wrap {
overflow-x: auto;
}
.cb-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid #f1f5f9;
}
th {
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.06em;
background: #f8fafc;
white-space: nowrap;
}
tbody tr:last-child td {
border-bottom: none;
}
}
.cb-row {
cursor: pointer;
transition: background 0.1s;
&:hover td {
background: #f0fdf4;
}
}
.cb-cell--num {
color: #64748b;
font-family: 'Courier New', monospace;
}
.cb-cell--date {
color: #64748b;
font-size: 12px;
}
.cb-cell--action {
color: #0f4f3c;
font-weight: 700;
font-size: 12px;
white-space: nowrap;
}
.cb-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
&--ok {
background: #dcfce7;
color: #166534;
}
&--bad {
background: #fee2e2;
color: #991b1b;
}
&--neutral {
background: #fef3c7;
color: #92400e;
}
}
@@ -0,0 +1,228 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, Input, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
import { ChequeBookService } from '../../chequebook/chequebook.service';
/** Read-only cheque-book view for the account currently loaded on the Clerk Dashboard —
* reached via the "Cheque Book" entry in the Account Facilities dropdown. */
@Component({
selector: 'mifosx-clerk-cheque-book',
standalone: true,
imports: [CommonModule],
styleUrl: './cheque-book.component.scss',
template: `
<div class="cb-page">
<!-- Header -->
<div class="cb-header">
<div class="cb-header__left">
<svg
class="cb-header__icon"
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
<div>
<div class="cb-header__title">Cheque Book</div>
@if (accountLoaded) {
<div class="cb-header__sub">
{{ accountNo }}
@if (clientName) {
&nbsp;&middot;&nbsp;{{ clientName }}
}
@if (books.length > 0) {
&nbsp;&middot;&nbsp;{{ books.length }} book{{ books.length === 1 ? '' : 's' }}
}
</div>
} @else {
<div class="cb-header__sub">View cheque books issued for the loaded account.</div>
}
</div>
</div>
@if (!embedded) {
<button type="button" class="cb-btn" (click)="goBack()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to Dashboard
</button>
}
</div>
@if (!accountLoaded) {
<div class="cb-empty">
<div class="cb-empty__icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<p class="cb-empty__title">No Account Loaded</p>
<p class="cb-empty__sub">Search an account on the Clerk Dashboard first.</p>
@if (!embedded) {
<button class="cb-empty__btn" (click)="goBack()">Go to Dashboard</button>
}
</div>
} @else {
@if (!facilityActive) {
<div class="cb-warn-banner">Cheque Book facility is not active for this account.</div>
}
@if (loading) {
<div class="cb-status-msg">Loading cheque books&hellip;</div>
} @else if (error) {
<div class="cb-status-msg cb-status-msg--error">{{ error }}</div>
} @else if (books.length === 0) {
<div class="cb-no-books">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.3">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
<span>No cheque book issued for this account</span>
</div>
} @else {
<div class="cb-table-card">
<div class="cb-table-wrap">
<table class="cb-table">
<thead>
<tr>
<th>Series</th>
<th>Books</th>
<th>Leaves</th>
<th>Status</th>
<th>Requested</th>
<th></th>
</tr>
</thead>
<tbody>
@for (b of books; track b.id) {
<tr class="cb-row" (click)="viewBook(b)">
<td>{{ b.seriesCode }}</td>
<td class="cb-cell--num">{{ b.noOfBooks }}</td>
<td class="cb-cell--num">{{ b.totalLeaves ?? '&#8212;' }}</td>
<td>
<span
class="cb-pill"
[class.cb-pill--ok]="b.status === 'ISSUED'"
[class.cb-pill--bad]="b.status === 'REJECTED' || b.status === 'WITHDRAWN'"
[class.cb-pill--neutral]="
b.status !== 'ISSUED' && b.status !== 'REJECTED' && b.status !== 'WITHDRAWN'
"
>{{ b.status }}</span
>
</td>
<td class="cb-cell--date">{{ b.requestedDate ?? '&#8212;' }}</td>
<td class="cb-cell--action">View &rsaquo;</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
</div>
`
})
export class ClerkChequeBookComponent implements OnInit {
private clerkUiState = inject(ClerkUiStateService);
private chequeBookService = inject(ChequeBookService);
private router = inject(Router);
@Input() embedded = false;
accountLoaded = false;
accountNo = '';
clientName = '';
facilityActive = true;
books: any[] = [];
loading = false;
error = '';
ngOnInit(): void {
const s = this.clerkUiState.snapshot;
this.accountLoaded = s.accountLoaded;
if (!this.accountLoaded) {
return;
}
this.accountNo = s.savedAccount?.accountDetails?.accountNo ?? s.savedRow?.acNo ?? '';
const clientDetails = s.savedAccount?.clientDetails;
this.clientName = clientDetails ? `${clientDetails.firstname ?? ''} ${clientDetails.lastname ?? ''}`.trim() : '';
const facility = s.facilities.find((f) => f.label === 'Cheque Book');
this.facilityActive = facility ? facility.active : true;
const accountType = this.toChequeAccountType(s.savedRow?.acTyp);
const accountId = s.savedAccount?.accountDetails?.id;
if (accountId) {
this.loadBooks(accountType, accountId);
} else if (this.accountNo) {
this.resolveAccountId(accountType, this.accountNo);
}
}
/** Cheque-book series/books are only ever SAVINGS or CURRENT — CA/CB is the clerk's UI code for current accounts. */
private toChequeAccountType(acTyp?: string): string {
const t = (acTyp || '').toUpperCase();
return t === 'CA' || t === 'CB' ? 'CURRENT' : 'SAVINGS';
}
private resolveAccountId(accountType: string, accountNo: string): void {
this.loading = true;
this.chequeBookService.searchAccountByNo(accountType, accountNo).subscribe({
next: (res: any) => {
const items = res?.pageItems ?? (Array.isArray(res) ? res : []);
const match = items.find((i: any) => i.accountNo === accountNo) ?? (items.length === 1 ? items[0] : null);
if (match) {
this.loadBooks(accountType, match.id);
} else {
this.loading = false;
this.error = 'Could not resolve this account for cheque book lookup';
}
},
error: () => {
this.loading = false;
this.error = 'Could not resolve this account for cheque book lookup';
}
});
}
private loadBooks(accountType: string, accountId: string | number): void {
this.loading = true;
this.chequeBookService.getBooks(accountType, accountId, undefined, undefined, 0, 50).subscribe({
next: (res: any) => {
this.books = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.loading = false;
},
error: () => {
this.loading = false;
this.error = 'Failed to load cheque book details';
}
});
}
viewBook(book: any): void {
this.router.navigate([
'/clerk/account-facilities/cheque-book',
book.id
]);
}
goBack(): void {
this.router.navigate(['/clerk/dashboard']);
}
}
@@ -0,0 +1,87 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.la-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
padding-top: 4px;
}
.la-field {
display: flex;
flex-direction: column;
gap: 4px;
&__lbl {
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
&__input {
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 7px;
font-size: 13px;
font-family: inherit;
color: #0d1b2a;
&:focus {
outline: none;
border-color: #0f4f3c;
}
}
&__hint {
font-size: 11px;
color: #94a3b8;
}
}
.la-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
border: 1px solid #e2e8f0;
transition: background 0.15s;
height: 34px;
padding: 0 16px;
font-size: 13px;
background: #fff;
color: #0d1b2a;
&:hover {
background: #f0fdf4;
border-color: #0f4f3c;
color: #0f4f3c;
}
&--primary {
background: #0f4f3c;
border-color: #0f4f3c;
color: #fff;
&:hover {
background: #0a3d2e;
color: #fff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
@@ -0,0 +1,145 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import {
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogContent,
MatDialogActions
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Observable } from 'rxjs';
import { ChequeBookService } from '../../../chequebook/chequebook.service';
export type LeafAction = 'stoppayment' | 'revokestoppayment' | 'reportlost' | 'cancelleaf' | 'markused' | 'return';
const ACTION_TITLES: Record<LeafAction, string> = {
stoppayment: 'Stop Payment',
revokestoppayment: 'Revoke Stop Payment',
reportlost: 'Report Lost',
cancelleaf: 'Cancel Leaf',
markused: 'Mark as Used',
return: 'Flag Returned'
};
/** Popup for the five leaf-range actions on a clerk-viewed cheque book — kept as its own dialog so the
* action form doesn't push the leaf grid around and always has full width to lay out its fields. */
@Component({
selector: 'mifosx-clerk-leaf-action-dialog',
standalone: true,
imports: [
ReactiveFormsModule,
MatDialogTitle,
CdkScrollable,
MatDialogContent,
MatDialogActions
],
styleUrl: './leaf-action-dialog.component.scss',
template: `
<h1 mat-dialog-title>{{ title }}</h1>
<div mat-dialog-content>
<form [formGroup]="actionForm" class="la-form">
<div class="la-form-grid">
<label class="la-field">
<span class="la-field__lbl">From Leaf Seq</span>
<input class="la-field__input" type="number" formControlName="fromLeafSeq" />
<span class="la-field__hint">Leave blank for the whole book</span>
</label>
<label class="la-field">
<span class="la-field__lbl">To Leaf Seq</span>
<input class="la-field__input" type="number" formControlName="toLeafSeq" />
</label>
@if (data.action !== 'revokestoppayment') {
<label class="la-field">
<span class="la-field__lbl">Reason</span>
<input class="la-field__input" formControlName="reason" />
@if (data.action === 'markused') {
<span class="la-field__hint">Optional — e.g. cleared per bank statement dated ...</span>
}
</label>
}
</div>
</form>
</div>
<mat-dialog-actions align="end">
<button type="button" class="la-btn" (click)="dialogRef.close()">Cancel</button>
<button type="button" class="la-btn la-btn--primary" [disabled]="actionForm.invalid || busy" (click)="submit()">
Submit
</button>
</mat-dialog-actions>
`
})
export class ClerkLeafActionDialogComponent {
dialogRef = inject<MatDialogRef<ClerkLeafActionDialogComponent>>(MatDialogRef);
data: { bookId: string; action: LeafAction } = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private chequeBookService = inject(ChequeBookService);
private snackBar = inject(MatSnackBar);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
busy = false;
actionForm: UntypedFormGroup;
get title(): string {
return ACTION_TITLES[this.data.action];
}
constructor() {
this.actionForm = this.formBuilder.group({
fromLeafSeq: [''],
toLeafSeq: [''],
reason: [
'',
this.data.action === 'revokestoppayment' || this.data.action === 'markused' ? [] : Validators.required
]
});
}
submit(): void {
if (this.actionForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.actionForm.value;
const payload: any = { locale: 'en' };
if (raw.fromLeafSeq !== '' && raw.fromLeafSeq != null) payload.fromLeafSeq = raw.fromLeafSeq;
if (raw.toLeafSeq !== '' && raw.toLeafSeq != null) payload.toLeafSeq = raw.toLeafSeq;
if (raw.reason) payload.reason = raw.reason;
const actionCalls: Record<LeafAction, (bookId: string, payload: any) => Observable<any>> = {
stoppayment: (id, p) => this.chequeBookService.stopPayment(id, p),
revokestoppayment: (id, p) => this.chequeBookService.revokeStopPayment(id, p),
reportlost: (id, p) => this.chequeBookService.reportBookLost(id, p),
cancelleaf: (id, p) => this.chequeBookService.cancelLeaf(id, p),
markused: (id, p) => this.chequeBookService.markUsed(id, p),
return: (id, p) => this.chequeBookService.returnBook(id, p)
};
actionCalls[this.data.action](this.data.bookId, payload).subscribe({
next: () => {
this.busy = false;
this.notify('Action applied');
this.dialogRef.close({ success: true });
},
error: () => {
this.busy = false;
this.notify(
'Action failed — check the leaf range and that the leaves are in a state that allows this action',
true
);
}
});
}
}
@@ -53,7 +53,8 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
const isClientImageRequest = request.url.includes('/clients/') && request.url.includes('/images');
const isClientKycRequest = request.url.match(/\/clients\/\d+\/kyc$/);
const isSilentRequest = isClientImageRequest || isClientKycRequest;
const isDirectorRequest = request.url.includes('/election/directors');
const isSilentRequest = isClientImageRequest || isClientKycRequest || isDirectorRequest;
if (!environment.production && !isSilentRequest) {
log.error(`Request Error: ${errorMessage}`);
+52 -17
View File
@@ -57,10 +57,16 @@
<!-- Toolbar always visible (shows CBSNexora, Branch, Session info) -->
<mifosx-toolbar [sidenav]="sidenav" (collapse)="toggleCollapse($event)"></mifosx-toolbar>
<!-- Simplified menu bar — cashier OR clerk fullscreen mode. The review screen renders its own nav. -->
<!-- Simplified menu bar — cashier OR clerk OR election fullscreen mode. The review screen renders its own nav. -->
@if (hideShellLayout && !isWithdrawalReviewUrl) {
<!-- Election fullscreen — just an ESC hint bar -->
@if (currentUrl.includes('/election')) {
<nav class="cashier-nav cashier-nav--election">
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
</nav>
}
<!-- Clerk Dashboard fullscreen nav — only visible at /clerk/* -->
@if (currentUrl.includes('/clerk/')) {
@else if (currentUrl.includes('/clerk/')) {
<nav class="cashier-nav cashier-nav--clerk">
<a
class="cashier-nav__item cashier-nav__item--primary"
@@ -94,17 +100,28 @@
My Disbursements
</a>
@if (clerkAccountLoaded) {
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/account-facilities')"
[routerLink]="['/clerk/account-facilities']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
Account Facilities
</a>
<div class="cashier-nav__item-wrap">
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/account-facilities')"
[routerLink]="['/clerk/account-facilities']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
Account Facilities
<fa-icon icon="caret-down" class="cashier-nav__caret"></fa-icon>
</a>
<div class="cashier-nav__dropdown">
@for (f of clerkFacilities; track f.label) {
<a class="cashier-nav__dd-item" [routerLink]="facilityRoute(f.label)">
<span class="cashier-nav__dd-dot" [class.cashier-nav__dd-dot--off]="!f.active"></span>
{{ f.label }}
</a>
}
</div>
</div>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/photo-signature')"
@@ -353,6 +370,9 @@
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon>Reconciliation
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" routerLink="/chequebook" *mifosxHasPermission="'MENU_CHEQUEBOOK'">
<fa-icon icon="book" class="ni-ico"></fa-icon>Cheque Book
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="ckycMenu" *mifosxHasPermission="'MENU_CKYC_MGMT'">
<fa-icon icon="fingerprint" class="ni-ico"></fa-icon>CKYC Mgmt
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
@@ -489,6 +509,9 @@
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon>Reconciliation
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" routerLink="/chequebook" *mifosxHasPermission="'MENU_CHEQUEBOOK'">
<fa-icon icon="book" class="ni-ico"></fa-icon>Cheque Book
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="letterMgmtMenu" *mifosxHasPermission="'MENU_LETTER_MGMT'">
<fa-icon icon="envelope" class="ni-ico"></fa-icon>Documents Hub
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
@@ -968,11 +991,23 @@
</mat-menu>
<mat-menu #clearingMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cheque Honoured
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/inward']">
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="ban" class="menu-item-icon"></fa-icon> Cheque Book Stopped
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/outward']">
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/positive-pay']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Positive Pay (PPS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/reconciliation']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/nachclearing']">
<fa-icon icon="money-check" class="menu-item-icon"></fa-icon> NACH Clearing
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward</button>
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward</button>
+63
View File
@@ -179,6 +179,69 @@ $color: #e2e4ec;
&--clerk {
border-bottom-color: #e87722;
}
&__item-wrap {
position: relative;
display: inline-flex;
&:hover .cashier-nav__dropdown {
display: grid;
}
}
&__caret {
font-size: 9px !important;
margin-left: 2px;
opacity: 0.75;
}
&__dropdown {
display: none;
grid-template-columns: repeat(2, minmax(150px, 1fr));
position: absolute;
top: 100%;
left: 0;
margin-top: 2px;
min-width: 320px;
background: #1a2744;
border: 1px solid #334155;
border-radius: 6px;
box-shadow: 0 8px 24px rgb(0 0 0 / 35%);
padding: 4px;
gap: 2px;
z-index: 50;
}
&__dd-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
color: #cbd5e1;
text-decoration: none;
white-space: nowrap;
transition: background 0.15s;
&:hover {
background: #223060;
color: #fff;
}
}
&__dd-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #22c55e;
flex-shrink: 0;
&--off {
background: #64748b;
}
}
}
.sidenav {
+16 -3
View File
@@ -116,6 +116,15 @@ export class ShellComponent implements OnInit, OnDestroy {
return label.toLowerCase().includes(this.rbiSearch.toLowerCase());
}
/** Route for a facility entry in the Account Facilities hover dropdown — only "Cheque Book" has a
* dedicated action page today; every other facility falls back to the existing overview page. */
facilityRoute(label: string): string[] {
if (label === 'Cheque Book') {
return ['/clerk/account-facilities/cheque-book'];
}
return ['/clerk/account-facilities'];
}
/**
* Subscribes to progress bar to update its mode.
*/
@@ -137,7 +146,8 @@ export class ShellComponent implements OnInit, OnDestroy {
url.includes('/clerk/today-activity') ||
url.includes('/clerk/withdrawal-request') ||
url.includes('/clerk/my-disbursement-requests');
this.hideShellLayout = isCashierOnly || isClerkRoute || this.isWithdrawalReviewUrl;
const isElectionRoute = url.includes('/election');
this.hideShellLayout = isCashierOnly || isClerkRoute || isElectionRoute || this.isWithdrawalReviewUrl;
this.cdr.detectChanges();
};
this.routeSub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => checkLayout());
@@ -174,10 +184,13 @@ export class ShellComponent implements OnInit, OnDestroy {
return /\/checker-inbox-and-tasks\/withdrawal-requests\/\d+\/review/.test(this.router.url);
}
/** ESC key — exit fullscreen at Clerk Dashboard / review screen, restoring full shell header. */
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.hideShellLayout && (this.router.url.includes('/clerk/') || this.isWithdrawalReviewUrl)) {
if (
this.hideShellLayout &&
(this.router.url.includes('/clerk/') || this.router.url.includes('/election') || this.isWithdrawalReviewUrl)
) {
this.hideShellLayout = false;
this.cdr.detectChanges();
}
@@ -182,22 +182,20 @@
</ng-container>
<!-- Election Management Section -->
<ng-container *mifosxHasPermission="'SIDEBAR_ELECTION'">
<div class="nav-item-sep mt-1">
<mat-divider></mat-divider>
<span class="text-muted">Election</span>
</div>
<mat-list-item
[routerLink]="['/election']"
[matTooltipPosition]="tooltipPosition"
matTooltip="Election Management"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
>
<mat-icon matListIcon>how_to_vote</mat-icon>
<a matLine>Election Management</a>
</mat-list-item>
</ng-container>
<div class="nav-item-sep mt-1">
<mat-divider></mat-divider>
<span class="text-muted">Election</span>
</div>
<mat-list-item
[routerLink]="['/election']"
[matTooltipPosition]="tooltipPosition"
matTooltip="Election Management"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
>
<mat-icon matListIcon>how_to_vote</mat-icon>
<a matLine>Election Management</a>
</mat-list-item>
<!-- RBI Integration Section -->
<ng-container *mifosxHasPermission="'SIDEBAR_RBI_INTEGRATION'">
@@ -236,6 +234,45 @@
<span class="text-muted">{{ 'labels.menus.Main Items' | translate }}</span>
</div>
<mat-list-item
[routerLink]="['/chequebook']"
[matTooltipPosition]="tooltipPosition"
matTooltip="{{ 'tooltips.Cheque Book Inventory' | translate }}"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
*mifosxHasPermission="'MENU_CHEQUEBOOK'"
>
<mat-icon matListIcon>
<fa-icon icon="book" size="sm"></fa-icon>
</mat-icon>
<a matLine>{{ 'labels.menus.Cheque Book Inventory' | translate }}</a>
</mat-list-item>
<mat-list-item
[routerLink]="['/ctsclearing']"
[matTooltipPosition]="tooltipPosition"
matTooltip="{{ 'tooltips.CTS Clearing' | translate }}"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
*mifosxHasPermission="'MENU_CLEARING'"
>
<mat-icon matListIcon>
<fa-icon icon="exchange" size="sm"></fa-icon>
</mat-icon>
<a matLine>{{ 'labels.menus.CTS Clearing' | translate }}</a>
</mat-list-item>
<mat-list-item
[routerLink]="['/nachclearing']"
[matTooltipPosition]="tooltipPosition"
matTooltip="{{ 'tooltips.NACH Clearing' | translate }}"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
*mifosxHasPermission="'MENU_CLEARING'"
>
<mat-icon matListIcon>
<fa-icon icon="money-check" size="sm"></fa-icon>
</mat-icon>
<a matLine>{{ 'labels.menus.NACH Clearing' | translate }}</a>
</mat-list-item>
<mat-list-item
[routerLink]="['/rbi-circulars']"
[matTooltipPosition]="tooltipPosition"
@@ -229,7 +229,7 @@
<fa-icon icon="users" class="ai-col-h-ic"></fa-icon>
<span>Governance</span>
</div>
<button mat-menu-item class="ai-card" tabindex="0">
<button mat-menu-item class="ai-card" routerLink="/election" tabindex="0">
<span class="ai-card-ic"><fa-icon icon="users"></fa-icon></span>
<span class="ai-card-body">
<span class="ai-card-t">Election Management</span>
@@ -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.CTS Clearing' | 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,73 @@
/**
* 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';
/**
* CTS Clearing landing page — one card per screen (inward presentment, outward presentment, settlement,
* reconciliation) plus a shortcut to the shared clearing network directory.
*/
@Component({
selector: 'mifosx-ctsclearing-dashboard',
templateUrl: './ctsclearing-dashboard.component.html',
styleUrls: ['./ctsclearing-dashboard.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatIconModule,
MatCardModule,
FaIconComponent
]
})
export class CtsClearingDashboardComponent {
screens = [
{
route: [
'/ctsclearing',
'inward'
],
label: 'Inward Clearing',
icon: 'arrow-left',
description: 'Presentment batches drawn on us — per-cheque validation, pass/return decisioning'
},
{
route: [
'/ctsclearing',
'outward'
],
label: 'Outward Clearing',
icon: 'arrow-right',
description:
'Cheques deposited by our customers — capture, then browse the 2-hourly clearing-session batches to settle/dishonour'
},
{
route: [
'/ctsclearing',
'settlement'
],
label: 'Settlement',
icon: 'money-check',
description: 'Net settlement batches — prepare, post to the GL, reject'
},
{
route: [
'/ctsclearing',
'reconciliation'
],
label: 'Reconciliation',
icon: 'check-circle',
description: 'Reconcile CBS totals against the clearing-house figures'
}
];
}
@@ -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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
/** Routing Imports */
import { Route } from '../core/route/route.service';
/** Custom Components */
import { CtsClearingDashboardComponent } from './ctsclearing-dashboard/ctsclearing-dashboard.component';
import { InwardBatchesComponent } from './inward-batches/inward-batches.component';
import { InwardBatchDetailComponent } from './inward-batch-detail/inward-batch-detail.component';
import { OutwardPresentmentComponent } from './outward-presentment/outward-presentment.component';
import { OutwardBatchDetailComponent } from './outward-batch-detail/outward-batch-detail.component';
import { SettlementComponent } from './settlement/settlement.component';
import { ReconciliationComponent } from './reconciliation/reconciliation.component';
import { PositivePayComponent } from './positive-pay/positive-pay.component';
/** CTS Clearing Routes */
const routes: Routes = [
Route.withShell([
{
path: 'ctsclearing',
data: { title: 'CTS Clearing', breadcrumb: 'CTS Clearing' },
children: [
{
path: '',
component: CtsClearingDashboardComponent
},
{
path: 'inward',
component: InwardBatchesComponent,
data: { title: 'Inward Clearing', breadcrumb: 'Inward Clearing' }
},
{
path: 'inward/:id',
component: InwardBatchDetailComponent,
data: { title: 'Inward Batch', routeParamBreadcrumb: 'id' }
},
{
path: 'outward',
component: OutwardPresentmentComponent,
data: { title: 'Outward Clearing', breadcrumb: 'Outward Clearing' }
},
{
path: 'outward-batches/:id',
component: OutwardBatchDetailComponent,
data: { title: 'Outward Batch', routeParamBreadcrumb: 'id' }
},
{
path: 'settlement',
component: SettlementComponent,
data: { title: 'Settlement', breadcrumb: 'Settlement' }
},
{
path: 'reconciliation',
component: ReconciliationComponent,
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
},
{
path: 'positive-pay',
component: PositivePayComponent,
data: { title: 'Positive Pay', breadcrumb: 'Positive Pay' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class CtsClearingRoutingModule {}
+53
View File
@@ -0,0 +1,53 @@
/**
* 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 { CtsClearingRoutingModule } from './ctsclearing-routing.module';
/** Custom Components (standalone) */
import { CtsClearingDashboardComponent } from './ctsclearing-dashboard/ctsclearing-dashboard.component';
import { InwardBatchesComponent } from './inward-batches/inward-batches.component';
import { InwardBatchDetailComponent } from './inward-batch-detail/inward-batch-detail.component';
import { ItemDecisionDialogComponent } from './inward-batch-detail/item-decision-dialog/item-decision-dialog.component';
import { OutwardPresentmentComponent } from './outward-presentment/outward-presentment.component';
import { OutwardBatchDetailComponent } from './outward-batch-detail/outward-batch-detail.component';
import { SettlementComponent } from './settlement/settlement.component';
import { ReconciliationComponent } from './reconciliation/reconciliation.component';
import { PositivePayComponent } from './positive-pay/positive-pay.component';
/**
* CTS (Cheque Truncation System) Clearing Module.
*
* Inward presentment batches with per-cheque validation and pass/return decisioning, outward
* presentment life-cycle (capture -> send -> settle/dishonour), plus the CTS views of the shared
* settlement and reconciliation screens.
*/
@NgModule({
imports: [
SharedModule,
DirectivesModule,
PipesModule,
CtsClearingRoutingModule,
CtsClearingDashboardComponent,
InwardBatchesComponent,
InwardBatchDetailComponent,
ItemDecisionDialogComponent,
OutwardPresentmentComponent,
OutwardBatchDetailComponent,
SettlementComponent,
ReconciliationComponent,
PositivePayComponent
]
})
export class CtsClearingModule {}
+219
View File
@@ -0,0 +1,219 @@
/**
* 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';
/**
* CTS (Cheque Truncation System) clearing — inward presentment batches with the per-cheque validation
* ruleset and pass/return decisioning, and the outward presentment life-cycle (capture -> send ->
* settle/dishonour). Shared settlement/reconciliation/bank/routing calls live in ClearingService.
*/
@Injectable({
providedIn: 'root'
})
export class CtsClearingService {
private http = inject(HttpClient);
/**
* Persists the Outward Clearing screen's Batches Today/All filter across navigation (e.g. viewing a batch
* and coming back) — otherwise the component would reset to "today" every time it's recreated.
*/
outwardBatchesDateFilter: 'today' | 'all' = 'today';
/** Same persistence, for the Inward Clearing screen's Today/Yesterday/All quick filter. */
inwardBatchesDateFilter: 'today' | 'yesterday' | 'all' = 'today';
// ----- Inward batches -----
/** Server-side paginated — returns { pageItems, totalFilteredRecords }. */
getInwardBatches(
status?: string,
offset = 0,
limit = 20,
filters?: { fromDate?: string; toDate?: string; batchReference?: string; presentingBankName?: string }
): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
if (filters?.fromDate) params = params.set('fromDate', filters.fromDate);
if (filters?.toDate) params = params.set('toDate', filters.toDate);
if (filters?.batchReference) params = params.set('batchReference', filters.batchReference);
if (filters?.presentingBankName) params = params.set('presentingBankName', filters.presentingBankName);
return this.http.get('/ctsclearing/inward-batches', { params });
}
getInwardBatch(id: string | number): Observable<any> {
return this.http.get(`/ctsclearing/inward-batches/${id}`);
}
/** Sub-list may come back as a raw array OR a page — callers handle both. */
getInwardBatchItems(id: string | number): Observable<any> {
return this.http.get(`/ctsclearing/inward-batches/${id}/items`);
}
createInwardBatch(payload: any): Observable<any> {
return this.http.post('/ctsclearing/inward-batches', payload);
}
validateInwardBatch(id: string | number): Observable<any> {
return this.http.post(
`/ctsclearing/inward-batches/${id}`,
{},
{ params: new HttpParams().set('command', 'validate') }
);
}
// ----- Inward items -----
getInwardItem(id: string | number): Observable<any> {
return this.http.get(`/ctsclearing/inward-items/${id}`);
}
passInwardItem(id: string | number, payload: any): Observable<any> {
return this.http.post(`/ctsclearing/inward-items/${id}`, payload, {
params: new HttpParams().set('command', 'pass')
});
}
/** Records the clerk's manual signature-verification decision — required before passInwardItem will succeed. */
verifyInwardItemSignature(id: string | number, signatureOk: boolean): Observable<any> {
return this.http.post(
`/ctsclearing/inward-items/${id}`,
{ signatureOk, locale: 'en' },
{ params: new HttpParams().set('command', 'verifySignature') }
);
}
/** Uploads a real scanned cheque image (front|back) for an inward item. */
uploadInwardItemImage(id: string | number, side: 'front' | 'back', file: File): Observable<any> {
const formData = new FormData();
formData.append('file', file, file.name);
return this.http.post(`/ctsclearing/inward-items/${id}/image`, formData, {
params: new HttpParams().set('side', side)
});
}
/** Fetches the front|back cheque image as a base64 data URL (bind straight to [src]), via an authenticated call. */
getInwardItemImage(id: string | number, side: 'front' | 'back'): Observable<string> {
return this.http.get(`/ctsclearing/inward-items/${id}/image`, {
params: new HttpParams().set('side', side),
responseType: 'text'
});
}
returnInwardItem(id: string | number, payload: any): Observable<any> {
return this.http.post(`/ctsclearing/inward-items/${id}`, payload, {
params: new HttpParams().set('command', 'return')
});
}
// ----- Outward presentments -----
getOutwardPresentments(
status?: string,
offset = 0,
limit = 20,
filters?: {
fromDate?: string;
toDate?: string;
chequeNumber?: string;
drawerBankName?: string;
payeeName?: string;
batchReference?: string;
}
): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
if (filters?.fromDate) params = params.set('fromDate', filters.fromDate);
if (filters?.toDate) params = params.set('toDate', filters.toDate);
if (filters?.chequeNumber) params = params.set('chequeNumber', filters.chequeNumber);
if (filters?.drawerBankName) params = params.set('drawerBankName', filters.drawerBankName);
if (filters?.payeeName) params = params.set('payeeName', filters.payeeName);
if (filters?.batchReference) params = params.set('batchReference', filters.batchReference);
return this.http.get('/ctsclearing/outward-presentments', { params });
}
/** One row per outward clearing session ("batch") — cheques grouped by the shared batchReference they were sent under. */
getOutwardBatches(
offset = 0,
limit = 20,
filters?: { fromDate?: string; toDate?: string; batchReference?: string }
): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (filters?.fromDate) params = params.set('fromDate', filters.fromDate);
if (filters?.toDate) params = params.set('toDate', filters.toDate);
if (filters?.batchReference) params = params.set('batchReference', filters.batchReference);
return this.http.get('/ctsclearing/outward-presentments/batches', { params });
}
getOutwardPresentment(id: string | number): Observable<any> {
return this.http.get(`/ctsclearing/outward-presentments/${id}`);
}
createOutwardPresentment(payload: any): Observable<any> {
return this.http.post('/ctsclearing/outward-presentments', payload);
}
/** command: 'send' | 'settle' | 'dishonour'. Dishonour carries { dishonourReasonCode }. */
actOnOutwardPresentment(id: string | number, command: string, payload: any = {}): Observable<any> {
return this.http.post(`/ctsclearing/outward-presentments/${id}`, payload, {
params: new HttpParams().set('command', command)
});
}
/** Uploads a real scanned cheque image (front|back) for an outward presentment. */
uploadOutwardPresentmentImage(id: string | number, side: 'front' | 'back', file: File): Observable<any> {
const formData = new FormData();
formData.append('file', file, file.name);
return this.http.post(`/ctsclearing/outward-presentments/${id}/image`, formData, {
params: new HttpParams().set('side', side)
});
}
/** Fetches the front|back cheque image as a base64 data URL (bind straight to [src]), via an authenticated call. */
getOutwardPresentmentImage(id: string | number, side: 'front' | 'back'): Observable<string> {
return this.http.get(`/ctsclearing/outward-presentments/${id}/image`, {
params: new HttpParams().set('side', side),
responseType: 'text'
});
}
/** Offices/branches, for the "presenting branch" selector on the outward capture form. */
getOffices(): Observable<any> {
return this.http.get('/offices');
}
/**
* Bundles every CAPTURED outward presentment into one CTS batch file and sends it to the clearing house in
* a single go — the same action the (disabled by default) clearing-session scheduler would run automatically.
*/
sendPendingOutwardBatch(): Observable<any> {
return this.http.post('/ctsclearing/outward-presentments/send-pending-batch', {});
}
// ----- Positive Pay (RBI PPS) -----
getPositivePayRegistrations(accountId?: string | number, status?: string, offset = 0, limit = 20): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (accountId) params = params.set('accountId', accountId);
if (status) params = params.set('status', status);
return this.http.get('/ctsclearing/positive-pay', { params });
}
registerPositivePay(payload: any): Observable<any> {
return this.http.post('/ctsclearing/positive-pay', payload);
}
cancelPositivePay(id: string | number, payload: any): Observable<any> {
return this.http.post(`/ctsclearing/positive-pay/${id}`, payload, {
params: new HttpParams().set('command', 'cancel')
});
}
}
@@ -0,0 +1,262 @@
<!--
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.Inward Batch' | translate }} #{{ batch.id }}</h2>
<button mat-raised-button [routerLink]="['/ctsclearing', 'inward']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
<mat-card class="info-card">
<mat-card-content>
<div class="info-grid">
<div>
<span class="k">{{ 'labels.inputs.Batch Reference' | translate }}</span
><span>{{ batch.batchReference }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Session Date' | translate }}</span
><span>{{ batch.sessionDate ? (batch.sessionDate | dateFormat) : '–' }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Presentment Type' | translate }}</span
><span>{{ batch.presentmentType }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Items' | translate }}</span
><span>{{ batch.itemCount ?? batch.totalCount ?? items.length }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Total Amount' | translate }}</span
><span>{{ batch.totalAmount ?? '–' }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Status' | translate }}</span>
<span
><span class="pill" [ngClass]="pillClass(batch.status)">{{ batch.status }}</span></span
>
</div>
@if (batch.presentingBankName || batch.presentingBankCode) {
<div>
<span class="k">{{ 'labels.inputs.Presenting Bank' | translate }}</span
><span
>{{ batch.presentingBankName || '–' }} <span class="muted">{{ batch.presentingBankCode }}</span></span
>
</div>
}
@if (batch.returnTatDeadline) {
<div>
<span class="k">{{ 'labels.inputs.Return TAT Deadline' | translate }}</span
><span>{{ batch.returnTatDeadline | dateFormat }}</span>
</div>
}
</div>
</mat-card-content>
</mat-card>
<div class="action-toolbar">
<button mat-raised-button color="primary" (click)="runValidation()" [disabled]="busy">
<fa-icon icon="check-circle"></fa-icon>&nbsp;{{ 'labels.buttons.Run Validation' | translate }}
</button>
<button mat-raised-button (click)="prepareSettlement()" [disabled]="busy">
<fa-icon icon="money-check"></fa-icon>&nbsp;{{ 'labels.buttons.Prepare Settlement' | translate }}
</button>
</div>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="items">
<ng-container matColumnDef="chequeNumber">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Cheque Number' | translate }}</th>
<td mat-cell *matCellDef="let it">
{{ it.chequeNumber }}
@if (it.instrumentStopPaymentFlag) {
<span class="pill bad" matTooltip="Stop payment was flagged on the depositing side">STOP</span>
}
@if (it.micrCode) {
<span class="muted">{{ it.micrCode }}</span>
}
@if (it.ifsc) {
<span class="muted">{{ it.ifsc }}</span>
}
@if (it.ctsReferenceNumber) {
<span class="muted">{{ it.ctsReferenceNumber }}</span>
}
@if (it.instrumentDate) {
<span class="muted">Dated {{ it.instrumentDate | dateFormat }}</span>
}
@if (it.instrumentTypeDescription || it.instrumentType) {
<span class="muted">{{ it.instrumentTypeDescription || it.instrumentType }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="drawer">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account' | translate }}</th>
<td mat-cell *matCellDef="let it">
{{ it.accountNo || it.accountId || '–' }}
@if (it.clientName) {
<span class="muted">{{ it.clientName }}</span>
}
@if (it.drawerAccountNumberMasked) {
<span class="muted">Drawer a/c {{ it.drawerAccountNumberMasked }}</span>
}
@if (it.draweeBankName) {
<span class="muted">{{ it.draweeBankName }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="payeeName">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payee Name' | translate }}</th>
<td mat-cell *matCellDef="let it">{{ it.payeeName }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Amount' | translate }}</th>
<td mat-cell *matCellDef="let it" class="num">
{{ it.amount }}
@if (it.amountInWords) {
<div class="muted">{{ it.amountInWords }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="image">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Image' | translate }}</th>
<td mat-cell *matCellDef="let it">
<div class="image-cell">
@if (getItemImageUrl(it, 'front')) {
<img
[src]="getItemImageUrl(it, 'front')"
class="cheque-thumb"
alt="front"
matTooltip="Front image — click to zoom"
(click)="openImageZoom(it, 'front')"
/>
} @else {
<input #frontFile type="file" accept="image/*" hidden (change)="uploadItemImage(it, 'front', $event)" />
<button mat-icon-button (click)="frontFile.click()" matTooltip="Upload front image">
<fa-icon icon="plus"></fa-icon>
</button>
}
@if (getItemImageUrl(it, 'back')) {
<img
[src]="getItemImageUrl(it, 'back')"
class="cheque-thumb"
alt="back"
matTooltip="Back image — click to zoom"
(click)="openImageZoom(it, 'back')"
/>
} @else {
<input #backFile type="file" accept="image/*" hidden (change)="uploadItemImage(it, 'back', $event)" />
<button mat-icon-button (click)="backFile.click()" matTooltip="Upload back image">
<fa-icon icon="plus"></fa-icon>
</button>
}
</div>
</td>
</ng-container>
<ng-container matColumnDef="checks">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Validation' | translate }}</th>
<td mat-cell *matCellDef="let it">
@if (isDecided(it)) {
<span class="pill" [ngClass]="hasFailedChecks(it) ? 'bad' : 'ok'">
{{ hasFailedChecks(it) ? 'Some checks failed' : 'All checks passed' }}
</span>
<div class="muted">See View → Comparison Details for the full breakdown.</div>
} @else {
<div class="check-badges">
@for (rule of rules; track rule.key) {
<span class="pill" [ngClass]="ruleClass(it, rule)" [title]="rule.label">{{ rule.label }}</span>
}
</div>
@if (it.validationDetail) {
<div class="validation-detail muted">{{ it.validationDetail }}</div>
}
@if (it.iqaStatus && it.iqaStatus !== 'NA') {
<span class="pill" [ngClass]="pillClass(it.iqaStatus)" matTooltip="Image Quality Assessment"
>IQA: {{ it.iqaStatus }}</span
>
}
@if (it.iqaDetail) {
<div class="muted">{{ it.iqaDetail }}</div>
}
@if (it.iqaSubChecks) {
<div class="muted">{{ it.iqaSubChecks }}</div>
}
}
</td>
</ng-container>
<ng-container matColumnDef="decision">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Decision' | translate }}</th>
<td mat-cell *matCellDef="let it">
@if (isDecided(it)) {
<span class="pill" [ngClass]="pillClass(it.decision)">{{ it.decision }}</span>
} @else {
<span class="pill neutral">{{ it.itemStatus || 'PENDING' }}</span>
}
@if (it.returnReasonCode) {
<div class="muted">{{ it.returnReasonCode }}</div>
}
@if (isDecided(it) && it.decisionedOn) {
<div class="muted">{{ it.decisionedOn | dateFormat }}</div>
}
@if (isDecided(it) && it.decisionReason) {
<div class="muted">{{ it.decisionReason }}</div>
}
@if (isDecided(it)) {
@if (it.settlementBatchId) {
<div class="muted">Settlement #{{ it.settlementBatchId }}</div>
} @else {
<div class="muted">Awaiting settlement</div>
}
}
</td>
</ng-container>
<ng-container matColumnDef="hold">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Hold' | translate }}</th>
<td mat-cell *matCellDef="let it" class="num">
@if (it.holdPlaced) {
{{ it.holdAmount ?? '✓' }}
} @else {
–
}
</td>
</ng-container>
<ng-container matColumnDef="returnDeadline">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Return Deadline' | translate }}</th>
<td mat-cell *matCellDef="let it">
{{ it.returnDeadline ? (it.returnDeadline | dateFormat) : '–' }}
@if (it.originalSerialNumber) {
<span class="muted">Orig # {{ it.originalSerialNumber }}</span>
}
@if (it.depositDate) {
<span class="muted">Deposited {{ it.depositDate | dateFormat }}</span>
}
@if (it.depositAccountNumber) {
<span class="muted">Deposit a/c {{ it.depositAccountNumber }}</span>
}
@if (it.presentingBranchCode) {
<span class="muted">Branch {{ it.presentingBranchCode }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let it">
<button mat-button color="primary" (click)="decide(it)">
{{ (isDecided(it) ? 'labels.buttons.View' : 'labels.buttons.Decide') | translate }}
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
@if (items.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
</div>
<div #lightboxRoot style="display: none"></div>
</div>
@@ -0,0 +1,127 @@
/**
* 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 20px;
> div {
display: flex;
flex-direction: column;
}
.k {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 50%);
margin-bottom: 2px;
}
}
.action-toolbar {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.check-badges {
display: flex;
gap: 4px;
flex-wrap: wrap;
max-width: 320px;
}
.validation-detail {
margin-top: 4px;
max-width: 320px;
}
.muted {
display: block;
color: rgb(0 0 0 / 50%);
font-size: 0.78rem;
margin-left: 0;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
white-space: nowrap;
&.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%);
}
.image-cell {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.cheque-thumb {
width: 40px;
height: 28px;
object-fit: cover;
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
}
@@ -0,0 +1,269 @@
/**
* 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, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog } from '@angular/material/dialog';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import lightGallery from 'lightgallery';
import lgZoom from 'lightgallery/plugins/zoom';
import lgFullscreen from 'lightgallery/plugins/fullscreen';
import type { LightGallery } from 'lightgallery/lightgallery';
import type { GalleryItem } from 'lightgallery/lg-utils';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { CtsClearingService } from '../ctsclearing.service';
import { ClearingService } from '../../clearing-network/clearing.service';
import { ItemDecisionDialogComponent } from './item-decision-dialog/item-decision-dialog.component';
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
/**
* One inward CTS batch — its header, the per-cheque items table with a badge per validation rule, and the
* batch-level "Run Validation" and "Prepare Settlement" actions. Each item can be manually PASSed or
* RETURNed through the item-decision dialog.
*/
@Component({
selector: 'mifosx-inward-batch-detail',
templateUrl: './inward-batch-detail.component.html',
styleUrls: ['./inward-batch-detail.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatButtonModule,
MatTableModule,
MatSnackBarModule,
MatTooltipModule,
FaIconComponent
]
})
export class InwardBatchDetailComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private ctsService = inject(CtsClearingService);
private clearingService = inject(ClearingService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('lightboxRoot', { static: true }) lightboxRoot: ElementRef<HTMLElement>;
private lightboxInstance: LightGallery | null = null;
private readonly lightboxPlugins = [
lgZoom,
lgFullscreen
];
pillClass = pillClass;
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
batchId!: string;
batch: any = {};
items: any[] = [];
busy = false;
/** Per-cheque validation rules — label + the item field, and whether "true" is the good outcome. */
readonly rules: { key: string; label: string; goodWhenTrue: boolean }[] = [
{ key: 'accountCheckPassed', label: 'Account', goodWhenTrue: true },
{ key: 'balanceCheckPassed', label: 'Balance', goodWhenTrue: true },
{ key: 'signatureOk', label: 'Signature', goodWhenTrue: true },
{ key: 'duplicateCheckPassed', label: 'Duplicate', goodWhenTrue: true },
{ key: 'stopPaymentCheckPassed', label: 'Stop Pay', goodWhenTrue: true },
{ key: 'amountWordsFiguresMatch', label: 'Amt Match', goodWhenTrue: true },
{ key: 'staleOrPostdated', label: 'Stale/Postdated', goodWhenTrue: false }
];
columns = [
'chequeNumber',
'drawer',
'payeeName',
'amount',
'image',
'checks',
'decision',
'hold',
'returnDeadline',
'actions'
];
ngOnInit(): void {
this.batchId = this.route.snapshot.paramMap.get('id') as string;
this.loadAll();
}
ngOnDestroy(): void {
this.destroyLightbox();
}
/** Cheque image data URLs, keyed "<itemId>_<side>" — populated once fetched, absent means none uploaded (yet). */
imageUrls: { [key: string]: string } = {};
loadAll(): void {
this.ctsService.getInwardBatch(this.batchId).subscribe((data: any) => (this.batch = data || {}));
this.ctsService.getInwardBatchItems(this.batchId).subscribe((data: any) => {
this.items = data?.pageItems ?? (Array.isArray(data) ? data : []);
this.items.forEach((it: any) => {
this.loadItemImage(it, 'front');
this.loadItemImage(it, 'back');
});
});
}
/** Fetches the image as an authenticated base64 data URL and caches it for [src] binding — 404 (no upload yet) is expected and silent. */
loadItemImage(item: any, side: 'front' | 'back'): void {
this.ctsService.getInwardItemImage(item.id, side).subscribe({
next: (dataUrl: string) => (this.imageUrls[`${item.id}_${side}`] = dataUrl),
error: () => {
/* no image uploaded for this side yet */
}
});
}
getItemImageUrl(item: any, side: 'front' | 'back'): string | null {
return this.imageUrls[`${item.id}_${side}`] || null;
}
/** Opens the front/back cheque image(s) in a zoomable, pannable lightbox — same lightgallery setup used app-wide. */
openImageZoom(item: any, side: 'front' | 'back'): void {
const frontUrl = this.getItemImageUrl(item, 'front');
const backUrl = this.getItemImageUrl(item, 'back');
const items: GalleryItem[] = [];
if (frontUrl) {
items.push({
src: frontUrl,
thumb: frontUrl,
subHtml: `<div class="lg-caption"><h4>Front — Cheque ${item.chequeNumber}</h4></div>`
});
}
if (backUrl) {
items.push({
src: backUrl,
thumb: backUrl,
subHtml: `<div class="lg-caption"><h4>Back — Cheque ${item.chequeNumber}</h4></div>`
});
}
if (!items.length) {
return;
}
const startIndex = side === 'back' && frontUrl ? 1 : 0;
this.destroyLightbox();
this.lightboxInstance = lightGallery(this.lightboxRoot.nativeElement, {
dynamic: true,
dynamicEl: items,
plugins: this.lightboxPlugins,
download: false,
closable: true,
escKey: true,
zoomFromOrigin: true
});
this.lightboxInstance.openGallery(startIndex);
}
private destroyLightbox(): void {
if (this.lightboxInstance) {
this.lightboxInstance.destroy();
this.lightboxInstance = null;
}
}
uploadItemImage(item: any, side: 'front' | 'back', event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files && input.files[0];
if (!file) {
return;
}
this.ctsService.uploadInwardItemImage(item.id, side, file).subscribe({
next: () => {
this.notify(side === 'front' ? 'Front image uploaded' : 'Back image uploaded');
this.loadItemImage(item, side);
},
error: (err: any) => this.notify(clearingError(err, 'Image upload failed'), true)
});
input.value = '';
}
/** ok / bad / neutral for a single boolean validation rule, respecting whether true is the good outcome. */
ruleClass(item: any, rule: { key: string; goodWhenTrue: boolean }): 'ok' | 'bad' | 'neutral' {
const value = item?.[rule.key];
if (value === null || value === undefined) {
return 'neutral';
}
const good = rule.goodWhenTrue ? value === true : value === false;
return good ? 'ok' : 'bad';
}
/** Once a cheque is decided, the clerk doesn't need the full rule-by-rule checklist sitting in the table
* row forever — a one-line outcome is enough, with the full breakdown one click away via View -> Comparison
* Details. Kept for PENDING items, where the full checklist is exactly what's needed to decide. */
hasFailedChecks(item: any): boolean {
return this.rules.some((rule) => this.ruleClass(item, rule) === 'bad') || item?.iqaStatus === 'FAIL';
}
/** item.decision is NEVER null/empty — InwardClearingItem.java sets it to the literal string "PENDING" at
* creation, then "PASS"/"RETURN" once decided. A plain truthy check on it.decision is always true, which
* silently broke every "is this item decided yet" branch (View-vs-Decide, read-only summary, collapsed
* validation) into always showing the decided state, even for a still-PENDING item. */
isDecided(item: any): boolean {
return item?.decision === 'PASS' || item?.decision === 'RETURN';
}
runValidation(): void {
if (this.busy) {
return;
}
this.busy = true;
this.ctsService.validateInwardBatch(this.batchId).subscribe({
next: () => {
this.busy = false;
this.notify('Validation run');
this.loadAll();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Validation failed'), true);
}
});
}
prepareSettlement(): void {
if (this.busy) {
return;
}
this.busy = true;
this.clearingService.prepareSettlement({ clearingType: 'CTS', sourceBatchId: this.batch.id }).subscribe({
next: () => {
this.busy = false;
this.notify('Settlement batch prepared — see Settlement');
this.loadAll();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not prepare settlement'), true);
}
});
}
decide(item: any): void {
const dialogRef = this.dialog.open(ItemDecisionDialogComponent, {
data: { item },
width: '760px',
maxWidth: '95vw',
maxHeight: '90vh'
});
dialogRef.afterClosed().subscribe((result: any) => {
if (result?.actioned) {
this.loadAll();
}
});
}
}
@@ -0,0 +1,387 @@
<!--
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.Cheque' | translate }} {{ item.chequeNumber }}</h1>
<div mat-dialog-content>
<div class="info-grid">
<div>
<span class="k">{{ 'labels.inputs.Payee Name' | translate }}</span
><span>{{ item.payeeName || '–' }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Amount' | translate }}</span
><span>{{ item.amount }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Account' | translate }}</span
><span>{{ item.accountNo || item.accountId || '–' }}</span>
</div>
@if (item.clientName) {
<div>
<span class="k">Account Holder</span><span>{{ item.clientName }}</span>
</div>
}
@if (accountInfo?.availableBalance !== null && accountInfo?.availableBalance !== undefined) {
<div>
<span class="k">Available Balance</span>
<span>
{{ accountInfo!.availableBalance }}
@if (balanceSufficient !== null) {
<span class="pill" [ngClass]="balanceSufficient ? 'ok' : 'bad'">
{{ balanceSufficient ? 'Sufficient' : 'Insufficient' }}
</span>
}
</span>
</div>
}
@if (
accountInfo?.onHoldAmount !== null && accountInfo?.onHoldAmount !== undefined && accountInfo!.onHoldAmount !== 0
) {
<div>
<span class="k">Existing Holds on Account</span><span>{{ accountInfo!.onHoldAmount }}</span>
</div>
}
<div>
<span class="k">Hold Placed</span><span>{{ item.holdPlaced ? (item.holdAmount ?? item.amount) : '–' }}</span>
</div>
</div>
<h3 class="section-title">Cheque Image</h3>
<div class="muted">
Click the cheque to go fullscreen, then drag any signer's stored signature from the shelf onto it and resize for a
close match.
</div>
<div class="image-row">
<div #chequeStage class="cheque-stage" [class.fullscreen-stage]="fullscreenActive">
<div
#chequeImageWrap
class="cheque-image-wrap"
[style.transform]="fullscreenActive ? 'scale(' + chequeZoom + ')' : null"
(wheel)="onChequeWheel($event)"
>
@if (frontImageUrl) {
<img
[src]="frontImageUrl"
class="cheque-image"
alt="front"
matTooltip="Click to compare fullscreen"
(click)="toggleFullscreen()"
/>
} @else {
<div class="no-image">No front image</div>
}
@if (fullscreenActive) {
@for (signer of requiredSigners; track $index) {
@if (signer.overlayVisible && signer.signatureUrl) {
<div
class="sig-overlay"
[style.left.px]="signer.overlayX"
[style.top.px]="signer.overlayY"
[style.width.px]="signer.overlayWidth"
[style.opacity]="signer.overlayOpacity"
(mousedown)="startOverlayDrag($event, signer)"
>
<div class="sig-overlay-handle">
<span>{{ signer.name }}</span>
<input
type="range"
class="sig-overlay-opacity"
min="0.3"
max="1"
step="0.05"
[(ngModel)]="signer.overlayOpacity"
(mousedown)="$event.stopPropagation()"
matTooltip="Overlay opacity"
/>
<button
type="button"
class="sig-overlay-close"
(mousedown)="$event.stopPropagation()"
(click)="toggleOverlay(signer)"
matTooltip="Hide overlay"
>
✕
</button>
</div>
<img [src]="signer.signatureUrl" class="sig-overlay-img" alt="signature overlay" draggable="false" />
<div
class="sig-overlay-resize"
(mousedown)="startOverlayResize($event, signer)"
matTooltip="Drag to resize"
></div>
</div>
}
}
}
</div>
@if (fullscreenActive) {
<button type="button" class="fullscreen-close" (click)="toggleFullscreen()" matTooltip="Exit fullscreen (Esc)">
✕
</button>
<div class="fullscreen-zoom">
<button type="button" (click)="zoomOut()" matTooltip="Zoom out">−</button>
<span>{{ chequeZoom * 100 | number: '1.0-0' }}%</span>
<button type="button" (click)="zoomIn()" matTooltip="Zoom in">+</button>
@if (chequeZoom !== 1) {
<button type="button" (click)="resetZoom()" matTooltip="Reset zoom">⟲</button>
}
</div>
<div class="fullscreen-shelf">
@for (signer of requiredSigners; track $index) {
@if (signer.signatureUrl) {
<div
class="shelf-item"
[class.active]="signer.overlayVisible"
(mousedown)="onShelfItemMouseDown($event, signer)"
matTooltip="Drag onto the cheque"
>
<img [src]="signer.signatureUrl" alt="signature" draggable="false" />
<span>{{ signer.name }}</span>
</div>
}
}
</div>
}
</div>
@if (backImageUrl) {
<img
[src]="backImageUrl"
class="cheque-image"
alt="back"
matTooltip="Back — click to zoom"
(click)="openImageZoom('back')"
/>
} @else {
<div class="no-image">No back image</div>
}
</div>
<h3 class="section-title">Instrument Details</h3>
<div class="info-grid">
@if (item.micrCode) {
<div>
<span class="k">MICR Code</span><span>{{ item.micrCode }}</span>
</div>
}
@if (item.ifsc) {
<div>
<span class="k">IFSC</span><span>{{ item.ifsc }}</span>
</div>
}
@if (item.instrumentDate) {
<div>
<span class="k">Instrument Date</span><span>{{ item.instrumentDate | dateFormat }}</span>
</div>
}
@if (item.amountInWords) {
<div>
<span class="k">Amount in Words</span><span>{{ item.amountInWords }}</span>
</div>
}
@if (item.drawerAccountNumberMasked) {
<div>
<span class="k">Drawer A/C (as on cheque)</span><span>{{ item.drawerAccountNumberMasked }}</span>
</div>
}
@if (item.draweeBankName) {
<div>
<span class="k">Drawee Bank</span><span>{{ item.draweeBankName }}</span>
</div>
}
@if (item.instrumentTypeDescription || item.instrumentType) {
<div>
<span class="k">Instrument Type</span><span>{{ item.instrumentTypeDescription || item.instrumentType }}</span>
</div>
}
@if (item.ctsReferenceNumber) {
<div>
<span class="k">CTS Reference</span><span>{{ item.ctsReferenceNumber }}</span>
</div>
}
</div>
<div class="section-header">
<h3 class="section-title">Automatic Validation</h3>
<button type="button" mat-stroked-button (click)="openValidationDetail()">Comparison Details</button>
</div>
@if (item.instrumentStopPaymentFlag) {
<div class="gate-warning">Stop payment was flagged on this instrument by the depositing bank.</div>
}
<div class="check-badges">
@for (rule of validationRules; track rule.key) {
<span class="pill" [ngClass]="ruleClass(rule)" [title]="rule.label">{{ rule.label }}</span>
}
@if (item.iqaStatus && item.iqaStatus !== 'NA') {
<span class="pill" [ngClass]="pillClass(item.iqaStatus)" matTooltip="Image Quality Assessment"
>IQA: {{ item.iqaStatus }}</span
>
}
</div>
@if (item.iqaDetail) {
<div class="muted">{{ item.iqaDetail }}</div>
}
@if (item.iqaSubChecks) {
<div class="muted">{{ item.iqaSubChecks }}</div>
}
@if (item.validationDetail) {
<div class="validation-detail muted">{{ item.validationDetail }}</div>
}
<h3 class="section-title">Signature Verification</h3>
@if (loadingAccountInfo) {
<div class="muted">Loading account signatories…</div>
} @else {
@if (accountInfo) {
<div class="mode-label muted">Mode of Operation: {{ modeOfOperationLabel }}</div>
}
@if (showManualOverride) {
<div class="signer-row">
<mat-checkbox [(ngModel)]="manualOverrideChecked">
Account/signatory details unavailable — I have manually verified this signature
</mat-checkbox>
</div>
} @else {
<div class="muted">
{{
signerSelectionMode === 'any'
? 'Any one signatory below may sign — tick the one that matches.'
: 'Every mandatory signatory below must be individually confirmed.'
}}
</div>
@for (signer of requiredSigners; track $index) {
<div class="signer-row">
<div class="signer-signature">
@if (signer.loadingSignature) {
<div class="no-image small">Loading…</div>
} @else if (signer.signatureUrl) {
<img
[src]="signer.signatureUrl"
class="signature-image"
alt="signature"
matTooltip="Click to zoom"
(click)="openSignatureZoom(signer)"
/>
} @else {
<div class="no-image small">No signature on file</div>
}
</div>
<div class="signer-info">
<div class="signer-name">
{{ signer.name }}
<span class="pill" [ngClass]="signer.mandatory ? 'bad' : 'neutral'">{{ signer.role }}</span>
</div>
</div>
<mat-checkbox [(ngModel)]="signer.checked">Signature Matches</mat-checkbox>
</div>
}
}
}
@if (isDecided) {
<h3 class="section-title">Decision</h3>
<div class="decided-summary" [ngClass]="pillClass(item.decision)">
<div class="decided-headline">
<span class="pill" [ngClass]="pillClass(item.decision)">{{ item.decision }}</span>
@if (item.decisionedOn) {
<span class="muted">on {{ item.decisionedOn | dateFormat }}</span>
}
</div>
@if (item.returnReasonCode) {
<div class="compare-row">
<span class="compare-label">Return Reason Code</span
><span class="compare-value">{{ item.returnReasonCode }}</span>
</div>
}
@if (item.decisionReason) {
<div class="compare-row">
<span class="compare-label">Note</span><span class="compare-value">{{ item.decisionReason }}</span>
</div>
}
@if (item.settlementBatchId) {
<div class="compare-row">
<span class="compare-label">Settlement Batch</span
><span class="compare-value">#{{ item.settlementBatchId }}</span>
</div>
} @else if (item.decision === 'PASS') {
<div class="compare-row">
<span class="compare-label">Settlement</span><span class="compare-value">Awaiting settlement</span>
</div>
}
<div class="muted">
This cheque has already been decided and cannot be decided again — this view is read-only.
</div>
</div>
} @else {
<div class="mode-toggle">
<button
mat-stroked-button
type="button"
[class.active]="mode === 'PASS'"
color="primary"
(click)="setMode('PASS')"
>
{{ 'labels.buttons.Pass' | translate }}
</button>
<button
mat-stroked-button
type="button"
[class.active]="mode === 'RETURN'"
color="warn"
(click)="setMode('RETURN')"
>
{{ 'labels.buttons.Return' | translate }}
</button>
</div>
@if (mode === 'PASS') {
<form [formGroup]="passForm">
@if (!signatureVerified) {
<div class="gate-warning">Signature verification required above before this cheque can be passed.</div>
}
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<input matInput formControlName="note" />
</mat-form-field>
</form>
} @else {
<form [formGroup]="returnForm">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Return Reason Code' | translate }}</mat-label>
<mat-select formControlName="returnReasonCode" required>
@for (c of reasonCodes; track c.id) {
<mat-option [value]="c.code">{{ c.code }} — {{ c.description }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
<input matInput formControlName="reason" />
</mat-form-field>
</form>
}
}
<div #lightboxRoot style="display: none"></div>
</div>
<div mat-dialog-actions align="end">
<button mat-raised-button type="button" mat-dialog-close>
{{ (isDecided ? 'labels.buttons.Close' : 'labels.buttons.Cancel') | translate }}
</button>
@if (!isDecided) {
<button
mat-raised-button
[color]="mode === 'RETURN' ? 'warn' : 'primary'"
(click)="submit()"
[disabled]="busy || (mode === 'RETURN' ? returnForm.invalid : !signatureVerified)"
>
{{ (mode === 'PASS' ? 'labels.buttons.Pass' : 'labels.buttons.Return') | translate }}
</button>
}
</div>
@@ -0,0 +1,442 @@
/**
* 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(130px, 1fr));
gap: 10px 16px;
margin-bottom: 16px;
> div {
display: flex;
flex-direction: column;
}
.k {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 50%);
margin-bottom: 2px;
}
.pill {
margin-left: 6px;
vertical-align: middle;
}
}
.mode-toggle {
display: flex;
gap: 8px;
margin: 16px 0 12px;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.decided-summary {
padding: 14px 16px;
border-radius: 8px;
border-left: 4px solid rgb(0 0 0 / 30%);
background: rgb(0 0 0 / 4%);
&.ok {
border-left-color: #1a9c63;
background: #e8f8f0;
}
&.bad {
border-left-color: #d32f2f;
background: #fdeaea;
}
}
.decided-headline {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.compare-row {
display: grid;
grid-template-columns: minmax(120px, 38%) 1fr;
gap: 6px 16px;
padding: 4px 0;
border-top: 1px solid rgb(255 255 255 / 60%);
&:first-of-type {
border-top: none;
}
}
.compare-label {
color: rgb(0 0 0 / 55%);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.compare-value {
font-size: 0.85rem;
word-break: break-word;
}
mat-form-field {
width: 100%;
}
.section-title {
margin: 20px 0 8px;
font-size: 0.95rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
.section-title {
margin: 20px 0 8px;
}
}
.muted {
color: rgb(0 0 0 / 50%);
font-size: 0.82rem;
margin-bottom: 8px;
}
.image-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.cheque-image {
max-width: 260px;
max-height: 140px;
object-fit: contain;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
background: #fafafa;
}
// the overlay boxes are positioned absolute relative to THIS wrapper, not the (possibly flex-centered)
// .cheque-stage — otherwise, once the stage centers the image via flexbox in fullscreen mode, the overlay's
// left/top coordinates (measured from the stage's own top-left corner) land nowhere near the actual image
.cheque-image-wrap {
position: relative;
display: inline-block;
line-height: 0;
transform-origin: center center;
}
.cheque-stage {
position: relative;
display: inline-block;
line-height: 0;
// fullscreen compare mode: the stage itself becomes a fixed, full-viewport backdrop so the cheque and
// every draggable signature overlay are shown as large as possible for a real side-by-side comparison
&.fullscreen-stage {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
max-width: none;
overflow: auto;
background: rgb(0 0 0 / 92%);
.cheque-image {
width: auto;
max-width: 82vw;
max-height: 88vh;
cursor: zoom-in;
}
}
// matches the native fullscreen state directly (independent of Angular's class-binding timing), so the
// stage fills correctly the instant the browser promotes it to the top layer
&:fullscreen {
width: 100vw;
height: 100vh;
max-width: none;
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 92%);
}
}
.fullscreen-close {
position: absolute;
top: 16px;
right: 16px;
z-index: 2010;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgb(255 255 255 / 15%);
color: #fff;
font-size: 16px;
cursor: pointer;
&:hover {
background: rgb(255 255 255 / 25%);
}
}
.fullscreen-zoom {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 2010;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 20px;
background: rgb(255 255 255 / 15%);
color: #fff;
font-size: 12px;
span {
min-width: 40px;
text-align: center;
font-variant-numeric: tabular-nums;
}
button {
width: 26px;
height: 26px;
border-radius: 50%;
border: none;
background: rgb(255 255 255 / 15%);
color: #fff;
font-size: 14px;
line-height: 1;
cursor: pointer;
&:hover {
background: rgb(255 255 255 / 25%);
}
}
}
.fullscreen-shelf {
position: absolute;
top: 16px;
left: 16px;
z-index: 2010;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 88vh;
overflow-y: auto;
padding-right: 4px;
}
.shelf-item {
width: 150px;
padding: 4px;
border: 2px solid transparent;
border-radius: 4px;
background: #fff;
cursor: pointer;
text-align: center;
&.active {
border-color: #1976d2;
}
img {
display: block;
width: 100%;
height: 54px;
object-fit: contain;
}
span {
display: block;
margin-top: 2px;
color: #333;
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.sig-overlay {
position: absolute;
// must beat the fullscreen shelf/close-button (z-index: 2010) — they share the same stacking context
// (the fullscreen-stage), so a low z-index here meant the overlay silently rendered BEHIND the shelf
// wherever the two visually overlapped, e.g. when dragging it toward the top-left where the shelf sits
z-index: 2020;
box-sizing: border-box;
border: 1px dashed rgb(25 118 210 / 70%);
background: rgb(255 255 255 / 40%);
cursor: move;
user-select: none;
}
.sig-overlay-handle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
padding: 1px 4px;
background: rgb(25 118 210 / 85%);
color: #fff;
font-size: 10px;
line-height: 1.6;
cursor: move;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sig-overlay-close {
background: none;
border: none;
color: #fff;
cursor: pointer;
font-size: 11px;
line-height: 1;
padding: 0 2px;
}
.sig-overlay-img {
display: block;
width: 100%;
height: auto;
pointer-events: none;
}
.sig-overlay-resize {
position: absolute;
right: 0;
bottom: 0;
width: 12px;
height: 12px;
cursor: nwse-resize;
background: repeating-linear-gradient(135deg, rgb(25 118 210 / 80%) 0 2px, transparent 2px 4px);
}
.sig-overlay-opacity {
width: 44px;
height: 10px;
margin: 0 4px;
cursor: pointer;
}
.no-image {
padding: 12px;
border: 1px dashed #ccc;
border-radius: 4px;
color: rgb(0 0 0 / 40%);
font-size: 0.82rem;
&.small {
padding: 6px 10px;
width: 120px;
}
}
.check-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.validation-detail {
margin-bottom: 8px;
}
.mode-label {
font-weight: 500;
}
.signer-row {
display: flex;
align-items: center;
gap: 14px;
padding: 8px 0;
border-bottom: 1px solid rgb(0 0 0 / 6%);
&:last-of-type {
border-bottom: none;
}
}
.signer-signature {
flex-shrink: 0;
}
.signature-image {
width: 140px;
height: 60px;
object-fit: contain;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
background: #fafafa;
}
.signer-info {
flex: 1;
min-width: 0;
}
.signer-name {
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.gate-warning {
color: #d32f2f;
font-size: 0.82rem;
margin-bottom: 8px;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
white-space: nowrap;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.bad {
background: #fde3e3;
color: #d32f2f;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
@@ -0,0 +1,689 @@
/**
* 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, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import { FormsModule, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import {
MatDialog,
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatSnackBar } from '@angular/material/snack-bar';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import lightGallery from 'lightgallery';
import lgZoom from 'lightgallery/plugins/zoom';
import lgFullscreen from 'lightgallery/plugins/fullscreen';
import type { LightGallery } from 'lightgallery/lightgallery';
import type { GalleryItem } from 'lightgallery/lg-utils';
/** Custom Services */
import { CtsClearingService } from '../../ctsclearing.service';
import { ClearingService } from '../../../clearing-network/clearing.service';
import { SignatoryService, AccountSignatoryInfo, JointHolder } from '../../../clearing-network/signatory.service';
import { clearingError, pillClass } from '../../../clearing-network/clearing-status';
import { ValidationDetailDialogComponent } from './validation-detail-dialog/validation-detail-dialog.component';
interface RequiredSigner {
clientId: number;
name: string;
role: string;
mandatory: boolean;
isPrimary: boolean;
signatureUrl: string | null;
loadingSignature: boolean;
checked: boolean;
/** Draggable/resizable overlay state — lets the clerk place the stored signature directly over the
* cheque's own signature area for a close visual comparison, at whatever size suits the cheque image. */
overlayVisible: boolean;
overlayX: number;
overlayY: number;
overlayWidth: number;
overlayOpacity: number;
}
const OVERLAY_MIN_WIDTH = 60;
const OVERLAY_MAX_WIDTH = 420;
const OVERLAY_DEFAULT_WIDTH = 160;
/**
* Single-panel cheque verification for the clerk: the cheque image, every automatic validation result the
* backend already computes, the account's mode of operation and mandatory signatories (with their stored
* signature images to visually compare against the cheque), and finally the PASS/RETURN decision. PASS is
* blocked until the applicable signature rule is satisfied — see {@link signatureVerified}.
*/
@Component({
selector: 'mifosx-item-decision-dialog',
templateUrl: './item-decision-dialog.component.html',
styleUrls: ['./item-decision-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
MatDialogTitle,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatButtonModule,
MatCheckboxModule,
MatTooltipModule
]
})
export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
dialogRef = inject<MatDialogRef<ItemDecisionDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private ctsService = inject(CtsClearingService);
private clearingService = inject(ClearingService);
private signatoryService = inject(SignatoryService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('lightboxRoot', { static: true }) lightboxRoot: ElementRef<HTMLElement>;
@ViewChild('chequeStage') chequeStageEl?: ElementRef<HTMLElement>;
@ViewChild('chequeImageWrap') chequeImageWrapEl?: ElementRef<HTMLElement>;
private lightboxInstance: LightGallery | null = null;
private readonly lightboxPlugins = [
lgZoom,
lgFullscreen
];
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
item: any = this.data?.item || {};
mode: 'PASS' | 'RETURN' = 'PASS';
busy = false;
reasonCodes: any[] = [];
/** Fullscreen compare mode: the cheque takes over the whole viewport with a shelf of every mandatory
* signer's stored signature down the side, so any one of them can be dragged onto the cheque at a big,
* workable size instead of squinting at a small inline image. */
fullscreenActive = false;
/** Zoom level applied to the cheque image itself while fullscreen — the clerk needs to zoom into fine
* detail (MICR line, ink texture) independent of dragging a signature overlay around. */
chequeZoom = 1;
private static readonly ZOOM_MIN = 1;
private static readonly ZOOM_MAX = 4;
private static readonly ZOOM_STEP = 0.25;
frontImageUrl: string | null = null;
backImageUrl: string | null = null;
loadingAccountInfo = false;
accountInfo: AccountSignatoryInfo | null = null;
requiredSigners: RequiredSigner[] = [];
/** 'any' = mode of operation lets any one signer sign (Either/Anyone or Survivor); 'all' = every mandatory signer must sign. */
signerSelectionMode: 'any' | 'all' = 'any';
manualOverrideChecked = false;
/** Every automatic validation rule InwardClearingValidationService already computes, shown so the clerk sees WHY, not just a decision. */
readonly validationRules: { key: string; label: string; goodWhenTrue: boolean }[] = [
{ key: 'accountCheckPassed', label: 'Account', goodWhenTrue: true },
{ key: 'balanceCheckPassed', label: 'Balance', goodWhenTrue: true },
{ key: 'duplicateCheckPassed', label: 'Duplicate', goodWhenTrue: true },
{ key: 'stopPaymentCheckPassed', label: 'Stop Payment', goodWhenTrue: true },
{ key: 'amountWordsFiguresMatch', label: 'Amount Match', goodWhenTrue: true },
{ key: 'staleOrPostdated', label: 'Stale / Postdated', goodWhenTrue: false },
{ key: 'positivePayCheck', label: 'Positive Pay', goodWhenTrue: true }
];
passForm: UntypedFormGroup;
returnForm: UntypedFormGroup;
constructor() {
this.passForm = this.formBuilder.group({
note: ['']
});
this.returnForm = this.formBuilder.group({
returnReasonCode: [
'',
Validators.required
],
reason: ['']
});
}
ngOnInit(): void {
this.clearingService.getReturnReasonCodes('CTS').subscribe((res: any) => {
const list = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.reasonCodes = list.filter((c: any) => c.active !== false);
});
this.loadImages();
this.loadAccountInfo();
}
ngOnDestroy(): void {
this.destroyLightbox();
}
/** Shared status-string -> pill-color mapping, same one the batch list table already uses for iqaStatus/decision. */
pillClass = pillClass;
/** ok / bad / neutral for a single boolean validation rule, respecting whether true is the good outcome. */
ruleClass(rule: { key: string; goodWhenTrue: boolean }): 'ok' | 'bad' | 'neutral' {
const value = this.item?.[rule.key];
if (value === null || value === undefined) {
return 'neutral';
}
const good = rule.goodWhenTrue ? value === true : value === false;
return good ? 'ok' : 'bad';
}
/** item.decision is NEVER null — InwardClearingItem.java sets it to the literal string "PENDING" at creation,
* then "PASS"/"RETURN" once decided. A plain truthy check on it is always true, which silently made this
* dialog show the read-only decision summary (and hide the Pass/Return form) for every item, including ones
* still genuinely PENDING. */
get isDecided(): boolean {
return this.item?.decision === 'PASS' || this.item?.decision === 'RETURN';
}
/** What each automatic rule actually compared (amount vs words, instrument date vs the valid-until window,
* balance vs amount, etc.) instead of just the pass/fail pill — opened on demand, not always-on clutter. */
openValidationDetail(): void {
this.dialog.open(ValidationDetailDialogComponent, {
data: { item: this.item, accountInfo: this.accountInfo },
width: '560px',
maxWidth: '95vw',
maxHeight: '85vh'
});
}
private loadImages(): void {
this.ctsService.getInwardItemImage(this.item.id, 'front').subscribe({
next: (url: string) => (this.frontImageUrl = url),
error: () => {
/* no image uploaded for this side yet */
}
});
this.ctsService.getInwardItemImage(this.item.id, 'back').subscribe({
next: (url: string) => (this.backImageUrl = url),
error: () => {
/* no image uploaded for this side yet */
}
});
}
/** Opens the front/back cheque image in a zoomable, pannable lightbox — same lightgallery setup used app-wide. */
openImageZoom(side: 'front' | 'back'): void {
const url = side === 'front' ? this.frontImageUrl : this.backImageUrl;
if (!url) {
return;
}
this.destroyLightbox();
const items: GalleryItem[] = [
{ src: url, thumb: url, subHtml: `<div class="lg-caption"><h4>Cheque ${side}</h4></div>` }];
this.lightboxInstance = lightGallery(this.lightboxRoot.nativeElement, {
dynamic: true,
dynamicEl: items,
plugins: this.lightboxPlugins,
download: false,
closable: true,
escKey: true,
zoomFromOrigin: true
});
this.lightboxInstance.openGallery(0);
}
/** Opens a signer's stored signature image in the same zoomable lightbox, for a closer visual comparison. */
openSignatureZoom(signer: RequiredSigner): void {
if (!signer.signatureUrl) {
return;
}
this.destroyLightbox();
const items: GalleryItem[] = [
{
src: signer.signatureUrl,
thumb: signer.signatureUrl,
subHtml: `<div class="lg-caption"><h4>${signer.name} — stored signature</h4></div>`
}
];
this.lightboxInstance = lightGallery(this.lightboxRoot.nativeElement, {
dynamic: true,
dynamicEl: items,
plugins: this.lightboxPlugins,
download: false,
closable: true,
escKey: true,
zoomFromOrigin: true
});
this.lightboxInstance.openGallery(0);
}
private destroyLightbox(): void {
if (this.lightboxInstance) {
this.lightboxInstance.destroy();
this.lightboxInstance = null;
}
}
private loadAccountInfo(): void {
if (!this.item.accountType || !this.item.accountId) {
return;
}
this.loadingAccountInfo = true;
this.signatoryService.fetchAccountSignatories(this.item.accountType, this.item.accountId).subscribe({
next: (info: AccountSignatoryInfo) => {
this.accountInfo = info;
this.buildRequiredSigners(info);
this.loadingAccountInfo = false;
},
error: () => {
this.loadingAccountInfo = false;
}
});
}
/**
* The required-signer list, built from the account's already-captured mode of operation and each joint
* holder's mandatory flag: Either/Anyone-or-Survivor needs any ONE signer; anything else (Jointly, Former
* or Survivor) needs every mandatory signer individually checked.
*/
private buildRequiredSigners(info: AccountSignatoryInfo): void {
const anyMode =
!info.modeOfOperation ||
info.modeOfOperation === 'EITHER_OR_SURVIVOR' ||
info.modeOfOperation === 'ANYONE_OR_SURVIVOR';
this.signerSelectionMode = anyMode ? 'any' : 'all';
const jointHolders = info.jointHolders || [];
// for a joint account the primary holder is already one of the joint-holder rows (isPrimary: true) —
// only fall back to the account-level clientId/name when no joint-holder rows exist at all (plain
// individual account), otherwise the primary would be added twice
const primaryAlreadyListed = jointHolders.some((h) => h.isPrimary);
// mandatory-ness is recorded two ways depending on how the account was set up: the isMandatory boolean
// column, or the signingAuthority text (the field the existing cashier joint-holder view keys off) —
// trust either signal rather than assuming both were populated consistently
const holderIsMandatory = (h: JointHolder): boolean =>
!!h.isMandatory || (h.signingAuthority ?? '').toLowerCase() === 'mandatory';
const signers: RequiredSigner[] = [];
if (!primaryAlreadyListed && info.primaryClientId) {
signers.push({
clientId: info.primaryClientId,
name: info.primaryName || 'Primary Holder',
role: 'Primary Holder',
mandatory: true,
isPrimary: true,
signatureUrl: null,
loadingSignature: true,
checked: false,
...this.defaultOverlayPosition(signers.length)
});
}
jointHolders
.filter((h) => (this.signerSelectionMode === 'all' ? holderIsMandatory(h) : true))
.forEach((h) => {
signers.push({
clientId: h.clientId,
name: h.displayName,
role: h.role || (holderIsMandatory(h) ? 'Mandatory' : 'Optional'),
mandatory: holderIsMandatory(h),
isPrimary: !!h.isPrimary,
signatureUrl: null,
loadingSignature: true,
checked: false,
...this.defaultOverlayPosition(signers.length)
});
});
this.requiredSigners = signers;
signers.forEach((s) => {
this.signatoryService.fetchClientSignature(s.clientId).subscribe({
next: (url: string | null) => {
s.signatureUrl = url;
s.loadingSignature = false;
},
error: () => {
s.loadingSignature = false;
}
});
});
}
/** Staggers each signer's initial overlay position so several stacked on the same cheque don't start exactly on top of each other. */
private defaultOverlayPosition(index: number): {
overlayVisible: boolean;
overlayX: number;
overlayY: number;
overlayWidth: number;
overlayOpacity: number;
} {
return {
overlayVisible: false,
overlayX: 12 + index * 28,
overlayY: 12 + index * 28,
overlayWidth: OVERLAY_DEFAULT_WIDTH,
overlayOpacity: 0.75
};
}
toggleOverlay(signer: RequiredSigner): void {
signer.overlayVisible = !signer.overlayVisible;
}
private dragState: {
signer: RequiredSigner;
startClientX: number;
startClientY: number;
startX: number;
startY: number;
elWidth: number;
elHeight: number;
} | null = null;
private resizeState: { signer: RequiredSigner; startClientX: number; startWidth: number } | null = null;
/** Drag begins anywhere on the overlay box (header, image, or body). Captures the overlay's own rendered
* size so the move handler below can keep it from being dragged off the cheque entirely. */
startOverlayDrag(event: MouseEvent, signer: RequiredSigner): void {
event.preventDefault();
event.stopPropagation();
const rect = (event.currentTarget as HTMLElement)?.getBoundingClientRect();
this.dragState = {
signer,
startClientX: event.clientX,
startClientY: event.clientY,
startX: signer.overlayX,
startY: signer.overlayY,
elWidth: rect?.width ?? signer.overlayWidth,
elHeight: rect?.height ?? 80
};
}
startOverlayResize(event: MouseEvent, signer: RequiredSigner): void {
event.preventDefault();
event.stopPropagation();
this.resizeState = { signer, startClientX: event.clientX, startWidth: signer.overlayWidth };
}
/**
* The overlay's left/top are CSS-relative to .cheque-image-wrap (its positioned ancestor), but the clerk
* should be able to drag it above/left/right/below the cheque image too — not just within its own
* rectangle — so the true bound is the full fullscreen stage, not the (smaller) image. This converts the
* wrap-relative coordinate into the stage's frame, clamps against the stage's actual size, then converts
* back — allowing free placement anywhere on screen while still keeping it reachable/visible.
*/
private clampToStage(value: number, elSize: number, wrapOffset: number, stageSize: number | undefined): number {
if (stageSize === undefined) {
return value;
}
const min = -wrapOffset;
const max = stageSize - wrapOffset - elSize;
return Math.min(Math.max(max, min), Math.max(min, value));
}
@HostListener('document:mousemove', ['$event'])
onDocumentMouseMove(event: MouseEvent): void {
if (this.dragState) {
const signer = this.dragState.signer;
const wrapRect = this.chequeImageWrapEl?.nativeElement.getBoundingClientRect();
const stageRect = this.chequeStageEl?.nativeElement.getBoundingClientRect();
const offsetX = wrapRect && stageRect ? wrapRect.left - stageRect.left : 0;
const offsetY = wrapRect && stageRect ? wrapRect.top - stageRect.top : 0;
const rawX = this.dragState.startX + (event.clientX - this.dragState.startClientX);
const rawY = this.dragState.startY + (event.clientY - this.dragState.startClientY);
signer.overlayX = this.clampToStage(rawX, this.dragState.elWidth, offsetX, stageRect?.width);
signer.overlayY = this.clampToStage(rawY, this.dragState.elHeight, offsetY, stageRect?.height);
} else if (this.resizeState) {
const signer = this.resizeState.signer;
signer.overlayWidth = Math.min(
OVERLAY_MAX_WIDTH,
Math.max(OVERLAY_MIN_WIDTH, this.resizeState.startWidth + (event.clientX - this.resizeState.startClientX))
);
}
}
@HostListener('document:mouseup')
onDocumentMouseUp(): void {
this.dragState = null;
this.resizeState = null;
}
/**
* Uses the real browser Fullscreen API (not just a CSS position:fixed simulation) — a CSS-only "fullscreen"
* div nested inside the Material dialog's animated container can end up sized/positioned relative to that
* ancestor instead of the true viewport (the dialog applies a CSS transform for its open/close animation,
* which becomes the containing block for any position:fixed descendant). Element.requestFullscreen() instead
* promotes the stage to the browser's top layer, fully independent of any ancestor transform.
*/
toggleFullscreen(): void {
if (this.fullscreenActive) {
this.exitFullscreen();
return;
}
const el = this.chequeStageEl?.nativeElement;
if (el?.requestFullscreen) {
el.requestFullscreen().catch(() => this.enterFallbackFullscreen());
} else {
this.enterFallbackFullscreen();
}
}
private enterFallbackFullscreen(): void {
this.fullscreenActive = true;
this.resetOverlaysForFullscreenEntry();
this.chequeZoom = 1;
}
private exitFullscreen(): void {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
this.fullscreenActive = false;
this.chequeZoom = 1;
}
}
/** Keeps fullscreenActive in sync with real browser fullscreen state, including when the user exits via Esc
* or the browser's own controls rather than our close button. */
@HostListener('document:fullscreenchange')
onFullscreenChange(): void {
const isNativeFullscreen = document.fullscreenElement === this.chequeStageEl?.nativeElement;
if (isNativeFullscreen) {
this.fullscreenActive = true;
this.resetOverlaysForFullscreenEntry();
this.chequeZoom = 1;
} else if (this.fullscreenActive) {
this.fullscreenActive = false;
this.chequeZoom = 1;
}
}
/** Ctrl/pinch-free scroll-to-zoom on the cheque itself — independent of dragging a signature overlay. */
onChequeWheel(event: WheelEvent): void {
if (!this.fullscreenActive) {
return;
}
event.preventDefault();
const delta = event.deltaY > 0 ? -ItemDecisionDialogComponent.ZOOM_STEP : ItemDecisionDialogComponent.ZOOM_STEP;
this.chequeZoom = Math.min(
ItemDecisionDialogComponent.ZOOM_MAX,
Math.max(ItemDecisionDialogComponent.ZOOM_MIN, Math.round((this.chequeZoom + delta) * 100) / 100)
);
}
zoomIn(): void {
this.chequeZoom = Math.min(
ItemDecisionDialogComponent.ZOOM_MAX,
this.chequeZoom + ItemDecisionDialogComponent.ZOOM_STEP
);
}
zoomOut(): void {
this.chequeZoom = Math.max(
ItemDecisionDialogComponent.ZOOM_MIN,
this.chequeZoom - ItemDecisionDialogComponent.ZOOM_STEP
);
}
resetZoom(): void {
this.chequeZoom = 1;
}
/** Escape only needs handling here for the CSS-fallback path — native fullscreen already exits itself on
* Escape and reports back through fullscreenchange above. */
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.fullscreenActive && !document.fullscreenElement) {
this.fullscreenActive = false;
}
}
/** Fullscreen always starts with nothing placed on the cheque — only the shelf of stored signatures on the
* side. A signature only appears on the cheque once it's actually dragged there from the shelf. */
private resetOverlaysForFullscreenEntry(): void {
this.requiredSigners.forEach((s) => {
s.overlayVisible = false;
});
}
/**
* Starting to drag a shelf thumbnail places that signature onto the cheque, right under the cursor, and
* immediately continues the same drag so it follows the mouse into position in one motion — rather than a
* separate "add" click followed by a second drag. Clicking an already-placed shelf item (no drag) just
* removes it instead, since there's nothing left to place.
*/
onShelfItemMouseDown(event: MouseEvent, signer: RequiredSigner): void {
if (signer.overlayVisible) {
this.toggleOverlay(signer);
return;
}
event.preventDefault();
const wrapRect = this.chequeImageWrapEl?.nativeElement.getBoundingClientRect();
const stageRect = this.chequeStageEl?.nativeElement.getBoundingClientRect();
const offsetX = wrapRect && stageRect ? wrapRect.left - stageRect.left : 0;
const offsetY = wrapRect && stageRect ? wrapRect.top - stageRect.top : 0;
// the overlay doesn't exist in the DOM yet at this point (overlayVisible is only just being set below),
// so its rendered size can't be measured — a signature image's own aspect ratio plus the header bar height
// gives a reasonable estimate for keeping the initial drop clamped to the stage like every drag after it
const estimatedHeight = Math.round(signer.overlayWidth / 2.5) + 20;
const x = wrapRect ? event.clientX - wrapRect.left - signer.overlayWidth / 2 : 0;
const y = wrapRect ? event.clientY - wrapRect.top - 14 : 0;
signer.overlayVisible = true;
signer.overlayX = this.clampToStage(x, signer.overlayWidth, offsetX, stageRect?.width);
signer.overlayY = this.clampToStage(y, estimatedHeight, offsetY, stageRect?.height);
this.dragState = {
signer,
startClientX: event.clientX,
startClientY: event.clientY,
startX: signer.overlayX,
startY: signer.overlayY,
elWidth: signer.overlayWidth,
elHeight: estimatedHeight
};
}
/** null when the account's balance couldn't be resolved at all — the automatic "Balance" check pill already
* covers that outcome, this just makes the actual sufficiency legible at a glance rather than requiring
* the clerk to mentally subtract Available Balance minus Amount themselves. */
get balanceSufficient(): boolean | null {
const available = this.accountInfo?.availableBalance;
if (
available === null ||
available === undefined ||
this.item?.amount === null ||
this.item?.amount === undefined
) {
return null;
}
return available >= this.item.amount;
}
get modeOfOperationLabel(): string {
const labels: { [key: string]: string } = {
EITHER_OR_SURVIVOR: 'Either or Survivor — any one signatory',
ANYONE_OR_SURVIVOR: 'Anyone or Survivor — any one signatory',
FORMER_OR_SURVIVOR: 'Former or Survivor — the mandatory signatory',
JOINTLY: 'Jointly — all mandatory signatories'
};
const mode = this.accountInfo?.modeOfOperation;
return (mode && labels[mode]) || mode || 'Not captured on the account';
}
/** True once account/signatory data couldn't be resolved at all — falls back to a manual override rather than a dead end. */
get showManualOverride(): boolean {
return !this.loadingAccountInfo && (!this.accountInfo || this.requiredSigners.length === 0);
}
get signatureVerified(): boolean {
if (this.showManualOverride) {
return this.manualOverrideChecked;
}
if (!this.requiredSigners.length) {
return false;
}
if (this.signerSelectionMode === 'any') {
return this.requiredSigners.some((s) => s.checked);
}
return this.requiredSigners.every((s) => s.checked);
}
setMode(mode: 'PASS' | 'RETURN'): void {
this.mode = mode;
}
submit(): void {
if (this.busy) {
return;
}
if (this.mode === 'PASS') {
if (!this.signatureVerified) {
this.notify('Confirm the required signature(s) before passing this cheque', true);
return;
}
this.busy = true;
this.ctsService.verifyInwardItemSignature(this.item.id, true).subscribe({
next: () => {
const payload = { note: this.passForm.value.note || undefined, locale: 'en' };
this.ctsService.passInwardItem(this.item.id, payload).subscribe({
next: () => {
this.busy = false;
this.notify('Cheque passed');
this.dialogRef.close({ actioned: true });
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not pass the cheque'), true);
}
});
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not record signature verification'), true);
}
});
} else {
if (this.returnForm.invalid) {
return;
}
this.busy = true;
const raw = this.returnForm.value;
const payload = { returnReasonCode: raw.returnReasonCode, reason: raw.reason || undefined, locale: 'en' };
this.ctsService.returnInwardItem(this.item.id, payload).subscribe({
next: () => {
this.busy = false;
this.notify('Cheque returned');
this.dialogRef.close({ actioned: true });
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not return the cheque'), true);
}
});
}
}
}
@@ -0,0 +1,199 @@
<!--
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="dialog-header">
<div>
<h2 mat-dialog-title>Validation Comparison</h2>
<div class="dialog-subtitle">Cheque {{ item.chequeNumber }} · what each automatic check actually compared</div>
</div>
<button type="button" mat-icon-button mat-dialog-close matTooltip="Close" class="header-close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div mat-dialog-content>
<div class="rule-card" [ngClass]="ruleClass(item.accountCheckPassed)">
<div class="rule-card-header">
<fa-icon [icon]="statusIcon(ruleClass(item.accountCheckPassed))"></fa-icon>
<span>Account</span>
</div>
<div class="compare-list">
<div class="compare-row">
<span class="compare-label">Account Type</span><span class="compare-value">{{ item.accountType || '–' }}</span>
</div>
<div class="compare-row">
<span class="compare-label">Account Number</span
><span class="compare-value">{{ item.accountNo || item.accountId || '–' }}</span>
</div>
<div class="compare-row">
<span class="compare-label">Account Holder</span><span class="compare-value">{{ item.clientName || '–' }}</span>
</div>
</div>
</div>
<div class="rule-card" [ngClass]="ruleClass(item.balanceCheckPassed)">
<div class="rule-card-header">
<fa-icon [icon]="statusIcon(ruleClass(item.balanceCheckPassed))"></fa-icon>
<span>Balance</span>
</div>
<div class="compare-list">
<div class="compare-row">
<span class="compare-label">Available Balance</span
><span class="compare-value">{{ accountInfo?.availableBalance ?? 'Not available' }}</span>
</div>
<div class="compare-row">
<span class="compare-label">Cheque Amount</span><span class="compare-value">{{ item.amount }}</span>
</div>
<div class="compare-row">
<span class="compare-label">Result</span
><span class="compare-value">{{
balanceSufficient === null ? 'Unknown' : balanceSufficient ? 'Sufficient' : 'Insufficient'
}}</span>
</div>
</div>
</div>
<div class="rule-card" [ngClass]="ruleClass(item.duplicateCheckPassed)">
<div class="rule-card-header">
<fa-icon [icon]="statusIcon(ruleClass(item.duplicateCheckPassed))"></fa-icon>
<span>Duplicate</span>
</div>
<div class="compare-list">
<div class="compare-row">
<span class="compare-label">Checked Against</span
><span class="compare-value"
>Same cheque number + account + amount already PASSed, and the cheque leaf's own USED status</span
>
</div>
<div class="compare-row">
<span class="compare-label">Cheque Number</span
><span class="compare-value">{{ item.chequeNumber || '–' }}</span>
</div>
</div>
</div>
<div class="rule-card" [ngClass]="ruleClass(item.stopPaymentCheckPassed)">
<div class="rule-card-header">
<fa-icon [icon]="statusIcon(ruleClass(item.stopPaymentCheckPassed))"></fa-icon>
<span>Stop Payment</span>
</div>
<div class="compare-list">
<div class="compare-row">
<span class="compare-label">Checked Against</span
><span class="compare-value">The matching cheque leaf's own stop-payment status</span>
</div>
@if (item.instrumentStopPaymentFlag) {
<div class="compare-row">
<span class="compare-label">Depositing-Bank Flag</span
><span class="compare-value warn-text"
>Stop payment was flagged on this instrument by the depositing bank</span
>
</div>
}
</div>
</div>
<div class="rule-card" [ngClass]="ruleClass(item.amountWordsFiguresMatch)">
<div class="rule-card-header">
<fa-icon [icon]="statusIcon(ruleClass(item.amountWordsFiguresMatch))"></fa-icon>
<span>Amount Match</span>
</div>
<div class="compare-list">
<div class="compare-row">
<span class="compare-label">Amount (Figures)</span><span class="compare-value">{{ item.amount }}</span>
</div>
<div class="compare-row">
<span class="compare-label">Amount (Words)</span
><span class="compare-value">{{ item.amountInWords || 'Not captured' }}</span>
</div>
@if (amountWordsDigits) {
<div class="compare-row">
<span class="compare-label">Digits Extracted From Words</span
><span class="compare-value">{{ amountWordsDigits }}</span>
</div>
}
</div>
</div>
<div class="rule-card" [ngClass]="ruleClass(item.staleOrPostdated, false)">
<div class="rule-card-header">
<fa-icon [icon]="statusIcon(ruleClass(item.staleOrPostdated, false))"></fa-icon>
<span>Stale / Postdated</span>
</div>
<div class="compare-list">
<div class="compare-row">
<span class="compare-label">Instrument Date</span
><span class="compare-value">{{ item.instrumentDate ? (item.instrumentDate | dateFormat) : '–' }}</span>
</div>
<div class="compare-row">
<span class="compare-label">Valid Until (+3 months)</span
><span class="compare-value">{{ instrumentValidUntil ? (instrumentValidUntil | dateFormat) : '–' }}</span>
</div>
<div class="compare-row">
<span class="compare-label">{{ asOfLabel }}</span
><span class="compare-value">{{ asOfDate | dateFormat }}</span>
</div>
</div>
</div>
<div class="rule-card" [ngClass]="ruleClass(item.positivePayCheck)">
<div class="rule-card-header">
<fa-icon [icon]="statusIcon(ruleClass(item.positivePayCheck))"></fa-icon>
<span>Positive Pay</span>
</div>
<div class="compare-list">
<div class="compare-row">
<span class="compare-label">RBI PPS Threshold</span
><span class="compare-value">{{ positivePayThreshold }}</span>
</div>
<div class="compare-row">
<span class="compare-label">Cheque Amount</span><span class="compare-value">{{ item.amount }}</span>
</div>
<div class="compare-row">
<span class="compare-label">Applicable</span
><span class="compare-value">{{
positivePayApplicable ? 'Yes — at or above threshold' : 'No — below threshold'
}}</span>
</div>
</div>
</div>
@if (item.iqaStatus && item.iqaStatus !== 'NA') {
<div class="rule-card" [ngClass]="pillClass(item.iqaStatus)">
<div class="rule-card-header">
<fa-icon [icon]="statusIcon(pillClass(item.iqaStatus))"></fa-icon>
<span>Image Quality — {{ item.iqaStatus }}</span>
</div>
@if (item.iqaDetail || item.iqaSubChecks) {
<div class="compare-list">
@if (item.iqaDetail) {
<div class="compare-row">
<span class="compare-label">Detail</span><span class="compare-value">{{ item.iqaDetail }}</span>
</div>
}
@if (item.iqaSubChecks) {
<div class="compare-row">
<span class="compare-label">Sub-checks</span><span class="compare-value">{{ item.iqaSubChecks }}</span>
</div>
}
</div>
}
</div>
}
@if (item.validationDetail) {
<div class="backend-notes">
<strong>Backend Validation Notes</strong>
<p>{{ item.validationDetail }}</p>
</div>
}
</div>
<div mat-dialog-actions align="end">
<button mat-raised-button color="primary" mat-dialog-close>Close</button>
</div>
@@ -0,0 +1,144 @@
/**
* 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/.
*/
$ok: #1a9c63;
$ok-bg: #e8f8f0;
$bad: #d32f2f;
$bad-bg: #fdeaea;
$neutral: #6b6b6b;
$neutral-bg: #f0f0f0;
.dialog-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 12px 4px 24px;
[mat-dialog-title] {
margin: 0;
font-size: 1.15rem;
font-weight: 600;
}
}
.dialog-subtitle {
margin-top: 2px;
color: rgb(0 0 0 / 55%);
font-size: 0.8rem;
}
.header-close {
flex-shrink: 0;
color: rgb(0 0 0 / 45%);
}
.rule-card {
margin-bottom: 12px;
padding: 14px 16px;
border-radius: 8px;
border-left: 4px solid $neutral;
background: $neutral-bg;
&:last-of-type {
margin-bottom: 0;
}
&.ok {
border-left-color: $ok;
background: $ok-bg;
.rule-card-header {
color: $ok;
}
}
&.bad {
border-left-color: $bad;
background: $bad-bg;
.rule-card-header {
color: $bad;
}
}
&.neutral {
.rule-card-header {
color: $neutral;
}
}
}
.rule-card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 0.9rem;
font-weight: 600;
fa-icon {
font-size: 0.95rem;
}
}
.compare-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.compare-row {
display: grid;
grid-template-columns: minmax(120px, 38%) 1fr;
gap: 6px 16px;
padding: 4px 0;
border-top: 1px solid rgb(255 255 255 / 60%);
&:first-child {
border-top: none;
}
}
.compare-label {
color: rgb(0 0 0 / 55%);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.compare-value {
font-size: 0.85rem;
word-break: break-word;
&.warn-text {
color: $bad;
font-weight: 600;
}
}
.backend-notes {
margin-top: 4px;
padding: 10px 14px;
border-radius: 8px;
background: rgb(0 0 0 / 4%);
font-size: 0.85rem;
strong {
display: block;
margin-bottom: 4px;
color: rgb(0 0 0 / 55%);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
p {
margin: 0;
}
}
@@ -0,0 +1,126 @@
/**
* 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 { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { pillClass } from '../../../../clearing-network/clearing-status';
import { AccountSignatoryInfo } from '../../../../clearing-network/signatory.service';
/** RBI Positive Pay System threshold — mirrors InwardClearingValidationService.POSITIVE_PAY_THRESHOLD. */
const POSITIVE_PAY_THRESHOLD = 50000;
/** Stale-instrument window — mirrors InwardClearingValidationService.STALE_MONTHS. */
const STALE_MONTHS = 3;
/**
* Shows exactly what each automatic validation rule actually compared, not just its pass/fail outcome —
* e.g. the amount in figures next to the amount in words, or the instrument date next to its valid-until
* date — mirroring the real values InwardClearingValidationService.java checks server-side.
*/
@Component({
selector: 'mifosx-validation-detail-dialog',
templateUrl: './validation-detail-dialog.component.html',
styleUrls: ['./validation-detail-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatButtonModule,
MatTooltipModule,
FaIconComponent
]
})
export class ValidationDetailDialogComponent {
dialogRef = inject<MatDialogRef<ValidationDetailDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
item: any = this.data?.item || {};
accountInfo: AccountSignatoryInfo | null = this.data?.accountInfo || null;
pillClass = pillClass;
ruleClass(value: boolean | null | undefined, goodWhenTrue = true): 'ok' | 'bad' | 'neutral' {
if (value === null || value === undefined) {
return 'neutral';
}
const good = goodWhenTrue ? value === true : value === false;
return good ? 'ok' : 'bad';
}
statusIcon(status: 'ok' | 'bad' | 'neutral'): string {
return status === 'ok' ? 'check-circle' : status === 'bad' ? 'times-circle' : 'minus-circle';
}
get balanceSufficient(): boolean | null {
const available = this.accountInfo?.availableBalance;
if (
available === null ||
available === undefined ||
this.item?.amount === null ||
this.item?.amount === undefined
) {
return null;
}
return available >= this.item.amount;
}
/** The digits InwardClearingValidationService.amountWordsFiguresMatch() extracts from amountInWords, for a
* transparent side-by-side with the figure amount rather than just trusting the pass/fail pill. */
get amountWordsDigits(): string | null {
const words: string | undefined = this.item?.amountInWords;
if (!words) {
return null;
}
const digits = words.replace(/[^0-9]/g, '');
return digits || null;
}
get instrumentValidUntil(): Date | null {
if (!this.item?.instrumentDate) {
return null;
}
const d = new Date(this.item.instrumentDate);
d.setMonth(d.getMonth() + STALE_MONTHS);
return d;
}
/**
* The date the stale/postdated check actually ran against — for an already-decided item that's when it was
* decided (decisionedOn), NOT today. Showing today's live date here for a cheque passed 50 days ago would
* make it look like the comparison is being silently re-run against the current date, which it isn't — the
* decision already happened, using whatever "today" was back then.
*/
get asOfDate(): Date {
return this.item?.decisionedOn ? new Date(this.item.decisionedOn) : new Date();
}
get asOfLabel(): string {
return this.item?.decisionedOn ? 'As Of Decision' : 'Today';
}
get positivePayApplicable(): boolean {
return (this.item?.amount ?? 0) >= POSITIVE_PAY_THRESHOLD;
}
readonly positivePayThreshold = POSITIVE_PAY_THRESHOLD;
}
@@ -0,0 +1,258 @@
<!--
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.Inward Clearing' | translate }}</h2>
<div class="header-actions">
<button mat-raised-button color="primary" (click)="showReceiveForm = !showReceiveForm">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'labels.buttons.Receive Batch' | translate }}
</button>
<button mat-raised-button [routerLink]="['/ctsclearing']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
@if (!showReceiveForm) {
<div class="filter-row">
@for (s of ['RECEIVED', 'VALIDATED', 'SETTLED']; track s) {
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
}
</div>
<div class="filter-row">
<button mat-stroked-button [class.active]="quickFilterMode === 'today'" (click)="setQuickFilter('today')">
Today
</button>
<button mat-stroked-button [class.active]="quickFilterMode === 'yesterday'" (click)="setQuickFilter('yesterday')">
Yesterday
</button>
<button mat-stroked-button [class.active]="quickFilterMode === 'all'" (click)="setQuickFilter('all')">All</button>
</div>
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="filterForm" (ngSubmit)="applyFilters()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
<input matInput [matDatepicker]="fromPicker" formControlName="fromDate" />
<mat-datepicker-toggle matSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
<input matInput [matDatepicker]="toPicker" formControlName="toDate" />
<mat-datepicker-toggle matSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Batch Reference' | translate }}</mat-label>
<input matInput formControlName="batchReference" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Presenting Bank Name' | translate }}</mat-label>
<input matInput formControlName="presentingBankName" />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="clearFilters()">
{{ 'labels.buttons.Clear' | translate }}
</button>
<button mat-raised-button color="primary" type="submit">{{ 'labels.buttons.Filter' | translate }}</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
@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.Batch Reference' | translate }}</mat-label>
<input matInput formControlName="batchReference" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Session Date' | translate }}</mat-label>
<input matInput [matDatepicker]="sessionPicker" formControlName="sessionDate" required />
<mat-datepicker-toggle matSuffix [for]="sessionPicker"></mat-datepicker-toggle>
<mat-datepicker #sessionPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Presentment Type' | translate }}</mat-label>
<mat-select formControlName="presentmentType" required>
<mat-option value="REGULAR">{{ 'labels.inputs.Regular' | translate }}</mat-option>
<mat-option value="EXPRESS">{{ 'labels.inputs.Express' | translate }}</mat-option>
<mat-option value="HIGH_VALUE">{{ 'labels.inputs.High Value' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Presenting Bank Name' | translate }}</mat-label>
<input matInput formControlName="presentingBankName" />
<mat-hint>{{ 'labels.text.Bank that presented this batch to us' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Presenting Bank Code' | translate }}</mat-label>
<input matInput formControlName="presentingBankCode" />
</mat-form-field>
</div>
<h3 class="section-title">{{ 'labels.inputs.Cheques' | translate }}</h3>
<div formArrayName="items" class="item-rows">
@for (row of items.controls; track row; let i = $index) {
<div class="item-row" [formGroupName]="i">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Cheque Number' | translate }}</mat-label>
<input matInput formControlName="chequeNumber" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.MICR Code' | translate }}</mat-label>
<input matInput formControlName="micrCode" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.IFSC' | translate }}</mat-label>
<input matInput formControlName="ifsc" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Payee Name' | translate }}</mat-label>
<input matInput formControlName="payeeName" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Amount' | translate }}</mat-label>
<input matInput type="number" formControlName="amount" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Amount In Words' | translate }}</mat-label>
<input matInput formControlName="amountInWords" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Instrument Date' | translate }}</mat-label>
<input matInput [matDatepicker]="instrPicker" formControlName="instrumentDate" />
<mat-datepicker-toggle matSuffix [for]="instrPicker"></mat-datepicker-toggle>
<mat-datepicker #instrPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Presented Date' | translate }}</mat-label>
<input matInput [matDatepicker]="presPicker" formControlName="presentedDate" />
<mat-datepicker-toggle matSuffix [for]="presPicker"></mat-datepicker-toggle>
<mat-datepicker #presPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Account Type' | translate }}</mat-label>
<mat-select formControlName="accountType">
<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 Id' | translate }}</mat-label>
<input matInput type="number" formControlName="accountId" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Original Serial No' | translate }}</mat-label>
<input matInput formControlName="originalSerialNumber" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Presenting Branch Code' | translate }}</mat-label>
<input matInput formControlName="presentingBranchCode" />
</mat-form-field>
<button
mat-icon-button
color="warn"
type="button"
class="row-remove"
(click)="removeItem(i)"
[disabled]="items.length === 1"
>
<fa-icon icon="trash"></fa-icon>
</button>
</div>
}
</div>
<button mat-stroked-button type="button" (click)="addItem()">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'labels.buttons.Add Cheque' | translate }}
</button>
<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 || busy">
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
@if (!showReceiveForm) {
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="batches">
<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="batchReference">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Batch Reference' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.batchReference }}</td>
</ng-container>
<ng-container matColumnDef="sessionDate">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Session Date' | translate }}</th>
<td mat-cell *matCellDef="let b">
{{ b.sessionDate | dateFormat }}
@if (b.receivedOn) {
<span class="muted">{{ b.receivedOn | dateFormat: 'hh:mm A' }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="presentmentType">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Presentment Type' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.presentmentType }}</td>
</ng-container>
<ng-container matColumnDef="items">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Items' | translate }}</th>
<td mat-cell *matCellDef="let b" class="num">{{ b.itemCount ?? b.totalCount ?? '–' }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Amount' | translate }}</th>
<td mat-cell *matCellDef="let b" class="num">{{ b.totalAmount ?? '–' }}</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]="pillClass(b.status)">{{ b.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let b" (click)="$event.stopPropagation()">
<button mat-button color="primary" (click)="open(b)">{{ 'labels.buttons.View' | translate }}</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns" (click)="open(row)" class="clickable-row"></tr>
</table>
@if (batches.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,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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
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%;
}
.section-title {
margin: 12px 0 8px;
font-size: 0.95rem;
}
.item-rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.item-row {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 8px 12px;
align-items: center;
padding: 8px;
border: 1px solid rgb(0 0 0 / 8%);
border-radius: 8px;
}
.row-remove {
align-self: center;
}
.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;
}
::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;
white-space: nowrap;
&.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,282 @@
/**
* 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 { formatDate } from '@angular/common';
import { UntypedFormArray, 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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { CtsClearingService } from '../ctsclearing.service';
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
/**
* Inward CTS presentment batches — cheques drawn on us, received from the clearing house. Lists batches
* and captures a new batch with inline per-cheque item rows before it is validated on the detail screen.
*/
@Component({
selector: 'mifosx-inward-batches',
templateUrl: './inward-batches.component.html',
styleUrls: ['./inward-batches.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class InwardBatchesComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private ctsService = inject(CtsClearingService);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
pillClass = pillClass;
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
batches: any[] = [];
totalRecords = 0;
pageSize = 10;
pageIndex = 0;
pageSizeOptions = [
10,
25,
50,
100
];
statusFilter = '';
busy = false;
showReceiveForm = false;
receiveForm: UntypedFormGroup;
filterForm: UntypedFormGroup;
columns = [
'id',
'batchReference',
'sessionDate',
'presentmentType',
'items',
'amount',
'status',
'actions'
];
constructor() {
this.receiveForm = this.formBuilder.group({
batchReference: [
'',
Validators.required
],
sessionDate: [
'',
Validators.required
],
presentmentType: [
'REGULAR',
Validators.required
],
presentingBankCode: [''],
presentingBankName: [''],
items: this.formBuilder.array([])
});
// Restores whichever quick filter (Today/Yesterday/All) was last selected, so navigating to a batch and
// back doesn't silently jump back to "today" — persisted on the singleton service, not this component.
const restoredDates = this.datesForQuickFilter(this.ctsService.inwardBatchesDateFilter);
this.filterForm = this.formBuilder.group({
fromDate: [restoredDates.fromDate],
toDate: [restoredDates.toDate],
batchReference: [''],
presentingBankName: ['']
});
}
get quickFilterMode(): 'today' | 'yesterday' | 'all' {
return this.ctsService.inwardBatchesDateFilter;
}
private datesForQuickFilter(mode: 'today' | 'yesterday' | 'all'): { fromDate: Date | null; toDate: Date | null } {
if (mode === 'all') {
return { fromDate: null, toDate: null };
}
const day = new Date();
if (mode === 'yesterday') {
day.setDate(day.getDate() - 1);
}
return { fromDate: day, toDate: day };
}
setQuickFilter(mode: 'today' | 'yesterday' | 'all'): void {
this.ctsService.inwardBatchesDateFilter = mode;
const dates = this.datesForQuickFilter(mode);
this.filterForm.patchValue({ fromDate: dates.fromDate, toDate: dates.toDate });
this.pageIndex = 0;
this.loadBatches();
}
ngOnInit(): void {
this.loadBatches();
this.addItem();
}
get items(): UntypedFormArray {
return this.receiveForm.get('items') as UntypedFormArray;
}
addItem(): void {
this.items.push(
this.formBuilder.group({
chequeNumber: [
'',
Validators.required
],
micrCode: [''],
ifsc: [''],
payeeName: [''],
amount: [
'',
Validators.required
],
amountInWords: [''],
instrumentDate: [''],
presentedDate: [''],
accountType: [
'SAVINGS',
Validators.required
],
accountId: [
'',
Validators.required
],
originalSerialNumber: [''],
presentingBranchCode: ['']
})
);
}
removeItem(index: number): void {
this.items.removeAt(index);
}
loadBatches(): void {
const f = this.filterForm.value;
const filters = {
fromDate: this.toApiDate(f.fromDate),
toDate: this.toApiDate(f.toDate),
batchReference: f.batchReference || undefined,
presentingBankName: f.presentingBankName || undefined
};
this.ctsService
.getInwardBatches(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize, filters)
.subscribe((res: any) => {
this.batches = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.totalRecords = Number(res?.totalFilteredRecords ?? this.batches.length);
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.loadBatches();
}
applyFilters(): void {
// A manual date edit no longer matches a plain Today/Yesterday/All quick state — treat it as custom.
this.ctsService.inwardBatchesDateFilter = 'all';
this.pageIndex = 0;
this.loadBatches();
}
clearFilters(): void {
this.ctsService.inwardBatchesDateFilter = 'all';
this.filterForm.reset({ fromDate: '', toDate: '', batchReference: '', presentingBankName: '' });
this.pageIndex = 0;
this.loadBatches();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadBatches();
}
open(batch: any): void {
this.router.navigate([
'/ctsclearing',
'inward',
batch.id
]);
}
private toApiDate(value: any): string | undefined {
if (!value) {
return undefined;
}
return formatDate(value, 'yyyy-MM-dd', 'en');
}
submitReceive(): void {
if (this.receiveForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.receiveForm.value;
const payload = {
batchReference: raw.batchReference,
sessionDate: this.toApiDate(raw.sessionDate),
presentmentType: raw.presentmentType,
presentingBankCode: raw.presentingBankCode || undefined,
presentingBankName: raw.presentingBankName || undefined,
items: (raw.items || []).map((it: any) => ({
chequeNumber: it.chequeNumber,
micrCode: it.micrCode || undefined,
ifsc: it.ifsc || undefined,
payeeName: it.payeeName || undefined,
amount: it.amount,
amountInWords: it.amountInWords || undefined,
instrumentDate: this.toApiDate(it.instrumentDate),
presentedDate: this.toApiDate(it.presentedDate),
accountType: it.accountType || undefined,
accountId: it.accountId || undefined,
originalSerialNumber: it.originalSerialNumber || undefined,
presentingBranchCode: it.presentingBranchCode || undefined
})),
dateFormat: 'yyyy-MM-dd',
locale: 'en'
};
this.ctsService.createInwardBatch(payload).subscribe({
next: () => {
this.busy = false;
this.showReceiveForm = false;
this.receiveForm.reset({ presentmentType: 'REGULAR' });
this.items.clear();
this.addItem();
this.notify('Inward batch received');
this.loadBatches();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not receive the batch'), true);
}
});
}
}
@@ -0,0 +1,192 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.inputs.Batch Reference' | translate }}: {{ batchReference }}</h2>
<button mat-raised-button [routerLink]="['/ctsclearing', 'outward']">
{{ 'labels.buttons.Back' | translate }}
</button>
</div>
<div class="filter-row">
@for (s of ['SENT_TO_CLEARING_HOUSE', 'SETTLED', 'DISHONOURED']; track s) {
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
}
</div>
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="filterForm" (ngSubmit)="applyFilters()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Cheque Number' | translate }}</mat-label>
<input matInput formControlName="chequeNumber" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Drawer Bank Name' | translate }}</mat-label>
<input matInput formControlName="drawerBankName" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Payee Name' | translate }}</mat-label>
<input matInput formControlName="payeeName" />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="clearFilters()">
{{ 'labels.buttons.Clear' | translate }}
</button>
<button mat-raised-button color="primary" type="submit">{{ 'labels.buttons.Filter' | translate }}</button>
</div>
</form>
</mat-card-content>
</mat-card>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="presentments">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
<td mat-cell *matCellDef="let p">{{ p.id }}</td>
</ng-container>
<ng-container matColumnDef="chequeNumber">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Cheque Number' | translate }}</th>
<td mat-cell *matCellDef="let p">{{ p.chequeNumber }}</td>
</ng-container>
<ng-container matColumnDef="drawerBank">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Drawer Bank' | translate }}</th>
<td mat-cell *matCellDef="let p">
{{ p.drawerBankName || '–' }}
@if (p.drawerIfsc) {
<span class="muted">{{ p.drawerIfsc }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="payeeName">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payee Name' | translate }}</th>
<td mat-cell *matCellDef="let p">{{ p.payeeName }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Amount' | translate }}</th>
<td mat-cell *matCellDef="let p" class="num">{{ p.amount }}</td>
</ng-container>
<ng-container matColumnDef="image">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Image' | translate }}</th>
<td mat-cell *matCellDef="let p">
<div class="image-cell">
@if (getItemImageUrl(p, 'front')) {
<img
[src]="getItemImageUrl(p, 'front')"
class="cheque-thumb"
alt="front"
matTooltip="Front image — click to zoom"
(click)="openImageZoom(p, 'front')"
/>
}
@if (getItemImageUrl(p, 'back')) {
<img
[src]="getItemImageUrl(p, 'back')"
class="cheque-thumb"
alt="back"
matTooltip="Back image — click to zoom"
(click)="openImageZoom(p, 'back')"
/>
}
</div>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let p">
<span class="pill" [ngClass]="pillClass(p.status)">{{ p.status }}</span>
@if (p.iqaStatus && p.iqaStatus !== 'NA') {
<span class="pill" [ngClass]="pillClass(p.iqaStatus)" matTooltip="Image Quality Assessment"
>IQA: {{ p.iqaStatus }}</span
>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let p">
@if (p.status === 'SENT_TO_CLEARING_HOUSE') {
<button mat-button color="primary" (click)="act(p, 'settle')">
{{ 'labels.buttons.Settle' | translate }}
</button>
<button mat-button color="warn" (click)="toggleDishonour(p)">
{{ 'labels.buttons.Dishonour' | translate }}
</button>
}
@if (p.ctsFileJson) {
<button mat-button color="primary" (click)="toggleEnvelope(p)">
{{ shownEnvelopeId === p.id ? 'Hide' : 'View' }} CTS file
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
@if (presentments.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@for (p of presentments; track p.id) {
@if (shownEnvelopeId === p.id && p.ctsFileJson) {
<div class="envelope-panel">
<div class="envelope-head">
📄 CTS-2010 outward file sent to the clearing house — cheque #{{ p.chequeNumber }}
</div>
<pre class="cts-file">{{ prettyJson(p.ctsFileJson) }}</pre>
@if (p.npciResponseJson) {
<div class="envelope-head">📥 NPCI's real routing response for this batch:</div>
<pre class="cts-file">{{ prettyJson(p.npciResponseJson) }}</pre>
}
</div>
}
}
@if (totalRecords > 0) {
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
@if (dishonourForId) {
<mat-card class="add-form">
<mat-card-content>
<h3>{{ 'labels.inputs.Dishonour Cheque' | translate }}</h3>
<form [formGroup]="dishonourForm" (ngSubmit)="submitDishonour(dishonourForId)">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Dishonour Reason Code' | translate }}</mat-label>
<mat-select formControlName="dishonourReasonCode" required>
@for (c of reasonCodes; track c.id) {
<mat-option [value]="c.code">{{ c.code }} — {{ c.description }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="dishonourForId = null">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="warn" type="submit" [disabled]="dishonourForm.invalid || busy">
{{ 'labels.buttons.Dishonour' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
<div #lightboxRoot style="display: none"></div>
</div>
@@ -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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
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;
}
.muted {
display: block;
color: rgb(0 0 0 / 50%);
font-size: 0.78rem;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
white-space: nowrap;
margin-right: 4px;
&.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%);
}
.envelope-panel {
margin: 8px;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 6px;
background: #fafafa;
.envelope-head {
font-weight: 600;
margin-bottom: 6px;
}
}
.image-cell {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.cheque-thumb {
width: 40px;
height: 28px;
object-fit: cover;
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
}
.cts-file {
font-family: monospace;
font-size: 0.74rem;
background: #f5f7fa;
color: #1a2733;
border: 1px solid #dfe6ee;
padding: 10px;
border-radius: 4px;
max-height: 360px;
overflow: auto;
white-space: pre;
margin: 4px 0 12px;
}
@@ -0,0 +1,289 @@
/**
* 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, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
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 lightGallery from 'lightgallery';
import lgZoom from 'lightgallery/plugins/zoom';
import lgFullscreen from 'lightgallery/plugins/fullscreen';
import type { LightGallery } from 'lightgallery/lightgallery';
import type { GalleryItem } from 'lightgallery/lg-utils';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { CtsClearingService } from '../ctsclearing.service';
import { ClearingService } from '../../clearing-network/clearing.service';
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
/**
* One outward clearing session ("batch") — every cheque sent to NPCI sharing this batchReference. Settle /
* dishonour and image viewing work exactly as on the Outward Clearing screen; this view is scoped to a
* single session instead of showing every cheque ever captured.
*/
@Component({
selector: 'mifosx-outward-batch-detail',
templateUrl: './outward-batch-detail.component.html',
styleUrls: ['./outward-batch-detail.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule
]
})
export class OutwardBatchDetailComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private ctsService = inject(CtsClearingService);
private formBuilder = inject(UntypedFormBuilder);
private clearingService = inject(ClearingService);
private snackBar = inject(MatSnackBar);
@ViewChild('lightboxRoot', { static: true }) lightboxRoot: ElementRef<HTMLElement>;
private lightboxInstance: LightGallery | null = null;
private readonly lightboxPlugins = [
lgZoom,
lgFullscreen
];
pillClass = pillClass;
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
batchReference!: string;
presentments: any[] = [];
busy = false;
dishonourForId: number | null = null;
shownEnvelopeId: number | null = null;
reasonCodes: any[] = [];
totalRecords = 0;
pageSize = 10;
pageIndex = 0;
pageSizeOptions = [
10,
25,
50,
100
];
statusFilter = '';
dishonourForm: UntypedFormGroup;
filterForm: UntypedFormGroup;
columns = [
'id',
'chequeNumber',
'drawerBank',
'payeeName',
'amount',
'image',
'status',
'actions'
];
constructor() {
this.dishonourForm = this.formBuilder.group({
dishonourReasonCode: [
'',
Validators.required
]
});
this.filterForm = this.formBuilder.group({
chequeNumber: [''],
drawerBankName: [''],
payeeName: ['']
});
}
ngOnInit(): void {
this.batchReference = this.route.snapshot.paramMap.get('id') as string;
this.loadAll();
this.clearingService.getReturnReasonCodes('CTS').subscribe((res: any) => {
const list = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.reasonCodes = list.filter((c: any) => c.active !== false);
});
}
ngOnDestroy(): void {
this.destroyLightbox();
}
/** Cheque image data URLs, keyed "<presentmentId>_<side>" — populated once fetched. */
imageUrls: { [key: string]: string } = {};
loadAll(): void {
const f = this.filterForm.value;
const filters = {
batchReference: this.batchReference,
chequeNumber: f.chequeNumber || undefined,
drawerBankName: f.drawerBankName || undefined,
payeeName: f.payeeName || undefined
};
this.ctsService
.getOutwardPresentments(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize, filters)
.subscribe((res: any) => {
this.presentments = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.totalRecords = Number(res?.totalFilteredRecords ?? this.presentments.length);
this.presentments.forEach((p: any) => {
this.loadItemImage(p, 'front');
this.loadItemImage(p, 'back');
});
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.loadAll();
}
applyFilters(): void {
this.pageIndex = 0;
this.loadAll();
}
clearFilters(): void {
this.filterForm.reset({ chequeNumber: '', drawerBankName: '', payeeName: '' });
this.pageIndex = 0;
this.loadAll();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadAll();
}
/** Fetches the image as an authenticated base64 data URL and caches it — 404 (no upload yet) is expected and silent. */
loadItemImage(presentment: any, side: 'front' | 'back'): void {
this.ctsService.getOutwardPresentmentImage(presentment.id, side).subscribe({
next: (dataUrl: string) => (this.imageUrls[`${presentment.id}_${side}`] = dataUrl),
error: () => {
/* no image uploaded for this side yet */
}
});
}
getItemImageUrl(presentment: any, side: 'front' | 'back'): string | null {
return this.imageUrls[`${presentment.id}_${side}`] || null;
}
/** Opens the front/back cheque image(s) in a zoomable, pannable lightbox — same lightgallery setup used app-wide. */
openImageZoom(presentment: any, side: 'front' | 'back'): void {
const frontUrl = this.getItemImageUrl(presentment, 'front');
const backUrl = this.getItemImageUrl(presentment, 'back');
const items: GalleryItem[] = [];
if (frontUrl) {
items.push({
src: frontUrl,
thumb: frontUrl,
subHtml: `<div class="lg-caption"><h4>Front — Cheque ${presentment.chequeNumber}</h4></div>`
});
}
if (backUrl) {
items.push({
src: backUrl,
thumb: backUrl,
subHtml: `<div class="lg-caption"><h4>Back — Cheque ${presentment.chequeNumber}</h4></div>`
});
}
if (!items.length) {
return;
}
const startIndex = side === 'back' && frontUrl ? 1 : 0;
this.destroyLightbox();
this.lightboxInstance = lightGallery(this.lightboxRoot.nativeElement, {
dynamic: true,
dynamicEl: items,
plugins: this.lightboxPlugins,
download: false,
closable: true,
escKey: true,
zoomFromOrigin: true
});
this.lightboxInstance.openGallery(startIndex);
}
private destroyLightbox(): void {
if (this.lightboxInstance) {
this.lightboxInstance.destroy();
this.lightboxInstance = null;
}
}
act(presentment: any, command: string): void {
if (this.busy) {
return;
}
this.busy = true;
const messages: any = {
settle: 'Presentment settled',
dishonour: 'Presentment dishonoured'
};
this.ctsService.actOnOutwardPresentment(presentment.id, command, { locale: 'en' }).subscribe({
next: () => {
this.busy = false;
this.notify(messages[command] || 'Done');
this.loadAll();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Action failed'), true);
}
});
}
toggleDishonour(presentment: any): void {
this.dishonourForId = this.dishonourForId === presentment.id ? null : presentment.id;
this.dishonourForm.reset();
}
submitDishonour(id: number): void {
if (this.dishonourForm.invalid || this.busy) {
return;
}
this.busy = true;
const payload = { dishonourReasonCode: this.dishonourForm.value.dishonourReasonCode, locale: 'en' };
this.ctsService.actOnOutwardPresentment(id, 'dishonour', payload).subscribe({
next: () => {
this.busy = false;
this.dishonourForId = null;
this.notify('Presentment dishonoured');
this.loadAll();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Dishonour failed'), true);
}
});
}
toggleEnvelope(presentment: any): void {
this.shownEnvelopeId = this.shownEnvelopeId === presentment.id ? null : presentment.id;
}
prettyJson(value: string): string {
if (!value) {
return '';
}
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
}
@@ -0,0 +1,434 @@
<!--
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.Outward Clearing' | translate }}</h2>
<div class="header-actions">
<button mat-raised-button color="primary" (click)="showCaptureForm = !showCaptureForm">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'labels.buttons.Capture Cheque' | translate }}
</button>
@if (showCaptureForm) {
<button
mat-stroked-button
color="accent"
(click)="fillDummyData()"
matTooltip="Auto-fill the capture form with sample values"
>
<fa-icon icon="magic"></fa-icon>&nbsp;Fill Dummy Data
</button>
}
<button
mat-raised-button
color="primary"
[disabled]="busy"
(click)="sendPendingBatch()"
matTooltip="Bundles every CAPTURED cheque into one CTS file and sends it to the clearing house — stands in for the (disabled) clearing-session scheduler"
>
<fa-icon icon="exchange"></fa-icon>&nbsp;Send Pending Batch
</button>
@if (showCaptureForm) {
<button mat-raised-button (click)="cancelCapture()">{{ 'labels.buttons.Back' | translate }}</button>
} @else {
<button mat-raised-button [routerLink]="['/ctsclearing']">{{ 'labels.buttons.Back' | translate }}</button>
}
</div>
</div>
@if (showCaptureForm) {
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="captureForm" (ngSubmit)="submitCapture()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Depositing Account Type' | translate }}</mat-label>
<mat-select formControlName="depositingAccountType" required>
<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.Depositing Account Id' | translate }}</mat-label>
<input matInput type="number" formControlName="depositingAccountId" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Cheque Number' | translate }}</mat-label>
<input matInput formControlName="chequeNumber" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Amount' | translate }}</mat-label>
<input matInput type="number" formControlName="amount" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Payee Name' | translate }}</mat-label>
<input matInput formControlName="payeeName" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Drawer Bank Name' | translate }}</mat-label>
<input matInput formControlName="drawerBankName" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Drawer Bank MICR' | translate }}</mat-label>
<input matInput formControlName="drawerBankMicr" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Drawer IFSC' | translate }}</mat-label>
<input matInput formControlName="drawerIfsc" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>Drawer Account Number</mat-label>
<input matInput formControlName="drawerAccountNumber" placeholder="account debited at the drawee bank" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Deposited Date' | translate }}</mat-label>
<input matInput [matDatepicker]="depPicker" formControlName="depositedDate" />
<mat-datepicker-toggle matSuffix [for]="depPicker"></mat-datepicker-toggle>
<mat-datepicker #depPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>IQA Status</mat-label>
<mat-select formControlName="iqaStatus">
<mat-option value="PASS">PASS</mat-option>
<mat-option value="FAIL">FAIL</mat-option>
<mat-option value="PENDING">PENDING</mat-option>
<mat-option value="NA">NA</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>IQA Detail</mat-label>
<input matInput formControlName="iqaDetail" placeholder="optional notes" />
</mat-form-field>
</div>
<div class="image-cell">
@if (frontPreviewUrl) {
<img [src]="frontPreviewUrl" class="cheque-thumb" alt="front" matTooltip="Front image" />
}
@if (backPreviewUrl) {
<img [src]="backPreviewUrl" class="cheque-thumb" alt="back" matTooltip="Back image" />
}
<input #frontFile type="file" accept="image/*" hidden (change)="onCaptureFileSelected('front', $event)" />
<input #backFile type="file" accept="image/*" hidden (change)="onCaptureFileSelected('back', $event)" />
<button
mat-stroked-button
type="button"
[color]="selectedFrontFile ? '' : 'warn'"
(click)="frontFile.click()"
>
<fa-icon icon="plus"></fa-icon>&nbsp;{{
selectedFrontFile ? selectedFrontFile.name : 'Front Cheque Image *'
}}
</button>
<button
mat-stroked-button
type="button"
[color]="selectedBackFile ? '' : 'warn'"
(click)="backFile.click()"
>
<fa-icon icon="plus"></fa-icon>&nbsp;{{
selectedBackFile ? selectedBackFile.name : 'Back Cheque Image *'
}}
</button>
@if (!selectedFrontFile || !selectedBackFile) {
<span class="muted">Both front &amp; back images are required</span>
}
</div>
<div class="stop-payment-row">
<mat-checkbox formControlName="stopPaymentFlag">Stop Payment on this cheque</mat-checkbox>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="cancelCapture()">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" type="submit" [disabled]="!canSubmitCapture()">
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
@if (!showCaptureForm) {
<mat-tab-group class="outward-tabs" animationDuration="0ms">
<mat-tab label="Pending Cheques">
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="pendingFilterForm" (ngSubmit)="applyPendingFilters()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
<input matInput [matDatepicker]="pendingFromPicker" formControlName="fromDate" />
<mat-datepicker-toggle matSuffix [for]="pendingFromPicker"></mat-datepicker-toggle>
<mat-datepicker #pendingFromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
<input matInput [matDatepicker]="pendingToPicker" formControlName="toDate" />
<mat-datepicker-toggle matSuffix [for]="pendingToPicker"></mat-datepicker-toggle>
<mat-datepicker #pendingToPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Cheque Number' | translate }}</mat-label>
<input matInput formControlName="chequeNumber" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Drawer Bank Name' | translate }}</mat-label>
<input matInput formControlName="drawerBankName" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Payee Name' | translate }}</mat-label>
<input matInput formControlName="payeeName" />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="clearPendingFilters()">
{{ 'labels.buttons.Clear' | translate }}
</button>
<button mat-raised-button color="primary" type="submit">
{{ 'labels.buttons.Filter' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="presentments">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
<td mat-cell *matCellDef="let p">{{ p.id }}</td>
</ng-container>
<ng-container matColumnDef="chequeNumber">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Cheque Number' | translate }}</th>
<td mat-cell *matCellDef="let p">{{ p.chequeNumber }}</td>
</ng-container>
<ng-container matColumnDef="drawerBank">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Drawer Bank' | translate }}</th>
<td mat-cell *matCellDef="let p">
{{ p.drawerBankName || '–' }}
@if (p.drawerIfsc) {
<span class="muted">{{ p.drawerIfsc }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="payeeName">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payee Name' | translate }}</th>
<td mat-cell *matCellDef="let p">{{ p.payeeName }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Amount' | translate }}</th>
<td mat-cell *matCellDef="let p" class="num">{{ p.amount }}</td>
</ng-container>
<ng-container matColumnDef="depositedDate">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Deposited Date' | translate }}</th>
<td mat-cell *matCellDef="let p">
{{ p.depositedDate ? (p.depositedDate | dateFormat) : '–' }}
@if (p.createdOn) {
<span class="muted">{{ p.createdOn | dateFormat: 'hh:mm A' }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="image">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Image' | translate }}</th>
<td mat-cell *matCellDef="let p">
<div class="image-cell">
@if (getItemImageUrl(p, 'front')) {
<img
[src]="getItemImageUrl(p, 'front')"
class="cheque-thumb"
alt="front"
matTooltip="Front image — click to zoom"
(click)="openImageZoom(p, 'front')"
/>
} @else {
<input
#frontFile
type="file"
accept="image/*"
hidden
(change)="uploadItemImage(p, 'front', $event)"
/>
<button mat-icon-button (click)="frontFile.click()" matTooltip="Upload front image">
<fa-icon icon="plus"></fa-icon>
</button>
}
@if (getItemImageUrl(p, 'back')) {
<img
[src]="getItemImageUrl(p, 'back')"
class="cheque-thumb"
alt="back"
matTooltip="Back image — click to zoom"
(click)="openImageZoom(p, 'back')"
/>
} @else {
<input
#backFile
type="file"
accept="image/*"
hidden
(change)="uploadItemImage(p, 'back', $event)"
/>
<button mat-icon-button (click)="backFile.click()" matTooltip="Upload back image">
<fa-icon icon="plus"></fa-icon>
</button>
}
</div>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let p">
<span class="pill" [ngClass]="pillClass(p.status)">{{ p.status }}</span>
@if (p.stopPaymentFlag) {
<span class="pill bad" matTooltip="Stop payment marked on this cheque">STOP</span>
}
@if (p.iqaStatus && p.iqaStatus !== 'NA') {
<span class="pill" [ngClass]="pillClass(p.iqaStatus)" matTooltip="Image Quality Assessment"
>IQA: {{ p.iqaStatus }}</span
>
}
@if (getItemImageUrl(p, 'front') && getItemImageUrl(p, 'back')) {
<span
class="muted"
matTooltip="Goes out automatically with the next 2-hourly clearing session, or via Send Pending Batch"
>
Awaiting next clearing session
</span>
} @else {
<span class="muted" matTooltip="Upload both front & back cheque image before it can be sent"
>Image required</span
>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="pendingColumns"></tr>
<tr mat-row *matRowDef="let row; columns: pendingColumns"></tr>
</table>
@if (presentments.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (pendingTotalRecords > 0) {
<mat-paginator
[length]="pendingTotalRecords"
[pageSize]="pendingPageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pendingPageIndex"
showFirstLastButtons
(page)="onPendingPageChange($event)"
></mat-paginator>
}
</div>
</mat-tab>
<mat-tab label="Batches">
<div class="filter-row">
<button mat-stroked-button [class.active]="batchDateFilter === 'today'" (click)="setBatchDateFilter('today')">
Today
</button>
<button mat-stroked-button [class.active]="batchDateFilter === 'all'" (click)="setBatchDateFilter('all')">
All
</button>
</div>
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="batchesFilterForm" (ngSubmit)="applyBatchesFilters()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
<input matInput [matDatepicker]="batchFromPicker" formControlName="fromDate" />
<mat-datepicker-toggle matSuffix [for]="batchFromPicker"></mat-datepicker-toggle>
<mat-datepicker #batchFromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
<input matInput [matDatepicker]="batchToPicker" formControlName="toDate" />
<mat-datepicker-toggle matSuffix [for]="batchToPicker"></mat-datepicker-toggle>
<mat-datepicker #batchToPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Batch Reference' | translate }}</mat-label>
<input matInput formControlName="batchReference" />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="clearBatchesFilters()">
{{ 'labels.buttons.Clear' | translate }}
</button>
<button mat-raised-button color="primary" type="submit">
{{ 'labels.buttons.Filter' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="batches">
<ng-container matColumnDef="batchReference">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Batch Reference' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.batchReference }}</td>
</ng-container>
<ng-container matColumnDef="session">
<th mat-header-cell *matHeaderCellDef>Session</th>
<td mat-cell *matCellDef="let b">
{{ b.sessionStartedOn | dateFormat: 'DD MMM YYYY, hh:mm A' }}
@if (b.sessionEndedOn) {
<span class="muted">to {{ b.sessionEndedOn | dateFormat: 'hh:mm A' }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="items">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Items' | translate }}</th>
<td mat-cell *matCellDef="let b" class="num">{{ b.itemCount ?? '–' }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Total Amount' | translate }}</th>
<td mat-cell *matCellDef="let b" class="num">{{ b.totalAmount ?? '–' }}</td>
</ng-container>
<ng-container matColumnDef="progress">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let b">
@if (b.settledCount) {
<span class="pill ok">{{ b.settledCount }} settled</span>
}
@if (b.dishonouredCount) {
<span class="pill bad">{{ b.dishonouredCount }} dishonoured</span>
}
@if (b.sentCount) {
<span class="pill neutral">{{ b.sentCount }} awaiting settlement</span>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let b" (click)="$event.stopPropagation()">
<button mat-button color="primary" (click)="openBatch(b)">
{{ 'labels.buttons.View' | translate }}
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="batchColumns"></tr>
<tr mat-row *matRowDef="let row; columns: batchColumns" (click)="openBatch(row)" class="clickable-row"></tr>
</table>
@if (batches.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (batchesTotalRecords > 0) {
<mat-paginator
[length]="batchesTotalRecords"
[pageSize]="batchesPageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="batchesPageIndex"
showFirstLastButtons
(page)="onBatchesPageChange($event)"
></mat-paginator>
}
</div>
</mat-tab>
</mat-tab-group>
}
<div #lightboxRoot style="display: none"></div>
</div>
@@ -0,0 +1,172 @@
/**
* 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;
flex-wrap: wrap;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
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%;
}
.stop-payment-row {
margin: 4px 0 12px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.outward-tabs {
margin-top: 8px;
::ng-deep .mat-mdc-tab-body-wrapper {
padding-top: 16px;
}
}
.table-wrapper {
overflow-x: auto;
background: #fff;
margin-bottom: 16px;
}
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 {
display: block;
color: rgb(0 0 0 / 50%);
font-size: 0.78rem;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
white-space: nowrap;
margin-right: 4px;
&.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%);
}
.envelope-panel {
margin: 8px;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 6px;
background: #fafafa;
.envelope-head {
font-weight: 600;
margin-bottom: 6px;
}
}
.image-cell {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.cheque-thumb {
width: 40px;
height: 28px;
object-fit: cover;
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
}
.cts-file {
font-family: monospace;
font-size: 0.74rem;
background: #f5f7fa;
color: #1a2733;
border: 1px solid #dfe6ee;
padding: 10px;
border-radius: 4px;
max-height: 360px;
overflow: auto;
white-space: pre;
margin: 4px 0 12px;
}
@@ -0,0 +1,537 @@
/**
* 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, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import { formatDate } from '@angular/common';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Observable, concat } from 'rxjs';
import { Router } 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 { FaIconComponent } from '@fortawesome/angular-fontawesome';
import lightGallery from 'lightgallery';
import lgZoom from 'lightgallery/plugins/zoom';
import lgFullscreen from 'lightgallery/plugins/fullscreen';
import type { LightGallery } from 'lightgallery/lightgallery';
import type { GalleryItem } from 'lightgallery/lg-utils';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { CtsClearingService } from '../ctsclearing.service';
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
/**
* Outward CTS clearing — landing page for the whole outward flow. Capture a cheque, see it sit as
* "Pending" until a clearing session sends it, then find it as a row in the Batches table below (a real
* session, one row per shared batchReference) — click a batch to see its cheques and settle/dishonour them.
* There's no per-cheque "Send" any more: cheques only leave via the 2-hourly session job or the manual
* "Send Pending Batch" fallback, so batch is always the unit you browse sent cheques by.
*/
@Component({
selector: 'mifosx-outward-presentment',
templateUrl: './outward-presentment.component.html',
styleUrls: ['./outward-presentment.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatTableModule,
MatTabsModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class OutwardPresentmentComponent implements OnInit, OnDestroy {
private formBuilder = inject(UntypedFormBuilder);
private ctsService = inject(CtsClearingService);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
@ViewChild('lightboxRoot', { static: true }) lightboxRoot: ElementRef<HTMLElement>;
private lightboxInstance: LightGallery | null = null;
private readonly lightboxPlugins = [
lgZoom,
lgFullscreen
];
pillClass = pillClass;
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
busy = false;
showCaptureForm = false;
/** Cheque images picked in the capture form itself — uploaded right after creation, in the same Submit. */
selectedFrontFile: File | null = null;
selectedBackFile: File | null = null;
frontPreviewUrl: string | null = null;
backPreviewUrl: string | null = null;
captureForm: UntypedFormGroup;
pendingFilterForm: UntypedFormGroup;
batchesFilterForm: UntypedFormGroup;
// ----- Pending cheques (CAPTURED, not yet in any batch) -----
presentments: any[] = [];
pendingTotalRecords = 0;
pendingPageSize = 10;
pendingPageIndex = 0;
pageSizeOptions = [
10,
25,
50,
100
];
pendingColumns = [
'id',
'chequeNumber',
'drawerBank',
'payeeName',
'amount',
'depositedDate',
'image',
'status'
];
// ----- Batches (real, already-sent clearing sessions) -----
batches: any[] = [];
batchesTotalRecords = 0;
batchesPageSize = 10;
batchesPageIndex = 0;
batchColumns = [
'batchReference',
'session',
'items',
'amount',
'progress',
'actions'
];
/**
* Defaults to today on first load so this isn't every session ever sent — "All" widens it. Persisted on
* the singleton service so opening a batch and navigating back doesn't silently reset the choice.
*/
get batchDateFilter(): 'today' | 'all' {
return this.ctsService.outwardBatchesDateFilter;
}
constructor() {
this.captureForm = this.formBuilder.group({
depositingAccountType: [
'SAVINGS',
Validators.required
],
depositingAccountId: [
'',
Validators.required
],
chequeNumber: [
'',
Validators.required
],
amount: [
'',
Validators.required
],
payeeName: [''],
drawerBankName: [''],
drawerBankMicr: [''],
drawerIfsc: [''],
depositedDate: [
'',
Validators.required
],
stopPaymentFlag: [false],
iqaStatus: ['PASS'],
iqaDetail: [''],
drawerAccountNumber: ['']
});
this.pendingFilterForm = this.formBuilder.group({
fromDate: [''],
toDate: [''],
chequeNumber: [''],
drawerBankName: [''],
payeeName: ['']
});
// Batches defaults to today on first load — matches the persisted Today/All quick toggle below.
const initialBatchDate = this.batchDateFilter === 'today' ? new Date() : null;
this.batchesFilterForm = this.formBuilder.group({
fromDate: [initialBatchDate],
toDate: [initialBatchDate],
batchReference: ['']
});
}
ngOnInit(): void {
this.loadPresentments();
this.loadBatches();
}
ngOnDestroy(): void {
this.destroyLightbox();
}
/** Cheque image data URLs, keyed "<presentmentId>_<side>" — populated once fetched. */
imageUrls: { [key: string]: string } = {};
loadPresentments(): void {
const f = this.pendingFilterForm.value;
const filters = {
fromDate: this.toApiDate(f.fromDate),
toDate: this.toApiDate(f.toDate),
chequeNumber: f.chequeNumber || undefined,
drawerBankName: f.drawerBankName || undefined,
payeeName: f.payeeName || undefined
};
this.ctsService
.getOutwardPresentments('CAPTURED', this.pendingPageIndex * this.pendingPageSize, this.pendingPageSize, filters)
.subscribe((res: any) => {
this.presentments = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.pendingTotalRecords = Number(res?.totalFilteredRecords ?? this.presentments.length);
this.presentments.forEach((p: any) => {
this.loadItemImage(p, 'front');
this.loadItemImage(p, 'back');
});
});
}
applyPendingFilters(): void {
this.pendingPageIndex = 0;
this.loadPresentments();
}
clearPendingFilters(): void {
this.pendingFilterForm.reset({ fromDate: '', toDate: '', chequeNumber: '', drawerBankName: '', payeeName: '' });
this.pendingPageIndex = 0;
this.loadPresentments();
}
onPendingPageChange(event: PageEvent): void {
this.pendingPageSize = event.pageSize;
this.pendingPageIndex = event.pageIndex;
this.loadPresentments();
}
loadBatches(): void {
const f = this.batchesFilterForm.value;
const filters = {
fromDate: this.toApiDate(f.fromDate),
toDate: this.toApiDate(f.toDate),
batchReference: f.batchReference || undefined
};
this.ctsService
.getOutwardBatches(this.batchesPageIndex * this.batchesPageSize, this.batchesPageSize, filters)
.subscribe((res: any) => {
this.batches = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.batchesTotalRecords = Number(res?.totalFilteredRecords ?? this.batches.length);
});
}
applyBatchesFilters(): void {
// A manual date edit no longer matches a plain "today"/"all" quick state — treat it as custom.
this.ctsService.outwardBatchesDateFilter = 'all';
this.batchesPageIndex = 0;
this.loadBatches();
}
clearBatchesFilters(): void {
this.ctsService.outwardBatchesDateFilter = 'all';
this.batchesFilterForm.reset({ fromDate: '', toDate: '', batchReference: '' });
this.batchesPageIndex = 0;
this.loadBatches();
}
setBatchDateFilter(filter: 'today' | 'all'): void {
this.ctsService.outwardBatchesDateFilter = filter;
const day = filter === 'today' ? new Date() : null;
this.batchesFilterForm.patchValue({ fromDate: day, toDate: day });
this.batchesPageIndex = 0;
this.loadBatches();
}
onBatchesPageChange(event: PageEvent): void {
this.batchesPageSize = event.pageSize;
this.batchesPageIndex = event.pageIndex;
this.loadBatches();
}
openBatch(batch: any): void {
this.router.navigate([
'/ctsclearing',
'outward-batches',
batch.batchReference
]);
}
/** Fetches the image as an authenticated base64 data URL and caches it — 404 (no upload yet) is expected and silent. */
loadItemImage(presentment: any, side: 'front' | 'back'): void {
this.ctsService.getOutwardPresentmentImage(presentment.id, side).subscribe({
next: (dataUrl: string) => (this.imageUrls[`${presentment.id}_${side}`] = dataUrl),
error: () => {
/* no image uploaded for this side yet */
}
});
}
getItemImageUrl(presentment: any, side: 'front' | 'back'): string | null {
return this.imageUrls[`${presentment.id}_${side}`] || null;
}
/** Opens the front/back cheque image(s) in a zoomable, pannable lightbox — same lightgallery setup used app-wide. */
openImageZoom(presentment: any, side: 'front' | 'back'): void {
const frontUrl = this.getItemImageUrl(presentment, 'front');
const backUrl = this.getItemImageUrl(presentment, 'back');
const items: GalleryItem[] = [];
if (frontUrl) {
items.push({
src: frontUrl,
thumb: frontUrl,
subHtml: `<div class="lg-caption"><h4>Front — Cheque ${presentment.chequeNumber}</h4></div>`
});
}
if (backUrl) {
items.push({
src: backUrl,
thumb: backUrl,
subHtml: `<div class="lg-caption"><h4>Back — Cheque ${presentment.chequeNumber}</h4></div>`
});
}
if (!items.length) {
return;
}
const startIndex = side === 'back' && frontUrl ? 1 : 0;
this.destroyLightbox();
this.lightboxInstance = lightGallery(this.lightboxRoot.nativeElement, {
dynamic: true,
dynamicEl: items,
plugins: this.lightboxPlugins,
download: false,
closable: true,
escKey: true,
zoomFromOrigin: true
});
this.lightboxInstance.openGallery(startIndex);
}
private destroyLightbox(): void {
if (this.lightboxInstance) {
this.lightboxInstance.destroy();
this.lightboxInstance = null;
}
}
uploadItemImage(presentment: any, side: 'front' | 'back', event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files && input.files[0];
if (!file) {
return;
}
this.ctsService.uploadOutwardPresentmentImage(presentment.id, side, file).subscribe({
next: () => {
this.notify(side === 'front' ? 'Front image uploaded' : 'Back image uploaded');
this.loadItemImage(presentment, side);
},
error: (err: any) => this.notify(clearingError(err, 'Image upload failed'), true)
});
input.value = '';
}
private toApiDate(value: any): string | undefined {
if (!value) {
return undefined;
}
return formatDate(value, 'yyyy-MM-dd', 'en');
}
/** Picks a front/back cheque image right in the capture form — uploaded once the cheque is created below. */
onCaptureFileSelected(side: 'front' | 'back', event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files && input.files[0];
if (!file) {
return;
}
const previewUrl = URL.createObjectURL(file);
if (side === 'front') {
if (this.frontPreviewUrl) {
URL.revokeObjectURL(this.frontPreviewUrl);
}
this.selectedFrontFile = file;
this.frontPreviewUrl = previewUrl;
} else {
if (this.backPreviewUrl) {
URL.revokeObjectURL(this.backPreviewUrl);
}
this.selectedBackFile = file;
this.backPreviewUrl = previewUrl;
}
}
private resetCaptureFiles(): void {
if (this.frontPreviewUrl) {
URL.revokeObjectURL(this.frontPreviewUrl);
}
if (this.backPreviewUrl) {
URL.revokeObjectURL(this.backPreviewUrl);
}
this.selectedFrontFile = null;
this.selectedBackFile = null;
this.frontPreviewUrl = null;
this.backPreviewUrl = null;
}
/** Front + back cheque image are mandatory before a cheque can be captured — checked in the template too. */
canSubmitCapture(): boolean {
return this.captureForm.valid && !!this.selectedFrontFile && !!this.selectedBackFile && !this.busy;
}
submitCapture(): void {
if (!this.canSubmitCapture()) {
if (this.captureForm.valid && (!this.selectedFrontFile || !this.selectedBackFile)) {
this.notify('Front and back cheque image are both required', true);
}
return;
}
this.busy = true;
const raw = this.captureForm.value;
const payload = {
depositingAccountType: raw.depositingAccountType,
depositingAccountId: raw.depositingAccountId,
chequeNumber: raw.chequeNumber,
amount: raw.amount,
payeeName: raw.payeeName || undefined,
drawerBankName: raw.drawerBankName || undefined,
drawerBankMicr: raw.drawerBankMicr || undefined,
drawerIfsc: raw.drawerIfsc || undefined,
depositedDate: this.toApiDate(raw.depositedDate),
stopPaymentFlag: !!raw.stopPaymentFlag,
iqaStatus: raw.iqaStatus || undefined,
iqaDetail: raw.iqaDetail || undefined,
drawerAccountNumber: raw.drawerAccountNumber || undefined,
dateFormat: 'yyyy-MM-dd',
locale: 'en'
};
this.ctsService.createOutwardPresentment(payload).subscribe({
next: (res: any) => {
const newId = res?.resourceId;
const front = this.selectedFrontFile;
const back = this.selectedBackFile;
if (!newId || (!front && !back)) {
this.finishCapture('Cheque captured');
return;
}
// upload whichever image(s) were picked, in the same Submit action, before closing the form.
// Sequential (concat), NOT parallel (forkJoin): two concurrent updates to the same row would race
// against its @Version optimistic lock and throw OptimisticLockException on the second write.
const uploads: Observable<any>[] = [];
if (front) {
uploads.push(this.ctsService.uploadOutwardPresentmentImage(newId, 'front', front));
}
if (back) {
uploads.push(this.ctsService.uploadOutwardPresentmentImage(newId, 'back', back));
}
concat(...uploads).subscribe({
complete: () => this.finishCapture('Cheque captured with image(s)'),
error: (err: any) => {
this.busy = false;
this.notify(
clearingError(err, 'Cheque captured, but image upload failed — upload it from the table row'),
true
);
this.showCaptureForm = false;
this.resetCaptureFiles();
this.captureForm.reset({ depositingAccountType: 'SAVINGS', stopPaymentFlag: false, iqaStatus: 'PASS' });
this.loadPresentments();
}
});
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not capture the cheque'), true);
}
});
}
cancelCapture(): void {
this.showCaptureForm = false;
this.resetCaptureFiles();
}
private finishCapture(message: string): void {
this.busy = false;
this.showCaptureForm = false;
this.resetCaptureFiles();
this.captureForm.reset({ depositingAccountType: 'SAVINGS', stopPaymentFlag: false, iqaStatus: 'PASS' });
this.notify(message);
this.loadPresentments();
}
/** Fills the capture form with realistic sample data in one click, for quick demo/testing. */
fillDummyData(): void {
const stamp = Date.now().toString().slice(-6);
const banks = [
{ name: 'IDFC First Bank', micr: '400229015', ifsc: 'IDFC0000045' },
{ name: 'HDFC Bank', micr: '400240012', ifsc: 'HDFC0000078' },
{ name: 'ICICI Bank', micr: '400229033', ifsc: 'ICIC0000112' }
];
const bank = banks[Math.floor(Math.random() * banks.length)];
const today = new Date();
this.captureForm.patchValue({
depositingAccountType: 'SAVINGS',
depositingAccountId: 1,
chequeNumber: stamp,
amount: (Math.floor(Math.random() * 900) + 100) * 100,
payeeName: 'Test Payee ' + stamp,
drawerBankName: bank.name,
drawerBankMicr: bank.micr,
drawerIfsc: bank.ifsc,
drawerAccountNumber: 'XXXXXXXX' + stamp,
depositedDate: today,
stopPaymentFlag: false,
iqaStatus: 'PASS',
iqaDetail: 'Auto-filled sample data'
});
this.notify('Dummy values filled — review and Submit');
}
/**
* Manual stand-in for the clearing-session scheduler (currently disabled): bundles every CAPTURED cheque
* into one CTS batch file and sends it to the clearing house in a single go.
*/
sendPendingBatch(): void {
if (this.busy) {
return;
}
this.busy = true;
this.ctsService.sendPendingOutwardBatch().subscribe({
next: (res: any) => {
this.busy = false;
const count = res?.resourceId ?? '';
this.notify(count ? `Batch sent to the clearing house — ${count} cheque(s)` : 'No pending cheques to send');
this.loadPresentments();
this.loadBatches();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not send the pending batch'), true);
}
});
}
}
@@ -0,0 +1,134 @@
<!--
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="page">
<div class="header">
<div>
<h2>Positive Pay (RBI PPS)</h2>
<p class="muted">
Pre-register high-value cheques (≥ ₹50,000). Inward clearing returns an unregistered or mismatched high-value
cheque with reason code 13.
</p>
</div>
<button mat-raised-button color="primary" (click)="showForm = !showForm">
<fa-icon icon="plus"></fa-icon> Register Cheque
</button>
</div>
@if (showForm) {
<form [formGroup]="form" (ngSubmit)="submit()" class="capture-form mat-elevation-z1">
<div class="row">
<mat-form-field appearance="outline">
<mat-label>Account Type</mat-label>
<mat-select formControlName="accountType">
<mat-option value="SAVINGS">Savings</mat-option>
<mat-option value="CURRENT">Current</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Account Id</mat-label>
<input matInput type="number" formControlName="accountId" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cheque Number</mat-label>
<input matInput formControlName="chequeNumber" />
</mat-form-field>
</div>
<div class="row">
<mat-form-field appearance="outline">
<mat-label>Instrument Date</mat-label>
<input matInput [matDatepicker]="idp" formControlName="instrumentDate" />
<mat-datepicker-toggle matSuffix [for]="idp"></mat-datepicker-toggle>
<mat-datepicker #idp></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Amount</mat-label>
<input matInput type="number" formControlName="amount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Payee Name</mat-label>
<input matInput formControlName="payeeName" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Channel</mat-label>
<mat-select formControlName="channel">
<mat-option value="BRANCH">Branch</mat-option>
<mat-option value="NETBANKING">Net Banking</mat-option>
<mat-option value="MOBILE">Mobile</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="actions">
<button mat-button type="button" (click)="showForm = false">Cancel</button>
<button mat-raised-button color="primary" type="submit" [disabled]="form.invalid || busy">Register</button>
</div>
</form>
}
<div class="filters">
@for (s of ['ACTIVE', 'CANCELLED', 'CONSUMED']; track s) {
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
}
</div>
<div class="mat-elevation-z1 table-wrap">
<table mat-table [dataSource]="registrations">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>#</th>
<td mat-cell *matCellDef="let r">{{ r.id }}</td>
</ng-container>
<ng-container matColumnDef="account">
<th mat-header-cell *matHeaderCellDef>Account</th>
<td mat-cell *matCellDef="let r">
{{ r.accountNo || r.accountId }} <span class="muted">({{ r.accountType }})</span>
</td>
</ng-container>
<ng-container matColumnDef="chequeNumber">
<th mat-header-cell *matHeaderCellDef>Cheque #</th>
<td mat-cell *matCellDef="let r">{{ r.chequeNumber }}</td>
</ng-container>
<ng-container matColumnDef="instrumentDate">
<th mat-header-cell *matHeaderCellDef>Instrument Date</th>
<td mat-cell *matCellDef="let r">{{ r.instrumentDate | date: 'mediumDate' }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef>Amount</th>
<td mat-cell *matCellDef="let r">{{ r.amount }}</td>
</ng-container>
<ng-container matColumnDef="payeeName">
<th mat-header-cell *matHeaderCellDef>Payee</th>
<td mat-cell *matCellDef="let r">{{ r.payeeName }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let r">
<span class="pill" [ngClass]="pillClass(r.status)">{{ r.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let r">
@if (r.status === 'ACTIVE') {
<button mat-icon-button color="warn" (click)="cancel(r)" matTooltip="Cancel">
<fa-icon icon="ban"></fa-icon>
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="pageSizeOptions"
(page)="onPageChange($event)"
></mat-paginator>
</div>
</div>
@@ -0,0 +1,89 @@
/**
* 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/.
*/
.page {
padding: 1.5rem;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.muted {
color: rgb(0 0 0 / 55%);
font-size: 0.85rem;
}
.capture-form {
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
.row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
mat-form-field {
flex: 1 1 200px;
}
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
}
.filters {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
button.active {
background: #3f51b5;
color: #fff;
}
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
}
.pill {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
&.ok {
background: #e6f4ea;
color: #1e7e34;
}
&.bad {
background: #fdecea;
color: #c62828;
}
&.neutral {
background: #eceff1;
color: #546e7a;
}
}
@@ -0,0 +1,178 @@
/**
* 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 { formatDate } from '@angular/common';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { CtsClearingService } from '../ctsclearing.service';
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
/**
* Positive Pay System (RBI PPS) — pre-registration of high-value cheque particulars. Inward clearing matches
* a presented high-value cheque against an active registration before honouring it.
*/
@Component({
selector: 'mifosx-positive-pay',
templateUrl: './positive-pay.component.html',
styleUrls: ['./positive-pay.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class PositivePayComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private ctsService = inject(CtsClearingService);
private snackBar = inject(MatSnackBar);
pillClass = pillClass;
registrations: any[] = [];
totalRecords = 0;
pageSize = 10;
pageIndex = 0;
pageSizeOptions = [
10,
25,
50,
100
];
statusFilter = '';
busy = false;
showForm = false;
form: UntypedFormGroup;
columns = [
'id',
'account',
'chequeNumber',
'instrumentDate',
'amount',
'payeeName',
'status',
'actions'
];
constructor() {
this.form = this.formBuilder.group({
accountType: [
'SAVINGS',
Validators.required
],
accountId: [
'',
Validators.required
],
chequeNumber: [
'',
Validators.required
],
instrumentDate: [''],
amount: [
'',
Validators.required
],
payeeName: [''],
channel: ['BRANCH']
});
}
ngOnInit(): void {
this.load();
}
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
private toApiDate(value: any): string | undefined {
return value ? formatDate(value, 'yyyy-MM-dd', 'en') : undefined;
}
load(): void {
this.ctsService
.getPositivePayRegistrations(
undefined,
this.statusFilter || undefined,
this.pageIndex * this.pageSize,
this.pageSize
)
.subscribe((res: any) => {
this.registrations = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.totalRecords = Number(res?.totalFilteredRecords ?? this.registrations.length);
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.load();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.load();
}
submit(): void {
if (this.form.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.form.value;
const payload = {
...raw,
instrumentDate: this.toApiDate(raw.instrumentDate),
dateFormat: 'yyyy-MM-dd',
locale: 'en'
};
this.ctsService.registerPositivePay(payload).subscribe({
next: () => {
this.busy = false;
this.showForm = false;
this.form.reset({ accountType: 'SAVINGS', channel: 'BRANCH' });
this.notify('Positive Pay registered');
this.load();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not register Positive Pay'), true);
}
});
}
cancel(registration: any): void {
const reason = window.prompt('Reason for cancelling this Positive Pay registration?');
if (!reason) {
return;
}
this.ctsService.cancelPositivePay(registration.id, { reason }).subscribe({
next: () => {
this.notify('Positive Pay cancelled');
this.load();
},
error: (err: any) => this.notify(clearingError(err, 'Could not cancel'), true)
});
}
}
@@ -0,0 +1,110 @@
<!--
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.Reconciliation' | translate }} — {{ 'labels.menus.CTS Clearing' | translate }}</h2>
<div class="header-actions">
<button mat-raised-button color="primary" (click)="showRunForm = !showRunForm">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'labels.buttons.Run Reconciliation' | translate }}
</button>
<button mat-raised-button [routerLink]="['/ctsclearing']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
@if (showRunForm) {
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="runForm" (ngSubmit)="submitRun()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Reconciliation Date' | translate }}</mat-label>
<input matInput [matDatepicker]="reconPicker" formControlName="reconciliationDate" required />
<mat-datepicker-toggle matSuffix [for]="reconPicker"></mat-datepicker-toggle>
<mat-datepicker #reconPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Source Reference' | translate }}</mat-label>
<input matInput formControlName="sourceReference" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.CBS Total' | translate }}</mat-label>
<input matInput type="number" formControlName="cbsTotal" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Clearing House Total' | translate }}</mat-label>
<input matInput type="number" formControlName="clearingHouseTotal" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Detail' | translate }}</mat-label>
<input matInput formControlName="detail" />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="showRunForm = false">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" type="submit" [disabled]="runForm.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]="reconciliations">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
<td mat-cell *matCellDef="let r">{{ r.id }}</td>
</ng-container>
<ng-container matColumnDef="reconciliationDate">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Reconciliation Date' | translate }}</th>
<td mat-cell *matCellDef="let r">{{ r.reconciliationDate ? (r.reconciliationDate | dateFormat) : '–' }}</td>
</ng-container>
<ng-container matColumnDef="sourceReference">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Source Reference' | translate }}</th>
<td mat-cell *matCellDef="let r">{{ r.sourceReference || '–' }}</td>
</ng-container>
<ng-container matColumnDef="cbsTotal">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.CBS Total' | translate }}</th>
<td mat-cell *matCellDef="let r" class="num">{{ r.cbsTotal }}</td>
</ng-container>
<ng-container matColumnDef="clearingHouseTotal">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Clearing House Total' | translate }}</th>
<td mat-cell *matCellDef="let r" class="num">{{ r.clearingHouseTotal }}</td>
</ng-container>
<ng-container matColumnDef="difference">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Difference' | translate }}</th>
<td mat-cell *matCellDef="let r" class="num">{{ r.difference ?? '–' }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let r">
<span class="pill" [ngClass]="pillClass(r.status)">{{ r.status }}</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
@if (reconciliations.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,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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.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;
white-space: nowrap;
&.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,148 @@
/**
* 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 { formatDate } from '@angular/common';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ClearingService } from '../../clearing-network/clearing.service';
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
/**
* CTS view of the shared reconciliation register — records CBS vs clearing-house totals for a date and shows
* whether they MATCHED or MISMATCHed. clearingType is fixed to 'CTS'.
*/
@Component({
selector: 'mifosx-cts-reconciliation',
templateUrl: './reconciliation.component.html',
styleUrls: ['./reconciliation.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class ReconciliationComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private clearingService = inject(ClearingService);
private snackBar = inject(MatSnackBar);
readonly clearingType = 'CTS';
pillClass = pillClass;
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
reconciliations: any[] = [];
totalRecords = 0;
pageSize = 10;
pageIndex = 0;
pageSizeOptions = [
10,
25,
50,
100
];
busy = false;
showRunForm = false;
runForm: UntypedFormGroup;
columns = [
'id',
'reconciliationDate',
'sourceReference',
'cbsTotal',
'clearingHouseTotal',
'difference',
'status'
];
constructor() {
this.runForm = this.formBuilder.group({
reconciliationDate: [
'',
Validators.required
],
sourceReference: [''],
cbsTotal: [
'',
Validators.required
],
clearingHouseTotal: [
'',
Validators.required
],
detail: ['']
});
}
ngOnInit(): void {
this.loadReconciliations();
}
loadReconciliations(): void {
this.clearingService
.getReconciliations(this.clearingType, this.pageIndex * this.pageSize, this.pageSize)
.subscribe((res: any) => {
this.reconciliations = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.totalRecords = Number(res?.totalFilteredRecords ?? this.reconciliations.length);
});
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadReconciliations();
}
submitRun(): void {
if (this.runForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.runForm.value;
const payload = {
clearingType: this.clearingType,
reconciliationDate: formatDate(raw.reconciliationDate, 'yyyy-MM-dd', 'en'),
sourceReference: raw.sourceReference || undefined,
cbsTotal: raw.cbsTotal,
clearingHouseTotal: raw.clearingHouseTotal,
detail: raw.detail || undefined,
dateFormat: 'yyyy-MM-dd',
locale: 'en'
};
this.clearingService.runReconciliation(payload).subscribe({
next: () => {
this.busy = false;
this.showRunForm = false;
this.runForm.reset();
this.notify('Reconciliation recorded');
this.loadReconciliations();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Reconciliation failed'), true);
}
});
}
}
@@ -0,0 +1,116 @@
<!--
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.Settlement' | translate }} — {{ 'labels.menus.CTS Clearing' | translate }}</h2>
<button mat-raised-button [routerLink]="['/ctsclearing']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
<div class="filter-row">
@for (s of ['CREATED', 'POSTED', 'REJECTED']; 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]="batches">
<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="settlementDate">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Settlement Date' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.settlementDate ? (b.settlementDate | dateFormat) : '–' }}</td>
</ng-container>
<ng-container matColumnDef="sourceReference">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Source Reference' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.sourceReference }}</td>
</ng-container>
<ng-container matColumnDef="passed">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Passed' | translate }}</th>
<td mat-cell *matCellDef="let b" class="num">
{{ b.passedCount ?? '–' }}
@if (b.totalPassedAmount != null) {
<span class="muted">{{ b.totalPassedAmount }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="returned">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Returned' | translate }}</th>
<td mat-cell *matCellDef="let b" class="num">
{{ b.returnedCount ?? '–' }}
@if (b.totalReturnedAmount != null) {
<span class="muted">{{ b.totalReturnedAmount }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="netAmount">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Net Amount' | translate }}</th>
<td mat-cell *matCellDef="let b" class="num">{{ b.netAmount ?? '–' }}</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]="pillClass(b.status)">{{ b.status }}</span>
@if (b.glTransactionId) {
<span class="muted">GL #{{ b.glTransactionId }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let b">
@if (b.status === 'CREATED') {
<button mat-button color="primary" (click)="post(b)">{{ 'labels.buttons.Post' | translate }}</button>
<button mat-button color="warn" (click)="toggleReject(b)">{{ 'labels.buttons.Reject' | translate }}</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
@if (batches.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 (rejectForId) {
<mat-card class="add-form">
<mat-card-content>
<h3>{{ 'labels.inputs.Reject Settlement' | translate }}</h3>
<form [formGroup]="rejectForm" (ngSubmit)="submitReject(rejectForId)">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
<input matInput formControlName="reason" required />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="rejectForId = 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,102 @@
/**
* 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;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.add-form {
margin-bottom: 16px;
margin-top: 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;
}
.muted {
display: block;
color: rgb(0 0 0 / 50%);
font-size: 0.78rem;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
white-space: nowrap;
&.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,161 @@
/**
* 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 { 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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ClearingService } from '../../clearing-network/clearing.service';
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
/**
* CTS view of the shared net-settlement batches — prepared from validated inward batches, posted to the GL
* or rejected. clearingType is fixed to 'CTS'.
*/
@Component({
selector: 'mifosx-cts-settlement',
templateUrl: './settlement.component.html',
styleUrls: ['./settlement.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class SettlementComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private clearingService = inject(ClearingService);
private snackBar = inject(MatSnackBar);
readonly clearingType = 'CTS';
pillClass = pillClass;
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
batches: any[] = [];
totalRecords = 0;
pageSize = 10;
pageIndex = 0;
pageSizeOptions = [
10,
25,
50,
100
];
statusFilter = '';
busy = false;
rejectForId: number | null = null;
rejectForm: UntypedFormGroup;
columns = [
'id',
'settlementDate',
'sourceReference',
'passed',
'returned',
'netAmount',
'status',
'actions'
];
constructor() {
this.rejectForm = this.formBuilder.group({
reason: [
'',
Validators.required
]
});
}
ngOnInit(): void {
this.loadBatches();
}
loadBatches(): void {
this.clearingService
.getSettlementBatches(
this.clearingType,
this.statusFilter || undefined,
this.pageIndex * this.pageSize,
this.pageSize
)
.subscribe((res: any) => {
this.batches = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.totalRecords = Number(res?.totalFilteredRecords ?? this.batches.length);
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.loadBatches();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadBatches();
}
post(batch: any): void {
if (this.busy) {
return;
}
this.busy = true;
this.clearingService.postSettlement(batch.id).subscribe({
next: () => {
this.busy = false;
this.notify('Settlement posted');
this.loadBatches();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Post failed'), true);
}
});
}
toggleReject(batch: any): void {
this.rejectForId = this.rejectForId === batch.id ? null : batch.id;
this.rejectForm.reset();
}
submitReject(id: number): void {
if (this.rejectForm.invalid || this.busy) {
return;
}
this.busy = true;
this.clearingService.rejectSettlement(id, { reason: this.rejectForm.value.reason, locale: 'en' }).subscribe({
next: () => {
this.busy = false;
this.rejectForId = null;
this.notify('Settlement rejected');
this.loadBatches();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Reject failed'), true);
}
});
}
}
@@ -0,0 +1,745 @@
<!--
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="va-page">
<div class="backdrop-blobs">
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
<div class="blob blob-3"></div>
</div>
<!-- ── Hero Header ──────────────────────────────────────────────────────── -->
<div class="hero">
<div class="hero-overlay"></div>
<div class="hero-actions">
<a [routerLink]="['/election', electionId, 'candidates']" class="hero-back">
<mat-icon>chevron_left</mat-icon> Back to Candidates
</a>
@if (!loading && data) {
<button
class="download-btn"
[class.downloading]="pdfGenerating"
[disabled]="pdfGenerating"
(click)="downloadPDF()"
>
@if (pdfGenerating) {
<mat-icon class="spin-icon">sync</mat-icon> Generating...
} @else {
<mat-icon>file_download</mat-icon> Download PDF
}
</button>
}
</div>
@if (data) {
<div class="hero-body">
<div class="hero-avatar-wrapper">
<div class="hero-avatar">{{ data.candidateName?.charAt(0) }}</div>
<div class="hero-avatar-glow"></div>
</div>
<div class="hero-info">
<div class="hero-badge-row">
<span class="post-badge">{{ data.postName }}</span>
<span class="analysis-tag">Voter Influence Analysis</span>
</div>
<h1 class="hero-name">{{ data.candidateName }}</h1>
@if (hasCriteria(data)) {
<div class="hero-criteria">
<span class="criteria-title">Target Criteria:</span>
@if (data.postGenderType && data.postGenderType !== 'ANY') {
<span class="cp">Gender: {{ data.postGenderType }}</span>
}
@if (data.postMinAge && data.postMaxAge) {
<span class="cp">Age: {{ data.postMinAge }}–{{ data.postMaxAge }} yrs</span>
} @else if (data.postMinAge) {
<span class="cp">Min Age: {{ data.postMinAge }} yrs</span>
} @else if (data.postMaxAge) {
<span class="cp">Max Age: {{ data.postMaxAge }} yrs</span>
}
@if (data.postMinShareHolding) {
<span class="cp">Min Shares: {{ data.postMinShareHolding }}</span>
}
</div>
}
</div>
</div>
}
</div>
@if (loading) {
<div class="state-box">
<div class="ring-spinner"></div>
<p>Building voter analysis...</p>
</div>
} @else if (error || !data) {
<div class="state-box">
<mat-icon class="err-icon">error_outline</mat-icon>
<p class="err-msg">{{ errorMessage || 'Analysis not available.' }}</p>
<a [routerLink]="['/election', electionId, 'candidates']" class="err-btn"> Return to Candidates </a>
</div>
} @else {
<!-- ── KPI Strip ─────────────────────────────────────────────────────────── -->
<div class="kpi-row">
<div class="kpi kpi-featured">
<div class="kpi-icon" style="background: rgba(37, 99, 235, 0.1)">
<mat-icon style="color: #2563eb">how_to_vote</mat-icon>
</div>
<div class="kpi-body">
<div class="kpi-val featured-val">{{ data.actualEligibleVoters }}</div>
<div class="kpi-lbl">Post-Eligible Voters</div>
<div class="kpi-sub">{{ eligiblePct(data) }}% of shareholders</div>
</div>
</div>
<div class="kpi">
<div class="kpi-icon" style="background: rgba(2, 132, 199, 0.1)">
<mat-icon style="color: #0284c7">group</mat-icon>
</div>
<div class="kpi-body">
<div class="kpi-val">{{ data.totalActiveShareholders }}</div>
<div class="kpi-lbl">Total Shareholders</div>
<div class="kpi-sub-text">Active members</div>
</div>
</div>
<div class="kpi">
<div class="kpi-icon" style="background: rgba(22, 163, 74, 0.1)">
<mat-icon style="color: #16a34a">cake</mat-icon>
</div>
<div class="kpi-body">
<div class="kpi-val">{{ data.eligibleByAge }}</div>
<div class="kpi-lbl">
@if (!data.postMinAge && !data.postMaxAge) {
Age-Eligible
} @else {
Age-Eligible ({{ data.postMinAge }}–{{ data.postMaxAge }})
}
</div>
<div class="kpi-sub-text">Fits age threshold</div>
</div>
</div>
<div class="kpi">
<div class="kpi-icon" style="background: rgba(126, 34, 206, 0.1)">
<mat-icon style="color: #7e22ce">diversity_3</mat-icon>
</div>
<div class="kpi-body">
<div class="kpi-val">{{ data.casteMatchCount }}</div>
<div class="kpi-lbl">Same Social Category</div>
<div class="kpi-sub-text">Matches candidate</div>
</div>
</div>
<div class="kpi">
<div class="kpi-icon" style="background: rgba(190, 24, 93, 0.1)">
<mat-icon style="color: #be185d">family_restroom</mat-icon>
</div>
<div class="kpi-body">
<div class="kpi-val">{{ data.shareholdersWithFamily }}</div>
<div class="kpi-lbl">With Family Records</div>
<div class="kpi-sub-text">Household influence</div>
</div>
</div>
</div>
<!-- ── Analysis Layout ───────────────────────────────────────────────────── -->
<div class="dashboard-sections">
<!-- SECTION 1: DEMOGRAPHICS & PROFILE -->
<div class="dashboard-section">
<div class="section-header">
<mat-icon>people_outline</mat-icon>
<h2>Demographic & Professional Profile</h2>
<span class="section-badge">6 Analysis Dimensions</span>
</div>
<div class="a-grid">
<!-- Gender -->
<div class="a-card" style="--ac: #ec4899; --ac-bg: rgba(236, 72, 153, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>people</mat-icon></div>
<span class="ac-title">Gender Distribution</span>
@if (data.candidateGender) {
<span class="ac-tag">{{ data.candidateGender }}</span>
}
</div>
<div class="cbs">
<div class="cr-outer" style="--cr-glow: #ec4899">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.genderBreakdown, '#ec4899', genderPalette)"
(mousemove)="onRingHover($event, data.genderBreakdown, '#ec4899', genderPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="cr-legend">
@for (item of data.genderBreakdown; track item.label; let i = $index) {
<div class="crl-item" [class.crl-match]="item.match">
<span class="crl-swatch" [style.background]="itemColor(item, i, '#ec4899', genderPalette)"></span>
<span class="crl-label"
>{{ item.label }}
@if (item.match) {
<span class="mbadge">★</span>
}
</span>
<span class="crl-pct" [class.crl-pct-match]="item.match">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Age Groups -->
<div class="a-card" style="--ac: #3b82f6; --ac-bg: rgba(59, 130, 246, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>cake</mat-icon></div>
<span class="ac-title">Age Group Distribution</span>
@if (data.candidateAge) {
<span class="ac-tag">Age: {{ data.candidateAge }} yrs</span>
}
</div>
<div class="cbs">
<div class="cr-outer" style="--cr-glow: #3b82f6">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.ageBreakdown, '#3b82f6', agePalette)"
(mousemove)="onRingHover($event, data.ageBreakdown, '#3b82f6', agePalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="cr-legend">
@for (item of data.ageBreakdown; track item.label; let i = $index) {
<div class="crl-item" [class.crl-match]="item.match">
<span class="crl-swatch" [style.background]="itemColor(item, i, '#3b82f6', agePalette)"></span>
<span class="crl-label"
>{{ item.label }} Group
@if (item.match) {
<span class="mbadge">★</span>
}
</span>
<span class="crl-pct" [class.crl-pct-match]="item.match">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Religion -->
<div class="a-card" style="--ac: #f97316; --ac-bg: rgba(249, 115, 22, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>temple_hindu</mat-icon></div>
<span class="ac-title">Religion Profile</span>
</div>
@if (data.religionBreakdown?.length > 0) {
<div class="cbs">
<div class="cr-outer" style="--cr-glow: #f97316">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.religionBreakdown, '#f97316', religionPalette)"
(mousemove)="onRingHover($event, data.religionBreakdown, '#f97316', religionPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="cr-legend">
@for (item of data.religionBreakdown; track item.label; let i = $index) {
<div class="crl-item" [class.crl-match]="item.match">
<span
class="crl-swatch"
[style.background]="itemColor(item, i, '#f97316', religionPalette)"
></span>
<span class="crl-label"
>{{ item.label }}
@if (item.match) {
<span class="mbadge">★</span>
}
</span>
<span class="crl-pct" [class.crl-pct-match]="item.match">{{ item.percent }}%</span>
</div>
}
</div>
</div>
} @else {
<p class="no-data">No religion data recorded yet.</p>
}
</div>
<!-- Occupation -->
<div class="a-card" style="--ac: #0891b2; --ac-bg: rgba(8, 145, 178, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>work</mat-icon></div>
<span class="ac-title">Occupation Profile</span>
</div>
@if (data.occupationBreakdown?.length > 0) {
<div class="cbs">
<div class="cr-outer" style="--cr-glow: #0891b2">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.occupationBreakdown, '#0891b2', occupationPalette)"
(mousemove)="onRingHover($event, data.occupationBreakdown, '#0891b2', occupationPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="cr-legend">
@for (item of data.occupationBreakdown; track item.label; let i = $index) {
<div class="crl-item">
<span
class="crl-swatch"
[style.background]="itemColor(item, i, '#0891b2', occupationPalette)"
></span>
<span class="crl-label">{{ item.label }}</span>
<span class="crl-pct">{{ item.percent }}%</span>
</div>
}
</div>
</div>
} @else {
<p class="no-data">No occupation data recorded yet.</p>
}
</div>
<!-- Education Level -->
<div class="a-card" style="--ac: #6366f1; --ac-bg: rgba(99, 102, 241, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>school</mat-icon></div>
<span class="ac-title">Education Level</span>
</div>
@if (data.educationBreakdown?.length > 0) {
<div class="cbs">
<div class="cr-outer" style="--cr-glow: #6366f1">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.educationBreakdown, '#6366f1', educationPalette)"
(mousemove)="onRingHover($event, data.educationBreakdown, '#6366f1', educationPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="cr-legend">
@for (item of data.educationBreakdown; track item.label; let i = $index) {
<div class="crl-item">
<span
class="crl-swatch"
[style.background]="itemColor(item, i, '#6366f1', educationPalette)"
></span>
<span class="crl-label">{{ item.label }}</span>
<span class="crl-pct">{{ item.percent }}%</span>
</div>
}
</div>
</div>
} @else {
<p class="no-data">No education data recorded yet.</p>
}
</div>
<!-- Marital Status -->
<div class="a-card" style="--ac: #f59e0b; --ac-bg: rgba(245, 158, 11, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>favorite</mat-icon></div>
<span class="ac-title">Marital Status Profile</span>
</div>
@if (data.maritalBreakdown?.length > 0) {
<div class="cbs">
<div class="cr-outer" style="--cr-glow: #f59e0b">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.maritalBreakdown, '#f59e0b', maritalPalette)"
(mousemove)="onRingHover($event, data.maritalBreakdown, '#f59e0b', maritalPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="cr-legend">
@for (item of data.maritalBreakdown; track item.label; let i = $index) {
<div class="crl-item">
<span
class="crl-swatch"
[style.background]="itemColor(item, i, '#f59e0b', maritalPalette)"
></span>
<span class="crl-label">{{ item.label }}</span>
<span class="crl-pct">{{ item.percent }}%</span>
</div>
}
</div>
</div>
} @else {
<p class="no-data">No marital status data recorded yet.</p>
}
</div>
</div>
</div>
<!-- SECTION 2: SOCIAL & GEOGRAPHIC PROFILE -->
<div class="dashboard-section">
<div class="section-header">
<mat-icon>public</mat-icon>
<h2>Social & Geographic Influence</h2>
<span class="section-badge">3 Analysis Dimensions</span>
</div>
<div class="a-grid">
<!-- Area / City -->
<div class="a-card" style="--ac: #10b981; --ac-bg: rgba(16, 185, 129, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>location_on</mat-icon></div>
<span class="ac-title">Area / City Distribution</span>
@if (data.candidateArea) {
<span class="ac-tag">{{ data.candidateArea }}</span>
}
</div>
<div class="cbs">
<div class="cr-outer" style="--cr-glow: #10b981">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.areaBreakdown, '#10b981', areaPalette)"
(mousemove)="onRingHover($event, data.areaBreakdown, '#10b981', areaPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="cr-legend">
@for (item of data.areaBreakdown; track item.label; let i = $index) {
<div class="crl-item" [class.crl-match]="item.match">
<span class="crl-swatch" [style.background]="itemColor(item, i, '#10b981', areaPalette)"></span>
<span class="crl-label"
>{{ item.label }}
@if (item.match) {
<span class="mbadge">★</span>
}
</span>
<span class="crl-pct" [class.crl-pct-match]="item.match">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Social Category (Caste) -->
<div class="a-card" style="--ac: #8b5cf6; --ac-bg: rgba(139, 92, 246, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>diversity_3</mat-icon></div>
<span class="ac-title">Social Category (Caste)</span>
@if (data.candidateCaste) {
<span class="ac-tag">{{ data.candidateCaste }}</span>
}
</div>
<div class="cbs">
<div class="cr-outer" style="--cr-glow: #8b5cf6">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.casteBreakdown, '#8b5cf6', castePalette)"
(mousemove)="onRingHover($event, data.casteBreakdown, '#8b5cf6', castePalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.casteMatchCount }}</span>
<span class="cr-sub">match</span>
</div>
</div>
</div>
<div class="cr-legend">
@for (item of data.casteBreakdown; track item.label; let i = $index) {
<div class="crl-item" [class.crl-match]="item.match">
<span class="crl-swatch" [style.background]="itemColor(item, i, '#8b5cf6', castePalette)"></span>
<span class="crl-label"
>{{ item.label }}
@if (item.match) {
<span class="mbadge">★</span>
}
</span>
<span class="crl-pct" [class.crl-pct-match]="item.match">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Sub-Caste — FULL WIDTH -->
<div class="a-card a-full" style="--ac: #d946ef; --ac-bg: rgba(217, 70, 239, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>account_tree</mat-icon></div>
<span class="ac-title">Sub-Caste Breakdown</span>
@if (data.candidateSubCaste) {
<span class="ac-tag">{{ data.candidateSubCaste }}</span>
}
</div>
@if (data.subCasteBreakdown?.length > 0) {
<div class="full-card-split">
<div class="split-left">
<div class="cr-outer" style="--cr-glow: #d946ef">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.subCasteBreakdown, '#d946ef', subCastePalette)"
(mousemove)="onRingHover($event, data.subCasteBreakdown, '#d946ef', subCastePalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
</div>
<div class="split-right">
<div class="caste-details-grid">
@for (item of data.subCasteBreakdown; track item.label; let i = $index) {
<div
class="caste-detail-item"
[class.caste-match]="item.match"
[style.--item-color]="itemColor(item, i, '#d946ef', subCastePalette)"
>
<div class="cdi-header">
<span
class="cdi-dot"
[style.background]="itemColor(item, i, '#d946ef', subCastePalette)"
></span>
<span class="cdi-label">{{ item.label }}</span>
@if (item.match) {
<span class="mbadge">★ Match</span>
}
</div>
<div class="cdi-footer">
<span class="cdi-count">{{ item.count }} members</span>
<span class="cdi-percent">{{ item.percent }}%</span>
</div>
</div>
}
</div>
</div>
</div>
} @else {
<p class="no-data">No sub-caste data recorded for shareholders yet.</p>
}
</div>
</div>
</div>
<!-- SECTION 3: FINANCIAL & FAMILY ENGAGEMENT -->
<div class="dashboard-section">
<div class="section-header">
<mat-icon>savings</mat-icon>
<h2>Financial & Family Engagement</h2>
<span class="section-badge">3 Analysis Dimensions</span>
</div>
<div class="a-grid">
<!-- Share Holding Distribution -->
<div class="a-card" style="--ac: #6366f1; --ac-bg: rgba(99, 102, 241, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>account_balance_wallet</mat-icon></div>
<span class="ac-title">Share Holding Distribution</span>
</div>
<div class="cbs">
<div class="cr-outer" style="--cr-glow: #6366f1">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.shareHoldingBreakdown, '#6366f1', shareHoldingPalette)"
(mousemove)="onRingHover($event, data.shareHoldingBreakdown, '#6366f1', shareHoldingPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="cr-legend">
@for (item of data.shareHoldingBreakdown; track item.label; let i = $index) {
<div class="crl-item">
<span
class="crl-swatch"
[style.background]="itemColor(item, i, '#6366f1', shareHoldingPalette)"
></span>
<span class="crl-label">{{ item.label }}</span>
<span class="crl-pct">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Member Tenure -->
<div class="a-card" style="--ac: #06b6d4; --ac-bg: rgba(6, 182, 212, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>history</mat-icon></div>
<span class="ac-title">Member Tenure</span>
</div>
<div class="cbs">
<div class="cr-outer" style="--cr-glow: #06b6d4">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.tenureBreakdown, '#06b6d4', tenurePalette)"
(mousemove)="onRingHover($event, data.tenureBreakdown, '#06b6d4', tenurePalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="cr-legend">
@for (item of data.tenureBreakdown; track item.label; let i = $index) {
<div class="crl-item">
<span class="crl-swatch" [style.background]="itemColor(item, i, '#06b6d4', tenurePalette)"></span>
<span class="crl-label">{{ item.label }}</span>
<span class="crl-pct">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Family Voter Influence -->
<div class="a-card" style="--ac: #be185d; --ac-bg: rgba(190, 24, 93, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>family_restroom</mat-icon></div>
<span class="ac-title">Family Voter Influence</span>
<span class="ac-tag"
>{{ data.shareholdersWithFamily }} of {{ data.totalActiveShareholders }} shareholders</span
>
</div>
@if (data.shareholdersWithFamily > 0) {
<div class="cbs">
<div class="cr-outer" style="--cr-glow: #be185d">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(getFamilyBreakdown(data), '#be185d', familyPalette)"
(mousemove)="onRingHover($event, getFamilyBreakdown(data), '#be185d', familyPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.shareholdersWithFamily }}</span>
<span class="cr-sub">family</span>
</div>
</div>
</div>
<div class="cr-legend">
@for (item of getFamilyBreakdown(data); track item.label; let i = $index) {
<div class="crl-item" [class.crl-match]="item.match">
<span class="crl-swatch" [style.background]="itemColor(item, i, '#be185d', familyPalette)"></span>
<span class="crl-label"
>{{ item.label }}
@if (item.match) {
<span class="mbadge">★</span>
}
</span>
<span class="crl-pct" [class.crl-pct-match]="item.match">{{ item.percent }}%</span>
</div>
}
</div>
</div>
} @else {
<p class="no-data">No family member records linked to this candidate.</p>
}
</div>
</div>
</div>
<!-- SECTION 4: VOTER INFLUENCE INSIGHTS -->
<div class="dashboard-section">
<div class="section-header">
<mat-icon>auto_graph</mat-icon>
<h2>Voter Influence Insights</h2>
<span class="section-badge">Multi-Dimension Analysis</span>
</div>
<div class="a-grid">
<div class="a-card a-full" style="--ac: #8b5cf6; --ac-bg: rgba(139, 92, 246, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>hub</mat-icon></div>
<span class="ac-title">Multi-Dimension Voter Alignment</span>
<span class="ac-tag">Candidate's core voter base overlap</span>
</div>
<div class="influence-insight">
<div class="influence-metric im-highlight">
<div class="im-icon" style="background: rgba(139, 92, 246, 0.12)">
<mat-icon style="color: #8b5cf6">hub</mat-icon>
</div>
<div class="im-val">{{ data.crossMatchCount }}</div>
<div class="im-pct" style="color: #8b5cf6">{{ crossMatchPct(data) }}%</div>
<div class="im-label">Core Base — Same Caste &amp; Same Area</div>
</div>
<div class="influence-metric">
<div class="im-icon" style="background: rgba(139, 92, 246, 0.08)">
<mat-icon style="color: #a78bfa">diversity_3</mat-icon>
</div>
<div class="im-val">{{ data.casteMatchCount }}</div>
<div class="im-pct" style="color: #a78bfa">{{ casteMatchPct(data) }}%</div>
<div class="im-label">Same Social Category (Caste Alignment)</div>
</div>
<div class="influence-metric">
<div class="im-icon" style="background: rgba(16, 185, 129, 0.08)">
<mat-icon style="color: #10b981">location_on</mat-icon>
</div>
<div class="im-val">{{ data.areaMatchCount }}</div>
<div class="im-pct" style="color: #10b981">{{ areaMatchPct(data) }}%</div>
<div class="im-label">Same Area / City (Geographic Proximity)</div>
</div>
</div>
<div class="insight-note" style="margin-top: 16px">
<mat-icon>lightbulb</mat-icon>
<span
>The <strong>Core Base</strong> — voters matching both the candidate's social category AND geographic
area — represents the strongest aligned voter segment. Caste, area, and financial engagement are the
three strongest predictors of voter support in cooperative elections.</span
>
</div>
</div>
</div>
</div>
</div>
}
</div>
@if (tooltipState.visible) {
<div class="chart-tooltip" [style.left.px]="tooltipState.x + 14" [style.top.px]="tooltipState.y - 48">
<span class="ct-swatch" [style.background]="tooltipState.color"></span>
<span class="ct-label">{{ tooltipState.label }}</span>
<span class="ct-sep">·</span>
<span class="ct-pct">{{ tooltipState.percent }}%</span>
<span class="ct-count">({{ tooltipState.count }})</span>
</div>
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,522 @@
/**
* 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 { RouterModule, ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { ElectionService } from '../../election.service';
import { jsPDF } from 'jspdf';
import html2canvas from 'html2canvas';
@Component({
selector: 'mifosx-candidate-analysis',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatIconModule
],
templateUrl: './candidate-analysis.component.html',
styleUrls: ['./candidate-analysis.component.scss']
})
export class CandidateAnalysisComponent implements OnInit {
private service = inject(ElectionService);
private route = inject(ActivatedRoute);
electionId!: number;
candidateId!: number;
data: any = null;
loading = true;
error = false;
errorMessage = '';
pdfGenerating = false;
tooltipState: {
visible: boolean;
label: string;
count: number;
percent: number;
color: string;
x: number;
y: number;
} = { visible: false, label: '', count: 0, percent: 0, color: '', x: 0, y: 0 };
private currentRingOuter: HTMLElement | null = null;
private currentRing: HTMLElement | null = null;
// ── Color palettes — IT-standard categorical colors (Tailwind 500 / Chart.js) ─
// Each non-match segment gets a distinct hue; grey (#94a3b8) for "unspecified"
// Gender accent #ec4899
readonly genderPalette = [
'#3b82f6',
'#a855f7',
'#94a3b8'
];
// Caste accent #8b5cf6
readonly castePalette = [
'#3b82f6',
'#06b6d4',
'#10b981',
'#f59e0b',
'#94a3b8'
];
// Religion accent #f97316
readonly religionPalette = [
'#3b82f6',
'#8b5cf6',
'#ef4444',
'#10b981',
'#f59e0b',
'#ec4899',
'#94a3b8'
];
// Age accent #3b82f6
readonly agePalette = [
'#10b981',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#f97316',
'#94a3b8'
];
// Education accent #6366f1
readonly educationPalette = [
'#10b981',
'#f59e0b',
'#ef4444',
'#ec4899',
'#94a3b8'
];
// Marital accent #f59e0b
readonly maritalPalette = [
'#3b82f6',
'#10b981',
'#ec4899',
'#8b5cf6',
'#94a3b8'
];
// Income accent #16a34a
readonly incomePalette = [
'#3b82f6',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#f97316',
'#94a3b8'
];
// Share Holding accent #6366f1
readonly shareHoldingPalette = [
'#f59e0b',
'#10b981',
'#ef4444',
'#94a3b8'
];
// Tenure accent #06b6d4
readonly tenurePalette = [
'#f59e0b',
'#8b5cf6',
'#10b981',
'#94a3b8'
];
// Area accent #10b981
readonly areaPalette = [
'#3b82f6',
'#f59e0b',
'#f97316',
'#94a3b8'
];
// Sub-Caste accent #d946ef
readonly subCastePalette = [
'#3b82f6',
'#10b981',
'#f59e0b',
'#f97316',
'#94a3b8'
];
// Occupation accent #0891b2
readonly occupationPalette = [
'#f59e0b',
'#8b5cf6',
'#ef4444',
'#10b981',
'#94a3b8'
];
// Family accent #be185d
readonly familyPalette = [
'#be185d',
'#94a3b8'
];
onRingHover(event: MouseEvent, breakdown: any[], accent: string, palette: string[]): void {
if (!breakdown?.length) return;
const el = event.currentTarget as HTMLElement;
const rect = el.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = event.clientX - cx;
const dy = event.clientY - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
const outerR = rect.width / 2;
const holeR = outerR * 0.42;
// Track cr-outer for glow color; track ring element for highlight overlay
const outer = el.parentElement as HTMLElement;
if (outer && outer !== this.currentRingOuter) {
if (this.currentRingOuter) {
this.currentRingOuter.classList.remove('ring-hovered');
this.currentRingOuter.style.removeProperty('--cr-glow');
}
this.currentRingOuter = outer;
this.currentRing = el;
outer.classList.add('ring-hovered');
}
// Ensure highlight overlay element exists (created once per ring, reused)
let hl = el.querySelector<HTMLElement>('.cr-highlight');
if (!hl) {
hl = document.createElement('div');
hl.className = 'cr-highlight';
el.insertBefore(hl, el.querySelector('.cr-hole'));
}
if (dist < holeR || dist > outerR) {
hl.classList.remove('active');
this.tooltipState.visible = false;
return;
}
// CSS conic-gradient uses clockwise-from-top convention; "from -90deg" starts at left (9 o'clock).
// atan2(dx, -dy) gives the clockwise-from-top angle in degrees.
// Subtract the start offset (-90deg) to get the gradient percentage position.
const cssDeg = Math.atan2(dx, -dy) * (180 / Math.PI);
const pct = (((((cssDeg + 90) % 360) + 360) % 360) / 360) * 100;
let cum = 0;
for (let i = 0; i < breakdown.length; i++) {
cum += breakdown[i].percent;
if (pct <= cum + 0.01) {
const segColor = breakdown[i].match ? accent : palette[i % palette.length];
const startPct = cum - breakdown[i].percent;
// Highlight overlay: only the hovered segment is colored, rest transparent
hl.style.background = `conic-gradient(from -90deg, transparent 0% ${startPct}%, ${segColor} ${startPct}% ${cum}%, transparent ${cum}% 100%)`;
hl.classList.add('active');
if (outer) outer.style.setProperty('--cr-glow', segColor);
this.tooltipState = {
visible: true,
label: breakdown[i].label,
count: breakdown[i].count,
percent: breakdown[i].percent,
color: segColor,
x: event.clientX,
y: event.clientY
};
return;
}
}
hl.classList.remove('active');
this.tooltipState.visible = false;
}
onRingLeave(): void {
const hl = this.currentRing?.querySelector<HTMLElement>('.cr-highlight');
if (hl) hl.classList.remove('active');
if (this.currentRingOuter) {
this.currentRingOuter.classList.remove('ring-hovered');
this.currentRingOuter.style.removeProperty('--cr-glow');
this.currentRingOuter = null;
}
this.currentRing = null;
this.tooltipState.visible = false;
}
ngOnInit(): void {
this.electionId = +this.route.snapshot.params['electionId'];
this.candidateId = +this.route.snapshot.params['candidateId'];
this.service.getCandidateVoterAnalysis(this.electionId, this.candidateId).subscribe({
next: (d) => {
this.data = d;
this.loading = false;
},
error: (err) => {
this.loading = false;
this.error = true;
this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'Analysis not available.';
}
});
}
/** Builds a CSS conic-gradient string from a breakdown array. */
getConicGradient(breakdown: any[], accent: string, palette: string[]): string {
if (!breakdown?.length) return '#e2e8f0';
const parts: string[] = [];
let cum = 0;
breakdown.forEach((item, i) => {
const color = item.match ? accent : palette[i % palette.length];
const segEnd = cum + item.percent;
parts.push(`${color} ${cum}% ${segEnd}%`);
cum = segEnd;
});
if (cum < 99.5) parts.push(`#e2e8f0 ${cum}% 100%`);
return `conic-gradient(from -90deg, ${parts.join(', ')})`;
}
/** Semicircle gauge for loan chart. */
getLoanGauge(pct: number): string {
const fill = pct / 2;
return `conic-gradient(from 270deg, #f59e0b 0% ${fill}%, #e2e8f0 ${fill}% 50%, transparent 50% 100%)`;
}
hasCriteria(d: any): boolean {
return (d.postGenderType && d.postGenderType !== 'ANY') || d.postMinAge || d.postMaxAge || d.postMinShareHolding;
}
eligiblePct(d: any): number {
if (!d?.totalActiveShareholders) return 0;
return Math.round((d.actualEligibleVoters / d.totalActiveShareholders) * 100);
}
activeLoanPct(d: any): number {
if (!d?.totalActiveShareholders) return 0;
return Math.round((d.activeLoanHolders / d.totalActiveShareholders) * 100);
}
activeSavingsPct(d: any): number {
if (!d?.totalActiveShareholders) return 0;
return Math.round((d.activeSavingsHolders / d.totalActiveShareholders) * 100);
}
crossMatchPct(d: any): number {
if (!d?.totalActiveShareholders) return 0;
return Math.round((d.crossMatchCount / d.totalActiveShareholders) * 100);
}
casteMatchPct(d: any): number {
if (!d?.totalActiveShareholders) return 0;
return Math.round((d.casteMatchCount / d.totalActiveShareholders) * 100);
}
areaMatchPct(d: any): number {
if (!d?.totalActiveShareholders) return 0;
return Math.round((d.areaMatchCount / d.totalActiveShareholders) * 100);
}
itemColor(item: any, idx: number, accent: string, palette: string[]): string {
if (item.match) return accent;
return palette[idx % palette.length];
}
getFamilyBreakdown(d: any): any[] {
if (!d?.totalActiveShareholders) return [];
const total = d.totalActiveShareholders;
const familyShareholders = d.shareholdersWithFamily ?? 0;
const others = total - familyShareholders;
const result: any[] = [];
if (familyShareholders > 0) {
result.push({
label: 'Family Shareholders',
count: familyShareholders,
percent: Math.round((familyShareholders / total) * 1000) / 10,
match: true
});
}
if (others > 0) {
result.push({
label: 'Other Shareholders',
count: others,
percent: Math.round((others / total) * 1000) / 10,
match: false
});
}
return result;
}
async downloadPDF(): Promise<void> {
if (this.pdfGenerating) return;
this.pdfGenerating = true;
// Hide navigation/decorative elements that shouldn't appear in the PDF
const hideSelectors = [
'.hero-actions',
'.backdrop-blobs'
];
const hiddenEls: HTMLElement[] = [];
hideSelectors.forEach((sel) => {
document.querySelectorAll<HTMLElement>(sel).forEach((el) => {
hiddenEls.push(el);
el.style.setProperty('display', 'none', 'important');
});
});
// Expand Angular Material scroll containers so the full page height is rendered
const scrollSaved: Array<{ el: HTMLElement; overflow: string; height: string; maxHeight: string }> = [];
[
'mat-sidenav-content',
'mat-drawer-content',
'mat-sidenav-container',
'mat-drawer-container'
].forEach((sel) => {
document.querySelectorAll<HTMLElement>(sel).forEach((el) => {
scrollSaved.push({ el, overflow: el.style.overflow, height: el.style.height, maxHeight: el.style.maxHeight });
el.style.setProperty('overflow', 'visible', 'important');
el.style.setProperty('height', 'auto', 'important');
el.style.setProperty('max-height', 'none', 'important');
});
});
// Allow full reflow to complete after container expansion
await new Promise<void>((resolve) => setTimeout(resolve, 300));
// html2canvas cannot render conic-gradient CSS — pre-bake each ring to a canvas image
const gradientBackups = this.preprocessConicGradients();
try {
const element = document.querySelector<HTMLElement>('.va-page')!;
// Read full height AFTER reflow — not just the clipped viewport height
const fullHeight = element.scrollHeight || element.offsetHeight;
const canvas = await html2canvas(element, {
scale: 1.5,
useCORS: true,
allowTaint: true,
backgroundColor: '#f8fafc',
logging: false,
scrollX: 0,
scrollY: 0,
width: element.offsetWidth,
height: fullHeight,
windowWidth: 1280,
windowHeight: fullHeight
});
const imgData = canvas.toDataURL('image/jpeg', 0.97);
const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pageW = pdf.internal.pageSize.getWidth();
const pageH = pdf.internal.pageSize.getHeight();
const imgW = pageW;
const imgH = (canvas.height * imgW) / canvas.width;
let yOffset = 0;
let remaining = imgH;
pdf.addImage(imgData, 'JPEG', 0, yOffset, imgW, imgH);
remaining -= pageH;
while (remaining > 0) {
yOffset -= pageH;
pdf.addPage();
pdf.addImage(imgData, 'JPEG', 0, yOffset, imgW, imgH);
remaining -= pageH;
}
const safeName = (this.data?.candidateName ?? 'candidate').replace(/\s+/g, '-');
pdf.save(`Voter-Analysis-${safeName}.pdf`);
} finally {
scrollSaved.forEach(({ el, overflow, height, maxHeight }) => {
el.style.overflow = overflow;
el.style.height = height;
el.style.maxHeight = maxHeight;
});
hiddenEls.forEach((el) => el.style.removeProperty('display'));
gradientBackups.forEach(({ el, originalBg, originalBgSize }) => {
el.style.background = originalBg;
el.style.backgroundSize = originalBgSize;
});
this.pdfGenerating = false;
}
}
/** Replace every conic-gradient background with a canvas-drawn image so html2canvas can capture it. */
private preprocessConicGradients(): Array<{ el: HTMLElement; originalBg: string; originalBgSize: string }> {
const backups: Array<{ el: HTMLElement; originalBg: string; originalBgSize: string }> = [];
document.querySelectorAll<HTMLElement>('.cr-ring, .loan-g-ring').forEach((el) => {
const bg = el.style.background;
if (!bg?.includes('conic-gradient')) return;
backups.push({ el, originalBg: bg, originalBgSize: el.style.backgroundSize });
const w = (el.offsetWidth || 118) * 2;
const h = (el.offsetHeight || 118) * 2;
const dataUrl = this.conicGradToDataUrl(bg, w, h);
if (dataUrl) {
el.style.background = `url(${dataUrl})`;
el.style.backgroundSize = '100% 100%';
}
});
return backups;
}
/** Parse a conic-gradient() CSS string and rasterise it onto a canvas, returning a PNG data URL.
* Two-pass parser: Pass 1 handles original range format `color X% Y%`.
* Pass 2 handles Chrome-normalised single-stop format `color X%` by grouping consecutive
* same-colour stops into ranges — Chrome writes each range as two identical-colour stops. */
private conicGradToDataUrl(gradStr: string, w: number, h: number): string | null {
try {
const angleMatch = gradStr.match(/from\s+([\d.-]+)deg/);
const startRad = angleMatch ? (parseFloat(angleMatch[1]) * Math.PI) / 180 : 0;
const C = 'white|transparent|#[0-9a-fA-F]{3,8}|rgb[a]?\\([^)]+\\)';
let stops: Array<{ color: string; from: number; to: number }> = [];
// Pass 1 — original range format: `color X% Y%`
const rangeRe = new RegExp(`(${C})\\s+([\\d.]+)%\\s+([\\d.]+)%`, 'g');
let m: RegExpExecArray | null;
while ((m = rangeRe.exec(gradStr)) !== null) {
stops.push({ color: m[1], from: parseFloat(m[2]), to: parseFloat(m[3]) });
}
if (!stops.length) {
// Pass 2 — Chrome-normalised single stops: group consecutive same-colour stops into ranges
const singleRe = new RegExp(`(${C})\\s+([\\d.]+)%`, 'g');
const raw: Array<{ color: string; pct: number }> = [];
while ((m = singleRe.exec(gradStr)) !== null) {
raw.push({ color: m[1], pct: parseFloat(m[2]) });
}
let i = 0;
while (i < raw.length) {
const { color } = raw[i];
const start = raw[i].pct;
let j = i + 1;
while (j < raw.length && raw[j].color === color) j++;
const end = raw[j - 1].pct;
if (start < end) stops.push({ color, from: start, to: end });
i = j;
}
}
if (!stops.length) return null;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d')!;
const cx = w / 2;
const cy = h / 2;
const r = Math.min(cx, cy);
stops.forEach(({ color, from, to }) => {
if (color === 'transparent' || from >= to) return;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, r, startRad + (from / 100) * 2 * Math.PI, startRad + (to / 100) * 2 * Math.PI);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
});
return canvas.toDataURL('image/png');
} catch {
return null;
}
}
}
@@ -15,13 +15,16 @@
<p class="sub-title">{{ election.title }}</p>
}
</div>
<a [routerLink]="['/election', electionId, 'shareholders', 'analysis']" class="btn-outline btn-analysis">
<mat-icon>analytics</mat-icon> Overall Analysis
</a>
<a [routerLink]="['/election', electionId, 'nominations']" class="btn-outline">
<mat-icon>list</mat-icon> Nominations
</a>
</div>
<div class="filter-row">
<span class="total-badge">{{ candidates.length }} candidates</span>
<span class="total-badge">{{ totalElements }} candidates</span>
<div class="post-filter">
<button class="filter-btn" [class.active]="!selectedPostId" (click)="selectedPostId = null; loadCandidates()">
All Posts
@@ -58,12 +61,18 @@
</div>
<div class="candidates-grid">
@for (candidate of group.candidates; track candidate.id) {
<div class="candidate-card" [class.inactive]="!candidate.isActive">
<div class="serial-badge"># {{ candidate.serialNumber }}</div>
<div class="candidate-card" [class.inactive]="!candidate.isActive" (click)="openDetail(candidate)">
<div class="serial-badge">#{{ candidate.serialNumber }}</div>
<div class="candidate-avatar">
@if (candidate.photoUrl) {
<img [src]="candidate.photoUrl" [alt]="candidate.candidateName" />
@if (getCandidatePhoto(candidate)) {
<img
[src]="getCandidatePhoto(candidate)"
[alt]="candidate.candidateName"
class="clickable-photo"
(click)="openPhotoViewer(candidate, $event)"
title="Click to view"
/>
} @else {
<div class="avatar-placeholder">
{{ candidate.candidateName.charAt(0).toUpperCase() }}
@@ -71,34 +80,56 @@
}
</div>
<div class="candidate-info">
<h4>{{ candidate.candidateName }}</h4>
@if (candidate.symbol) {
<div class="symbol">Symbol: {{ candidate.symbol }}</div>
}
<div class="meta-row">
@if (candidate.gender) {
<span>{{ candidate.gender }}</span>
}
@if (candidate.caste) {
<span>{{ candidate.caste }}</span>
}
@if (candidate.location) {
<span class="location"><mat-icon>location_on</mat-icon>{{ candidate.location }}</span>
}
<div class="candidate-name">{{ candidate.candidateName }}</div>
@if (showVoteCounts) {
<div class="vote-count-badge">
<mat-icon>how_to_vote</mat-icon>
{{ getVoteCount(candidate) }} votes
</div>
@if (candidate.briefProfile) {
<p class="brief-profile">{{ candidate.briefProfile }}</p>
}
<div class="symbol-area" (click)="$event.stopPropagation()">
@if (getSymbol(candidate)) {
<img
[src]="getSymbol(candidate)"
class="symbol-thumb"
title="Click to view symbol"
(click)="openSymbolViewer(candidate, $event)"
/>
<span class="symbol-label">Election Symbol</span>
} @else {
<span class="no-symbol-text">No symbol</span>
}
<label
class="btn-symbol-upload"
[class.uploading]="symbolUploading[candidate.id]"
[class.btn-locked]="!canModifyCandidates"
>
<mat-icon>{{ symbolUploading[candidate.id] ? 'hourglass_top' : 'upload' }}</mat-icon>
{{ candidate.hasSymbol ? 'Change' : 'Upload' }}
<input
type="file"
accept="image/*"
style="display: none"
[disabled]="!canModifyCandidates"
(change)="onSymbolFileChange(candidate, $event)"
/>
</label>
</div>
<div class="candidate-actions">
<div class="candidate-actions" (click)="$event.stopPropagation()">
<a [routerLink]="['/election', electionId, 'candidates', candidate.id, 'analysis']" class="btn-analyse">
<mat-icon>analytics</mat-icon> Analyse
</a>
@if (!candidate.isActive) {
<span class="removed-badge">Removed</span>
} @else {
} @else if (canModifyCandidates) {
<button class="btn-remove" (click)="removeCandidate(candidate)">
<mat-icon>person_remove</mat-icon> Remove
</button>
} @else {
<span class="locked-badge"><mat-icon>lock</mat-icon> Locked</span>
}
</div>
</div>
@@ -106,5 +137,192 @@
</div>
</div>
}
@if (totalPages > 1 || totalElements > pageSizeOptions[0]) {
<div class="pagination-bar">
<div class="pg-info">Showing {{ startItem }}–{{ endItem }} of {{ totalElements }}</div>
<div class="pg-controls">
<button class="pg-btn" [disabled]="currentPage === 1" (click)="goToPage(1)" title="First">
<mat-icon>first_page</mat-icon>
</button>
<button class="pg-btn" [disabled]="currentPage === 1" (click)="goToPage(currentPage - 1)" title="Previous">
<mat-icon>chevron_left</mat-icon>
</button>
@for (p of pageNumbers; track $index) {
@if (isNumber(p)) {
<button class="pg-btn pg-num" [class.pg-num--active]="p === currentPage" (click)="goToPage(p)">
{{ p }}
</button>
} @else {
<span class="pg-ellipsis">…</span>
}
}
<button
class="pg-btn"
[disabled]="currentPage === totalPages"
(click)="goToPage(currentPage + 1)"
title="Next"
>
<mat-icon>chevron_right</mat-icon>
</button>
<button class="pg-btn" [disabled]="currentPage === totalPages" (click)="goToPage(totalPages)" title="Last">
<mat-icon>last_page</mat-icon>
</button>
</div>
<div class="pg-size">
<select [(ngModel)]="pageSize" (change)="onPageSizeChange()">
@for (n of pageSizeOptions; track n) {
<option [value]="n">{{ n }} / page</option>
}
</select>
</div>
</div>
}
}
</div>
@if (selectedCandidate) {
<div class="drawer-overlay" (click)="closeDetail()"></div>
<div class="candidate-drawer">
<div class="drawer-header">
<h3>Candidate Details</h3>
<button class="drawer-close" (click)="closeDetail()"><mat-icon>close</mat-icon></button>
</div>
<div class="drawer-body">
<div class="drawer-avatar-wrap">
@if (getCandidatePhoto(selectedCandidate)) {
<img
[src]="getCandidatePhoto(selectedCandidate)"
class="drawer-avatar-img clickable-photo"
[alt]="selectedCandidate.candidateName"
(click)="openPhotoViewer(selectedCandidate, $event)"
title="Click to view"
/>
} @else {
<div class="drawer-avatar-placeholder">
{{ selectedCandidate.candidateName.charAt(0).toUpperCase() }}
</div>
}
<div class="drawer-serial">#{{ selectedCandidate.serialNumber }}</div>
</div>
<div class="drawer-name">{{ selectedCandidate.candidateName }}</div>
<div class="drawer-post">{{ selectedCandidate.postName }}</div>
@if (showVoteCounts) {
<div class="drawer-vote-count">
<mat-icon>how_to_vote</mat-icon>
{{ getVoteCount(selectedCandidate) }} votes received
</div>
}
@if (selectedCandidate.approvedDate) {
<div class="drawer-meta-row">
<mat-icon>event_available</mat-icon>
<span>Approved on {{ selectedCandidate.approvedDate | date: 'dd MMM yyyy, hh:mm a' }}</span>
</div>
}
<div class="drawer-section">
<div class="drawer-section-title"><mat-icon>how_to_vote</mat-icon> Election Symbol</div>
<div class="symbol-preview-area">
@if (getSymbol(selectedCandidate)) {
<div
class="symbol-seal-wrap clickable-photo"
(click)="openSymbolViewer(selectedCandidate, $event)"
title="Click to view"
>
<img [src]="getSymbol(selectedCandidate)" class="symbol-preview-img" alt="Election Symbol" />
<span class="symbol-seal-label">Election Symbol</span>
</div>
} @else {
<div class="symbol-empty">
<mat-icon>how_to_vote</mat-icon>
<span>No symbol yet</span>
</div>
}
<label
class="btn-upload-symbol"
[class.uploading]="symbolUploading[selectedCandidate.id]"
[class.btn-locked]="!canModifyCandidates"
>
<mat-icon>{{
symbolUploading[selectedCandidate.id] ? 'hourglass_top' : !canModifyCandidates ? 'lock' : 'upload_file'
}}</mat-icon>
{{
symbolUploading[selectedCandidate.id]
? 'Uploading...'
: !canModifyCandidates
? 'Symbol Locked'
: selectedCandidate.hasSymbol
? 'Change Symbol'
: 'Upload Symbol'
}}
<input
type="file"
accept="image/*"
style="display: none"
[disabled]="!canModifyCandidates"
(change)="onSymbolFileChange(selectedCandidate, $event)"
/>
</label>
</div>
</div>
@if (selectedCandidate.briefProfile) {
<div class="drawer-section">
<div class="drawer-section-title"><mat-icon>person</mat-icon> Brief Profile</div>
<p class="drawer-profile-text">{{ selectedCandidate.briefProfile }}</p>
</div>
}
<div class="drawer-section">
<div class="drawer-section-title"><mat-icon>info</mat-icon> Details</div>
<div class="drawer-info-grid">
<div class="info-item">
<span class="info-label">Serial No.</span>
<span class="info-value">#{{ selectedCandidate.serialNumber }}</span>
</div>
<div class="info-item">
<span class="info-label">Post</span>
<span class="info-value">{{ selectedCandidate.postName }}</span>
</div>
<div class="info-item">
<span class="info-label">Status</span>
<span
class="info-value"
[class.text-green]="selectedCandidate.isActive"
[class.text-red]="!selectedCandidate.isActive"
>
{{ selectedCandidate.isActive ? 'Active' : 'Removed' }}
</span>
</div>
@if (selectedCandidate.nominationId) {
<div class="info-item">
<span class="info-label">Nomination</span>
<a
[routerLink]="['/election', electionId, 'nomination', selectedCandidate.nominationId]"
class="info-link"
(click)="closeDetail()"
>
View Nomination <mat-icon>open_in_new</mat-icon>
</a>
</div>
}
</div>
</div>
@if (selectedCandidate.isActive && canModifyCandidates) {
<button class="btn-remove-full" (click)="removeCandidate(selectedCandidate)">
<mat-icon>person_remove</mat-icon> Remove Candidate
</button>
} @else if (selectedCandidate.isActive && !canModifyCandidates) {
<div class="locked-notice">
<mat-icon>lock</mat-icon>
Candidate cannot be removed after voting has started
</div>
}
</div>
</div>
}
@@ -8,8 +8,6 @@
.candidate-board {
padding: 24px;
max-width: 1300px;
margin: 0 auto;
}
.page-header {
@@ -44,7 +42,7 @@
align-items: center;
gap: 4px;
background: #f7f8fc;
color: #4a6cf7;
color: #2563eb;
border: 1px solid #e2e8f0;
padding: 8px 16px;
border-radius: 8px;
@@ -54,6 +52,21 @@
mat-icon {
font-size: 18px;
}
&.btn-analysis {
margin-left: auto;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
color: #fff;
border-color: transparent;
&:hover {
opacity: 0.9;
}
}
& + .btn-outline {
margin-left: 8px;
}
}
}
@@ -87,11 +100,12 @@
color: #718096;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
&.active {
background: #4a6cf7;
background: #2563eb;
color: #fff;
border-color: #4a6cf7;
border-color: #2563eb;
}
}
}
@@ -124,7 +138,7 @@
.candidates-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
@@ -132,12 +146,17 @@
background: #fff;
border-radius: 14px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 20px;
padding: 18px 16px 14px;
position: relative;
transition: box-shadow 0.2s;
transition:
box-shadow 0.2s,
transform 0.15s;
cursor: pointer;
text-align: center;
&:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
box-shadow: 0 6px 20px rgb(0 0 0 / 14%);
transform: translateY(-2px);
}
&.inactive {
@@ -146,114 +165,202 @@
.serial-badge {
position: absolute;
top: 12px;
right: 12px;
background: #f0f4ff;
color: #4a6cf7;
padding: 2px 10px;
top: 10px;
right: 10px;
background: #eff6ff;
color: #2563eb;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-size: 11px;
font-weight: 700;
}
}
.clickable-photo {
cursor: zoom-in;
transition: opacity 0.15s;
&:hover {
opacity: 0.85;
}
}
.candidate-avatar {
display: flex;
justify-content: center;
margin-bottom: 16px;
margin-bottom: 12px;
img {
width: 80px;
height: 80px;
width: 72px;
height: 72px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #e2e8f0;
}
.avatar-placeholder {
width: 80px;
height: 80px;
width: 72px;
height: 72px;
border-radius: 50%;
background: linear-gradient(135deg, #4a6cf7, #7c3aed);
background: linear-gradient(135deg, #2563eb, #1e40af);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-size: 28px;
font-weight: 700;
}
}
.candidate-info {
text-align: center;
.candidate-name {
font-size: 14px;
font-weight: 600;
color: #2d3748;
margin-bottom: 4px;
line-height: 1.3;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #2d3748;
margin: 0 0 6px;
}
.vote-count-badge {
display: inline-flex;
align-items: center;
gap: 3px;
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
border-radius: 20px;
padding: 2px 8px;
font-size: 11px;
font-weight: 700;
margin: 0 auto 8px;
.symbol {
mat-icon {
font-size: 12px;
color: #718096;
margin-bottom: 8px;
height: 12px;
width: 12px;
}
}
.meta-row {
display: flex;
justify-content: center;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 8px;
.symbol-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin-bottom: 10px;
span {
font-size: 12px;
background: #f7fafc;
padding: 2px 8px;
border-radius: 10px;
color: #718096;
display: inline-flex;
align-items: center;
gap: 2px;
.symbol-thumb {
width: 64px;
height: 64px;
object-fit: contain;
border-radius: 50%;
background: #fff;
padding: 6px;
border: 3px solid #f59e0b;
box-shadow:
0 0 0 3px #fef3c7,
0 4px 12px rgb(245 158 11 / 25%);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
mat-icon {
font-size: 12px;
height: 12px;
width: 12px;
}
&:hover {
transform: scale(1.12);
box-shadow:
0 0 0 3px #fde68a,
0 6px 18px rgb(245 158 11 / 40%);
}
}
.brief-profile {
font-size: 12px;
.symbol-label {
font-size: 10px;
font-weight: 600;
color: #b45309;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.no-symbol-text {
font-size: 11px;
color: #a0aec0;
line-height: 1.5;
text-align: center;
}
}
.btn-symbol-upload {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 4px 10px;
border: 1px dashed #a0aec0;
border-radius: 6px;
background: #f8fafc;
color: #4a5568;
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
mat-icon {
font-size: 13px;
height: 13px;
width: 13px;
}
&:hover {
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
}
&.uploading {
opacity: 0.6;
pointer-events: none;
}
}
.candidate-actions {
display: flex;
justify-content: center;
margin-top: 14px;
gap: 6px;
margin-top: 6px;
.btn-analyse {
display: inline-flex;
align-items: center;
gap: 3px;
background: #eff6ff;
color: #2563eb;
border: 1px solid #bfdbfe;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
text-decoration: none;
cursor: pointer;
mat-icon {
font-size: 13px;
height: 13px;
width: 13px;
}
&:hover {
background: #dbeafe;
}
}
.btn-remove {
display: inline-flex;
align-items: center;
gap: 4px;
gap: 3px;
background: #fff5f5;
color: #e53e3e;
border: 1px solid #fed7d7;
padding: 5px 12px;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-size: 11px;
cursor: pointer;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
font-size: 13px;
height: 13px;
width: 13px;
}
&:hover {
@@ -264,12 +371,446 @@
.removed-badge {
background: #edf2f7;
color: #a0aec0;
padding: 5px 12px;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-size: 11px;
}
.locked-badge {
display: inline-flex;
align-items: center;
gap: 3px;
background: #fffbeb;
color: #92400e;
border: 1px solid #fde68a;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
mat-icon {
font-size: 12px;
height: 12px;
width: 12px;
}
}
}
.btn-locked {
opacity: 0.5;
pointer-events: none;
cursor: not-allowed !important;
}
// ── Candidate Detail Drawer ────────────────────────────────────
.drawer-overlay {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 30%);
z-index: 999;
animation: fade-in 0.2s ease;
}
.candidate-drawer {
position: fixed;
top: 0;
right: 0;
width: 380px;
height: 100vh;
background: #fff;
z-index: 1000;
display: flex;
flex-direction: column;
box-shadow: -4px 0 24px rgb(0 0 0 / 15%);
animation: slide-in 0.25s ease;
overflow: hidden;
@media (width <= 480px) {
width: 100vw;
}
}
@keyframes slide-in {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
background: #fafbfc;
flex-shrink: 0;
h3 {
font-size: 16px;
font-weight: 600;
margin: 0;
color: #2d3748;
}
.drawer-close {
background: none;
border: none;
cursor: pointer;
color: #718096;
display: flex;
align-items: center;
padding: 4px;
border-radius: 6px;
&:hover {
background: #f0f0f0;
color: #2d3748;
}
}
}
.drawer-body {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.drawer-avatar-wrap {
display: flex;
justify-content: center;
position: relative;
margin-bottom: 14px;
.drawer-avatar-img {
width: 96px;
height: 96px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #e2e8f0;
}
.drawer-avatar-placeholder {
width: 96px;
height: 96px;
border-radius: 50%;
background: linear-gradient(135deg, #2563eb, #1e40af);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
font-weight: 700;
}
.drawer-serial {
position: absolute;
bottom: 0;
right: calc(50% - 60px);
background: #2563eb;
color: #fff;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 700;
}
}
.drawer-name {
text-align: center;
font-size: 18px;
font-weight: 700;
color: #2d3748;
margin-bottom: 4px;
}
.drawer-post {
text-align: center;
font-size: 13px;
color: #718096;
margin-bottom: 8px;
}
.drawer-vote-count {
display: inline-flex;
align-items: center;
gap: 4px;
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
border-radius: 20px;
padding: 4px 12px;
font-size: 13px;
font-weight: 700;
margin: 0 auto 14px;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
}
.drawer-meta-row {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 12px;
color: #718096;
margin-bottom: 20px;
mat-icon {
font-size: 15px;
height: 15px;
width: 15px;
color: #a0aec0;
}
}
.drawer-section {
margin-bottom: 20px;
}
.drawer-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.6px;
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid #f0f0f0;
mat-icon {
font-size: 15px;
height: 15px;
width: 15px;
}
}
.symbol-preview-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
.symbol-seal-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 144px;
height: 144px;
border-radius: 50%;
background: linear-gradient(135deg, #fef3c7, #fff);
border: 4px solid #f59e0b;
box-shadow:
0 0 0 6px #fef9e7,
0 8px 28px rgb(245 158 11 / 30%);
}
.symbol-preview-img {
width: 110px;
height: 110px;
object-fit: contain;
border-radius: 50%;
background: #fff;
padding: 10px;
}
.symbol-seal-label {
position: absolute;
bottom: -1px;
background: #f59e0b;
color: #fff;
font-size: 9px;
font-weight: 700;
letter-spacing: 1.2px;
text-transform: uppercase;
padding: 2px 10px;
border-radius: 10px;
}
.symbol-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: #a0aec0;
padding: 12px 0;
width: 144px;
height: 144px;
border-radius: 50%;
border: 3px dashed #e2e8f0;
justify-content: center;
mat-icon {
font-size: 36px;
height: 36px;
width: 36px;
}
span {
font-size: 11px;
}
}
}
.btn-upload-symbol {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
background: #2563eb;
color: #fff;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:hover {
background: #1d4ed8;
}
&.uploading {
background: #93c5fd;
pointer-events: none;
}
}
.drawer-profile-text {
font-size: 13px;
color: #4a5568;
line-height: 1.6;
margin: 0;
}
.drawer-info-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
.info-label {
color: #718096;
}
.info-value {
font-weight: 500;
color: #2d3748;
&.text-green {
color: #276749;
}
&.text-red {
color: #e53e3e;
}
}
.info-link {
color: #2563eb;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 2px;
font-weight: 500;
mat-icon {
font-size: 13px;
height: 13px;
width: 13px;
}
&:hover {
text-decoration: underline;
}
}
}
.locked-notice {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
margin-top: 8px;
padding: 10px 14px;
background: #fffbeb;
color: #92400e;
border: 1px solid #fde68a;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
flex-shrink: 0;
}
}
.btn-remove-full {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
margin-top: 8px;
padding: 10px;
background: #fff5f5;
color: #e53e3e;
border: 1px solid #fed7d7;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:hover {
background: #fed7d7;
}
}
// ── Common states ──────────────────────────────────────────────
.loading-state,
.empty-state {
display: flex;
@@ -298,7 +839,7 @@
width: 36px;
height: 36px;
border: 4px solid #e2e8f0;
border-top-color: #4a6cf7;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-bottom: 12px;
@@ -309,3 +850,103 @@
transform: rotate(360deg);
}
}
// ── Pagination ─────────────────────────────────────────────────
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-top: 24px;
padding: 12px 16px;
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
}
.pg-info {
font-size: 13px;
color: #718096;
white-space: nowrap;
}
.pg-controls {
display: flex;
align-items: center;
gap: 4px;
}
.pg-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 34px;
height: 34px;
padding: 0 6px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
color: #4a5568;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
&:hover:not(:disabled) {
background: #eff6ff;
border-color: #2563eb;
color: #2563eb;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.pg-num--active {
background: #2563eb;
color: #fff;
border-color: #2563eb;
&:hover {
background: #1d4ed8;
}
}
.pg-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 34px;
height: 34px;
color: #a0aec0;
font-size: 14px;
}
.pg-size {
select {
padding: 6px 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #4a5568;
outline: none;
cursor: pointer;
background: #fff;
&:focus {
border-color: #2563eb;
}
}
}
@@ -12,8 +12,11 @@ import { RouterModule, ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { catchError, of } from 'rxjs';
import { ElectionService } from '../../election.service';
import { ImagePreviewDialogComponent } from '../../../cashier/transaction-entry/transaction-entry.component';
@Component({
selector: 'mifosx-candidate-board',
@@ -23,7 +26,8 @@ import { ElectionService } from '../../election.service';
RouterModule,
FormsModule,
MatIconModule,
MatSnackBarModule
MatSnackBarModule,
MatDialogModule
],
templateUrl: './candidate-board.component.html',
styleUrls: ['./candidate-board.component.scss']
@@ -32,13 +36,34 @@ export class CandidateBoardComponent implements OnInit {
private service = inject(ElectionService);
private route = inject(ActivatedRoute);
private snackBar = inject(MatSnackBar);
private sanitizer = inject(DomSanitizer);
private dialog = inject(MatDialog);
electionId!: number;
election: any = null;
candidates: any[] = [];
totalElements = 0;
loading = true;
selectedPostId: number | null = null;
pageSize = 12;
currentPage = 1;
readonly pageSizeOptions = [
6,
12,
24,
48
];
selectedCandidate: any = null;
symbolUploading: Record<number, boolean> = {};
candidatePhotoMap: Record<number, SafeResourceUrl | null> = {};
candidatePhotoRaw: Record<number, string> = {};
symbolCache: Record<number, SafeResourceUrl | null> = {};
symbolRaw: Record<number, string> = {};
voteCounts: Record<number, number> = {};
electionStatus = '';
ngOnInit(): void {
this.electionId = +this.route.snapshot.params['electionId'];
this.loadElection();
@@ -46,29 +71,171 @@ export class CandidateBoardComponent implements OnInit {
}
loadElection(): void {
this.service.getElection(this.electionId).subscribe((e) => (this.election = e));
this.service.getElection(this.electionId).subscribe((e) => {
this.election = e;
this.electionStatus = e?.status ?? '';
if (this.electionStatus === 'VOTING_OPEN' || this.electionStatus === 'VOTING_CLOSED') {
this.loadVoteCounts();
}
});
}
loadCandidates(): void {
this.currentPage = 1;
this.fetchPage();
}
private fetchPage(): void {
this.loading = true;
this.service
.getCandidates(this.electionId, this.selectedPostId ?? undefined)
.getCandidates(this.electionId, this.selectedPostId ?? undefined, this.currentPage - 1, this.pageSize)
.pipe(
catchError(() => {
this.loading = false;
return of([]);
return of({ content: [], totalElements: 0 });
})
)
.subscribe((data: any[]) => {
this.candidates = data;
.subscribe((resp: any) => {
this.candidates = resp.content ?? [];
this.totalElements = resp.totalElements ?? 0;
this.loading = false;
this.loadCandidatePhotos();
this.loadSymbols();
});
}
openDetail(candidate: any): void {
this.selectedCandidate = candidate;
}
closeDetail(): void {
this.selectedCandidate = null;
}
onSymbolFileChange(candidate: any, event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
input.value = '';
this.symbolUploading[candidate.id] = true;
this.service.uploadCandidateSymbol(this.electionId, candidate.id, file).subscribe({
next: () => {
this.service.getCandidateSymbol(this.electionId, candidate.id).subscribe((base64) => {
candidate.hasSymbol = true;
if (base64) {
this.symbolRaw[candidate.id] = base64;
this.symbolCache[candidate.id] = this.sanitizer.bypassSecurityTrustResourceUrl(base64);
}
if (this.selectedCandidate?.id === candidate.id) {
this.selectedCandidate = { ...this.selectedCandidate, hasSymbol: true };
}
this.symbolUploading[candidate.id] = false;
this.snackBar.open('Symbol uploaded successfully', 'Close', { duration: 3000 });
});
},
error: () => {
this.symbolUploading[candidate.id] = false;
this.snackBar.open('Symbol upload failed', 'Close', { duration: 3000 });
}
});
}
private loadCandidatePhotos(): void {
for (const c of this.candidates) {
if (this.candidatePhotoMap[c.id] !== undefined) continue;
const clientId: number | null = c.memberId ?? null;
if (!clientId) {
this.candidatePhotoMap[c.id] = null;
continue;
}
this.service.getClientImage(clientId).subscribe((base64) => {
if (base64) {
this.candidatePhotoRaw[c.id] = base64;
this.candidatePhotoMap[c.id] = this.sanitizer.bypassSecurityTrustResourceUrl(base64);
} else {
this.candidatePhotoMap[c.id] = null;
}
});
}
}
getCandidatePhoto(candidate: any): SafeResourceUrl | null {
return this.candidatePhotoMap[candidate.id] ?? null;
}
private loadSymbols(): void {
for (const c of this.candidates) {
if (!c.hasSymbol) continue;
if (this.symbolCache[c.id] !== undefined) continue;
this.service.getCandidateSymbol(this.electionId, c.id).subscribe((base64) => {
if (base64) {
this.symbolRaw[c.id] = base64;
this.symbolCache[c.id] = this.sanitizer.bypassSecurityTrustResourceUrl(base64);
} else {
this.symbolCache[c.id] = null;
}
});
}
}
getSymbol(candidate: any): SafeResourceUrl | null {
return this.symbolCache[candidate.id] ?? null;
}
openPhotoViewer(candidate: any, event: MouseEvent): void {
event.stopPropagation();
const url = this.candidatePhotoRaw[candidate.id];
if (!url) return;
this.dialog.open(ImagePreviewDialogComponent, {
data: { url, title: candidate.candidateName },
panelClass: 'ipd-dialog',
backdropClass: 'ipd-backdrop'
});
}
openSymbolViewer(candidate: any, event: MouseEvent): void {
event.stopPropagation();
const url = this.symbolRaw[candidate.id];
if (!url) return;
this.dialog.open(ImagePreviewDialogComponent, {
data: { url, title: 'Election Symbol — ' + candidate.candidateName },
panelClass: 'ipd-dialog',
backdropClass: 'ipd-backdrop'
});
}
private loadVoteCounts(): void {
// Vote counts are updated manually via the results page (manual voting mode)
}
getVoteCount(candidate: any): number {
return this.voteCounts[candidate.id] ?? 0;
}
get showVoteCounts(): boolean {
return this.electionStatus === 'VOTING_OPEN' || this.electionStatus === 'VOTING_CLOSED';
}
get canModifyCandidates(): boolean {
const locked = [
'VOTING_OPEN',
'VOTING_CLOSED',
'RESULT_DECLARED',
'CANCELLED'
];
return !locked.includes(this.electionStatus);
}
isNumber(p: number | string): boolean {
return typeof p === 'number';
}
removeCandidate(candidate: any): void {
if (!confirm(`Remove ${candidate.candidateName} from the candidate list?`)) return;
this.service.removeCandidate(this.electionId, candidate.id, 'Removed by admin').subscribe(() => {
this.snackBar.open('Candidate removed', 'Close', { duration: 3000 });
if (this.selectedCandidate?.id === candidate.id) this.closeDetail();
this.loadCandidates();
});
}
@@ -76,10 +243,37 @@ export class CandidateBoardComponent implements OnInit {
get posts(): any[] {
return this.election?.posts ?? [];
}
get filteredCandidates(): any[] {
if (!this.selectedPostId) return this.candidates;
return this.candidates.filter((c) => c.postId === this.selectedPostId);
return this.candidates;
}
get paginatedCandidates(): any[] {
return this.candidates;
}
get totalPages(): number {
return Math.max(1, Math.ceil(this.totalElements / this.pageSize));
}
get startItem(): number {
return this.totalElements === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1;
}
get endItem(): number {
return Math.min(this.currentPage * this.pageSize, this.totalElements);
}
get pageNumbers(): (number | string)[] {
return buildPageNumbers(this.currentPage, this.totalPages);
}
goToPage(p: number | string): void {
const page = +p;
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
this.currentPage = page;
this.fetchPage();
}
}
onPageSizeChange(): void {
this.currentPage = 1;
this.fetchPage();
}
groupByPost(): { postName: string; candidates: any[] }[] {
@@ -96,3 +290,13 @@ export class CandidateBoardComponent implements OnInit {
);
}
}
function buildPageNumbers(current: number, total: number): (number | string)[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const pages: (number | string)[] = [1];
if (current > 3) pages.push('...');
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) pages.push(i);
if (current < total - 2) pages.push('...');
pages.push(total);
return pages;
}
@@ -0,0 +1,656 @@
<!--
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="committee-page">
<!-- Ambient background glow elements -->
<div class="ambient-glow blob-1"></div>
<div class="ambient-glow blob-2"></div>
<div class="ambient-glow blob-3"></div>
<!-- Header -->
<div class="page-header">
<div class="header-left">
<a routerLink="/election" class="back-btn"><mat-icon>arrow_back</mat-icon></a>
<div>
<h2>Committees</h2>
<p class="header-sub">Manage committees formed from elected directors</p>
</div>
</div>
<button class="btn-create" (click)="openCreate()"><mat-icon>group_add</mat-icon> New Committee</button>
</div>
<!-- Loading -->
@if (loading) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading committees...</p>
</div>
} @else if (committees.length === 0) {
<div class="empty-state">
<mat-icon class="empty-icon">groups</mat-icon>
<h3>No committees yet</h3>
<p>Create a committee by selecting directors from the current election.</p>
<button class="btn-create" (click)="openCreate()"><mat-icon>group_add</mat-icon> Create First Committee</button>
</div>
} @else {
<div class="committees-grid">
@for (c of committees; track c.id) {
<div class="committee-card" [class.dissolved]="c.status === 'DISSOLVED'">
<div class="card-header">
<div class="committee-icon">
@if (c.image) {
<img
[src]="c.image"
[alt]="c.name"
class="committee-img clickable-photo"
(click)="openImageViewer(c.image, c.name, $event)"
/>
} @else {
<mat-icon>groups</mat-icon>
}
</div>
<div class="committee-meta">
<h3>{{ c.name }}</h3>
<span
class="status-chip"
[style.background]="statusColor(c.status) + '20'"
[style.color]="statusColor(c.status)"
>{{ c.status }}</span
>
</div>
</div>
@if (c.description) {
<p class="committee-desc">{{ c.description }}</p>
}
<div class="member-count-row">
<mat-icon>person</mat-icon>
<span>{{ c.members?.length ?? 0 }} member{{ (c.members?.length ?? 0) !== 1 ? 's' : '' }}</span>
</div>
<!-- Member Avatars -->
@if (c.members?.length > 0) {
<div class="member-avatars">
@for (m of c.members.slice(0, 5); track m.id) {
<div class="member-avatar" [title]="m.candidateName">
@if (getMemberPhoto(m.memberId)) {
<img
[src]="getMemberPhoto(m.memberId)"
[alt]="m.candidateName"
class="clickable-photo"
(click)="openMemberPhotoViewer(m.memberId, m.candidateName, $event)"
/>
} @else {
{{ m.candidateName?.charAt(0) }}
}
</div>
}
@if (c.members.length > 5) {
<div class="member-avatar more-avatar">+{{ c.members.length - 5 }}</div>
}
</div>
<!-- Member List -->
<div class="member-list">
@for (m of c.members; track m.id) {
<div class="member-row">
<div class="member-avatar-sm">
@if (getMemberPhoto(m.memberId)) {
<img
[src]="getMemberPhoto(m.memberId)"
[alt]="m.candidateName"
class="clickable-photo"
(click)="openMemberPhotoViewer(m.memberId, m.candidateName, $event)"
/>
} @else {
{{ m.candidateName?.charAt(0) }}
}
</div>
<div class="member-info">
<span class="member-name">{{ m.candidateName }}</span>
<span class="member-desig">{{ m.designation || m.postName }}</span>
</div>
</div>
}
</div>
}
<!-- Meetings Section -->
<div class="meetings-section">
<button class="meetings-toggle" (click)="toggleMeetings(c)" [class.expanded]="isMeetingsExpanded(c.id)">
<mat-icon>event</mat-icon>
<span>Meetings</span>
@if (getMeetings(c.id).length > 0) {
<span class="meeting-count-badge">{{ getMeetings(c.id).length }}</span>
}
<mat-icon class="chevron">{{ isMeetingsExpanded(c.id) ? 'expand_less' : 'expand_more' }}</mat-icon>
</button>
@if (isMeetingsExpanded(c.id)) {
<div class="meetings-panel">
@if (c.status === 'ACTIVE') {
<button class="btn-schedule" (click)="openScheduleMeeting(c, $event)">
<mat-icon>add</mat-icon> Schedule Meeting
</button>
}
@if (isMeetingsLoading(c.id)) {
<div class="loading-inline">
<div class="spinner-sm"></div>
<span>Loading...</span>
</div>
} @else if (getMeetings(c.id).length === 0) {
<div class="meetings-empty">
<mat-icon>event_busy</mat-icon>
<span>No meetings scheduled</span>
</div>
} @else {
<div class="meeting-list">
@for (m of getMeetings(c.id); track m.id) {
<div class="meeting-row">
<!-- Top Header Row -->
<div class="meeting-header-row">
<div class="meeting-mode-icon" [title]="m.mode">
<mat-icon>{{ meetingModeIcon(m.mode) }}</mat-icon>
</div>
<span class="meeting-title" [title]="m.title">{{ m.title }}</span>
<span
class="meeting-status-chip"
[style.background]="meetingStatusColor(m.status) + '18'"
[style.color]="meetingStatusColor(m.status)"
>{{ m.status }}</span
>
</div>
<!-- Details Area -->
<div class="meeting-details-area">
<div class="meeting-meta-row">
<mat-icon class="meta-icon">calendar_today</mat-icon>
<span>{{ m.meetingDate | date: 'dd MMM yyyy' }}</span>
@if (m.meetingTime) {
<span class="meta-sep">·</span>
<mat-icon class="meta-icon">schedule</mat-icon>
<span>{{ m.meetingTime }}</span>
}
</div>
@if (m.mode === 'ONLINE' && m.platform) {
<div class="meeting-platform-row">
<mat-icon class="meta-icon">videocam</mat-icon>
<span>{{ m.platform }}</span>
@if (m.meetingLink) {
<a
[href]="m.meetingLink"
target="_blank"
class="join-link"
(click)="$event.stopPropagation()"
>
<mat-icon>open_in_new</mat-icon> Join
</a>
}
</div>
}
@if (m.mode === 'OFFLINE' && m.venue) {
<div class="meeting-platform-row">
<mat-icon class="meta-icon">place</mat-icon>
<span>{{ m.venue }}{{ m.city ? ', ' + m.city : '' }}</span>
</div>
}
@if (m.agendaItems?.length > 0) {
<div class="meeting-agenda-preview">
<mat-icon class="meta-icon">format_list_bulleted</mat-icon>
<span
>{{ m.agendaItems.length }} agenda item{{ m.agendaItems.length !== 1 ? 's' : '' }}</span
>
</div>
}
</div>
<!-- Actions Row -->
<div class="meeting-actions-row">
<button class="btn-icon" title="Copy Details" (click)="copyMeetingDetails(m, $event)">
<mat-icon>content_copy</mat-icon>
</button>
<button class="btn-icon" title="Download PDF" (click)="downloadMeetingPdf(m, c, $event)">
<mat-icon>picture_as_pdf</mat-icon>
</button>
@if (m.status === 'COMPLETED') {
<button
class="btn-icon purple"
title="Send Voting Emails"
(click)="sendVotingEmailsDirect(m, $event)"
>
<mat-icon>forward_to_inbox</mat-icon>
</button>
<button
class="btn-icon purple"
title="View Votes & Resolutions"
(click)="openVotePanel(m, $event)"
>
<mat-icon>how_to_vote</mat-icon>
</button>
}
@if (c.status === 'ACTIVE') {
@if (m.status === 'SCHEDULED') {
<button class="btn-icon" title="Edit" (click)="openEditMeeting(m, c.id, $event)">
<mat-icon>edit</mat-icon>
</button>
<button
class="btn-icon green"
title="Mark Completed"
(click)="patchMeetingStatus(m, c.id, 'COMPLETED', $event)"
>
<mat-icon>check_circle</mat-icon>
</button>
<button
class="btn-icon red"
title="Cancel Meeting"
(click)="patchMeetingStatus(m, c.id, 'CANCELLED', $event)"
>
<mat-icon>cancel</mat-icon>
</button>
}
<button
class="btn-icon danger"
title="Delete"
(click)="confirmDeleteMeeting(m, c.id, $event)"
>
<mat-icon>delete_outline</mat-icon>
</button>
}
</div>
</div>
}
</div>
}
</div>
}
</div>
<!-- Card Actions -->
<div class="card-actions">
@if (c.status === 'ACTIVE') {
<button class="btn-edit" (click)="openEdit(c)"><mat-icon>edit</mat-icon> Edit</button>
<button class="btn-dissolve" (click)="confirmDissolve(c)"><mat-icon>cancel</mat-icon> Dissolve</button>
} @else {
<span class="dissolved-label"><mat-icon>block</mat-icon> Dissolved</span>
}
</div>
</div>
}
</div>
}
</div>
<!-- Committee Drawer -->
@if (showForm) {
<div class="overlay" (click)="closeForm()"></div>
<div class="form-drawer">
<div class="drawer-header">
<h3>{{ editingId ? 'Edit Committee' : 'New Committee' }}</h3>
<button class="close-btn" (click)="closeForm()"><mat-icon>close</mat-icon></button>
</div>
<div class="drawer-body">
<!-- Image Upload -->
<div class="image-upload-section">
<div class="image-upload-ring" (click)="imageInput.click()">
@if (form.image) {
<img [src]="form.image" alt="Committee photo" class="upload-preview" />
<div class="upload-overlay">
<mat-icon>edit</mat-icon>
<mat-icon
class="view-icon"
title="View full size"
(click)="openImageViewer(form.image, form.name || 'Committee Photo', $event)"
>zoom_in</mat-icon
>
</div>
} @else {
<mat-icon class="upload-placeholder-icon">add_photo_alternate</mat-icon>
<span class="upload-placeholder-text">Add Photo</span>
}
</div>
@if (form.image) {
<button type="button" class="btn-remove-image" (click)="removeImage()">
<mat-icon>delete_outline</mat-icon> Remove
</button>
}
<input #imageInput type="file" accept="image/*" style="display: none" (change)="onImageSelect($event)" />
</div>
<div class="form-group">
<label>Committee Name <span class="req">*</span></label>
<input
type="text"
[(ngModel)]="form.name"
placeholder="e.g. Finance Committee"
[class.input-error]="submitted && !form.name?.trim()"
/>
@if (submitted && !form.name?.trim()) {
<span class="error-msg">Name is required</span>
}
</div>
<div class="form-group">
<label>Description</label>
<textarea [(ngModel)]="form.description" rows="2" placeholder="Optional description..."></textarea>
</div>
<div class="section-title">Select Members (Current Election Directors)</div>
@if (directorsLoading) {
<div class="loading-inline">
<div class="spinner-sm"></div>
<span>Loading directors...</span>
</div>
} @else if (availableDirectors.length === 0) {
<div class="empty-directors">
<mat-icon>info</mat-icon>
<span>No active directors found for the current election.</span>
</div>
} @else {
<div class="director-select-list">
@for (d of availableDirectors; track d.id) {
<div class="director-option" [class.selected]="isSelected(d.id)" (click)="toggleDirector(d.id)">
<div class="dir-avatar">
@if (getMemberPhoto(d.memberId)) {
<img [src]="getMemberPhoto(d.memberId)" [alt]="d.candidateName" />
} @else {
{{ d.candidateName?.charAt(0) }}
}
</div>
<div class="dir-info">
<span class="dir-name">{{ d.candidateName }}</span>
<span class="dir-desig">{{ d.designation || d.postName }}</span>
</div>
<div class="dir-check">
@if (isSelected(d.id)) {
<mat-icon class="check-icon">check_circle</mat-icon>
} @else {
<mat-icon class="uncheck-icon">radio_button_unchecked</mat-icon>
}
</div>
</div>
}
</div>
<div class="selected-count">
{{ form.selectedIds.length }} member{{ form.selectedIds.length !== 1 ? 's' : '' }} selected
</div>
}
</div>
<div class="drawer-footer">
<button class="btn-cancel" (click)="closeForm()">Cancel</button>
<button class="btn-save" (click)="save()" [disabled]="saving">
<mat-icon>{{ saving ? 'hourglass_empty' : 'save' }}</mat-icon>
{{ saving ? 'Saving...' : editingId ? 'Update' : 'Create' }}
</button>
</div>
</div>
}
<!-- Meeting Drawer -->
@if (showMeetingForm) {
<div class="overlay" (click)="closeMeetingForm()"></div>
<div class="form-drawer meeting-drawer">
<div class="drawer-header">
<h3>{{ editingMeetingId ? 'Edit Meeting' : 'Schedule Meeting' }}</h3>
<button class="close-btn" (click)="closeMeetingForm()"><mat-icon>close</mat-icon></button>
</div>
<div class="drawer-body">
<!-- Title -->
<div class="form-group">
<label>Title <span class="req">*</span></label>
<input
type="text"
[(ngModel)]="meetingForm.title"
placeholder="e.g. Q1 Strategy Review"
maxlength="200"
[class.input-error]="submittedMeeting && meetingErrors['title']"
/>
@if (submittedMeeting && meetingErrors['title']) {
<span class="error-msg">{{ meetingErrors['title'] }}</span>
}
</div>
<!-- Date + Time -->
<div class="form-row-2">
<div class="form-group">
<label>Date <span class="req">*</span></label>
<input
type="date"
[(ngModel)]="meetingForm.meetingDate"
[min]="todayDate"
[class.input-error]="submittedMeeting && meetingErrors['meetingDate']"
/>
@if (submittedMeeting && meetingErrors['meetingDate']) {
<span class="error-msg">{{ meetingErrors['meetingDate'] }}</span>
}
</div>
<div class="form-group">
<label>Time</label>
<input type="time" [(ngModel)]="meetingForm.meetingTime" />
</div>
</div>
<!-- Mode Toggle -->
<div class="form-group">
<label>Meeting Mode <span class="req">*</span></label>
<div class="mode-toggle">
<button
class="mode-btn"
[class.active]="meetingForm.mode === 'OFFLINE'"
(click)="meetingForm.mode = 'OFFLINE'"
type="button"
>
<mat-icon>location_on</mat-icon> Offline
</button>
<button
class="mode-btn"
[class.active]="meetingForm.mode === 'ONLINE'"
(click)="meetingForm.mode = 'ONLINE'"
type="button"
>
<mat-icon>video_call</mat-icon> Online
</button>
</div>
</div>
<!-- Online Fields -->
@if (meetingForm.mode === 'ONLINE') {
<div class="mode-fields-group">
<div class="mode-label online-label"><mat-icon>video_call</mat-icon> Online Details</div>
<div class="form-group">
<label>Platform</label>
<input type="text" [(ngModel)]="meetingForm.platform" placeholder="e.g. Zoom, Google Meet, Teams" />
</div>
<div class="form-group">
<label>Meeting Link</label>
<input
type="url"
[(ngModel)]="meetingForm.meetingLink"
placeholder="https://..."
[class.input-error]="submittedMeeting && meetingErrors['meetingLink']"
/>
@if (submittedMeeting && meetingErrors['meetingLink']) {
<span class="error-msg">{{ meetingErrors['meetingLink'] }}</span>
}
</div>
<div class="form-row-2">
<div class="form-group">
<label>Meeting ID</label>
<input type="text" [(ngModel)]="meetingForm.meetingIdRef" placeholder="123 456 789" />
</div>
<div class="form-group">
<label>Password</label>
<input type="text" [(ngModel)]="meetingForm.meetingPassword" placeholder="Optional" />
</div>
</div>
</div>
}
<!-- Offline Fields -->
@if (meetingForm.mode === 'OFFLINE') {
<div class="mode-fields-group">
<div class="mode-label offline-label"><mat-icon>location_on</mat-icon> Venue Details</div>
<div class="form-group">
<label>Venue</label>
<input type="text" [(ngModel)]="meetingForm.venue" placeholder="e.g. Board Room, Conference Hall" />
</div>
<div class="form-group">
<label>Address</label>
<textarea [(ngModel)]="meetingForm.address" rows="2" placeholder="Full address..."></textarea>
</div>
<div class="form-group">
<label>City</label>
<input type="text" [(ngModel)]="meetingForm.city" placeholder="City name" />
</div>
</div>
}
<!-- Agenda Items -->
<div class="form-group">
<label>Agenda Items</label>
<div class="agenda-list">
@for (item of meetingForm.agendaItems; track $index) {
<div class="agenda-item-row">
<span class="agenda-num">{{ $index + 1 }}.</span>
<input
type="text"
[name]="'agendaItem' + $index"
[value]="meetingForm.agendaItems[$index]"
(input)="meetingForm.agendaItems[$index] = $any($event.target).value"
placeholder="e.g. Review financials, Discuss elections..."
/>
@if (meetingForm.agendaItems.length > 1) {
<button type="button" class="btn-remove-agenda" title="Remove" (click)="removeAgendaItem($index)">
<mat-icon>remove_circle_outline</mat-icon>
</button>
}
</div>
}
</div>
<button type="button" class="btn-add-agenda" (click)="addAgendaItem()">
<mat-icon>add_circle_outline</mat-icon> Add Item
</button>
</div>
</div>
<div class="drawer-footer">
<button class="btn-cancel" (click)="closeMeetingForm()">Cancel</button>
<button class="btn-save" (click)="saveMeeting()" [disabled]="savingMeeting">
<mat-icon>{{ savingMeeting ? 'hourglass_empty' : 'event' }}</mat-icon>
{{ savingMeeting ? 'Saving...' : editingMeetingId ? 'Update' : 'Schedule' }}
</button>
</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
@@ -0,0 +1,711 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { ElectionService } from '../../election.service';
import { ImagePreviewDialogComponent } from '../../../cashier/transaction-entry/transaction-entry.component';
@Component({
selector: 'mifosx-committee-board',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
MatIconModule,
MatSnackBarModule,
MatDialogModule
],
templateUrl: './committee-board.component.html',
styleUrls: ['./committee-board.component.scss']
})
export class CommitteeBoardComponent implements OnInit {
private service = inject(ElectionService);
private snackBar = inject(MatSnackBar);
private sanitizer = inject(DomSanitizer);
private dialog = inject(MatDialog);
committees: any[] = [];
loading = true;
// available directors from current election
availableDirectors: any[] = [];
directorsLoading = false;
memberPhotoMap: Record<number, SafeResourceUrl | null> = {};
memberPhotoRaw: Record<number, string> = {};
// committee drawer state
showForm = false;
editingId: number | null = null;
saving = false;
submitted = false;
form: any = this.emptyForm();
// meeting state
meetingsByCommittee: Record<number, any[]> = {};
meetingsLoading: Set<number> = new Set();
expandedMeetings: Set<number> = new Set();
// meeting drawer state
showMeetingForm = false;
editingMeetingId: number | null = null;
currentMeetingCommitteeId: number | null = null;
savingMeeting = false;
submittedMeeting = false;
meetingForm: any = this.emptyMeetingForm();
meetingErrors: Record<string, string> = {};
readonly todayDate: string = new Date().toISOString().split('T')[0];
resendingVoteEmails = false;
mandatoryDesignations: string[] = [];
// vote summary / resolution drawer state
showVotePanel = false;
votePanelMeeting: any = null;
voteSummary: any[] = [];
voteSummaryLoading = false;
ngOnInit(): void {
this.load();
this.service.getDesignations().subscribe((data: any[]) => {
this.mandatoryDesignations = (data ?? []).filter((d: any) => d.mandatory).map((d: any) => d.name);
});
}
load(): void {
this.loading = true;
this.service.getCommittees().subscribe({
next: (data) => {
this.committees = data ?? [];
this.loading = false;
this.loadMemberPhotos();
},
error: () => {
this.committees = [];
this.loading = false;
}
});
}
private loadMemberPhotos(): void {
for (const c of this.committees) {
for (const m of c.members ?? []) {
if (m.memberId && this.memberPhotoMap[m.memberId] === undefined) {
this.service.getClientImage(m.memberId).subscribe((base64) => {
if (base64) {
this.memberPhotoRaw[m.memberId] = base64;
this.memberPhotoMap[m.memberId] = this.sanitizer.bypassSecurityTrustResourceUrl(base64);
} else {
this.memberPhotoMap[m.memberId] = null;
}
});
}
}
}
}
getMemberPhoto(memberId: number): SafeResourceUrl | null {
return this.memberPhotoMap[memberId] ?? null;
}
openCreate(): void {
this.editingId = null;
this.submitted = false;
this.form = this.emptyForm();
this.loadAvailableDirectors();
this.showForm = true;
}
openEdit(committee: any): void {
this.editingId = committee.id;
this.submitted = false;
this.form = {
name: committee.name,
description: committee.description ?? '',
image: committee.image ?? null,
selectedIds: (committee.members ?? []).map((m: any) => m.directorAppointmentId)
};
this.loadAvailableDirectors();
this.showForm = true;
}
private loadAvailableDirectors(): void {
this.directorsLoading = true;
this.service.getCurrentElectionDirectors().subscribe((data) => {
this.availableDirectors = data ?? [];
this.directorsLoading = false;
for (const d of this.availableDirectors) {
if (d.memberId && this.memberPhotoMap[d.memberId] === undefined) {
this.service.getClientImage(d.memberId).subscribe((base64) => {
if (base64) {
this.memberPhotoRaw[d.memberId] = base64;
this.memberPhotoMap[d.memberId] = this.sanitizer.bypassSecurityTrustResourceUrl(base64);
} else {
this.memberPhotoMap[d.memberId] = null;
}
});
}
}
});
}
onImageSelect(event: Event): void {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
this.form.image = reader.result as string;
};
reader.readAsDataURL(file);
}
removeImage(): void {
this.form.image = null;
}
openImageViewer(url: string, title: string, event: MouseEvent): void {
event.stopPropagation();
if (!url) return;
this.dialog.open(ImagePreviewDialogComponent, {
data: { url, title },
panelClass: 'ipd-dialog',
backdropClass: 'ipd-backdrop'
});
}
openMemberPhotoViewer(memberId: number, name: string, event: MouseEvent): void {
event.stopPropagation();
const url = this.memberPhotoRaw[memberId];
if (!url) return;
this.dialog.open(ImagePreviewDialogComponent, {
data: { url, title: name },
panelClass: 'ipd-dialog',
backdropClass: 'ipd-backdrop'
});
}
toggleDirector(dirId: number): void {
const idx = this.form.selectedIds.indexOf(dirId);
if (idx >= 0) {
this.form.selectedIds.splice(idx, 1);
} else {
this.form.selectedIds.push(dirId);
}
}
isSelected(dirId: number): boolean {
return this.form.selectedIds.includes(dirId);
}
closeForm(): void {
this.showForm = false;
this.editingId = null;
this.form = this.emptyForm();
this.submitted = false;
}
save(): void {
this.submitted = true;
if (!this.form.name?.trim()) return;
// Mandatory designations (e.g. Chairman, Vice Chairman) required for new committee
if (this.editingId == null && this.mandatoryDesignations.length > 0) {
const selected = this.availableDirectors.filter((d: any) => this.form.selectedIds.includes(d.id));
const presentDesignations = new Set(selected.map((d: any) => d.designation));
const missing = this.mandatoryDesignations.filter((des) => !presentDesignations.has(des));
if (missing.length > 0) {
this.snackBar.open(`Committee must include an active ${missing.join(' and ')}.`, 'Close', { duration: 5000 });
return;
}
}
this.saving = true;
const payload = {
name: this.form.name.trim(),
description: this.form.description?.trim() || null,
image: this.form.image || null,
directorAppointmentIds: this.form.selectedIds
};
const call =
this.editingId != null
? this.service.updateCommittee(this.editingId, payload)
: this.service.createCommittee(payload);
call.subscribe({
next: () => {
if (this.editingId != null) {
const existing = this.committees.find((c) => c.id === this.editingId);
const existingIds: number[] = (existing?.members ?? []).map((m: any) => m.directorAppointmentId);
const toAdd = this.form.selectedIds.filter((id: number) => !existingIds.includes(id));
const toRemove = existingIds.filter((id: number) => !this.form.selectedIds.includes(id));
toRemove.forEach((id: number) => this.service.removeCommitteeMember(this.editingId!, id).subscribe());
if (toAdd.length > 0) {
this.service.addCommitteeMembers(this.editingId!, toAdd).subscribe();
}
}
this.saving = false;
this.closeForm();
setTimeout(() => this.load(), 300);
this.snackBar.open(this.editingId != null ? 'Committee updated!' : 'Committee created!', 'Close', {
duration: 3000
});
},
error: (err) => {
this.saving = false;
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Operation failed';
this.snackBar.open(msg, 'Close', { duration: 5000 });
}
});
}
confirmDissolve(committee: any): void {
if (!confirm(`Dissolve committee "${committee.name}"? This cannot be undone.`)) return;
this.service.dissolveCommittee(committee.id).subscribe({
next: () => {
this.load();
this.snackBar.open('Committee dissolved.', 'Close', { duration: 3000 });
},
error: () => this.snackBar.open('Failed to dissolve.', 'Close', { duration: 3000 })
});
}
statusColor(status: string): string {
return status === 'ACTIVE' ? '#10b981' : '#94a3b8';
}
// ── Meetings ──────────────────────────────────────────────────────
toggleMeetings(committee: any): void {
if (this.expandedMeetings.has(committee.id)) {
this.expandedMeetings.delete(committee.id);
} else {
this.expandedMeetings.add(committee.id);
if (!this.meetingsByCommittee[committee.id]) {
this.loadMeetings(committee.id);
}
}
}
isMeetingsExpanded(committeeId: number): boolean {
return this.expandedMeetings.has(committeeId);
}
loadMeetings(committeeId: number): void {
this.meetingsLoading.add(committeeId);
this.service.getMeetings(committeeId).subscribe({
next: (data) => {
this.meetingsByCommittee[committeeId] = data ?? [];
this.meetingsLoading.delete(committeeId);
},
error: () => {
this.meetingsByCommittee[committeeId] = [];
this.meetingsLoading.delete(committeeId);
}
});
}
isMeetingsLoading(committeeId: number): boolean {
return this.meetingsLoading.has(committeeId);
}
getMeetings(committeeId: number): any[] {
return this.meetingsByCommittee[committeeId] ?? [];
}
openScheduleMeeting(committee: any, event: MouseEvent): void {
event.stopPropagation();
this.editingMeetingId = null;
this.currentMeetingCommitteeId = committee.id;
this.submittedMeeting = false;
this.meetingForm = this.emptyMeetingForm();
this.showMeetingForm = true;
}
openEditMeeting(meeting: any, committeeId: number, event: MouseEvent): void {
event.stopPropagation();
this.editingMeetingId = meeting.id;
this.currentMeetingCommitteeId = committeeId;
this.submittedMeeting = false;
this.service.getMeeting(meeting.id).subscribe({
next: (fresh) => {
this.meetingForm = this.buildMeetingFormObj(fresh);
this.showMeetingForm = true;
},
error: () => {
this.meetingForm = this.buildMeetingFormObj(meeting);
this.showMeetingForm = true;
}
});
}
private buildMeetingFormObj(m: any): any {
return {
title: m.title,
meetingDate: m.meetingDate,
meetingTime: m.meetingTime ?? '',
mode: m.mode ?? 'OFFLINE',
agendaItems: m.agendaItems?.length > 0 ? [...m.agendaItems] : [''],
platform: m.platform ?? '',
meetingLink: m.meetingLink ?? '',
meetingIdRef: m.meetingIdRef ?? '',
meetingPassword: m.meetingPassword ?? '',
venue: m.venue ?? '',
address: m.address ?? '',
city: m.city ?? ''
};
}
closeMeetingForm(): void {
this.showMeetingForm = false;
this.editingMeetingId = null;
this.currentMeetingCommitteeId = null;
this.meetingForm = this.emptyMeetingForm();
this.submittedMeeting = false;
this.meetingErrors = {};
}
private validateMeetingForm(): boolean {
const errors: Record<string, string> = {};
const title = this.meetingForm.title?.trim() ?? '';
if (!title) {
errors['title'] = 'Meeting title is required';
} else if (title.length > 200) {
errors['title'] = 'Meeting title must not exceed 200 characters';
}
if (!this.meetingForm.meetingDate) {
errors['meetingDate'] = 'Meeting date is required';
} else {
const today = new Date();
today.setHours(0, 0, 0, 0);
const selected = new Date(this.meetingForm.meetingDate);
if (selected < today) {
errors['meetingDate'] = 'Meeting date cannot be in the past';
}
}
if (!this.meetingForm.mode) {
errors['mode'] = 'Please select a meeting mode';
}
if (this.meetingForm.mode === 'ONLINE' && this.meetingForm.meetingLink?.trim()) {
const link: string = this.meetingForm.meetingLink.trim();
if (!link.startsWith('http://') && !link.startsWith('https://')) {
errors['meetingLink'] = 'Meeting link must start with http:// or https://';
}
}
this.meetingErrors = errors;
return Object.keys(errors).length === 0;
}
saveMeeting(): void {
this.submittedMeeting = true;
if (!this.validateMeetingForm()) return;
this.savingMeeting = true;
const payload: any = {
title: this.meetingForm.title.trim(),
meetingDate: this.meetingForm.meetingDate,
meetingTime: this.meetingForm.meetingTime || null,
mode: this.meetingForm.mode,
agendaItems: (this.meetingForm.agendaItems as string[]).filter((a) => a?.trim())
};
if (this.meetingForm.mode === 'ONLINE') {
payload.platform = this.meetingForm.platform || null;
payload.meetingLink = this.meetingForm.meetingLink || null;
payload.meetingIdRef = this.meetingForm.meetingIdRef || null;
payload.meetingPassword = this.meetingForm.meetingPassword || null;
} else {
payload.venue = this.meetingForm.venue || null;
payload.address = this.meetingForm.address || null;
payload.city = this.meetingForm.city || null;
}
const committeeId = this.currentMeetingCommitteeId!;
const call =
this.editingMeetingId != null
? this.service.updateMeeting(this.editingMeetingId, payload)
: this.service.scheduleMeeting(committeeId, payload);
call.subscribe({
next: () => {
this.savingMeeting = false;
this.closeMeetingForm();
this.loadMeetings(committeeId);
this.snackBar.open(this.editingMeetingId != null ? 'Meeting updated!' : 'Meeting scheduled!', 'Close', {
duration: 3000
});
},
error: (err) => {
this.savingMeeting = false;
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Operation failed';
this.snackBar.open(msg, 'Close', { duration: 5000 });
}
});
}
confirmDeleteMeeting(meeting: any, committeeId: number, event: MouseEvent): void {
event.stopPropagation();
if (!confirm(`Delete meeting "${meeting.title}"?`)) return;
this.service.deleteMeeting(meeting.id).subscribe({
next: () => {
this.loadMeetings(committeeId);
this.snackBar.open('Meeting deleted.', 'Close', { duration: 3000 });
},
error: (err) => {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Delete failed';
this.snackBar.open(msg, 'Close', { duration: 4000 });
}
});
}
patchMeetingStatus(meeting: any, committeeId: number, status: string, event: MouseEvent): void {
event.stopPropagation();
this.service.patchMeetingStatus(meeting.id, status).subscribe({
next: () => {
this.loadMeetings(committeeId);
this.snackBar.open(`Marked as ${status}.`, 'Close', { duration: 3000 });
},
error: () => this.snackBar.open('Status update failed.', 'Close', { duration: 3000 })
});
}
meetingStatusColor(status: string): string {
const map: Record<string, string> = { SCHEDULED: '#2563eb', COMPLETED: '#10b981', CANCELLED: '#94a3b8' };
return map[status] ?? '#64748b';
}
meetingModeIcon(mode: string): string {
return mode === 'ONLINE' ? 'video_call' : 'location_on';
}
copyMeetingDetails(meeting: any, event: MouseEvent): void {
event.stopPropagation();
const lines: string[] = [];
lines.push(`📅 Committee Meeting: ${meeting.title}`);
lines.push(`Date: ${meeting.meetingDate}`);
if (meeting.meetingTime) lines.push(`Time: ${meeting.meetingTime}`);
lines.push(`Mode: ${meeting.mode}`);
if (meeting.mode === 'ONLINE') {
if (meeting.platform) lines.push(`Platform: ${meeting.platform}`);
if (meeting.meetingLink) lines.push(`Join Link: ${meeting.meetingLink}`);
if (meeting.meetingIdRef) lines.push(`Meeting ID: ${meeting.meetingIdRef}`);
if (meeting.meetingPassword) lines.push(`Password: ${meeting.meetingPassword}`);
} else {
if (meeting.venue) lines.push(`Venue: ${meeting.venue}`);
if (meeting.address) lines.push(`Address: ${meeting.address}`);
if (meeting.city) lines.push(`City: ${meeting.city}`);
}
if (meeting.agendaItems?.length > 0) {
lines.push('Agenda:');
(meeting.agendaItems as string[]).forEach((item, i) => lines.push(` ${i + 1}. ${item}`));
}
navigator.clipboard.writeText(lines.join('\n')).then(() => {
this.snackBar.open('Meeting details copied!', 'Close', { duration: 2500 });
});
}
downloadMeetingPdf(meeting: any, committee: any, event: MouseEvent): void {
event.stopPropagation();
this.service.getMeeting(meeting.id).subscribe({
next: (fresh) => this.openPrintWindow(fresh, committee),
error: () => this.openPrintWindow(meeting, committee)
});
}
private openPrintWindow(meeting: any, committee: any): void {
const w = window.open('', '_blank', 'width=900,height=700');
if (!w) {
this.snackBar.open('Popup blocked. Please allow popups.', 'Close', { duration: 3500 });
return;
}
const fmt = (d: string) => {
if (!d) return '—';
const dt = new Date(d);
return isNaN(dt.getTime())
? d
: dt.toLocaleDateString('en-IN', { day: '2-digit', month: 'long', year: 'numeric' });
};
const agendaRows = ((meeting.agendaItems as string[]) || [])
.filter((a) => a?.trim())
.map(
(a, i) =>
`<tr><td style="width:28px;vertical-align:top;padding:4px 0;"><span style="background:#f59e0b;color:#fff;border-radius:50%;width:20px;height:20px;display:inline-block;text-align:center;line-height:20px;font-size:11px;font-weight:700;">${i + 1}</span></td><td style="padding:4px 0 4px 10px;font-size:13px;color:#1c1917;">${a}</td></tr>`
)
.join('');
const row = (icon: string, label: string, value: string) =>
`<tr><td style="padding:9px 12px;font-size:12px;color:#64748b;font-weight:600;letter-spacing:0.3px;text-transform:uppercase;border-bottom:1px solid #e2e8f0;width:130px;">${icon} ${label}</td><td style="padding:9px 12px;font-size:13px;color:#334155;border-bottom:1px solid #e2e8f0;">${value || '—'}</td></tr>`;
const isOnline = meeting.mode === 'ONLINE';
let locationRows = '';
if (isOnline) {
if (meeting.platform) locationRows += row('📹', 'Platform', meeting.platform);
if (meeting.meetingLink)
locationRows += row('🔗', 'Join Link', `<a href="${meeting.meetingLink}">${meeting.meetingLink}</a>`);
if (meeting.meetingIdRef) locationRows += row('🔑', 'Meeting ID', meeting.meetingIdRef);
if (meeting.meetingPassword) locationRows += row('🔒', 'Password', meeting.meetingPassword);
} else {
if (meeting.venue) locationRows += row('🏛', 'Venue', meeting.venue);
if (meeting.address) locationRows += row('📍', 'Address', meeting.address);
if (meeting.city) locationRows += row('🌍', 'City', meeting.city);
}
const html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Meeting - ${meeting.title}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, Helvetica, sans-serif; background: #f0f4ff; padding: 24px; }
.card { max-width: 720px; margin: 0 auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,.10); }
.header { background: #1e40af; padding: 22px 28px; }
.header-eyebrow { font-size: 11px; font-weight: 700; color: rgba(255,255,255,.6); letter-spacing: 1.5px; text-transform: uppercase; margin-bottom: 4px; }
.header-title { font-size: 22px; font-weight: 800; color: #fff; }
.header-sub { margin-top: 6px; font-size: 13px; color: rgba(255,255,255,.75); }
.body { padding: 24px 28px; }
.section-label { font-size: 11px; font-weight: 700; color: #94a3b8; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 10px; }
.details-table { width: 100%; border-collapse: collapse; background: #f8faff; border-radius: 10px; border: 1px solid #e2e8f0; overflow: hidden; margin-bottom: 20px; }
.agenda-box { background: #fefce8; border: 1px solid #fde68a; border-radius: 10px; padding: 16px 20px; margin-bottom: 20px; }
.agenda-label { font-size: 13px; font-weight: 700; color: #92400e; margin-bottom: 10px; }
.footer { background: #f8fafc; border-top: 1px solid #e2e8f0; padding: 12px 28px; display: flex; justify-content: space-between; font-size: 11px; color: #94a3b8; }
@media print { body { background: #fff; padding: 0; } .card { box-shadow: none; border-radius: 0; } .no-print { display: none !important; } }
</style></head><body>
<div class="no-print" style="max-width:720px;margin:0 auto 12px;text-align:right;">
<button onclick="window.print()" style="background:#1e40af;color:#fff;border:none;padding:8px 20px;border-radius:6px;font-size:13px;cursor:pointer;">🖨 Print / Save as PDF</button>
</div>
<div class="card">
<div class="header">
<div class="header-eyebrow">CBSnexora · Committee Meeting</div>
<div class="header-title">📅 ${meeting.title}</div>
${committee?.name ? `<div class="header-sub">👥 ${committee.name}</div>` : ''}
</div>
<div class="body">
<div class="section-label">Meeting Details</div>
<table class="details-table">
<tbody>
${committee?.name ? row('👥', 'Committee', committee.name) : ''}
${row('📅', 'Date', fmt(meeting.meetingDate))}
${row('⏰', 'Time', meeting.meetingTime || '—')}
${row('🔗', 'Mode', isOnline ? '🌐 Online' : '📍 Offline')}
${row('📊', 'Status', meeting.status || '—')}
${locationRows}
</tbody>
</table>
${agendaRows ? `<div class="agenda-box"><div class="agenda-label">📋 AGENDA</div><table style="width:100%;border-collapse:collapse;">${agendaRows}</table></div>` : ''}
</div>
<div class="footer">
<span>CBSnexora — Committee Meeting Record</span>
<span>Generated: ${new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
</div>
</div>
</body></html>`;
w.document.write(html);
w.document.close();
w.focus();
}
sendVotingEmailsDirect(meeting: any, event: MouseEvent): void {
event.stopPropagation();
this.service.resendVotingEmails(meeting.id).subscribe({
next: () => this.snackBar.open('Voting emails sent to all members!', 'Close', { duration: 4000 }),
error: () => this.snackBar.open('Failed to send voting emails.', 'Close', { duration: 3000 })
});
}
resendVotingEmails(event: MouseEvent): void {
event.stopPropagation();
if (this.resendingVoteEmails || !this.votePanelMeeting) return;
this.resendingVoteEmails = true;
this.service.resendVotingEmails(this.votePanelMeeting.id).subscribe({
next: () => {
this.resendingVoteEmails = false;
this.snackBar.open('Voting emails sent to all committee members.', 'Close', { duration: 4000 });
},
error: () => {
this.resendingVoteEmails = false;
this.snackBar.open('Failed to send voting emails.', 'Close', { duration: 3000 });
}
});
}
openVotePanel(meeting: any, event: MouseEvent): void {
event.stopPropagation();
this.votePanelMeeting = meeting;
this.showVotePanel = true;
this.voteSummary = [];
this.voteSummaryLoading = true;
this.service.getVoteSummary(meeting.id).subscribe({
next: (data) => {
this.voteSummary = data ?? [];
this.voteSummaryLoading = false;
},
error: () => {
this.voteSummaryLoading = false;
this.snackBar.open('Failed to load vote summary.', 'Close', { duration: 3000 });
}
});
}
closeVotePanel(): void {
this.showVotePanel = false;
this.votePanelMeeting = null;
this.voteSummary = [];
}
resolutionIcon(r: string): string {
return r === 'APPROVED' ? 'check_circle' : r === 'REJECTED' ? 'cancel' : 'pause_circle';
}
resolutionColor(r: string): string {
return r === 'APPROVED' ? '#16a34a' : r === 'REJECTED' ? '#dc2626' : '#d97706';
}
private emptyForm(): any {
return { name: '', description: '', image: null, selectedIds: [] };
}
addAgendaItem(): void {
this.meetingForm.agendaItems.push('');
}
removeAgendaItem(index: number): void {
if (this.meetingForm.agendaItems.length > 1) {
this.meetingForm.agendaItems.splice(index, 1);
}
}
private emptyMeetingForm(): any {
return {
title: '',
meetingDate: '',
meetingTime: '',
mode: 'OFFLINE',
agendaItems: [''],
platform: '',
meetingLink: '',
meetingIdRef: '',
meetingPassword: '',
venue: '',
address: '',
city: ''
};
}
}
@@ -0,0 +1,439 @@
<!--
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="board-page">
<!-- Header -->
<div class="board-header">
<div class="header-left">
<a routerLink="/election" class="back-btn">
<mat-icon>arrow_back</mat-icon>
</a>
<div>
<h2>Board of Directors</h2>
<p class="header-sub">Registry of elected directors, their designations and tenures</p>
</div>
</div>
<button class="btn-appoint" (click)="openAppoint()">
<mat-icon>person_add</mat-icon>
<span>Appoint Director</span>
</button>
</div>
<!-- Pending Appointments -->
@if (!pendingLoading && pendingCandidates.length > 0) {
<div class="pending-section">
<div class="pending-header">
<mat-icon class="pending-icon">pending_actions</mat-icon>
<div>
<h4>Pending Appointments</h4>
<p>{{ pendingCandidates.length }} won candidate(s) awaiting designation</p>
</div>
</div>
<div class="pending-list">
@for (p of pendingCandidates; track p.candidateId) {
<div class="pending-card">
<div class="pending-avatar">{{ p.candidateName?.charAt(0) }}</div>
<div class="pending-info">
<span class="pending-name">{{ p.candidateName }}</span>
<span class="pending-meta">{{ p.postName }} &nbsp;·&nbsp; {{ p.electionTitle }}</span>
</div>
<div class="pending-votes">
<mat-icon>emoji_events</mat-icon>
<span>{{ p.voteCount }} votes</span>
</div>
<button class="btn-appoint-now" (click)="pickPending(p)">
<mat-icon>person_add</mat-icon>
Appoint
</button>
</div>
}
</div>
</div>
}
<!-- Filters -->
<div class="filter-bar">
@for (s of statusOptions; track s) {
<button class="filter-chip" [class.active]="filterStatus === s" (click)="filterStatus = s; applyFilter()">
{{ statusLabels[s] }}
</button>
}
</div>
<!-- Loading -->
@if (loading) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading board registry...</p>
</div>
} @else if (directors.length === 0) {
<div class="empty-state">
<mat-icon class="empty-icon">groups</mat-icon>
<h3>No directors appointed yet</h3>
<p>Select a won candidate from election results to appoint as director.</p>
<button class="btn-appoint" (click)="openAppoint()">
<mat-icon>person_add</mat-icon>
<span>Appoint First Director</span>
</button>
</div>
} @else {
<!-- Grouped by Election -->
@for (group of electionGroups; track group.electionId) {
<div class="election-group" [class.past-group]="!group.isCurrent">
<!-- Election Group Header (clickable) -->
<div
class="election-group-header"
[class.current-header]="group.isCurrent"
[class.header-open]="group.open"
(click)="group.open = !group.open"
>
<div class="election-group-icon" [class.past-icon]="!group.isCurrent">
<mat-icon>how_to_vote</mat-icon>
</div>
<div class="election-group-info">
<div class="election-title-row">
<h3>{{ group.electionTitle }}</h3>
@if (group.isCurrent) {
<span class="current-badge"><mat-icon>verified</mat-icon> Current</span>
} @else {
<span class="past-badge">Past</span>
}
</div>
@if (group.tenureStart || group.tenureEnd) {
<span class="election-tenure-range">
<mat-icon>date_range</mat-icon>
{{ group.tenureStart ? (group.tenureStart | date: 'dd MMM yyyy') : '—' }}
&nbsp;→&nbsp;
{{ group.tenureEnd ? (group.tenureEnd | date: 'dd MMM yyyy') : '—' }}
</span>
}
</div>
<span class="election-member-count" [class.past-count]="!group.isCurrent">
{{ group.directors.length }} member{{ group.directors.length !== 1 ? 's' : '' }}
</span>
<mat-icon class="group-chevron" [class.chevron-open]="group.open">expand_more</mat-icon>
</div>
<!-- Director Cards -->
@if (group.open) {
<div class="directors-grid">
@for (d of group.showAll ? group.directors : group.directors.slice(0, 6); track d.id) {
<div class="director-card">
<div class="card-top">
<div class="director-avatar">
@if (getDirectorPhoto(d)) {
<img [src]="getDirectorPhoto(d)" [alt]="d.candidateName" class="director-avatar-img" />
} @else {
{{ d.candidateName?.charAt(0) }}
}
</div>
<div class="director-info">
<h4>{{ d.candidateName }}</h4>
<span class="post-badge">{{ d.postName }}</span>
</div>
<span
class="status-chip"
[style.background-color]="statusColor(d.status) + '20'"
[style.color]="statusColor(d.status)"
>
{{ d.status }}
</span>
</div>
<div class="card-field designation-field">
<mat-icon>badge</mat-icon>
<span>{{ d.designation || '—' }}</span>
</div>
<div class="tenure-row" [class.tenure-expired]="d.isExpired">
<div class="card-field">
<mat-icon>event_available</mat-icon>
<div>
<span class="field-label">Tenure Start</span>
<span class="field-val">{{
d.tenureStartDate ? (d.tenureStartDate | date: 'dd MMM yyyy') : '—'
}}</span>
</div>
</div>
<div class="tenure-divider">→</div>
<div class="card-field">
<mat-icon>event_busy</mat-icon>
<div>
<span class="field-label">Tenure End</span>
<span class="field-val" [class.expired-date]="d.isExpired">
{{ d.tenureEndDate ? (d.tenureEndDate | date: 'dd MMM yyyy') : '—' }}
@if (d.isExpired) {
<span class="expired-tag">Expired</span>
}
</span>
</div>
</div>
</div>
<div class="meta-row">
@if (d.resolutionNo) {
<span class="meta-tag"><mat-icon>description</mat-icon>{{ d.resolutionNo }}</span>
}
@if (d.appointmentDate) {
<span class="meta-tag"
><mat-icon>calendar_today</mat-icon>{{ d.appointmentDate | date: 'dd MMM yyyy' }}</span
>
}
</div>
@if (d.remarks) {
<p class="remarks-text">{{ d.remarks }}</p>
}
<div class="card-actions">
<button class="btn-edit" (click)="openEdit(d)"><mat-icon>edit</mat-icon> Edit</button>
<button
class="btn-delete"
(click)="confirmRemove(d)"
[disabled]="deleting === d.id || d.status === 'REMOVED'"
>
<mat-icon>person_remove</mat-icon>
{{ deleting === d.id ? 'Removing...' : 'Remove' }}
</button>
</div>
</div>
}
</div>
@if (group.directors.length > 6) {
<button class="expand-btn" (click)="group.showAll = !group.showAll; $event.stopPropagation()">
@if (!group.showAll) {
<mat-icon>expand_more</mat-icon>
Show {{ group.directors.length - 6 }} more member{{ group.directors.length - 6 !== 1 ? 's' : '' }}
} @else {
<mat-icon>expand_less</mat-icon>
Show less
}
</button>
}
}
</div>
}
}
</div>
<!-- ── Drawer Overlay ── -->
@if (showForm) {
<div class="overlay" (click)="closeForm()"></div>
<div class="form-drawer">
<!-- Drawer Header -->
<div class="drawer-header">
<div class="drawer-title-row">
@if (formStep === 2 && !editingId) {
<button class="icon-btn" (click)="backToStep1()">
<mat-icon>arrow_back</mat-icon>
</button>
}
<div>
<h3>{{ editingId ? 'Edit Director' : formStep === 1 ? 'Select Won Candidate' : 'Appointment Details' }}</h3>
@if (!editingId) {
<p class="step-hint">Step {{ formStep }} of 2</p>
}
</div>
</div>
<button class="close-btn" (click)="closeForm()"><mat-icon>close</mat-icon></button>
</div>
<!-- ── STEP 1: Select Won Candidate ── -->
@if (formStep === 1) {
<div class="drawer-body">
<!-- Election Selector -->
@if (electionsLoading) {
<div class="loading-state small">
<div class="spinner"></div>
<p>Loading elections...</p>
</div>
} @else {
<mat-form-field appearance="outline" class="full-field">
<mat-label>Select Election</mat-label>
<mat-select [(ngModel)]="selectedElectionId" (ngModelChange)="selectElection($event)">
<mat-option [value]="null">— Choose an election —</mat-option>
@for (e of elections; track e.id) {
<mat-option [value]="e.id">{{ e.title }} ({{ e.status }})</mat-option>
}
</mat-select>
</mat-form-field>
}
<!-- WON Candidates List -->
@if (wonLoading) {
<div class="loading-state small">
<div class="spinner"></div>
<p>Loading results...</p>
</div>
} @else if (selectedElectionId && wonCandidates.length === 0) {
<div class="empty-won">
<mat-icon>info</mat-icon>
<span>No won candidates found for this election.</span>
</div>
} @else if (wonCandidates.length > 0) {
<div class="form-section-title">Won Candidates — click to select</div>
<div class="won-list">
@for (c of wonCandidates; track c.candidateId) {
<button class="won-card" (click)="pickCandidate(c)">
<div class="won-avatar">{{ c.candidateName?.charAt(0) }}</div>
<div class="won-info">
<span class="won-name">{{ c.candidateName }}</span>
<span class="won-post">{{ c.postName }}</span>
</div>
<div class="won-votes">
<mat-icon class="trophy">emoji_events</mat-icon>
<span>{{ c.voteCount }} votes</span>
</div>
<mat-icon class="pick-arrow">chevron_right</mat-icon>
</button>
}
</div>
}
</div>
<div class="drawer-footer">
<button class="btn-cancel" (click)="closeForm()">Cancel</button>
</div>
}
<!-- ── STEP 2: Appointment Details ── -->
@if (formStep === 2) {
<div class="drawer-body">
<!-- Auto-filled candidate info banner -->
<div class="selected-candidate-banner">
<div class="selected-avatar">{{ form.candidateName?.charAt(0) }}</div>
<div>
<span class="selected-name">{{ form.candidateName }}</span>
<span class="selected-post">{{ form.postName }}</span>
</div>
<mat-icon class="check-icon">verified</mat-icon>
</div>
<!-- Appointment Details -->
<div class="form-section-title">Appointment Details</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Designation <span class="req">*</span></mat-label>
<mat-select [(ngModel)]="form.designation" #desig="ngModel" required>
<mat-option value="">— Select —</mat-option>
@for (opt of designationOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
@if (submitted && !form.designation) {
<mat-error>Designation is required</mat-error>
}
</mat-form-field>
@if (editingId) {
<mat-form-field appearance="outline">
<mat-label>Status</mat-label>
<mat-select [(ngModel)]="form.status">
<mat-option value="ACTIVE">Active</mat-option>
<mat-option value="RESIGNED">Resigned</mat-option>
<mat-option value="REMOVED">Removed</mat-option>
<mat-option value="TENURE_ENDED">Tenure Ended</mat-option>
</mat-select>
</mat-form-field>
}
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Appointment Date <span class="req">*</span></mat-label>
<input matInput [matDatepicker]="apptDate" [(ngModel)]="form.appointmentDate" [min]="today" required />
<mat-datepicker-toggle matSuffix [for]="apptDate"></mat-datepicker-toggle>
<mat-datepicker #apptDate></mat-datepicker>
@if (submitted && !form.appointmentDate) {
<mat-error>Appointment date is required</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Resolution No.</mat-label>
<input matInput [(ngModel)]="form.resolutionNo" placeholder="e.g. AGM/2026/001" />
</mat-form-field>
</div>
<!-- Tenure -->
<div class="form-section-title">Tenure Period</div>
<div class="form-row">
<mat-form-field appearance="outline" [class.field-auto-filled]="isTenureStartAutoFilled">
<mat-label>
Tenure Start Date <span class="req">*</span>
@if (isTenureStartAutoFilled) {
<span class="auto-badge">Auto</span>
}
</mat-label>
<input
matInput
[matDatepicker]="tenureStart"
[(ngModel)]="form.tenureStartDate"
[min]="today"
[readonly]="isTenureStartAutoFilled"
(ngModelChange)="onTenureStartChange($event)"
required
/>
<mat-datepicker-toggle
matSuffix
[for]="tenureStart"
[disabled]="isTenureStartAutoFilled"
></mat-datepicker-toggle>
<mat-datepicker #tenureStart [disabled]="isTenureStartAutoFilled"></mat-datepicker>
@if (isTenureStartAutoFilled) {
<mat-hint>Derived from election result date</mat-hint>
}
@if (submitted && !form.tenureStartDate) {
<mat-error>Tenure start date is required</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" [class.field-auto-filled]="isTenureEndAutoFilled">
<mat-label>
Tenure End Date <span class="req">*</span>
@if (isTenureEndAutoFilled) {
<span class="auto-badge">Auto</span>
}
</mat-label>
<input
matInput
[matDatepicker]="tenureEnd"
[(ngModel)]="form.tenureEndDate"
[min]="form.tenureStartDate || today"
[readonly]="isTenureEndAutoFilled"
required
/>
<mat-datepicker-toggle
matSuffix
[for]="tenureEnd"
[disabled]="isTenureEndAutoFilled"
></mat-datepicker-toggle>
<mat-datepicker #tenureEnd [disabled]="isTenureEndAutoFilled"></mat-datepicker>
@if (isTenureEndAutoFilled) {
<mat-hint>Derived from election duration ({{ selectedElection?.durationYears }} yr)</mat-hint>
}
@if (submitted && !form.tenureEndDate) {
<mat-error>Tenure end date is required</mat-error>
}
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="full-field">
<mat-label>Remarks</mat-label>
<textarea matInput [(ngModel)]="form.remarks" rows="3" placeholder="Additional notes..."></textarea>
</mat-form-field>
</div>
<div class="drawer-footer">
<button class="btn-cancel" (click)="closeForm()">Cancel</button>
<button class="btn-save" (click)="save()" [disabled]="saving">
<mat-icon>{{ saving ? 'hourglass_empty' : 'save' }}</mat-icon>
{{ saving ? 'Saving...' : editingId ? 'Update' : 'Appoint' }}
</button>
</div>
}
</div>
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,452 @@
/**
* 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, DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { ElectionService } from '../../election.service';
@Component({
selector: 'mifosx-director-board',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
MatIconModule,
MatSnackBarModule,
MatDatepickerModule,
MatNativeDateModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
DatePipe
],
templateUrl: './director-board.component.html',
styleUrls: ['./director-board.component.scss']
})
export class DirectorBoardComponent implements OnInit {
private service = inject(ElectionService);
private snackBar = inject(MatSnackBar);
private sanitizer = inject(DomSanitizer);
directorPhotoMap: Record<number, SafeResourceUrl | null> = {};
// ── Pending appointments ───────────────────────────────────────
pendingCandidates: any[] = [];
pendingLoading = true;
// ── Director list ──────────────────────────────────────────────
directors: any[] = [];
electionGroups: any[] = [];
loading = true;
totalElements = 0;
page = 0;
pageSize = 100;
filterStatus = '';
statusOptions = [
'',
'ACTIVE',
'RESIGNED',
'REMOVED',
'TENURE_ENDED'
];
statusLabels: Record<string, string> = {
'': 'All',
ACTIVE: 'Active',
RESIGNED: 'Resigned',
REMOVED: 'Removed',
TENURE_ENDED: 'Tenure Ended'
};
// ── Drawer state ───────────────────────────────────────────────
showForm = false;
/** 1 = pick candidate, 2 = fill details */
formStep: 1 | 2 = 1;
editingId: number | null = null;
saving = false;
deleting: number | null = null;
submitted = false;
selectedElection: any = null;
// ── Step-1: election + WON candidates ─────────────────────────
elections: any[] = [];
electionsLoading = false;
selectedElectionId: number | null = null;
wonCandidates: any[] = [];
wonLoading = false;
// ── Step-2: form ───────────────────────────────────────────────
form: any = this.emptyForm();
readonly today: Date = new Date();
designationOptions: string[] = [];
ngOnInit(): void {
this.load();
this.loadPending();
this.loadDesignations();
}
loadDesignations(): void {
this.service.getDesignations().subscribe((data: any[]) => {
this.designationOptions = (data ?? []).map((d: any) => d.name);
});
}
loadPending(): void {
this.pendingLoading = true;
this.service.getPendingDirectors().subscribe((data: any[]) => {
this.pendingCandidates = data ?? [];
this.pendingLoading = false;
});
}
pickPending(p: any): void {
this.editingId = null;
this.selectedElectionId = p.electionId;
this.selectedElection = null;
this.form = {
...this.emptyForm(),
electionId: p.electionId,
candidateId: p.candidateId,
candidateName: p.candidateName,
postName: p.postName
};
this.formStep = 2;
this.showForm = true;
this.service.getElection(p.electionId).subscribe((election: any) => {
this.selectedElection = election;
if (election?.durationEndDate) {
this.form.tenureEndDate = new Date(election.durationEndDate);
if (election?.resultDate) {
this.form.tenureStartDate = new Date(election.resultDate);
}
}
});
}
load(): void {
this.loading = true;
this.service
.getDirectors({ status: this.filterStatus || undefined, page: this.page, size: this.pageSize })
.subscribe({
next: (data: any) => {
const todayStr = new Date().toISOString().slice(0, 10);
this.directors = (data?.content ?? []).map((d: any) => ({
...d,
isExpired: d.tenureEndDate ? d.tenureEndDate < todayStr : false
}));
this.totalElements = data?.totalElements ?? 0;
this.electionGroups = this.groupByElection(this.directors);
this.loading = false;
this.loadDirectorPhotos(this.directors);
},
error: () => {
this.directors = [];
this.electionGroups = [];
this.totalElements = 0;
this.loading = false;
}
});
}
applyFilter(): void {
this.page = 0;
this.load();
}
private loadDirectorPhotos(directors: any[]): void {
for (const d of directors) {
if (!d.memberId || this.directorPhotoMap[d.id] !== undefined) continue;
this.service.getClientImage(d.memberId).subscribe((base64) => {
this.directorPhotoMap[d.id] = base64 ? this.sanitizer.bypassSecurityTrustResourceUrl(base64) : null;
});
}
}
getDirectorPhoto(d: any): SafeResourceUrl | null {
return this.directorPhotoMap[d.id] ?? null;
}
// ── Open appoint drawer (step 1) ───────────────────────────────
openAppoint(): void {
this.submitted = false;
this.editingId = null;
this.selectedElection = null;
this.form = this.emptyForm();
this.formStep = 1;
this.selectedElectionId = null;
this.wonCandidates = [];
this.showForm = true;
this.loadElections();
}
loadElections(): void {
this.electionsLoading = true;
this.service.getAllElections(0, 50).subscribe((data: any) => {
this.elections = data?.content ?? [];
this.electionsLoading = false;
});
}
selectElection(electionId: number): void {
this.selectedElectionId = electionId;
this.selectedElection = this.elections.find((e) => e.id === electionId) ?? null;
this.wonCandidates = [];
this.wonLoading = true;
this.service.getResults(electionId).subscribe((results: any[]) => {
this.wonCandidates = (results ?? []).filter((r) => r.result === 'WON');
this.wonLoading = false;
});
}
pickCandidate(candidate: any): void {
const tenureEndDate = this.selectedElection?.durationEndDate
? new Date(this.selectedElection.durationEndDate)
: null;
const tenureStartDate =
this.selectedElection?.durationEndDate && this.selectedElection?.resultDate
? new Date(this.selectedElection.resultDate)
: null;
this.form = {
...this.emptyForm(),
electionId: this.selectedElectionId,
candidateId: candidate.candidateId,
candidateName: candidate.candidateName,
postName: candidate.postName,
tenureStartDate,
tenureEndDate
};
this.formStep = 2;
}
backToStep1(): void {
this.formStep = 1;
}
onTenureStartChange(val: Date | null): void {
if (!val) return;
if (this.isTenureStartAutoFilled) return;
const end = new Date(val);
end.setFullYear(end.getFullYear() + 5);
this.form.tenureEndDate = end;
}
get isTenureStartAutoFilled(): boolean {
return !!this.selectedElection?.durationEndDate && !!this.selectedElection?.resultDate;
}
get isTenureEndAutoFilled(): boolean {
return !!this.selectedElection?.durationEndDate;
}
// ── Open edit drawer (skip step 1) ────────────────────────────
openEdit(d: any): void {
this.submitted = false;
this.editingId = d.id;
this.selectedElectionId = d.electionId;
this.selectedElection = null;
this.form = {
electionId: d.electionId,
candidateId: d.candidateId,
candidateName: d.candidateName,
postName: d.postName,
designation: d.designation ?? '',
tenureStartDate: d.tenureStartDate ? new Date(d.tenureStartDate) : null,
tenureEndDate: d.tenureEndDate ? new Date(d.tenureEndDate) : null,
resolutionNo: d.resolutionNo ?? '',
appointmentDate: d.appointmentDate ? new Date(d.appointmentDate) : null,
status: d.status ?? 'ACTIVE',
remarks: d.remarks ?? ''
};
this.formStep = 2;
this.showForm = true;
this.service.getElection(d.electionId).subscribe((election: any) => {
this.selectedElection = election;
if (election?.durationEndDate && !this.form.tenureEndDate) {
this.form.tenureEndDate = new Date(election.durationEndDate);
}
});
}
closeForm(): void {
this.showForm = false;
this.editingId = null;
this.form = this.emptyForm();
this.formStep = 1;
this.submitted = false;
this.selectedElection = null;
}
save(): void {
this.submitted = true;
if (
!this.form.designation ||
!this.form.appointmentDate ||
!this.form.tenureStartDate ||
!this.form.tenureEndDate
) {
return;
}
this.saving = true;
const payload = {
...this.form,
appointmentDate: this.formatDate(this.form.appointmentDate),
tenureStartDate: this.formatDate(this.form.tenureStartDate),
tenureEndDate: this.formatDate(this.form.tenureEndDate)
};
const call =
this.editingId != null
? this.service.updateDirector(this.editingId, payload)
: this.service.appointDirector(payload);
call.subscribe({
next: () => {
this.saving = false;
this.closeForm();
this.load();
this.loadPending();
this.snackBar.open(
this.editingId != null ? 'Director updated successfully!' : 'Director appointed successfully!',
'Close',
{ duration: 3000 }
);
},
error: (err: any) => {
this.saving = false;
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Operation failed';
this.snackBar.open(msg, 'Close', { duration: 5000 });
}
});
}
confirmRemove(d: any): void {
if (!confirm(`Mark ${d.candidateName} as Removed? This will update their status but keep the record for audit.`))
return;
this.deleting = d.id;
this.service
.updateDirector(d.id, {
...d,
status: 'REMOVED',
appointmentDate: d.appointmentDate,
tenureStartDate: d.tenureStartDate,
tenureEndDate: d.tenureEndDate
})
.subscribe({
next: () => {
this.deleting = null;
this.load();
this.snackBar.open('Director marked as Removed.', 'Close', { duration: 3000 });
},
error: () => {
this.deleting = null;
this.snackBar.open('Failed to remove director.', 'Close', { duration: 4000 });
}
});
}
get totalPages(): number {
return Math.ceil(this.totalElements / this.pageSize);
}
prevPage(): void {
if (this.page > 0) {
this.page--;
this.load();
}
}
nextPage(): void {
if (this.page < this.totalPages - 1) {
this.page++;
this.load();
}
}
statusColor(status: string): string {
const map: Record<string, string> = {
ACTIVE: '#10b981',
RESIGNED: '#f59e0b',
REMOVED: '#ef4444',
TENURE_ENDED: '#94a3b8'
};
return map[status] ?? '#94a3b8';
}
private groupByElection(directors: any[]): any[] {
const todayStr = new Date().toISOString().slice(0, 10);
const map = new Map<number, any>();
for (const d of directors) {
if (!map.has(d.electionId)) {
map.set(d.electionId, {
electionId: d.electionId,
electionTitle: d.electionTitle || `Election #${d.electionId}`,
tenureStart: null,
tenureEnd: null,
isCurrent: false,
directors: []
});
}
const group = map.get(d.electionId);
group.directors.push(d);
if (d.tenureStartDate && (!group.tenureStart || d.tenureStartDate < group.tenureStart)) {
group.tenureStart = d.tenureStartDate;
}
if (d.tenureEndDate && (!group.tenureEnd || d.tenureEndDate > group.tenureEnd)) {
group.tenureEnd = d.tenureEndDate;
}
}
const groups = Array.from(map.values());
for (const g of groups) {
g.isCurrent = g.tenureEnd ? g.tenureEnd >= todayStr : false;
g.open = g.isCurrent;
g.showAll = false;
}
return groups.sort((a, b) => {
const aStart = a.tenureStart ?? '';
const bStart = b.tenureStart ?? '';
return bStart.localeCompare(aStart);
});
}
private formatDate(date: Date | null): string | null {
if (!date) return null;
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
private emptyForm(): any {
return {
electionId: null,
candidateId: null,
candidateName: '',
postName: '',
designation: '',
tenureStartDate: null,
tenureEndDate: null,
resolutionNo: '',
appointmentDate: null,
status: 'ACTIVE',
remarks: ''
};
}
}
@@ -0,0 +1,222 @@
<!--
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="audit-log-page">
<div class="page-header">
<a [routerLink]="['/election']" class="back-link"><mat-icon>arrow_back</mat-icon></a>
<div>
<h2>Audit Log</h2>
@if (election) {
<p class="sub-title">{{ election.title }}</p>
}
</div>
<button class="btn-refresh" (click)="loadLogs()" title="Refresh">
<mat-icon>refresh</mat-icon>
</button>
</div>
<!-- Filters -->
<div class="filter-bar">
<div class="search-wrap">
<mat-icon>search</mat-icon>
<input
type="text"
placeholder="Search by action, user, description..."
[(ngModel)]="searchText"
(ngModelChange)="applyFilter()"
/>
</div>
<select [(ngModel)]="filterEntity" (change)="onEntityChange()" class="filter-select">
<option value="">All entities</option>
@for (e of entityTypes; track e) {
<option [value]="e">{{ e }}</option>
}
</select>
<select [(ngModel)]="filterAction" (change)="applyFilter()" class="filter-select">
<option value="">All actions</option>
@for (a of availableActions; track a) {
<option [value]="a">{{ actionMeta(a).label }}</option>
}
</select>
@if (filterEntity || filterAction || searchText) {
<button class="btn-clear" (click)="clearFilters()"><mat-icon>close</mat-icon> Clear</button>
}
<span class="count-badge">{{ totalElements }} entries</span>
</div>
@if (loading) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading audit log...</p>
</div>
} @else if (logs.length === 0) {
<div class="empty-state">
<mat-icon>history</mat-icon>
<p>No audit entries found.</p>
</div>
} @else {
<div class="timeline">
@for (log of logs; track log.id) {
<div class="timeline-item" [class.expanded]="expandedId === log.id">
<div class="timeline-dot" [style.background]="actionMeta(log.action).color">
<mat-icon>{{ actionMeta(log.action).icon }}</mat-icon>
</div>
<div class="timeline-card" (click)="toggleExpand(log.id)">
<div class="card-main">
<div class="card-left">
<span class="action-label" [style.color]="actionMeta(log.action).color">
{{ actionMeta(log.action).label }}
</span>
<span class="entity-chip"
>{{ log.entityType }}
@if (log.entityId) {
#{{ log.entityId }}
}
</span>
</div>
<div class="card-right">
<span class="timestamp">{{ log.createdAt }}</span>
<mat-icon class="expand-icon">{{ expandedId === log.id ? 'expand_less' : 'expand_more' }}</mat-icon>
</div>
</div>
<p class="description">{{ log.description }}</p>
<div class="card-meta">
@if (log.performedByName) {
<span class="meta-chip"><mat-icon>person</mat-icon>{{ log.performedByName }}</span>
}
@if (log.ipAddress) {
<span class="meta-chip"><mat-icon>computer</mat-icon>{{ log.ipAddress }}</span>
}
@if (log.officeId) {
<span class="meta-chip"><mat-icon>business</mat-icon>Office #{{ log.officeId }}</span>
}
</div>
@if (expandedId === log.id && log.details) {
<div class="details-block" (click)="$event.stopPropagation()">
<span class="details-label">Details</span>
@if (isArrayDetails(log.details)) {
@if (isWinnerArray(log.details)) {
<!-- Winner results array -->
<table class="vote-table">
<thead>
<tr>
<th>Post</th>
<th>Winner</th>
<th>Votes</th>
</tr>
</thead>
<tbody>
@for (row of voteEntries(log.details); track $index) {
<tr>
<td>
<span class="post-chip">{{ row.post }}</span>
</td>
<td class="winner-name">{{ row.winner }}</td>
<td class="vote-count">{{ row.votes }}</td>
</tr>
}
</tbody>
</table>
} @else {
<!-- Manual vote counts array -->
<table class="vote-table">
<thead>
<tr>
<th>Candidate</th>
<th>Post</th>
<th>Votes</th>
@if (hasVoteEdits(log.details)) {
<th>Previous</th>
}
</tr>
</thead>
<tbody>
@for (row of voteEntries(log.details); track $index) {
<tr>
<td>{{ row.candidate }}</td>
<td>
<span class="post-chip">{{ row.post }}</span>
</td>
<td class="vote-count">{{ row.votes }}</td>
@if (hasVoteEdits(log.details)) {
<td class="vote-prev">{{ row.previousVotes ?? '—' }}</td>
}
</tr>
}
</tbody>
</table>
}
} @else if (detailsEntries(log.details).length > 0) {
<!-- Key-value structured display -->
<div class="detail-rows">
@for (entry of detailsEntries(log.details); track entry.key) {
<div class="detail-row">
<span class="detail-key">{{ entry.label }}</span>
<span class="detail-val" [class]="valueBadge(entry.value)">{{ formatValue(entry) }}</span>
</div>
}
</div>
} @else {
<!-- Fallback raw text -->
<pre class="detail-raw">{{ parsedDetails(log.details) }}</pre>
}
</div>
}
</div>
</div>
}
</div>
@if (totalPages > 1) {
<div class="pagination-bar">
<div class="pg-info">
@if (totalElements === 0) {
No entries
} @else {
Showing {{ startItem }}–{{ endItem }} of {{ totalElements }}
}
</div>
<div class="pg-controls">
<button class="pg-btn" [disabled]="page === 0" (click)="goToPage(0)" title="First">
<mat-icon>first_page</mat-icon>
</button>
<button class="pg-btn" [disabled]="page === 0" (click)="goToPage(page - 1)" title="Previous">
<mat-icon>chevron_left</mat-icon>
</button>
@for (p of pageNumbers; track p) {
<button class="pg-btn" [class.pg-num--active]="p === page" (click)="goToPage(p)">
{{ p + 1 }}
</button>
}
<button class="pg-btn" [disabled]="page === totalPages - 1" (click)="goToPage(page + 1)" title="Next">
<mat-icon>chevron_right</mat-icon>
</button>
<button class="pg-btn" [disabled]="page === totalPages - 1" (click)="goToPage(totalPages - 1)" title="Last">
<mat-icon>last_page</mat-icon>
</button>
</div>
<div class="pg-size">
<select [(ngModel)]="pageSize" (change)="onPageSizeChange()">
@for (n of pageSizeOptions; track n) {
<option [value]="n">{{ n }} / page</option>
}
</select>
</div>
</div>
}
}
</div>
@@ -0,0 +1,537 @@
/**
* 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/.
*/
.audit-log-page {
padding: 24px;
max-width: 960px;
}
.page-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 24px;
h2 {
font-size: 22px;
font-weight: 700;
margin: 0 0 2px;
}
.sub-title {
font-size: 13px;
color: #718096;
margin: 0;
}
.back-link {
display: flex;
align-items: center;
color: #4a5568;
text-decoration: none;
&:hover {
color: #2563eb;
}
}
.btn-refresh {
margin-left: auto;
background: none;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
display: flex;
align-items: center;
color: #4a5568;
&:hover {
background: #f8fafc;
color: #2563eb;
}
}
}
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 24px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px 16px;
.search-wrap {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 200px;
mat-icon {
color: #a0aec0;
font-size: 18px;
}
input {
border: none;
outline: none;
font-size: 14px;
width: 100%;
background: transparent;
}
}
.filter-select {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 6px 10px;
font-size: 13px;
color: #4a5568;
background: #f8fafc;
outline: none;
cursor: pointer;
}
.btn-clear {
display: flex;
align-items: center;
gap: 4px;
background: none;
border: 1px solid #fed7d7;
border-radius: 8px;
padding: 5px 10px;
font-size: 13px;
color: #e53e3e;
cursor: pointer;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
&:hover {
background: #fff5f5;
}
}
.count-badge {
margin-left: auto;
background: #eff6ff;
color: #1d4ed8;
border-radius: 20px;
padding: 3px 10px;
font-size: 12px;
font-weight: 600;
}
}
.loading-state,
.empty-state {
text-align: center;
padding: 60px 20px;
color: #718096;
mat-icon {
font-size: 48px;
height: 48px;
width: 48px;
opacity: 0.4;
display: block;
margin: 0 auto 12px;
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid #e2e8f0;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 12px;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.timeline {
display: flex;
flex-direction: column;
gap: 0;
position: relative;
&::before {
content: '';
position: absolute;
left: 19px;
top: 0;
bottom: 0;
width: 2px;
background: #e2e8f0;
}
}
.timeline-item {
display: flex;
gap: 16px;
padding-bottom: 16px;
position: relative;
}
.timeline-dot {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
z-index: 1;
box-shadow: 0 0 0 3px #fff;
mat-icon {
color: #fff;
font-size: 18px;
height: 18px;
width: 18px;
}
}
.timeline-card {
flex: 1;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 14px 16px;
cursor: pointer;
transition: box-shadow 0.15s;
&:hover {
box-shadow: 0 2px 10px rgb(0 0 0 / 8%);
}
.timeline-item.expanded & {
border-color: #bfdbfe;
box-shadow: 0 2px 12px rgb(37 99 235 / 10%);
}
}
.card-main {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
.card-left {
display: flex;
align-items: center;
gap: 8px;
}
.card-right {
display: flex;
align-items: center;
gap: 6px;
}
}
.action-label {
font-size: 14px;
font-weight: 700;
}
.entity-chip {
background: #f1f5f9;
color: #475569;
border-radius: 20px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
}
.timestamp {
font-size: 12px;
color: #94a3b8;
}
.expand-icon {
font-size: 18px;
height: 18px;
width: 18px;
color: #94a3b8;
}
.description {
font-size: 13px;
color: #4a5568;
margin: 0 0 8px;
}
.card-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
.meta-chip {
display: inline-flex;
align-items: center;
gap: 3px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 20px;
padding: 2px 8px;
font-size: 11px;
color: #64748b;
mat-icon {
font-size: 12px;
height: 12px;
width: 12px;
}
}
}
.details-block {
margin-top: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px 14px;
.details-label {
display: block;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: #94a3b8;
margin-bottom: 10px;
}
.detail-rows {
display: flex;
flex-direction: column;
}
.detail-row {
display: flex;
align-items: baseline;
gap: 12px;
padding: 5px 0;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
}
.detail-key {
min-width: 160px;
max-width: 160px;
font-size: 12px;
color: #64748b;
font-weight: 500;
flex-shrink: 0;
}
.detail-val {
font-size: 13px;
color: #1e293b;
word-break: break-word;
&.badge-pass {
display: inline-block;
color: #16a34a;
font-weight: 600;
background: #f0fdf4;
padding: 1px 10px;
border-radius: 20px;
border: 1px solid #bbf7d0;
font-size: 12px;
}
&.badge-fail {
display: inline-block;
color: #dc2626;
font-weight: 600;
background: #fef2f2;
padding: 1px 10px;
border-radius: 20px;
border: 1px solid #fecaca;
font-size: 12px;
}
&.badge-warn {
display: inline-block;
color: #92400e;
font-weight: 600;
background: #fffbeb;
padding: 1px 10px;
border-radius: 20px;
border: 1px solid #fde68a;
font-size: 12px;
}
}
.vote-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
text-align: left;
padding: 6px 10px;
border-bottom: 2px solid #e2e8f0;
color: #64748b;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
padding: 7px 10px;
border-bottom: 1px solid #f1f5f9;
color: #1e293b;
}
tr:last-child td {
border-bottom: none;
}
.vote-count {
font-weight: 700;
color: #2563eb;
font-size: 15px;
}
.vote-prev {
color: #94a3b8;
text-decoration: line-through;
}
.winner-name {
font-weight: 600;
color: #16a34a;
}
.post-chip {
background: #eff6ff;
color: #1d4ed8;
border-radius: 4px;
padding: 1px 7px;
font-size: 11px;
font-weight: 600;
}
}
.detail-raw {
margin: 0;
font-size: 12px;
color: #2d3748;
white-space: pre-wrap;
word-break: break-all;
font-family: Consolas, Monaco, monospace;
}
}
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-top: 20px;
padding: 10px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
}
.pg-info {
font-size: 12px;
color: #718096;
white-space: nowrap;
}
.pg-controls {
display: flex;
align-items: center;
gap: 3px;
}
.pg-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 30px;
padding: 0 4px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
color: #4a5568;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:hover:not(:disabled) {
background: #eff6ff;
border-color: #2563eb;
color: #2563eb;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.pg-num--active {
background: #2563eb;
color: #fff;
border-color: #2563eb;
&:hover {
background: #1d4ed8;
}
}
.pg-size {
select {
padding: 5px 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
color: #4a5568;
outline: none;
cursor: pointer;
background: #fff;
&:focus {
border-color: #2563eb;
}
}
}
@@ -0,0 +1,504 @@
/**
* 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, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { Subject } from 'rxjs';
import { debounceTime, takeUntil } from 'rxjs/operators';
import { ElectionService } from '../../election.service';
const DETAIL_LABELS: Record<string, string> = {
// Election state transitions
electionTitle: 'Election',
newStatus: 'New Status',
reason: 'Reason',
// Election config fields
title: 'Title',
description: 'Description',
electionType: 'Election Type',
electionDate: 'Election Date',
resultDate: 'Result Date',
nominationStartDate: 'Nomination Start',
nominationEndDate: 'Nomination End',
votingStartDate: 'Voting Start',
votingEndDate: 'Voting End',
// Nomination submission
nominationId: 'Nomination ID',
applicantName: 'Applicant Name',
memberId: 'Member ID',
postId: 'Post ID',
gender: 'Gender',
caste: 'Caste',
// Eligibility check
overallResult: 'Overall Result',
age: 'Age',
shareHolding: 'Share Holding',
area: 'Area',
ageRemarks: 'Age Note',
genderRemarks: 'Gender Note',
casteRemarks: 'Caste Note',
shareRemarks: 'Share Note',
areaRemarks: 'Area Note',
// CEO Pre-check
directorLoan: 'Director Loan',
directorLoanOutstanding: 'Loan Outstanding (₹)',
directorLoanRemarks: 'Director Loan Note',
relationAccount: 'Relation Account',
relationRemarks: 'Relation Account Note',
kyc: 'KYC',
kycRemarks: 'KYC Note',
familyKyc: 'Family KYC',
familyKycRemarks: 'Family KYC Note',
guarantor: 'Guarantor',
guarantorRemarks: 'Guarantor Note',
aml: 'AML',
amlRemarks: 'AML Note',
familyLoan: 'Family Loan',
familyLoanRemarks: 'Family Loan Note',
// Approval action
action: 'Action',
stepName: 'Approval Step',
stepNumber: 'Step Number',
remarks: 'Remarks',
rejectionReason: 'Rejection Reason',
// Candidate
symbolUpdated: 'Symbol Updated',
profileUpdated: 'Profile Updated',
briefProfile: 'Brief Profile',
filename: 'File Name',
mimeType: 'MIME Type',
// Winner results (array items)
post: 'Post',
winner: 'Winner',
votes: 'Votes',
candidate: 'Candidate',
previousVotes: 'Previous Votes',
// Director appointment
designation: 'Designation',
tenureStart: 'Tenure Start',
tenureEnd: 'Tenure End',
resolutionNo: 'Resolution No',
appointmentDate: 'Appointment Date',
status: 'Status',
expiredCount: 'Expired Count',
date: 'Date',
// Committee
name: 'Name',
committeeId: 'Committee ID',
members: 'Members',
addedIds: 'Added IDs',
removedAppointmentId: 'Removed Appointment ID',
// Committee Meeting
meetingId: 'Meeting ID',
meetingTitle: 'Meeting Title',
meetingDate: 'Meeting Date',
mode: 'Mode',
platform: 'Platform',
venue: 'Venue',
city: 'City'
};
const ACTION_META: Record<string, { label: string; icon: string; color: string }> = {
// Election Config
CREATE_ELECTION: { label: 'Election Created', icon: 'add_circle', color: '#16a34a' },
UPDATE_ELECTION: { label: 'Election Updated', icon: 'edit', color: '#2563eb' },
ACTIVATE_ELECTION: { label: 'Activated', icon: 'play_circle', color: '#16a34a' },
CLOSE_NOMINATIONS: { label: 'Nominations Closed', icon: 'lock', color: '#d97706' },
OPEN_VOTING: { label: 'Voting Opened', icon: 'how_to_vote', color: '#16a34a' },
CLOSE_VOTING: { label: 'Voting Closed', icon: 'lock_clock', color: '#d97706' },
CANCEL_ELECTION: { label: 'Election Cancelled', icon: 'cancel', color: '#dc2626' },
CREATE_POST: { label: 'Post Created', icon: 'work', color: '#7c3aed' },
UPDATE_POST: { label: 'Post Updated', icon: 'work_outline', color: '#2563eb' },
// Election Config — Failed
ACTIVATE_ELECTION_FAILED: { label: 'Activate Failed', icon: 'error_outline', color: '#dc2626' },
CLOSE_NOMINATIONS_FAILED: { label: 'Close Nominations Failed', icon: 'error_outline', color: '#dc2626' },
OPEN_VOTING_FAILED: { label: 'Open Voting Failed', icon: 'error_outline', color: '#dc2626' },
CLOSE_VOTING_FAILED: { label: 'Close Voting Failed', icon: 'error_outline', color: '#dc2626' },
CANCEL_ELECTION_FAILED: { label: 'Cancel Failed', icon: 'error_outline', color: '#dc2626' },
// Nomination
SUBMIT_NOMINATION: { label: 'Nomination Submitted', icon: 'person_add', color: '#2563eb' },
WITHDRAW_NOMINATION: { label: 'Nomination Withdrawn', icon: 'person_remove', color: '#dc2626' },
RESUBMIT_NOMINATION: { label: 'Nomination Resubmitted', icon: 'refresh', color: '#2563eb' },
ELIGIBILITY_CHECK: { label: 'Eligibility Checked', icon: 'fact_check', color: '#7c3aed' },
CEO_PRECHECK: { label: 'CEO Pre-check', icon: 'manage_accounts', color: '#7c3aed' },
APPROVAL_ACTION: { label: 'Approval Action', icon: 'approval', color: '#d97706' },
GENERATE_NO_DUE_CERTIFICATE: { label: 'No Due Certificate', icon: 'receipt_long', color: '#0891b2' },
// Nomination — Failed
SUBMIT_NOMINATION_FAILED: { label: 'Nomination Submit Failed', icon: 'error_outline', color: '#dc2626' },
WITHDRAW_NOMINATION_FAILED: { label: 'Withdraw Failed', icon: 'error_outline', color: '#dc2626' },
ELIGIBILITY_CHECK_FAILED: { label: 'Eligibility Check Failed', icon: 'error_outline', color: '#dc2626' },
CEO_PRECHECK_FAILED: { label: 'CEO Pre-check Failed', icon: 'error_outline', color: '#dc2626' },
APPROVAL_ACTION_FAILED: { label: 'Approval Failed', icon: 'error_outline', color: '#dc2626' },
// Candidate
APPROVE_CANDIDATE: { label: 'Candidate Approved', icon: 'verified', color: '#16a34a' },
REMOVE_CANDIDATE: { label: 'Candidate Removed', icon: 'remove_circle', color: '#dc2626' },
UPLOAD_SYMBOL: { label: 'Symbol Uploaded', icon: 'image', color: '#2563eb' },
UPDATE_CANDIDATE: { label: 'Candidate Updated', icon: 'edit_note', color: '#2563eb' },
// Candidate — Failed
APPROVE_CANDIDATE_FAILED: { label: 'Approve Candidate Failed', icon: 'error_outline', color: '#dc2626' },
REMOVE_CANDIDATE_FAILED: { label: 'Remove Candidate Failed', icon: 'error_outline', color: '#dc2626' },
// Voting
CAST_VOTE: { label: 'Vote Cast', icon: 'ballot', color: '#16a34a' },
MANUAL_VOTE_ENTRY: { label: 'Manual Vote Entry', icon: 'edit_note', color: '#0891b2' },
MANUAL_VOTE_ENTRY_FAILED: { label: 'Vote Entry Failed', icon: 'error_outline', color: '#dc2626' },
// Results
DECLARE_RESULTS: { label: 'Results Declared', icon: 'gavel', color: '#7c3aed' },
DECLARE_RESULTS_FAILED: { label: 'Declare Results Failed', icon: 'error_outline', color: '#dc2626' },
// Director Appointment
APPOINT_DIRECTOR: { label: 'Director Appointed', icon: 'person_check', color: '#16a34a' },
UPDATE_DIRECTOR_APPOINTMENT: { label: 'Appointment Updated', icon: 'manage_accounts', color: '#2563eb' },
REMOVE_DIRECTOR_APPOINTMENT: { label: 'Director Removed', icon: 'person_remove', color: '#dc2626' },
APPOINT_DIRECTOR_FAILED: { label: 'Appoint Failed', icon: 'error_outline', color: '#dc2626' },
UPDATE_DIRECTOR_APPOINTMENT_FAILED: { label: 'Update Appointment Failed', icon: 'error_outline', color: '#dc2626' },
REMOVE_DIRECTOR_APPOINTMENT_FAILED: { label: 'Remove Appointment Failed', icon: 'error_outline', color: '#dc2626' },
// Committee
CREATE_COMMITTEE: { label: 'Committee Created', icon: 'groups', color: '#16a34a' },
UPDATE_COMMITTEE: { label: 'Committee Updated', icon: 'edit_note', color: '#2563eb' },
DISSOLVE_COMMITTEE: { label: 'Committee Dissolved', icon: 'group_off', color: '#dc2626' },
ADD_COMMITTEE_MEMBER: { label: 'Member Added', icon: 'group_add', color: '#0891b2' },
REMOVE_COMMITTEE_MEMBER: { label: 'Member Removed', icon: 'person_off', color: '#d97706' },
// Committee Meeting
SCHEDULE_MEETING: { label: 'Meeting Scheduled', icon: 'event', color: '#16a34a' },
UPDATE_MEETING: { label: 'Meeting Updated', icon: 'edit_calendar', color: '#2563eb' },
CANCEL_MEETING: { label: 'Meeting Cancelled', icon: 'event_busy', color: '#dc2626' },
COMPLETE_MEETING: { label: 'Meeting Completed', icon: 'event_available', color: '#10b981' },
DELETE_MEETING: { label: 'Meeting Deleted', icon: 'delete_outline', color: '#94a3b8' },
SCHEDULE_MEETING_FAILED: { label: 'Schedule Failed', icon: 'error_outline', color: '#dc2626' },
UPDATE_MEETING_FAILED: { label: 'Update Meeting Failed', icon: 'error_outline', color: '#dc2626' }
};
@Component({
selector: 'mifosx-election-audit-log',
standalone: true,
imports: [
CommonModule,
RouterModule,
FormsModule,
MatIconModule
],
templateUrl: './election-audit-log.component.html',
styleUrls: ['./election-audit-log.component.scss']
})
export class ElectionAuditLogComponent implements OnInit, OnDestroy {
private service = inject(ElectionService);
private route = inject(ActivatedRoute);
private destroy$ = new Subject<void>();
private filterTrigger$ = new Subject<void>();
electionId!: number;
election: any = null;
logs: any[] = [];
loading = true;
filterEntity = '';
filterAction = '';
searchText = '';
page = 0;
pageSize = 20;
totalElements = 0;
totalPages = 0;
expandedId: number | null = null;
readonly entityTypes = [
'ELECTION',
'NOMINATION',
'CANDIDATE',
'VOTE',
'RESULT',
'DIRECTOR_APPOINTMENT',
'COMMITTEE'
];
readonly pageSizeOptions = [
10,
20,
50
];
private readonly ENTITY_ACTIONS: Record<string, string[]> = {
ELECTION: [
'CREATE_ELECTION',
'UPDATE_ELECTION',
'ACTIVATE_ELECTION',
'CLOSE_NOMINATIONS',
'OPEN_VOTING',
'CLOSE_VOTING',
'CANCEL_ELECTION',
'CREATE_POST',
'UPDATE_POST',
'ACTIVATE_ELECTION_FAILED',
'CLOSE_NOMINATIONS_FAILED',
'OPEN_VOTING_FAILED',
'CLOSE_VOTING_FAILED',
'CANCEL_ELECTION_FAILED'
],
NOMINATION: [
'SUBMIT_NOMINATION',
'WITHDRAW_NOMINATION',
'RESUBMIT_NOMINATION',
'ELIGIBILITY_CHECK',
'CEO_PRECHECK',
'APPROVAL_ACTION',
'GENERATE_NO_DUE_CERTIFICATE',
'SUBMIT_NOMINATION_FAILED',
'WITHDRAW_NOMINATION_FAILED',
'ELIGIBILITY_CHECK_FAILED',
'CEO_PRECHECK_FAILED',
'APPROVAL_ACTION_FAILED'
],
CANDIDATE: [
'APPROVE_CANDIDATE',
'REMOVE_CANDIDATE',
'UPLOAD_SYMBOL',
'UPDATE_CANDIDATE',
'APPROVE_CANDIDATE_FAILED',
'REMOVE_CANDIDATE_FAILED'
],
VOTE: [
'CAST_VOTE',
'MANUAL_VOTE_ENTRY',
'MANUAL_VOTE_ENTRY_FAILED'
],
RESULT: [
'DECLARE_RESULTS',
'DECLARE_RESULTS_FAILED'
],
DIRECTOR_APPOINTMENT: [
'APPOINT_DIRECTOR',
'UPDATE_DIRECTOR_APPOINTMENT',
'REMOVE_DIRECTOR_APPOINTMENT',
'APPOINT_DIRECTOR_FAILED',
'UPDATE_DIRECTOR_APPOINTMENT_FAILED',
'REMOVE_DIRECTOR_APPOINTMENT_FAILED'
],
COMMITTEE: [
'CREATE_COMMITTEE',
'UPDATE_COMMITTEE',
'DISSOLVE_COMMITTEE',
'ADD_COMMITTEE_MEMBER',
'REMOVE_COMMITTEE_MEMBER',
'SCHEDULE_MEETING',
'UPDATE_MEETING',
'CANCEL_MEETING',
'COMPLETE_MEETING',
'DELETE_MEETING',
'SCHEDULE_MEETING_FAILED',
'UPDATE_MEETING_FAILED'
]
};
get availableActions(): string[] {
if (!this.filterEntity) return Object.keys(ACTION_META);
return this.ENTITY_ACTIONS[this.filterEntity] ?? Object.keys(ACTION_META);
}
ngOnInit(): void {
this.electionId = +this.route.snapshot.params['electionId'];
this.service.getElection(this.electionId).subscribe((e) => (this.election = e));
this.filterTrigger$.pipe(debounceTime(350), takeUntil(this.destroy$)).subscribe(() => this.fetchLogs());
this.triggerFetch();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
loadLogs(): void {
this.page = 0;
this.triggerFetch();
}
onEntityChange(): void {
this.filterAction = '';
this.page = 0;
this.triggerFetch();
}
applyFilter(): void {
this.page = 0;
this.triggerFetch();
}
clearFilters(): void {
this.filterEntity = '';
this.filterAction = '';
this.searchText = '';
this.page = 0;
this.triggerFetch();
}
goToPage(p: number): void {
if (p < 0 || p >= this.totalPages) return;
this.page = p;
this.fetchLogs();
}
onPageSizeChange(): void {
this.page = 0;
this.fetchLogs();
}
private triggerFetch(): void {
this.filterTrigger$.next();
}
private fetchLogs(): void {
this.loading = true;
this.service
.getAuditLog(this.electionId, {
entityType: this.filterEntity || undefined,
action: this.filterAction || undefined,
search: this.searchText || undefined,
page: this.page,
size: this.pageSize
})
.subscribe((data: any) => {
this.logs = data.content ?? [];
this.totalElements = data.totalElements ?? 0;
this.totalPages = data.totalPages ?? 0;
this.loading = false;
});
}
get startItem(): number {
return this.totalElements === 0 ? 0 : this.page * this.pageSize + 1;
}
get endItem(): number {
return Math.min((this.page + 1) * this.pageSize, this.totalElements);
}
get pageNumbers(): number[] {
const total = this.totalPages;
if (total <= 7) return Array.from({ length: total }, (_, i) => i);
const half = 2;
let start = Math.max(0, this.page - half);
let end = Math.min(total - 1, this.page + half);
if (end - start < 4) {
if (start === 0) end = Math.min(4, total - 1);
else start = Math.max(0, end - 4);
}
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
}
actionMeta(action: string): { label: string; icon: string; color: string } {
return ACTION_META[action] ?? { label: action, icon: 'info', color: '#6b7280' };
}
parsedDetails(details: string | null): string | null {
if (!details) return null;
try {
return JSON.stringify(JSON.parse(details), null, 2);
} catch {
return details;
}
}
isArrayDetails(details: string | null): boolean {
if (!details) return false;
try {
return Array.isArray(JSON.parse(details));
} catch {
return false;
}
}
voteEntries(details: string | null): any[] {
if (!details) return [];
try {
const arr = JSON.parse(details);
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
hasVoteEdits(details: string | null): boolean {
return this.voteEntries(details).some((r) => r.previousVotes !== undefined);
}
isWinnerArray(details: string | null): boolean {
const arr = this.voteEntries(details);
return arr.length > 0 && arr[0].winner !== undefined;
}
detailsEntries(details: string | null): { key: string; label: string; value: string }[] {
if (!details) return [];
try {
const obj = JSON.parse(details);
if (Array.isArray(obj)) return [];
return Object.entries(obj)
.filter(
([
,
v
]) => v !== null && v !== undefined && v !== ''
)
.map(
([
k,
v
]) => ({
key: k,
label: DETAIL_LABELS[k] ?? k.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase()),
value: typeof v === 'boolean' ? (v ? 'Yes' : 'No') : String(v)
})
);
} catch {
return [];
}
}
valueBadge(value: string): string {
const v = value?.toUpperCase();
if ([
'PASS',
'YES',
'APPROVE',
'WON',
'ACTIVE',
'VOTING_OPEN'
].includes(v)) return 'badge-pass';
if ([
'FAIL',
'NO',
'REJECT',
'CANCELLED'
].includes(v)) return 'badge-fail';
if ([
'NOMINATION_CLOSED',
'VOTING_CLOSED'
].includes(v)) return 'badge-warn';
return '';
}
formatValue(entry: { key: string; value: string }): string {
const STATUS_LABELS: Record<string, string> = {
ACTIVE: 'Active (Nominations Open)',
NOMINATION_CLOSED: 'Nominations Closed',
VOTING_OPEN: 'Voting Open',
VOTING_CLOSED: 'Voting Closed',
CANCELLED: 'Cancelled'
};
if (entry.key === 'newStatus' && STATUS_LABELS[entry.value]) {
return STATUS_LABELS[entry.value];
}
return entry.value;
}
toggleExpand(id: number): void {
this.expandedId = this.expandedId === id ? null : id;
}
}
@@ -34,15 +34,6 @@
<mat-error *ngIf="basicForm.get('title')?.hasError('required')">Title is required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Election Type *</mat-label>
<mat-select formControlName="electionType">
@for (type of electionTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Description</mat-label>
<textarea
@@ -100,6 +91,12 @@
<mat-datepicker #resDate></mat-datepicker>
<mat-error *ngIf="basicForm.errors?.['resultBeforeElection']">Must be after Election Date</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Duration (Years)</mat-label>
<input matInput type="number" formControlName="durationYears" min="1" max="20" placeholder="e.g. 5" />
<mat-hint>Elected board tenure. No new election until this period expires.</mat-hint>
</mat-form-field>
</div>
<div class="step-actions">
@@ -121,7 +118,6 @@
<form [formGroup]="postsForm" class="step-form">
<div class="posts-header">
<h3>Define Election Posts</h3>
<button class="btn-add-post" type="button" (click)="addPost()"><mat-icon>add</mat-icon> Add Post</button>
</div>
<div formArrayName="posts">
@@ -182,10 +178,6 @@
<span class="review-label">Title</span>
<span class="review-value">{{ basicForm.value.title || '—' }}</span>
</div>
<div class="review-item">
<span class="review-label">Type</span>
<span class="review-value">{{ basicForm.value.electionType }}</span>
</div>
<div class="review-item">
<span class="review-label">Nomination Period</span>
<span class="review-value">
@@ -204,6 +196,12 @@
{{ basicForm.value.resultDate ? (basicForm.value.resultDate | date: 'dd MMM yyyy') : '—' }}
</span>
</div>
@if (basicForm.value.durationYears) {
<div class="review-item">
<span class="review-label">Duration</span>
<span class="review-value">{{ basicForm.value.durationYears }} year(s)</span>
</div>
}
@if (basicForm.value.description) {
<div class="review-item full-span">
<span class="review-label">Description</span>
@@ -7,20 +7,20 @@
*/
// ── Variables ──────────────────────────────────────────────────────────────
$primary: #4a6cf7;
$primary-d: #3a5ce7;
$primary: #2563eb;
$primary-d: #1d4ed8;
$success: #38a169;
$danger: #e53e3e;
$text-dark: #1a202c;
$text-mid: #4a5568;
$text-soft: #718096;
$border: #e2e8f0;
$bg-page: #f0f4ff;
$bg-page: #eff6ff;
$bg-white: #fff;
$radius-lg: 16px;
$radius-md: 10px;
$shadow-sm: 0 2px 8px rgb(74 108 247 / 8%);
$shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
$shadow-sm: 0 2px 8px rgb(37 99 235 / 8%);
$shadow-md: 0 4px 20px rgb(37 99 235 / 13%);
// ── Page wrapper ───────────────────────────────────────────────────────────
.config-container {
@@ -30,8 +30,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
}
.config-inner {
max-width: 860px;
margin: 0 auto;
// layout wrapper
}
// ── Page header ────────────────────────────────────────────────────────────
@@ -57,9 +56,9 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
flex-shrink: 0;
&:hover {
background: #ebf0ff;
background: #dbeafe;
color: $primary;
border-color: #c3d0fb;
border-color: #bfdbfe;
}
mat-icon {
@@ -100,7 +99,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
padding: 20px 24px;
&:hover {
background: #f7f9ff;
background: #f5f9ff;
}
.mat-step-icon {
@@ -187,7 +186,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
.mdc-notched-outline:hover .mdc-notched-outline__leading,
.mdc-notched-outline:hover .mdc-notched-outline__notch,
.mdc-notched-outline:hover .mdc-notched-outline__trailing {
border-color: #b0bfff !important;
border-color: #93c5fd !important;
}
.mdc-text-field--focused .mdc-notched-outline__leading,
@@ -244,10 +243,10 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
font-size: 13px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #4a6cf7 0%, #6a3de8 100%);
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
border: none;
cursor: pointer;
box-shadow: 0 4px 12px rgb(74 108 247 / 35%);
box-shadow: 0 4px 12px rgb(37 99 235 / 35%);
transition: all 0.15s;
mat-icon {
@@ -257,8 +256,8 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
}
&:hover:not(:disabled) {
background: linear-gradient(135deg, #3a5ce7 0%, #5a2dd8 100%);
box-shadow: 0 6px 16px rgb(74 108 247 / 45%);
background: linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%);
box-shadow: 0 6px 16px rgb(37 99 235 / 45%);
transform: translateY(-1px);
}
@@ -328,13 +327,13 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
font-size: 13px;
font-weight: 600;
color: $primary;
background: #ebf0ff;
border: 1px solid #c3d0fb;
background: #dbeafe;
border: 1px solid #bfdbfe;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: #dce6ff;
background: #dbeafe;
}
mat-icon {
@@ -347,7 +346,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
// ── Post card ──────────────────────────────────────────────────────────────
.post-card {
background: #fafbff;
background: #f0f6ff;
border: 1.5px solid $border;
border-radius: $radius-md;
padding: 20px 24px;
@@ -355,7 +354,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
transition: border-color 0.15s;
&:hover {
border-color: #c3d0fb;
border-color: #bfdbfe;
}
.post-card-header {
@@ -371,7 +370,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
font-size: 12px;
font-weight: 700;
color: $primary;
background: #ebf0ff;
background: #dbeafe;
padding: 4px 12px;
border-radius: 20px;
letter-spacing: 0.3px;
@@ -420,7 +419,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
display: inline-block;
width: 4px;
height: 18px;
background: linear-gradient(180deg, $primary, #6a3de8);
background: linear-gradient(180deg, #1e3a8a, $primary);
border-radius: 4px;
}
}
@@ -437,7 +436,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
align-items: center;
gap: 8px;
padding: 11px 16px;
background: linear-gradient(90deg, #f0f4ff 0%, #f7f9fc 100%);
background: linear-gradient(90deg, #eff6ff 0%, #f7f9fc 100%);
border-bottom: 1px solid $border;
font-size: 12px;
font-weight: 700;
@@ -514,7 +513,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
font-size: 10px;
font-weight: 700;
color: $primary;
background: #ebf0ff;
background: #dbeafe;
padding: 2px 10px;
border-radius: 20px;
}
@@ -63,20 +63,7 @@ export class ElectionConfigComponent implements OnInit {
basicForm!: FormGroup;
postsForm!: FormGroup;
electionTypes = [
'DIRECTOR',
'GENERAL',
'BY_ELECTION'
];
postNames = [
'Director',
'Chairman',
'Vice Chairman',
'Secretary',
'Treasurer',
'President',
'Managing Director'
];
postNames = ['Director'];
today = new Date();
@@ -95,10 +82,6 @@ export class ElectionConfigComponent implements OnInit {
Validators.required
],
description: [''],
electionType: [
'DIRECTOR',
Validators.required
],
nominationStartDate: [
null,
Validators.required
@@ -111,7 +94,8 @@ export class ElectionConfigComponent implements OnInit {
null,
Validators.required
],
resultDate: [null]
resultDate: [null],
durationYears: [null]
},
{ validators: this.dateOrderValidator() }
);
@@ -124,7 +108,7 @@ export class ElectionConfigComponent implements OnInit {
createPostGroup(): FormGroup {
return this.fb.group({
postName: [
'',
'Director',
Validators.required
],
totalSeats: [
@@ -234,6 +218,7 @@ export class ElectionConfigComponent implements OnInit {
const bv = this.basicForm.value;
const payload = {
...bv,
electionType: 'DIRECTOR',
nominationStartDate: this.toDateString(bv.nominationStartDate),
nominationEndDate: this.toDateString(bv.nominationEndDate),
electionDate: this.toDateString(bv.electionDate),
@@ -9,7 +9,17 @@
<div class="election-dashboard">
<div class="page-header">
<h2 class="page-title">Election Management</h2>
<a routerLink="/election/config/new" class="btn-primary"> <mat-icon>add</mat-icon> Create Election </a>
<div class="header-actions">
<a routerLink="/election/committees" class="btn-directors">
<mat-icon>group_work</mat-icon>
<span>Committees</span>
</a>
<a routerLink="/election/directors" class="btn-directors">
<mat-icon>groups</mat-icon>
<span>Board of Directors</span>
</a>
<a routerLink="/election/config/new" class="btn-primary"> <mat-icon>add</mat-icon> Create Election </a>
</div>
</div>
@if (loading) {
@@ -27,8 +37,8 @@
} @else {
<div class="stats-row">
<div class="stat-card">
<span class="stat-value">{{ elections.length }}</span>
<span class="stat-label">Total Elections</span>
<span class="stat-value">{{ totalElements }}</span>
<span class="stat-label">Total</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ countByStatus('ACTIVE') }}</span>
@@ -45,7 +55,7 @@
</div>
<div class="elections-grid">
@for (election of elections; track election.id) {
@for (election of paginatedElections; track election.id) {
<div class="election-card">
<div class="card-header">
<div>
@@ -76,6 +86,15 @@
<span class="date-value">{{ election.electionDate | date: 'dd MMM yyyy' }}</span>
</div>
</div>
@if (election.durationEndDate) {
<div class="date-item">
<mat-icon>schedule</mat-icon>
<div>
<span class="date-label">Board Active Until</span>
<span class="date-value">{{ election.durationEndDate | date: 'dd MMM yyyy' }}</span>
</div>
</div>
}
</div>
@if (election.description) {
@@ -112,19 +131,76 @@
<mat-icon>how_to_vote</mat-icon> Open Voting
</button>
}
@if (canVote(election)) {
<a [routerLink]="['/election', election.id, 'vote']" class="btn-vote">
<mat-icon>ballot</mat-icon> Vote Now
@if (election.status === 'VOTING_OPEN') {
<button class="btn-warn" [disabled]="actionLoading[election.id]" (click)="closeVoting(election)">
<mat-icon>lock</mat-icon> Close Voting
</button>
<a [routerLink]="['/election', election.id, 'results']" class="btn-results">
<mat-icon>edit_note</mat-icon> Enter Votes
</a>
}
@if (election.status === 'VOTING_CLOSED') {
<button
class="btn-declare-result"
[disabled]="actionLoading[election.id]"
(click)="declareResults(election)"
>
<mat-icon>gavel</mat-icon>
{{ actionLoading[election.id] ? 'Declaring...' : 'Declare Results' }}
</button>
}
@if (canViewResults(election)) {
<a [routerLink]="['/election', election.id, 'results']" class="btn-results">
<mat-icon>bar_chart</mat-icon> Results
</a>
}
<a [routerLink]="['/election', election.id, 'audit-log']" class="btn-audit">
<mat-icon>history</mat-icon> Audit Log
</a>
</div>
</div>
}
</div>
@if (totalPages > 1 || totalElements > pageSizeOptions[0]) {
<div class="pagination-bar">
<div class="pg-info">Showing {{ startItem }}–{{ endItem }} of {{ totalElements }}</div>
<div class="pg-controls">
<button class="pg-btn" [disabled]="currentPage === 1" (click)="goToPage(1)" title="First">
<mat-icon>first_page</mat-icon>
</button>
<button class="pg-btn" [disabled]="currentPage === 1" (click)="goToPage(currentPage - 1)" title="Previous">
<mat-icon>chevron_left</mat-icon>
</button>
@for (p of pageNumbers; track $index) {
@if (isNumber(p)) {
<button class="pg-btn pg-num" [class.pg-num--active]="p === currentPage" (click)="goToPage(p)">
{{ p }}
</button>
} @else {
<span class="pg-ellipsis">…</span>
}
}
<button
class="pg-btn"
[disabled]="currentPage === totalPages"
(click)="goToPage(currentPage + 1)"
title="Next"
>
<mat-icon>chevron_right</mat-icon>
</button>
<button class="pg-btn" [disabled]="currentPage === totalPages" (click)="goToPage(totalPages)" title="Last">
<mat-icon>last_page</mat-icon>
</button>
</div>
<div class="pg-size">
<select [(ngModel)]="pageSize" (change)="onPageSizeChange()">
@for (n of pageSizeOptions; track n) {
<option [value]="n">{{ n }} / page</option>
}
</select>
</div>
</div>
}
}
</div>

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