feat(clerk): add clerk-facing Cheque Book screen and shell nav wiring

Adds a customer-account-scoped Cheque Book view under Clerk > Account
Facilities (list + leaf detail, with stop-payment/report-lost/mark-used/
return actions), reachable via a new hover dropdown on the Account
Facilities nav item. Also refreshes the Account Facilities page layout,
and carries over a few chequebook service/detail-dialog fixes from the
back-office module (used-leaf display, leaf-lookup dialog polish).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-15 16:08:43 +05:30
co-authored by Claude Sonnet 5
parent 7abfa394df
commit 0cc79ccd57
20 changed files with 1517 additions and 242 deletions
+12
View File
@@ -70,6 +70,18 @@ const routes: Routes = [
),
data: { title: 'Account Facilities', breadcrumb: 'Account Facilities' }
},
{
path: 'account-facilities/cheque-book',
loadComponent: () =>
import('../clerk/cheque-book/cheque-book.component').then((m) => m.ClerkChequeBookComponent),
data: { title: 'Cheque Book', breadcrumb: 'Cheque Book' }
},
{
path: 'account-facilities/cheque-book/:id',
loadComponent: () =>
import('../clerk/cheque-book/cheque-book-detail.component').then((m) => m.ClerkChequeBookDetailComponent),
data: { title: 'Cheque Book Detail', breadcrumb: 'Cheque Book Detail' }
},
{
path: 'photo-signature',
loadComponent: () =>
@@ -73,6 +73,9 @@
<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>
}
@@ -130,7 +133,9 @@
? 'warn'
: l.status === 'LOST'
? 'bad'
: 'neutral'
: l.status === 'RETURNED'
? 'returned'
: 'neutral'
"
(click)="selectLeaf(l)"
>
@@ -118,6 +118,10 @@ mat-form-field {
&.neutral {
color: #757575;
}
&.returned {
color: #9333ea;
}
}
.empty-state {
@@ -21,13 +21,14 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ChequeBookService } from '../chequebook.service';
import { LeafDetailDialogComponent } from './leaf-detail-dialog/leaf-detail-dialog.component';
type LeafAction = 'stoppayment' | 'revokestoppayment' | 'reportlost' | 'cancelleaf' | 'markused';
type LeafAction = 'stoppayment' | 'revokestoppayment' | 'reportlost' | 'cancelleaf' | 'markused' | 'return';
/**
* One issued cheque book: its leaf register, and the five leaf-range actions (stop-payment, revoke,
* report-lost, cancel-leaf, mark-used) — each defaults to the whole book when no range is given.
* 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.
* — 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',
@@ -112,7 +113,8 @@ export class ChequeBookDetailComponent implements OnInit {
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)
markused: (id, p) => this.chequeBookService.markUsed(id, p),
return: (id, p) => this.chequeBookService.returnBook(id, p)
};
const call = actionCalls[this.activeAction](this.bookId, payload);
@@ -18,7 +18,9 @@
? 'warn'
: data.status === 'LOST'
? 'bad'
: 'neutral'
: data.status === 'RETURNED'
? 'returned'
: 'neutral'
"
>{{ data.leafSerialNo }}</span
>
@@ -57,6 +59,12 @@
><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">
+5
View File
@@ -166,6 +166,11 @@ export class ChequeBookService {
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) -----
/**
@@ -53,7 +53,9 @@
? 'warn'
: result.status === 'LOST'
? 'bad'
: 'neutral'
: result.status === 'RETURNED'
? 'returned'
: 'neutral'
"
>{{ result.status }}</span
>
@@ -117,6 +119,12 @@
><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>
@@ -116,6 +116,11 @@
background: #e0e0e0;
color: #757575;
}
&.returned {
background: #f3e8fd;
color: #9333ea;
}
}
.empty-state {
@@ -11,116 +11,79 @@
}
.af-page {
background: #f8fafc;
min-height: calc(100vh - 68px);
background: #f0f4f8;
padding: 24px 28px;
display: flex;
flex-direction: column;
gap: 20px;
gap: 12px;
}
// ── Hero ──────────────────────────────────────────────────────────────────────
// ── Header ──────────────────────────────────────────────────────────────────────
.af-hero {
.af-header {
display: flex;
align-items: center;
gap: 18px;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 40%, #2563eb 100%);
border-radius: 16px;
padding: 22px 28px;
color: #fff;
box-shadow: 0 8px 32px rgb(37 99 235 / 35%);
position: relative;
overflow: hidden;
justify-content: space-between;
padding: 12px 20px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&::before {
content: '';
position: absolute;
top: -40px;
right: -40px;
width: 200px;
height: 200px;
background: rgb(255 255 255 / 5%);
border-radius: 50%;
}
&__icon-wrap {
width: 56px;
height: 56px;
background: rgb(255 255 255 / 15%);
border: 1px solid rgb(255 255 255 / 25%);
border-radius: 14px;
&__left {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
backdrop-filter: blur(8px);
gap: 10px;
}
&__text {
flex: 1;
&__icon {
color: #0f4f3c;
flex-shrink: 0;
}
&__title {
font-size: 22px;
font-weight: 800;
letter-spacing: -0.01em;
margin: 0 0 5px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
&__stats {
display: flex;
gap: 14px;
align-items: center;
&__sub {
font-size: 12px;
color: #64748b;
margin-top: 1px;
}
&__stat {
display: flex;
align-items: center;
gap: 5px;
font-size: 12.5px;
color: rgb(255 255 255 / 75%);
font-weight: 700;
strong {
color: #fff;
font-weight: 700;
&--green {
color: #16a34a;
}
&--green strong {
color: #86efac;
&--red {
color: #dc2626;
}
&--red strong {
color: #fca5a5;
}
}
&__sep {
width: 1px;
height: 14px;
background: rgb(255 255 255 / 25%);
}
}
.af-back-btn {
.af-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 10px 20px;
background: rgb(255 255 255 / 12%);
border: 1px solid rgb(255 255 255 / 25%);
border-radius: 10px;
color: #fff;
font-size: 12.5px;
gap: 6px;
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
backdrop-filter: blur(8px);
border: 1px solid #e2e8f0;
transition: background 0.15s;
height: 30px;
padding: 0 12px;
font-size: 12.5px;
background: #fff;
color: #0d1b2a;
&:hover {
background: rgb(255 255 255 / 22%);
transform: translateX(-2px);
background: #f0fdf4;
border-color: #0f4f3c;
color: #0f4f3c;
}
}
@@ -132,12 +95,12 @@
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 60px 20px;
gap: 10px;
padding: 40px 20px;
&__icon {
width: 88px;
height: 88px;
width: 64px;
height: 64px;
background: #e2e8f0;
border: 1px solid #cbd5e1;
border-radius: 50%;
@@ -147,80 +110,87 @@
}
&__title {
font-size: 18px;
font-size: 15px;
font-weight: 700;
color: #1e293b;
margin: 6px 0 0;
margin: 4px 0 0;
}
&__sub {
font-size: 13.5px;
font-size: 12.5px;
color: #64748b;
text-align: center;
margin: 0;
}
&__btn {
margin-top: 10px;
padding: 10px 24px;
background: #2563eb;
margin-top: 6px;
padding: 8px 20px;
background: #0f4f3c;
border: none;
border-radius: 10px;
border-radius: 8px;
color: #fff;
font-size: 13px;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #1d4ed8;
background: #0a3d2e;
}
}
}
// ── Body ────────────────────────────────────────────────────────────────────────
.af-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 20px 16px;
}
// ── Progress card ──────────────────────────────────────────────────────────────
.af-progress-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 18px 22px;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
border-radius: 10px;
padding: 12px 16px;
&__top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
margin-bottom: 8px;
}
&__label {
font-size: 11.5px;
font-weight: 600;
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.06em;
}
&__fraction {
font-size: 13px;
font-size: 12.5px;
font-weight: 700;
color: #16a34a;
color: #0f4f3c;
}
}
.af-progress-bar {
height: 10px;
height: 7px;
background: #e2e8f0;
border-radius: 999px;
overflow: hidden;
&__fill {
height: 100%;
background: linear-gradient(90deg, #16a34a, #22c55e);
background: #16a34a;
border-radius: 999px;
transition: width 0.5s ease;
box-shadow: 0 0 8px rgb(34 197 94 / 35%);
transition: width 0.4s ease;
}
}
@@ -229,65 +199,53 @@
.af-fac-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
&__header {
display: flex;
align-items: center;
gap: 9px;
padding: 12px 22px;
background: #1e293b;
color: #94a3b8;
font-size: 11.5px;
padding: 9px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
color: #64748b;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.06em;
}
}
.af-pills {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 20px 22px;
gap: 7px;
padding: 12px 16px;
}
.af-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 15px;
gap: 5px;
padding: 4px 11px;
border-radius: 999px;
font-size: 13px;
font-size: 12px;
font-weight: 600;
border: 1.5px solid;
transition:
transform 0.15s,
box-shadow 0.15s;
border: 1px solid;
cursor: default;
&__dot {
width: 7px;
height: 7px;
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
&--on {
background: #f0fdf4;
border-color: #22c55e;
color: #15803d;
border-color: #86efac;
color: #166534;
.af-pill__dot {
background: #22c55e;
box-shadow: 0 0 5px rgb(34 197 94 / 50%);
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgb(34 197 94 / 20%);
}
}
@@ -307,63 +265,46 @@
.af-stats-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
gap: 10px;
}
.af-stat-card {
border-radius: 14px;
padding: 20px 24px;
border-radius: 10px;
padding: 10px 14px;
display: flex;
align-items: center;
gap: 16px;
align-items: baseline;
gap: 7px;
border: 1px solid;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
font-size: 12px;
&__icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 20px;
font-size: 12px;
font-weight: 700;
}
&__num {
font-size: 30px;
font-size: 17px;
font-weight: 800;
line-height: 1;
display: block;
}
&__label {
font-size: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
display: block;
margin-top: 3px;
letter-spacing: 0.05em;
opacity: 0.75;
}
&--green {
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
background: #f0fdf4;
border-color: #86efac;
color: #15803d;
.af-stat-card__icon {
background: #bbf7d0;
}
color: #166534;
}
&--red {
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
border-color: #cbd5e1;
color: #475569;
.af-stat-card__icon {
background: #e2e8f0;
}
background: #f8fafc;
border-color: #e2e8f0;
color: #64748b;
}
}
@@ -18,34 +18,37 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
styleUrl: './account-facilities.component.scss',
template: `
<div class="af-page">
<!-- Hero -->
<div class="af-hero">
<div class="af-hero__icon-wrap">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<!-- Header -->
<div class="af-header">
<div class="af-header__left">
<svg
class="af-header__icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<div class="af-hero__text">
<h1 class="af-hero__title">Account Facilities</h1>
@if (accountLoaded) {
<div class="af-hero__stats">
<span class="af-hero__stat af-hero__stat--green">
<strong>{{ activeCount }}</strong> Active
</span>
<span class="af-hero__sep"></span>
<span class="af-hero__stat af-hero__stat--red">
<strong>{{ facilities.length - activeCount }}</strong> Inactive
</span>
<span class="af-hero__sep"></span>
<span class="af-hero__stat">
<strong>{{ facilities.length }}</strong> Total
</span>
</div>
}
<div>
<div class="af-header__title">Account Facilities</div>
@if (accountLoaded) {
<div class="af-header__sub">
<span class="af-header__stat af-header__stat--green">{{ activeCount }} active</span>
&middot;
<span class="af-header__stat af-header__stat--red">{{ facilities.length - activeCount }} inactive</span>
&middot; {{ facilities.length }} total
</div>
} @else {
<div class="af-header__sub">View all facilities enabled for the loaded account.</div>
}
</div>
</div>
@if (!embedded) {
<button class="af-back-btn" (click)="goBack()">
<button type="button" class="af-btn" (click)="goBack()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
@@ -58,7 +61,7 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
<!-- Empty state -->
<div class="af-empty">
<div class="af-empty__icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
@@ -70,48 +73,40 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
}
</div>
} @else {
<!-- Progress bar -->
<div class="af-progress-card">
<div class="af-progress-card__top">
<span class="af-progress-card__label">Facility Coverage</span>
<span class="af-progress-card__fraction">{{ activeCount }} / {{ facilities.length }}</span>
<div class="af-body">
<!-- Progress bar -->
<div class="af-progress-card">
<div class="af-progress-card__top">
<span class="af-progress-card__label">Facility Coverage</span>
<span class="af-progress-card__fraction">{{ activeCount }} / {{ facilities.length }}</span>
</div>
<div class="af-progress-bar">
<div class="af-progress-bar__fill" [style.width.%]="(activeCount / facilities.length) * 100"></div>
</div>
</div>
<div class="af-progress-bar">
<div class="af-progress-bar__fill" [style.width.%]="(activeCount / facilities.length) * 100"></div>
</div>
</div>
<!-- Facilities pills -->
<div class="af-fac-card">
<div class="af-fac-card__header">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
All Facilities
<!-- Facilities pills -->
<div class="af-fac-card">
<div class="af-fac-card__header">All Facilities</div>
<div class="af-pills">
@for (f of facilities; track f.label) {
<span class="af-pill" [class.af-pill--on]="f.active" [class.af-pill--off]="!f.active">
<span class="af-pill__dot"></span>
{{ f.label }}
</span>
}
</div>
</div>
<div class="af-pills">
@for (f of facilities; track f.label) {
<span class="af-pill" [class.af-pill--on]="f.active" [class.af-pill--off]="!f.active">
<span class="af-pill__dot"></span>
{{ f.label }}
</span>
}
</div>
</div>
<!-- Stats row -->
<div class="af-stats-row">
<div class="af-stat-card af-stat-card--green">
<div class="af-stat-card__icon">✓</div>
<div>
<!-- Stats row -->
<div class="af-stats-row">
<div class="af-stat-card af-stat-card--green">
<span class="af-stat-card__icon">&check;</span>
<span class="af-stat-card__num">{{ activeCount }}</span>
<span class="af-stat-card__label">Active</span>
</div>
</div>
<div class="af-stat-card af-stat-card--red">
<div class="af-stat-card__icon">✗</div>
<div>
<div class="af-stat-card af-stat-card--red">
<span class="af-stat-card__icon">&#10005;</span>
<span class="af-stat-card__num">{{ facilities.length - activeCount }}</span>
<span class="af-stat-card__label">Inactive</span>
</div>
@@ -0,0 +1,281 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
}
.cb-page {
background: #f8fafc;
min-height: calc(100vh - 68px);
display: flex;
flex-direction: column;
gap: 16px;
}
// ── Header ──────────────────────────────────────────────────────────────────────
.cb-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
color: #0f4f3c;
flex-shrink: 0;
}
&__title {
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
}
.cb-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
border: 1px solid #e2e8f0;
transition: background 0.15s;
height: 32px;
padding: 0 12px;
font-size: 13px;
background: #fff;
color: #0d1b2a;
&:hover {
background: #f0fdf4;
border-color: #0f4f3c;
color: #0f4f3c;
}
&--primary {
background: #0f4f3c;
border-color: #0f4f3c;
color: #fff;
&:hover {
background: #0a3d2e;
color: #fff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.cb-status-msg {
padding: 40px 20px;
text-align: center;
color: #64748b;
font-size: 13.5px;
}
// ── Body ────────────────────────────────────────────────────────────────────────
.cb-body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0 20px 20px;
}
.cb-info-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 18px 22px;
}
.cb-info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px 24px;
&__k {
display: block;
font-size: 11px;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 3px;
}
&__v {
display: block;
font-size: 13.5px;
font-weight: 600;
color: #0d1b2a;
}
}
.cb-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
&--ok {
background: #dcfce7;
color: #166534;
}
&--bad {
background: #fee2e2;
color: #991b1b;
}
&--neutral {
background: #fef3c7;
color: #92400e;
}
}
.cb-no-books {
padding: 30px 10px;
text-align: center;
color: #94a3b8;
font-size: 13px;
}
// ── Action toolbar ─────────────────────────────────────────────────────────────
.cb-action-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 20px;
}
.cb-action-btn {
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
border: 1px solid #e2e8f0;
transition: background 0.15s;
height: 32px;
padding: 0 14px;
font-size: 12.5px;
background: #fff;
color: #0d1b2a;
&:hover {
background: #f0fdf4;
border-color: #0f4f3c;
}
&--warn {
border-color: #fca5a5;
color: #dc2626;
&:hover {
background: #fef2f2;
border-color: #dc2626;
}
}
&--primary {
background: #0f4f3c;
border-color: #0f4f3c;
color: #fff;
&:hover {
background: #0a3d2e;
}
}
}
// ── Leaf grid ──────────────────────────────────────────────────────────────────
.cb-leaf-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.cb-leaf-circle {
width: 46px;
height: 46px;
border-radius: 50%;
border: 1.5px solid #cbd5e1;
background: #fff;
color: #64748b;
font-size: 12px;
font-weight: 700;
font-family: 'Courier New', monospace;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
transform 0.1s,
box-shadow 0.1s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgb(0 0 0 / 12%);
}
&--ok {
border-color: #22c55e;
color: #166534;
background: #f0fdf4;
}
&--used {
border-color: #cbd5e1;
color: #64748b;
background: #f8fafc;
}
&--warn {
border-color: #f59e0b;
color: #92400e;
background: #fffbeb;
}
&--bad {
border-color: #ef4444;
color: #991b1b;
background: #fef2f2;
}
&--returned {
border-color: #a855f7;
color: #6b21a8;
background: #faf5ff;
}
}
@@ -0,0 +1,207 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { ChequeBookService } from '../../chequebook/chequebook.service';
import { LeafDetailDialogComponent } from '../../chequebook/cheque-book-detail/leaf-detail-dialog/leaf-detail-dialog.component';
import { ClerkLeafActionDialogComponent, LeafAction } from './leaf-action-dialog/leaf-action-dialog.component';
/** Cheque-book detail for the Clerk Dashboard — a fullscreen clerk-styled view of one book, its leaf
* register, and the same maker-side leaf actions (stop-payment, revoke, report-lost, cancel-leaf,
* mark-used) as the back-office ChequeBookDetailComponent, so a clerk can act on a leaf without leaving
* the clerk shell/nav. Every action still goes through the existing maker-checker approval flow server-side.
* Each action opens as a popup (ClerkLeafActionDialogComponent) rather than an inline form so it never
* pushes the leaf grid around and gets the full dialog width to lay out its fields. */
@Component({
selector: 'mifosx-clerk-cheque-book-detail',
standalone: true,
imports: [CommonModule],
styleUrl: './cheque-book-detail.component.scss',
template: `
<div class="cb-page">
<!-- Header -->
<div class="cb-header">
<div class="cb-header__left">
<svg
class="cb-header__icon"
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
<div>
<div class="cb-header__title">Cheque Book #{{ book.id }}</div>
<div class="cb-header__sub">
{{ book.accountNo || book.accountId }}
@if (book.clientName) {
&nbsp;&middot;&nbsp;{{ book.clientName }}
}
</div>
</div>
</div>
<button type="button" class="cb-btn" (click)="goBack()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
Back
</button>
</div>
@if (loading) {
<div class="cb-status-msg">Loading cheque book&hellip;</div>
} @else {
<div class="cb-body">
<!-- Info card -->
<div class="cb-info-card">
<div class="cb-info-grid">
<div>
<span class="cb-info-grid__k">Account Type</span>
<span class="cb-info-grid__v">{{ book.accountType }}</span>
</div>
<div>
<span class="cb-info-grid__k">Office</span>
<span class="cb-info-grid__v">{{ book.officeName }}</span>
</div>
<div>
<span class="cb-info-grid__k">Series</span>
<span class="cb-info-grid__v">{{ book.seriesCode ?? '&#8212;' }}</span>
</div>
<div>
<span class="cb-info-grid__k">Leaf Range</span>
<span class="cb-info-grid__v">
{{ book.rangeStart ? book.rangeStart + ' – ' + book.rangeEnd : '&#8212;' }}
</span>
</div>
<div>
<span class="cb-info-grid__k">Quantity</span>
<span class="cb-info-grid__v">{{ book.noOfBooks }} book{{ book.noOfBooks === 1 ? '' : 's' }}</span>
</div>
<div>
<span class="cb-info-grid__k">Status</span>
<span
class="cb-pill"
[class.cb-pill--ok]="book.status === 'ISSUED'"
[class.cb-pill--bad]="book.status === 'REJECTED' || book.status === 'WITHDRAWN'"
[class.cb-pill--neutral]="
book.status !== 'ISSUED' && book.status !== 'REJECTED' && book.status !== 'WITHDRAWN'
"
>{{ book.status }}</span
>
</div>
<div>
<span class="cb-info-grid__k">Issue Date</span>
<span class="cb-info-grid__v">{{ book.issueDate ?? '&#8212;' }}</span>
</div>
</div>
</div>
<!-- Action toolbar — only for an issued book; each button pops up its own action dialog -->
@if (book.status === 'ISSUED') {
<div class="cb-action-toolbar">
<button type="button" class="cb-action-btn cb-action-btn--warn" (click)="openAction('stoppayment')">
Stop Payment
</button>
<button type="button" class="cb-action-btn" (click)="openAction('revokestoppayment')">
Revoke Stop Payment
</button>
<button type="button" class="cb-action-btn cb-action-btn--warn" (click)="openAction('reportlost')">
Report Lost
</button>
<button type="button" class="cb-action-btn" (click)="openAction('cancelleaf')">Cancel Leaf</button>
<button type="button" class="cb-action-btn cb-action-btn--primary" (click)="openAction('markused')">
Mark as Used
</button>
<button type="button" class="cb-action-btn cb-action-btn--warn" (click)="openAction('return')">
Flag Returned
</button>
</div>
}
<!-- Leaf grid -->
<div class="cb-info-card">
@if (leaves.length === 0) {
<div class="cb-no-books">No leaves to display</div>
} @else {
<div class="cb-leaf-grid">
@for (l of leaves; track l.leafSerialNo) {
<button
type="button"
class="cb-leaf-circle"
[class.cb-leaf-circle--ok]="l.status === 'ACTIVE'"
[class.cb-leaf-circle--used]="l.status === 'USED'"
[class.cb-leaf-circle--warn]="l.status === 'STOP_PAYMENT'"
[class.cb-leaf-circle--bad]="l.status === 'LOST'"
[class.cb-leaf-circle--returned]="l.status === 'RETURNED'"
(click)="selectLeaf(l)"
>
{{ l.leafSerialNo }}
</button>
}
</div>
}
</div>
</div>
}
</div>
`
})
export class ClerkChequeBookDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private chequeBookService = inject(ChequeBookService);
private dialog = inject(MatDialog);
bookId!: string;
book: any = {};
leaves: any[] = [];
loading = false;
ngOnInit(): void {
this.bookId = this.route.snapshot.paramMap.get('id') as string;
this.loadAll();
}
loadAll(): void {
this.loading = true;
this.chequeBookService.getBook(this.bookId).subscribe((data: any) => {
this.book = data || {};
this.loading = false;
});
this.chequeBookService.getLeaves(this.bookId).subscribe((data: any[]) => {
this.leaves = data || [];
});
}
selectLeaf(leaf: any): void {
this.dialog.open(LeafDetailDialogComponent, { data: leaf, width: '420px' });
}
openAction(action: LeafAction): void {
const ref = this.dialog.open(ClerkLeafActionDialogComponent, {
width: '480px',
data: { bookId: this.bookId, action }
});
ref.afterClosed().subscribe((result) => {
if (result?.success) {
this.loadAll();
}
});
}
goBack(): void {
this.router.navigate(['/clerk/account-facilities/cheque-book']);
}
}
@@ -0,0 +1,257 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
}
.cb-page {
background: #f8fafc;
min-height: calc(100vh - 68px);
display: flex;
flex-direction: column;
gap: 16px;
}
// ── Header ──────────────────────────────────────────────────────────────────────
.cb-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
color: #0f4f3c;
flex-shrink: 0;
}
&__title {
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
}
.cb-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
border: 1px solid #e2e8f0;
transition: background 0.15s;
height: 32px;
padding: 0 12px;
font-size: 13px;
background: #fff;
color: #0d1b2a;
&:hover {
background: #f0fdf4;
border-color: #0f4f3c;
color: #0f4f3c;
}
}
// ── Empty state ────────────────────────────────────────────────────────────────
.cb-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 60px 20px;
&__icon {
width: 88px;
height: 88px;
background: #e2e8f0;
border: 1px solid #cbd5e1;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
&__title {
font-size: 18px;
font-weight: 700;
color: #1e293b;
margin: 6px 0 0;
}
&__sub {
font-size: 13.5px;
color: #64748b;
text-align: center;
margin: 0;
}
&__btn {
margin-top: 10px;
padding: 10px 24px;
background: #0f4f3c;
border: none;
border-radius: 10px;
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #0a3d2e;
}
}
}
// ── Warning banner ─────────────────────────────────────────────────────────────
.cb-warn-banner {
margin: 0 20px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
font-size: 13px;
font-weight: 600;
padding: 12px 18px;
border-radius: 10px;
}
.cb-status-msg {
padding: 40px 20px;
text-align: center;
color: #64748b;
font-size: 13.5px;
&--error {
color: #dc2626;
}
}
.cb-no-books {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 40px 20px;
color: #94a3b8;
font-size: 13px;
}
// ── Table card ─────────────────────────────────────────────────────────────────
.cb-table-card {
margin: 0 20px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
}
.cb-table-wrap {
overflow-x: auto;
}
.cb-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid #f1f5f9;
}
th {
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.06em;
background: #f8fafc;
white-space: nowrap;
}
tbody tr:last-child td {
border-bottom: none;
}
}
.cb-row {
cursor: pointer;
transition: background 0.1s;
&:hover td {
background: #f0fdf4;
}
}
.cb-cell--num {
color: #64748b;
font-family: 'Courier New', monospace;
}
.cb-cell--date {
color: #64748b;
font-size: 12px;
}
.cb-cell--action {
color: #0f4f3c;
font-weight: 700;
font-size: 12px;
white-space: nowrap;
}
.cb-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
&--ok {
background: #dcfce7;
color: #166534;
}
&--bad {
background: #fee2e2;
color: #991b1b;
}
&--neutral {
background: #fef3c7;
color: #92400e;
}
}
@@ -0,0 +1,228 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, Input, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
import { ChequeBookService } from '../../chequebook/chequebook.service';
/** Read-only cheque-book view for the account currently loaded on the Clerk Dashboard —
* reached via the "Cheque Book" entry in the Account Facilities dropdown. */
@Component({
selector: 'mifosx-clerk-cheque-book',
standalone: true,
imports: [CommonModule],
styleUrl: './cheque-book.component.scss',
template: `
<div class="cb-page">
<!-- Header -->
<div class="cb-header">
<div class="cb-header__left">
<svg
class="cb-header__icon"
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
<div>
<div class="cb-header__title">Cheque Book</div>
@if (accountLoaded) {
<div class="cb-header__sub">
{{ accountNo }}
@if (clientName) {
&nbsp;&middot;&nbsp;{{ clientName }}
}
@if (books.length > 0) {
&nbsp;&middot;&nbsp;{{ books.length }} book{{ books.length === 1 ? '' : 's' }}
}
</div>
} @else {
<div class="cb-header__sub">View cheque books issued for the loaded account.</div>
}
</div>
</div>
@if (!embedded) {
<button type="button" class="cb-btn" (click)="goBack()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to Dashboard
</button>
}
</div>
@if (!accountLoaded) {
<div class="cb-empty">
<div class="cb-empty__icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<p class="cb-empty__title">No Account Loaded</p>
<p class="cb-empty__sub">Search an account on the Clerk Dashboard first.</p>
@if (!embedded) {
<button class="cb-empty__btn" (click)="goBack()">Go to Dashboard</button>
}
</div>
} @else {
@if (!facilityActive) {
<div class="cb-warn-banner">Cheque Book facility is not active for this account.</div>
}
@if (loading) {
<div class="cb-status-msg">Loading cheque books&hellip;</div>
} @else if (error) {
<div class="cb-status-msg cb-status-msg--error">{{ error }}</div>
} @else if (books.length === 0) {
<div class="cb-no-books">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.3">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
<span>No cheque book issued for this account</span>
</div>
} @else {
<div class="cb-table-card">
<div class="cb-table-wrap">
<table class="cb-table">
<thead>
<tr>
<th>Series</th>
<th>Books</th>
<th>Leaves</th>
<th>Status</th>
<th>Requested</th>
<th></th>
</tr>
</thead>
<tbody>
@for (b of books; track b.id) {
<tr class="cb-row" (click)="viewBook(b)">
<td>{{ b.seriesCode }}</td>
<td class="cb-cell--num">{{ b.noOfBooks }}</td>
<td class="cb-cell--num">{{ b.totalLeaves ?? '&#8212;' }}</td>
<td>
<span
class="cb-pill"
[class.cb-pill--ok]="b.status === 'ISSUED'"
[class.cb-pill--bad]="b.status === 'REJECTED' || b.status === 'WITHDRAWN'"
[class.cb-pill--neutral]="
b.status !== 'ISSUED' && b.status !== 'REJECTED' && b.status !== 'WITHDRAWN'
"
>{{ b.status }}</span
>
</td>
<td class="cb-cell--date">{{ b.requestedDate ?? '&#8212;' }}</td>
<td class="cb-cell--action">View &rsaquo;</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
</div>
`
})
export class ClerkChequeBookComponent implements OnInit {
private clerkUiState = inject(ClerkUiStateService);
private chequeBookService = inject(ChequeBookService);
private router = inject(Router);
@Input() embedded = false;
accountLoaded = false;
accountNo = '';
clientName = '';
facilityActive = true;
books: any[] = [];
loading = false;
error = '';
ngOnInit(): void {
const s = this.clerkUiState.snapshot;
this.accountLoaded = s.accountLoaded;
if (!this.accountLoaded) {
return;
}
this.accountNo = s.savedAccount?.accountDetails?.accountNo ?? s.savedRow?.acNo ?? '';
const clientDetails = s.savedAccount?.clientDetails;
this.clientName = clientDetails ? `${clientDetails.firstname ?? ''} ${clientDetails.lastname ?? ''}`.trim() : '';
const facility = s.facilities.find((f) => f.label === 'Cheque Book');
this.facilityActive = facility ? facility.active : true;
const accountType = this.toChequeAccountType(s.savedRow?.acTyp);
const accountId = s.savedAccount?.accountDetails?.id;
if (accountId) {
this.loadBooks(accountType, accountId);
} else if (this.accountNo) {
this.resolveAccountId(accountType, this.accountNo);
}
}
/** Cheque-book series/books are only ever SAVINGS or CURRENT — CA/CB is the clerk's UI code for current accounts. */
private toChequeAccountType(acTyp?: string): string {
const t = (acTyp || '').toUpperCase();
return t === 'CA' || t === 'CB' ? 'CURRENT' : 'SAVINGS';
}
private resolveAccountId(accountType: string, accountNo: string): void {
this.loading = true;
this.chequeBookService.searchAccountByNo(accountType, accountNo).subscribe({
next: (res: any) => {
const items = res?.pageItems ?? (Array.isArray(res) ? res : []);
const match = items.find((i: any) => i.accountNo === accountNo) ?? (items.length === 1 ? items[0] : null);
if (match) {
this.loadBooks(accountType, match.id);
} else {
this.loading = false;
this.error = 'Could not resolve this account for cheque book lookup';
}
},
error: () => {
this.loading = false;
this.error = 'Could not resolve this account for cheque book lookup';
}
});
}
private loadBooks(accountType: string, accountId: string | number): void {
this.loading = true;
this.chequeBookService.getBooks(accountType, accountId, undefined, undefined, 0, 50).subscribe({
next: (res: any) => {
this.books = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.loading = false;
},
error: () => {
this.loading = false;
this.error = 'Failed to load cheque book details';
}
});
}
viewBook(book: any): void {
this.router.navigate([
'/clerk/account-facilities/cheque-book',
book.id
]);
}
goBack(): void {
this.router.navigate(['/clerk/dashboard']);
}
}
@@ -0,0 +1,87 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.la-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
padding-top: 4px;
}
.la-field {
display: flex;
flex-direction: column;
gap: 4px;
&__lbl {
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
&__input {
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 7px;
font-size: 13px;
font-family: inherit;
color: #0d1b2a;
&:focus {
outline: none;
border-color: #0f4f3c;
}
}
&__hint {
font-size: 11px;
color: #94a3b8;
}
}
.la-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
border: 1px solid #e2e8f0;
transition: background 0.15s;
height: 34px;
padding: 0 16px;
font-size: 13px;
background: #fff;
color: #0d1b2a;
&:hover {
background: #f0fdf4;
border-color: #0f4f3c;
color: #0f4f3c;
}
&--primary {
background: #0f4f3c;
border-color: #0f4f3c;
color: #fff;
&:hover {
background: #0a3d2e;
color: #fff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
@@ -0,0 +1,145 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import {
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogContent,
MatDialogActions
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Observable } from 'rxjs';
import { ChequeBookService } from '../../../chequebook/chequebook.service';
export type LeafAction = 'stoppayment' | 'revokestoppayment' | 'reportlost' | 'cancelleaf' | 'markused' | 'return';
const ACTION_TITLES: Record<LeafAction, string> = {
stoppayment: 'Stop Payment',
revokestoppayment: 'Revoke Stop Payment',
reportlost: 'Report Lost',
cancelleaf: 'Cancel Leaf',
markused: 'Mark as Used',
return: 'Flag Returned'
};
/** Popup for the five leaf-range actions on a clerk-viewed cheque book — kept as its own dialog so the
* action form doesn't push the leaf grid around and always has full width to lay out its fields. */
@Component({
selector: 'mifosx-clerk-leaf-action-dialog',
standalone: true,
imports: [
ReactiveFormsModule,
MatDialogTitle,
CdkScrollable,
MatDialogContent,
MatDialogActions
],
styleUrl: './leaf-action-dialog.component.scss',
template: `
<h1 mat-dialog-title>{{ title }}</h1>
<div mat-dialog-content>
<form [formGroup]="actionForm" class="la-form">
<div class="la-form-grid">
<label class="la-field">
<span class="la-field__lbl">From Leaf Seq</span>
<input class="la-field__input" type="number" formControlName="fromLeafSeq" />
<span class="la-field__hint">Leave blank for the whole book</span>
</label>
<label class="la-field">
<span class="la-field__lbl">To Leaf Seq</span>
<input class="la-field__input" type="number" formControlName="toLeafSeq" />
</label>
@if (data.action !== 'revokestoppayment') {
<label class="la-field">
<span class="la-field__lbl">Reason</span>
<input class="la-field__input" formControlName="reason" />
@if (data.action === 'markused') {
<span class="la-field__hint">Optional — e.g. cleared per bank statement dated ...</span>
}
</label>
}
</div>
</form>
</div>
<mat-dialog-actions align="end">
<button type="button" class="la-btn" (click)="dialogRef.close()">Cancel</button>
<button type="button" class="la-btn la-btn--primary" [disabled]="actionForm.invalid || busy" (click)="submit()">
Submit
</button>
</mat-dialog-actions>
`
})
export class ClerkLeafActionDialogComponent {
dialogRef = inject<MatDialogRef<ClerkLeafActionDialogComponent>>(MatDialogRef);
data: { bookId: string; action: LeafAction } = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private chequeBookService = inject(ChequeBookService);
private snackBar = inject(MatSnackBar);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
busy = false;
actionForm: UntypedFormGroup;
get title(): string {
return ACTION_TITLES[this.data.action];
}
constructor() {
this.actionForm = this.formBuilder.group({
fromLeafSeq: [''],
toLeafSeq: [''],
reason: [
'',
this.data.action === 'revokestoppayment' || this.data.action === 'markused' ? [] : Validators.required
]
});
}
submit(): void {
if (this.actionForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.actionForm.value;
const payload: any = { locale: 'en' };
if (raw.fromLeafSeq !== '' && raw.fromLeafSeq != null) payload.fromLeafSeq = raw.fromLeafSeq;
if (raw.toLeafSeq !== '' && raw.toLeafSeq != null) payload.toLeafSeq = raw.toLeafSeq;
if (raw.reason) payload.reason = raw.reason;
const actionCalls: Record<LeafAction, (bookId: string, payload: any) => Observable<any>> = {
stoppayment: (id, p) => this.chequeBookService.stopPayment(id, p),
revokestoppayment: (id, p) => this.chequeBookService.revokeStopPayment(id, p),
reportlost: (id, p) => this.chequeBookService.reportBookLost(id, p),
cancelleaf: (id, p) => this.chequeBookService.cancelLeaf(id, p),
markused: (id, p) => this.chequeBookService.markUsed(id, p),
return: (id, p) => this.chequeBookService.returnBook(id, p)
};
actionCalls[this.data.action](this.data.bookId, payload).subscribe({
next: () => {
this.busy = false;
this.notify('Action applied');
this.dialogRef.close({ success: true });
},
error: () => {
this.busy = false;
this.notify(
'Action failed — check the leaf range and that the leaves are in a state that allows this action',
true
);
}
});
}
}
+22 -11
View File
@@ -100,17 +100,28 @@
My Disbursements
</a>
@if (clerkAccountLoaded) {
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/account-facilities')"
[routerLink]="['/clerk/account-facilities']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
Account Facilities
</a>
<div class="cashier-nav__item-wrap">
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/account-facilities')"
[routerLink]="['/clerk/account-facilities']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
Account Facilities
<fa-icon icon="caret-down" class="cashier-nav__caret"></fa-icon>
</a>
<div class="cashier-nav__dropdown">
@for (f of clerkFacilities; track f.label) {
<a class="cashier-nav__dd-item" [routerLink]="facilityRoute(f.label)">
<span class="cashier-nav__dd-dot" [class.cashier-nav__dd-dot--off]="!f.active"></span>
{{ f.label }}
</a>
}
</div>
</div>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/photo-signature')"
+63
View File
@@ -179,6 +179,69 @@ $color: #e2e4ec;
&--clerk {
border-bottom-color: #e87722;
}
&__item-wrap {
position: relative;
display: inline-flex;
&:hover .cashier-nav__dropdown {
display: grid;
}
}
&__caret {
font-size: 9px !important;
margin-left: 2px;
opacity: 0.75;
}
&__dropdown {
display: none;
grid-template-columns: repeat(2, minmax(150px, 1fr));
position: absolute;
top: 100%;
left: 0;
margin-top: 2px;
min-width: 320px;
background: #1a2744;
border: 1px solid #334155;
border-radius: 6px;
box-shadow: 0 8px 24px rgb(0 0 0 / 35%);
padding: 4px;
gap: 2px;
z-index: 50;
}
&__dd-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
color: #cbd5e1;
text-decoration: none;
white-space: nowrap;
transition: background 0.15s;
&:hover {
background: #223060;
color: #fff;
}
}
&__dd-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #22c55e;
flex-shrink: 0;
&--off {
background: #64748b;
}
}
}
.sidenav {
+9
View File
@@ -116,6 +116,15 @@ export class ShellComponent implements OnInit, OnDestroy {
return label.toLowerCase().includes(this.rbiSearch.toLowerCase());
}
/** Route for a facility entry in the Account Facilities hover dropdown — only "Cheque Book" has a
* dedicated action page today; every other facility falls back to the existing overview page. */
facilityRoute(label: string): string[] {
if (label === 'Cheque Book') {
return ['/clerk/account-facilities/cheque-book'];
}
return ['/clerk/account-facilities'];
}
/**
* Subscribes to progress bar to update its mode.
*/
+2
View File
@@ -362,6 +362,7 @@
"Revoke Stop Payment": "Revoke Stop Payment",
"Cancel Leaf": "Cancel Leaf",
"Mark as Used": "Mark as Used",
"Flag Returned": "Flag Returned",
"Activate": "Activate",
"Activate Self Service User": "Activate Self Service User",
"Add": "Add",
@@ -1524,6 +1525,7 @@
"Lost Reason": "Lost Reason",
"Cancelled Reason": "Cancelled Reason",
"Used Reason": "Used Reason",
"Return Reason": "Return Reason",
"Issue Date": "Issue Date",
"ACCOUNTING": "ACCOUNTING",
"ADDRESS": "ADDRESS",