Compare commits
32
Commits
3cf01e2733
...
d0536dbb11
@@ -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,
|
||||
|
||||
@@ -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 · 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 · 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
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+72
@@ -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>
|
||||
+59
@@ -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;
|
||||
}
|
||||
}
|
||||
+39
@@ -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> {{ '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">
|
||||
✓ {{ 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> {{ '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 {}
|
||||
@@ -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 {}
|
||||
@@ -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> {{ '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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+47
@@ -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>
|
||||
+34
@@ -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;
|
||||
}
|
||||
+90
@@ -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 }} × {{ 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> {{ '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
|
||||
}}
|
||||
× {{ 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> {{ '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>
|
||||
·
|
||||
<span class="af-header__stat af-header__stat--red">{{ facilities.length - activeCount }} inactive</span>
|
||||
· {{ 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">✓</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">✕</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) {
|
||||
· {{ 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…</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 ?? '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="cb-info-grid__k">Leaf Range</span>
|
||||
<span class="cb-info-grid__v">
|
||||
{{ book.rangeStart ? book.rangeStart + ' – ' + book.rangeEnd : '—' }}
|
||||
</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 ?? '—' }}</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) {
|
||||
· {{ clientName }}
|
||||
}
|
||||
@if (books.length > 0) {
|
||||
· {{ 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…</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 ?? '—' }}</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 ?? '—' }}</td>
|
||||
<td class="cb-cell--action">View ›</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}`);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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> {{ 'labels.buttons.Run Validation' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button (click)="prepareSettlement()" [disabled]="busy">
|
||||
<fa-icon icon="money-check"></fa-icon> {{ '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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+387
@@ -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>
|
||||
+442
@@ -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;
|
||||
}
|
||||
}
|
||||
+689
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+199
@@ -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>
|
||||
+144
@@ -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;
|
||||
}
|
||||
}
|
||||
+126
@@ -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> {{ '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> {{ '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> {{ '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> 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> 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> {{
|
||||
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> {{
|
||||
selectedBackFile ? selectedBackFile.name : 'Back Cheque Image *'
|
||||
}}
|
||||
</button>
|
||||
@if (!selectedFrontFile || !selectedBackFile) {
|
||||
<span class="muted">Both front & 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> {{ '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 & 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 & 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 }} · {{ 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') : '—' }}
|
||||
→
|
||||
{{ 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
Reference in New Issue
Block a user