feat(clerk): add dedicated nav pages, state restore, and fullscreen ESC fix

- Add ClerkUiStateService with full account state save/restore (no double network call on back navigation)
- Add Photo & Signature, Standing Instructions, Today's Activity as dedicated nav pages
- Remove photo/signature, standing instructions, linked accounts, today's activity panels from main dashboard
- Add ESC key to exit fullscreen; clicking Clerk nav immediately restores fullscreen without re-navigating
- Compact clerk dashboard UI with reduced font sizes and padding throughout
- Add CashierOnlyGuard for route protection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-09 12:49:06 +05:30
co-authored by Claude Sonnet 4.6
parent f7d9259eb7
commit 40a436dabe
20 changed files with 3708 additions and 1020 deletions
+42
View File
@@ -10,6 +10,7 @@ import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { CashierOnlyGuard } from '../core/authentication/cashier-only.guard';
import { CashExchangeEntryComponent } from './cash-exchange-entry/cash-exchange-entry.component';
import { CurrencyExchangeDetailComponent } from './currency-exchange-detail/currency-exchange-detail.component';
import { CashTransferComponent } from './cash-transfer/cash-transfer.component';
@@ -24,6 +25,46 @@ import { ShareCollectionComponent } from './share-collection/share-collection.co
const routes: Routes = [
Route.withShell([
{
path: 'clerk',
data: { title: 'Clerk', breadcrumb: 'Clerk' },
children: [
{
path: 'dashboard',
loadComponent: () =>
import('../clerk/clerk-dashboard/clerk-dashboard.component').then((m) => m.ClerkDashboardComponent),
data: { title: 'Clerk Dashboard', breadcrumb: 'Clerk Dashboard' }
},
{
path: 'account-facilities',
loadComponent: () =>
import('../clerk/account-facilities/account-facilities.component').then(
(m) => m.AccountFacilitiesComponent
),
data: { title: 'Account Facilities', breadcrumb: 'Account Facilities' }
},
{
path: 'photo-signature',
loadComponent: () =>
import('../clerk/photo-signature/photo-signature.component').then((m) => m.PhotoSignatureComponent),
data: { title: 'Photo & Signature', breadcrumb: 'Photo & Signature' }
},
{
path: 'standing-instructions',
loadComponent: () =>
import('../clerk/standing-instructions/standing-instructions.component').then(
(m) => m.StandingInstructionsComponent
),
data: { title: 'Standing Instructions', breadcrumb: 'Standing Instructions' }
},
{
path: 'today-activity',
loadComponent: () =>
import('../clerk/today-activity/today-activity.component').then((m) => m.TodayActivityComponent),
data: { title: "Today's Activity", breadcrumb: "Today's Activity" }
}
]
},
{
path: 'cashier',
data: { title: 'Cashier', breadcrumb: 'Cashier' },
@@ -85,6 +126,7 @@ const routes: Routes = [
},
{
path: 'transaction-entry',
canActivate: [CashierOnlyGuard],
loadComponent: () =>
import('./transaction-entry/transaction-entry.component').then((m) => m.TransactionEntryComponent),
data: {
+63
View File
@@ -0,0 +1,63 @@
/**
* 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 { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
export interface ClerkSavedAccount {
accountDetails: any;
clientDetails: any;
kycDetails: any;
nominees: any[];
jointHolders: any[];
clientAddresses: any[];
nonPersonDetails: any;
clientPhotoUrl: string | null;
signatureUrl: string | null;
accountNotes: any[];
standingInstructions: any[];
todayTransactions: any[];
linkedAccounts: any;
sameDayNeftRtgs: number;
sameDayChequeClearing: number;
selectedRow: any;
}
export interface ClerkUiState {
accountLoaded: boolean;
facilities: { label: string; active: boolean }[];
savedRow: { acNo: string; acTyp: string } | null;
savedAccount: ClerkSavedAccount | null;
}
@Injectable({ providedIn: 'root' })
export class ClerkUiStateService {
private readonly _state = new BehaviorSubject<ClerkUiState>({
accountLoaded: false,
facilities: [],
savedRow: null,
savedAccount: null
});
readonly state$ = this._state.asObservable();
get snapshot(): ClerkUiState {
return this._state.value;
}
setFacilities(facilities: { label: string; active: boolean }[], acNo: string, acTyp: string): void {
this._state.next({ ...this._state.value, accountLoaded: true, facilities, savedRow: { acNo, acTyp } });
}
saveFullState(account: ClerkSavedAccount): void {
this._state.next({ ...this._state.value, savedAccount: account });
}
clear(): void {
this._state.next({ accountLoaded: false, facilities: [], savedRow: null, savedAccount: null });
}
}
File diff suppressed because it is too large Load Diff
@@ -8,10 +8,9 @@
:host {
display: block;
min-height: 100vh;
background: #f0f2f5;
font-family: Inter, Roboto, sans-serif;
font-size: 13px;
font-size: 12px;
}
// ── Root wrapper ──────────────────────────────────────────────────────────────
@@ -19,8 +18,8 @@
.te-root {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 14px;
gap: 6px;
padding: 6px 10px;
}
// ── Day-status blocking banners ───────────────────────────────────────────────
@@ -60,6 +59,223 @@
}
}
// ── Cashier compact account info strip ───────────────────────────────────────
.te-acs {
display: flex;
align-items: center;
background: #fff;
border: 1px solid #dce4ef;
border-radius: 8px;
overflow: hidden;
min-height: 44px;
}
.te-acs-id {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
flex-shrink: 0;
}
.te-acs-photo {
width: 30px;
height: 30px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
border: 2px solid #dce4ef;
&--ph {
background: #f0f4f9;
display: flex;
align-items: center;
justify-content: center;
}
&--clickable {
cursor: zoom-in;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgb(37 99 235 / 18%);
}
}
}
.te-acs-nameblock {
display: flex;
flex-direction: column;
gap: 1px;
}
.te-acs-name {
font-size: 12px;
font-weight: 700;
color: #0d1b2e;
white-space: nowrap;
}
.te-acs-acno {
font-size: 10px;
font-family: 'Roboto Mono', monospace;
color: #5a7089;
letter-spacing: 0.4px;
}
.te-acs-sep {
width: 1px;
align-self: stretch;
background: #dce4ef;
flex-shrink: 0;
}
.te-acs-meta {
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
flex-shrink: 0;
}
.te-acs-tag {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 7px;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
background: #f0fdf4;
color: #16a34a;
border: 1px solid #bbf7d0;
&--blocked {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
}
&--dormant {
background: #fffbeb;
color: #d97706;
border-color: #fde68a;
}
}
.te-acs-kv {
font-size: 11px;
color: #4a607a;
white-space: nowrap;
b {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #8aa0b8;
margin-right: 2px;
}
}
.te-acs-bals {
display: flex;
align-items: stretch;
flex: 1;
}
.te-acs-b {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 5px 10px;
border-right: 1px solid #f0f4f9;
gap: 1px;
&:last-child {
border-right: none;
}
&--main {
background: #f8fbff;
}
&--after {
background: #f0fdf6;
}
}
.te-acs-b-l {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #8aa0b8;
white-space: nowrap;
}
.te-acs-b-v {
font-size: 11.5px;
font-weight: 700;
color: #1a2a3e;
font-variant-numeric: tabular-nums;
white-space: nowrap;
&--cr {
color: #059669;
}
}
.te-acs-alerts {
display: flex;
align-items: center;
gap: 4px;
padding: 0 10px;
flex-shrink: 0;
}
.te-acs-alert {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 600;
border-radius: 4px;
padding: 3px 7px;
&--danger {
background: #fef2f2;
color: #991b1b;
svg {
stroke: #dc2626;
}
}
&--warning {
background: #fffbeb;
color: #92400e;
svg {
stroke: #d97706;
}
}
&--info {
background: #eff6ff;
color: #1e40af;
svg {
stroke: #3b82f6;
}
}
}
// ── CBS Blue Transaction Entry ────────────────────────────────────────────────
$ws-ink: #0d1b2e; // nav bar dark navy
@@ -80,11 +296,11 @@ $ws-fld-bd: #c8d8eb;
.ws-wrap {
background: $ws-canvas;
border-radius: 12px;
border-radius: 10px;
overflow: hidden;
box-shadow:
0 1px 3px rgb(13 27 46 / 8%),
0 8px 24px -10px rgb(13 27 46 / 18%);
0 1px 3px rgb(13 27 46 / 6%),
0 4px 14px -8px rgb(13 27 46 / 14%);
border: 1px solid $ws-border;
font-family: 'Hanken Grotesk', Inter, sans-serif;
}
@@ -94,27 +310,27 @@ $ws-fld-bd: #c8d8eb;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 18px;
height: 48px;
padding: 0 12px;
height: 32px;
background: linear-gradient(90deg, #0d1b2e 0%, #162542 100%);
border-bottom: 2px solid $ws-accent;
&__left {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
}
&__right {
display: flex;
align-items: center;
gap: 14px;
gap: 10px;
}
&__icon {
width: 28px;
height: 28px;
border-radius: 7px;
width: 22px;
height: 22px;
border-radius: 6px;
background: $ws-accent;
display: flex;
align-items: center;
@@ -122,12 +338,14 @@ $ws-fld-bd: #c8d8eb;
svg {
stroke: #fff;
width: 12px;
height: 12px;
}
}
&__title {
font-family: Newsreader, Georgia, serif;
font-size: 16px;
font-size: 14px;
font-weight: 500;
color: #e8f0fb;
letter-spacing: 0.01em;
@@ -136,8 +354,8 @@ $ws-fld-bd: #c8d8eb;
&__effdt {
display: flex;
align-items: center;
gap: 7px;
font-size: 11.5px;
gap: 6px;
font-size: 11px;
color: #7a95b0;
svg {
@@ -146,14 +364,14 @@ $ws-fld-bd: #c8d8eb;
}
&__date {
height: 28px;
padding: 0 10px;
height: 24px;
padding: 0 8px;
border: 1px solid #2a4060;
border-radius: 6px;
border-radius: 5px;
background: #0a1628;
color: #e8f0fb;
font-family: 'Hanken Grotesk', monospace;
font-size: 12px;
font-size: 11px;
font-weight: 600;
outline: none;
@@ -166,9 +384,9 @@ $ws-fld-bd: #c8d8eb;
.ws-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 15px;
border-radius: 8px;
gap: 4px;
padding: 5px 12px;
border-radius: 7px;
border: none;
font-family: 'Hanken Grotesk', sans-serif;
font-size: 12px;
@@ -243,7 +461,7 @@ $ws-fld-bd: #c8d8eb;
background: $ws-accent;
color: #fff;
font-weight: 700;
padding: 8px 20px;
padding: 6px 16px;
box-shadow: 0 2px 8px rgb(232 119 34 / 30%);
&:hover:not(:disabled) {
@@ -259,20 +477,20 @@ $ws-fld-bd: #c8d8eb;
// ── Row cards ────────────────────────────────────────────────────────────────
.ws-rows {
padding: 12px 14px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 10px;
gap: 6px;
}
.ws-card {
display: flex;
align-items: flex-start;
gap: 10px;
gap: 6px;
background: $ws-card;
border: 1px solid $ws-border;
border-radius: 12px;
padding: 12px 14px 12px 10px;
border-radius: 10px;
padding: 6px 8px;
cursor: pointer;
transition:
border-color 0.15s,
@@ -293,17 +511,17 @@ $ws-fld-bd: #c8d8eb;
&__badge {
flex-shrink: 0;
width: 26px;
height: 26px;
width: 22px;
height: 22px;
border-radius: 50%;
background: $ws-ink;
color: #c8daf0;
font-size: 11px;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin-top: 22px;
margin-top: 16px;
}
&__body {
@@ -354,7 +572,7 @@ $ws-fld-bd: #c8d8eb;
gap: 0;
&--mt {
margin-top: 10px;
margin-top: 6px;
}
}
@@ -372,7 +590,7 @@ $ws-fld-bd: #c8d8eb;
&__fields {
display: flex;
align-items: flex-end;
gap: 8px;
gap: 6px;
flex-wrap: nowrap;
height: 100%;
align-content: flex-end;
@@ -390,7 +608,7 @@ $ws-fld-bd: #c8d8eb;
.ws-vdivider {
width: 1px;
background: $ws-divider;
margin: 18px 10px 0;
margin: 12px 8px 0;
flex-shrink: 0;
}
@@ -471,14 +689,14 @@ $ws-fld-bd: #c8d8eb;
}
&__ctl {
height: 34px;
padding: 0 9px;
height: 26px;
padding: 0 7px;
border: 1px solid $ws-fld-bd;
border-radius: 8px;
border-radius: 6px;
background: $ws-fld-bg;
color: $ws-text;
font-family: 'Hanken Grotesk', sans-serif;
font-size: 13px;
font-size: 11.5px;
outline: none;
width: 100%;
transition:
@@ -523,9 +741,9 @@ select.ws-f__ctl {
display: flex;
align-items: center;
border: 1px solid $ws-fld-bd;
border-radius: 8px;
border-radius: 7px;
background: $ws-fld-bg;
height: 34px;
height: 30px;
transition:
border-color 0.15s,
box-shadow 0.15s;
@@ -553,8 +771,8 @@ select.ws-f__ctl {
}
.ws-amt-prefix {
padding: 0 6px 0 9px;
font-size: 13px;
padding: 0 5px 0 8px;
font-size: 12px;
color: $ws-muted;
font-weight: 600;
flex-shrink: 0;
@@ -566,10 +784,10 @@ select.ws-f__ctl {
border: none;
background: transparent;
font-family: 'Hanken Grotesk', monospace;
font-size: 13px;
font-size: 12px;
font-weight: 600;
text-align: right;
padding-right: 9px;
padding-right: 8px;
color: $ws-text;
outline: none;
font-variant-numeric: tabular-nums;
@@ -578,8 +796,8 @@ select.ws-f__ctl {
// ── Segmented DR/CR toggle ───────────────────────────────────────────────────
.ws-seg {
display: flex;
height: 34px;
border-radius: 8px;
height: 30px;
border-radius: 7px;
overflow: hidden;
border: 1px solid $ws-fld-bd;
@@ -624,7 +842,7 @@ select.ws-f__ctl {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 18px;
padding: 7px 14px;
background: linear-gradient(180deg, #f4f7fc, #edf2f9);
border-top: 1px solid $ws-divider;
@@ -903,24 +1121,50 @@ select.te-cell {
// ── Status messages ──────────────────────────────────────────────────────────
.te-loading {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 14px 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
color: #64748b;
font-size: 13px;
justify-content: center;
gap: 14px;
background: rgb(241 245 249 / 88%);
backdrop-filter: blur(6px);
z-index: 999;
&::before {
content: '';
width: 16px;
height: 16px;
border: 2px solid #e2e8f0;
border-top-color: #3b82f6;
&__spinner {
position: relative;
width: 56px;
height: 56px;
svg {
animation: te-spin 1s linear infinite;
}
}
&__arc {
transform-origin: 28px 28px;
}
&__pulse {
position: absolute;
inset: 6px;
border-radius: 50%;
animation: te-spin 0.7s linear infinite;
background: radial-gradient(circle, rgb(37 99 235 / 12%), transparent 70%);
animation: te-pulse 1.4s ease-in-out infinite;
}
&__title {
font-size: 15px;
font-weight: 700;
color: #1e293b;
letter-spacing: -0.01em;
}
&__sub {
font-size: 12.5px;
color: #94a3b8;
margin-top: -6px;
}
}
@@ -930,6 +1174,19 @@ select.te-cell {
}
}
@keyframes te-pulse {
0%,
100% {
opacity: 0.4;
transform: scale(0.9);
}
50% {
opacity: 1;
transform: scale(1.1);
}
}
.te-error {
padding: 10px 16px;
background: #fef2f2;
@@ -945,13 +1202,13 @@ select.te-cell {
.te-detail {
display: flex;
flex-direction: column;
gap: 10px;
gap: 5px;
}
.te-detail-top {
display: grid;
grid-template-columns: 1.1fr 0.9fr 1.1fr;
gap: 10px;
gap: 7px;
@media (width <= 1100px) {
grid-template-columns: 1fr 1fr;
@@ -974,11 +1231,11 @@ select.te-cell {
&__header {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
gap: 5px;
padding: 4px 8px;
background: linear-gradient(135deg, #1e293b, #334155);
color: #e2e8f0;
font-size: 11px;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
@@ -991,13 +1248,13 @@ select.te-cell {
}
&__body {
padding: 10px 14px;
padding: 4px 8px;
&--center {
display: flex;
align-items: center;
justify-content: center;
min-height: 60px;
min-height: 32px;
}
}
}
@@ -1007,11 +1264,11 @@ select.te-cell {
.te-field {
display: flex;
align-items: flex-start;
gap: 6px;
margin-bottom: 5px;
font-size: 12px;
gap: 5px;
margin-bottom: 3px;
font-size: 11px;
border-bottom: 1px dotted #f1f5f9;
padding-bottom: 4px;
padding-bottom: 3px;
&:last-child {
border-bottom: none;
@@ -1021,7 +1278,7 @@ select.te-cell {
.te-field-row {
display: flex;
gap: 10px;
gap: 8px;
.te-field {
flex: 1;
@@ -1029,9 +1286,9 @@ select.te-cell {
}
.te-label {
min-width: 88px;
min-width: 76px;
color: #64748b;
font-size: 11px;
font-size: 10.5px;
font-weight: 600;
white-space: nowrap;
padding-top: 1px;
@@ -1039,14 +1296,14 @@ select.te-cell {
.te-value {
color: #1e293b;
font-size: 12px;
font-size: 11px;
font-weight: 500;
flex: 1;
&-name {
font-weight: 700;
color: #0f172a;
font-size: 13px;
font-size: 12px;
}
&-wrap {
@@ -1069,10 +1326,10 @@ select.te-cell {
.te-joint-row {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 8px;
gap: 6px;
padding: 5px 6px;
border-bottom: 1px solid #f1f5f9;
font-size: 12px;
font-size: 11px;
border-radius: 6px;
margin-bottom: 2px;
@@ -1225,9 +1482,9 @@ select.te-cell {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 0;
padding: 3px 0;
border-bottom: 1px dotted #f1f5f9;
font-size: 12px;
font-size: 11px;
&:last-child {
border-bottom: none;
@@ -1252,7 +1509,7 @@ select.te-cell {
.te-bal-label {
flex: 1;
color: #475569;
font-size: 12px;
font-size: 11px;
}
.te-bal-value {
@@ -1288,8 +1545,8 @@ select.te-cell {
.te-bal-item {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
gap: 5px;
padding: 7px 14px;
border-right: 1px solid #e2e8f0;
&:last-child {
@@ -1312,7 +1569,7 @@ select.te-cell {
&__val {
font-weight: 800;
font-size: 13px;
font-size: 12px;
color: #1e293b;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
@@ -1322,7 +1579,7 @@ select.te-cell {
}
&--big {
font-size: 16px;
font-size: 14px;
}
}
}
@@ -2246,7 +2503,7 @@ select.te-cell {
.te-denom-wrap {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
gap: 6px;
@media (width <= 1100px) {
grid-template-columns: 1fr 1fr;
@@ -2260,16 +2517,16 @@ select.te-cell {
.te-denom-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
font-size: 11px;
th {
background: #f1f5f9;
color: #64748b;
font-size: 10px;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 5px 10px;
padding: 3px 8px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
@@ -2279,10 +2536,10 @@ select.te-cell {
}
td {
padding: 5px 10px;
padding: 2px 8px;
border-bottom: 1px solid #f8fafc;
color: #1e293b;
font-size: 12px;
font-size: 11px;
&.num {
text-align: right;
@@ -2301,8 +2558,8 @@ select.te-cell {
tfoot td {
background: #f1f5f9;
border-top: 2px solid #e2e8f0;
font-size: 12px;
padding: 6px 10px;
font-size: 11px;
padding: 4px 8px;
}
}
@@ -2349,11 +2606,11 @@ select.te-cell {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 48px;
height: 24px;
padding: 0 8px;
border-radius: 6px;
font-size: 12px;
min-width: 36px;
height: 20px;
padding: 0 6px;
border-radius: 5px;
font-size: 11px;
font-weight: 700;
background: linear-gradient(135deg, #ecfeff, #cffafe);
color: #155e75;
@@ -2361,10 +2618,11 @@ select.te-cell {
&--coin {
border-radius: 50%;
width: 32px;
height: 32px;
min-width: 32px;
width: 24px;
height: 24px;
min-width: 24px;
padding: 0;
font-size: 10px;
background: linear-gradient(135deg, #fef9c3, #fde68a);
color: #854d0e;
border-color: #fcd34d;
@@ -2374,16 +2632,16 @@ select.te-cell {
.te-denom-stepper {
display: inline-flex;
align-items: center;
gap: 6px;
gap: 4px;
margin-left: auto;
button {
width: 28px;
height: 28px;
width: 22px;
height: 22px;
border-radius: 50%;
border: 1.5px solid #e2e8f0;
background: #f8fafc;
font-size: 16px;
font-size: 14px;
font-weight: 600;
color: #475569;
cursor: pointer;
@@ -2408,12 +2666,12 @@ select.te-cell {
}
input {
width: 64px;
height: 32px;
width: 50px;
height: 24px;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
border-radius: 6px;
text-align: center;
font-size: 14px;
font-size: 11px;
font-weight: 700;
color: #1e293b;
background: #fff;
@@ -2441,9 +2699,9 @@ select.te-cell {
.te-denom-actions {
display: flex;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
gap: 6px;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid #e2e8f0;
}
@@ -2470,7 +2728,7 @@ select.te-cell {
align-items: flex-start;
border-right: 1px solid #f1f5f9;
border-bottom: none;
padding: 10px 16px;
padding: 6px 12px;
&:last-child {
border-right: none;
@@ -2478,18 +2736,18 @@ select.te-cell {
&--total {
border-top: none;
padding-top: 10px;
padding-top: 6px;
margin-top: 0;
}
.te-bal-label {
font-size: 11px;
font-size: 10px;
color: #94a3b8;
margin-bottom: 4px;
margin-bottom: 2px;
}
.te-bal-value {
font-size: 15px;
font-size: 13px;
min-width: unset;
}
}
@@ -6,7 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
@@ -14,6 +14,8 @@ import { catchError } from 'rxjs/operators';
import { forkJoin, of } from 'rxjs';
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CashierService } from '../cashier.service';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
interface TxnRow {
typ: string;
@@ -621,10 +623,12 @@ export class ImagePreviewDialogComponent {
MatDialogModule
]
})
export class TransactionEntryComponent implements OnInit {
export class TransactionEntryComponent implements OnInit, OnDestroy {
private http = inject(HttpClient);
private cashierService = inject(CashierService);
private authService = inject(AuthenticationService);
private dialog = inject(MatDialog);
private clerkUiState = inject(ClerkUiStateService);
today = '';
rows: TxnRow[] = [];
@@ -748,6 +752,56 @@ export class TransactionEntryComponent implements OnInit {
.getPaymentTypes()
.pipe(catchError(() => of([])))
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
if (this.isClerk) {
const saved = this.clerkUiState.snapshot.savedAccount;
if (saved) {
this.accountDetails = saved.accountDetails;
this.clientDetails = saved.clientDetails;
this.kycDetails = saved.kycDetails;
this.nominees = saved.nominees;
this.jointHolders = saved.jointHolders;
this.clientAddresses = saved.clientAddresses;
this.nonPersonDetails = saved.nonPersonDetails;
this.clientPhotoUrl = saved.clientPhotoUrl;
this.signatureUrl = saved.signatureUrl;
this.accountNotes = saved.accountNotes;
this.standingInstructions = saved.standingInstructions;
this.todayTransactions = saved.todayTransactions;
this.linkedAccounts = saved.linkedAccounts;
this.sameDayNeftRtgs = saved.sameDayNeftRtgs;
this.sameDayChequeClearing = saved.sameDayChequeClearing;
this.selectedRow = saved.selectedRow;
if (saved.accountDetails) {
const acNo = saved.accountDetails.accountNo ?? '';
const acTyp = this.clerkUiState.snapshot.savedRow?.acTyp ?? '';
this.rows[0] = { ...this.rows[0], acNo, acTyp };
}
}
}
}
ngOnDestroy(): void {
if (this.isClerk && this.accountDetails) {
this.clerkUiState.saveFullState({
accountDetails: this.accountDetails,
clientDetails: this.clientDetails,
kycDetails: this.kycDetails,
nominees: this.nominees,
jointHolders: this.jointHolders,
clientAddresses: this.clientAddresses,
nonPersonDetails: this.nonPersonDetails,
clientPhotoUrl: this.clientPhotoUrl,
signatureUrl: this.signatureUrl,
accountNotes: this.accountNotes,
standingInstructions: this.standingInstructions,
todayTransactions: this.todayTransactions,
linkedAccounts: this.linkedAccounts,
sameDayNeftRtgs: this.sameDayNeftRtgs,
sameDayChequeClearing: this.sameDayChequeClearing,
selectedRow: this.selectedRow
});
}
}
private loadDenominations(): void {
@@ -1010,7 +1064,7 @@ export class TransactionEntryComponent implements OnInit {
// returns all accounts as an array (e.g. recurringdepositaccounts)
const match = allItems.find((x: any) => x.accountNo === acNo) ?? allItems[0];
if (match?.id) {
this.fetchAllDetails(match.id, acNo, cfg, token);
this.fetchAllDetails(match.id, acNo, cfg, token, acTyp);
return;
}
// Fallback: global search
@@ -1021,7 +1075,7 @@ export class TransactionEntryComponent implements OnInit {
if (token !== this.searchToken) return;
const list: any[] = Array.isArray(found) ? found : [];
if (list.length > 0 && list[0].entityId) {
this.fetchAllDetails(list[0].entityId, acNo, cfg, token);
this.fetchAllDetails(list[0].entityId, acNo, cfg, token, acTyp);
} else {
this.loadingAccount = false;
this.accountError = 'Account not found for: ' + acNo;
@@ -1107,7 +1161,8 @@ export class TransactionEntryComponent implements OnInit {
txnBase: string;
txnSearchSupported: boolean;
},
token: number
token: number,
acTyp: string = ''
): void {
this.http
.get<any>(`${cfg.detail}/${accountId}?associations=all`)
@@ -1127,6 +1182,7 @@ export class TransactionEntryComponent implements OnInit {
}
this.accountDetails = acc;
this.jointHolders = acc.jointHolders ?? [];
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
// Compute same-day compliance indicators from included transactions
this.computeSameDayNeftRtgs(acc.transactions);
this.computeSameDayChequeClearing(acc.transactions);
@@ -1263,11 +1319,6 @@ export class TransactionEntryComponent implements OnInit {
if (acc.withHoldTax)
alerts.push({ type: 'info', message: `TDS Applicable — Tax Group: ${acc.taxGroupData?.name ?? ''}` });
// Notes
(this.accountNotes ?? []).forEach((n: any) => {
if (n.note) alerts.push({ type: 'info', message: n.note });
});
// Sanctions (from KYC) — DB stores 'YES'/'NO' strings, not booleans
const isYes = (v: any) => v === true || v === 'YES' || v === 'yes' || v === 'Y';
if (isYes(this.kycDetails?.bannedEntityMatch))
@@ -1423,6 +1474,10 @@ export class TransactionEntryComponent implements OnInit {
if (this.selectedRow && !this.rows.includes(this.selectedRow)) this.selectedRow = null;
}
get isClerk(): boolean {
return this.authService.isClerkUser();
}
get isNonIndividual(): boolean {
// legalForm.id=1 → Individual, legalForm.id=2 → Non-Individual (Entity)
const lf = this.clientDetails?.legalForm?.id ?? this.clientDetails?.legalFormId;
@@ -1996,6 +2051,7 @@ export class TransactionEntryComponent implements OnInit {
}
onCancel(): void {
this.clerkUiState.clear();
this.accountDetails = null;
this.clientDetails = null;
this.kycDetails = null;
@@ -0,0 +1,369 @@
/**
* 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;
}
.af-page {
min-height: calc(100vh - 68px);
background: #f0f4f8;
padding: 24px 28px;
display: flex;
flex-direction: column;
gap: 20px;
}
// ── Hero ──────────────────────────────────────────────────────────────────────
.af-hero {
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;
&::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;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
backdrop-filter: blur(8px);
}
&__text {
flex: 1;
}
&__title {
font-size: 22px;
font-weight: 800;
letter-spacing: -0.01em;
margin: 0 0 5px;
}
&__stats {
display: flex;
gap: 14px;
align-items: center;
}
&__stat {
display: flex;
align-items: center;
gap: 5px;
font-size: 12.5px;
color: rgb(255 255 255 / 75%);
strong {
color: #fff;
font-weight: 700;
}
&--green strong {
color: #86efac;
}
&--red strong {
color: #fca5a5;
}
}
&__sep {
width: 1px;
height: 14px;
background: rgb(255 255 255 / 25%);
}
}
.af-back-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;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
backdrop-filter: blur(8px);
&:hover {
background: rgb(255 255 255 / 22%);
transform: translateX(-2px);
}
}
// ── Empty state ────────────────────────────────────────────────────────────────
.af-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: #2563eb;
border: none;
border-radius: 10px;
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #1d4ed8;
}
}
}
// ── 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%);
&__top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
&__label {
font-size: 11.5px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.08em;
}
&__fraction {
font-size: 13px;
font-weight: 700;
color: #16a34a;
}
}
.af-progress-bar {
height: 10px;
background: #e2e8f0;
border-radius: 999px;
overflow: hidden;
&__fill {
height: 100%;
background: linear-gradient(90deg, #16a34a, #22c55e);
border-radius: 999px;
transition: width 0.5s ease;
box-shadow: 0 0 8px rgb(34 197 94 / 35%);
}
}
// ── Facilities card ────────────────────────────────────────────────────────────
.af-fac-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
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;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
}
.af-pills {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 20px 22px;
}
.af-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 15px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
border: 1.5px solid;
transition:
transform 0.15s,
box-shadow 0.15s;
cursor: default;
&__dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
&--on {
background: #f0fdf4;
border-color: #22c55e;
color: #15803d;
.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%);
}
}
&--off {
background: #f8fafc;
border-color: #e2e8f0;
color: #94a3b8;
.af-pill__dot {
background: #cbd5e1;
}
}
}
// ── Stats row ─────────────────────────────────────────────────────────────────
.af-stats-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.af-stat-card {
border-radius: 14px;
padding: 20px 24px;
display: flex;
align-items: center;
gap: 16px;
border: 1px solid;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
&__icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 20px;
}
&__num {
font-size: 30px;
font-weight: 800;
line-height: 1;
display: block;
}
&__label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
display: block;
margin-top: 3px;
opacity: 0.75;
}
&--green {
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
border-color: #86efac;
color: #15803d;
.af-stat-card__icon {
background: #bbf7d0;
}
}
&--red {
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
border-color: #cbd5e1;
color: #475569;
.af-stat-card__icon {
background: #e2e8f0;
}
}
}
@@ -0,0 +1,141 @@
/**
* 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 { Router } from '@angular/router';
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
@Component({
selector: 'mifosx-account-facilities',
standalone: true,
imports: [CommonModule],
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">
<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>
<button class="af-back-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) {
<!-- 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">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<p class="af-empty__title">No Account Loaded</p>
<p class="af-empty__sub">Search an account on the Clerk Dashboard first.</p>
<button class="af-empty__btn" (click)="goBack()">Go to Dashboard</button>
</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>
<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
</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>
<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>
<span class="af-stat-card__num">{{ facilities.length - activeCount }}</span>
<span class="af-stat-card__label">Inactive</span>
</div>
</div>
</div>
}
</div>
`
})
export class AccountFacilitiesComponent implements OnInit {
private clerkUiState = inject(ClerkUiStateService);
private router = inject(Router);
accountLoaded = false;
facilities: { label: string; active: boolean }[] = [];
get activeCount(): number {
return this.facilities.filter((f) => f.active).length;
}
ngOnInit(): void {
this.clerkUiState.state$.subscribe((s) => {
this.accountLoaded = s.accountLoaded;
this.facilities = s.facilities;
});
}
goBack(): void {
this.router.navigate(['/clerk/dashboard']);
}
}
@@ -0,0 +1,18 @@
/**
* 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 } from '@angular/core';
import { TransactionEntryComponent } from '../../cashier/transaction-entry/transaction-entry.component';
@Component({
selector: 'mifosx-clerk-dashboard',
standalone: true,
imports: [TransactionEntryComponent],
template: `<mifosx-transaction-entry></mifosx-transaction-entry>`
})
export class ClerkDashboardComponent {}
@@ -0,0 +1,341 @@
/**
* 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;
}
.ps-page {
min-height: calc(100vh - 68px);
background: #f0f4f8;
padding: 24px 28px;
display: flex;
flex-direction: column;
gap: 20px;
}
// ── Hero ──────────────────────────────────────────────────────────────────────
.ps-hero {
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;
&::before {
content: '';
position: absolute;
top: -40px;
right: -40px;
width: 200px;
height: 200px;
background: rgb(255 255 255 / 5%);
border-radius: 50%;
pointer-events: none;
}
&__icon-wrap {
width: 56px;
height: 56px;
background: rgb(255 255 255 / 15%);
border: 1px solid rgb(255 255 255 / 25%);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
backdrop-filter: blur(8px);
}
&__text {
flex: 1;
}
&__title {
font-size: 22px;
font-weight: 800;
letter-spacing: -0.01em;
margin: 0 0 6px;
}
&__meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 12.5px;
color: rgb(255 255 255 / 75%);
}
&__ac {
font-weight: 700;
color: #bfdbfe;
font-family: monospace;
font-size: 13px;
}
&__sep {
width: 1px;
height: 13px;
background: rgb(255 255 255 / 30%);
}
&__name {
color: rgb(255 255 255 / 85%);
}
}
.ps-back-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;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
backdrop-filter: blur(8px);
&:hover {
background: rgb(255 255 255 / 22%);
transform: translateX(-2px);
}
}
// ── Empty state ────────────────────────────────────────────────────────────────
.ps-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: #2563eb;
border: none;
border-radius: 10px;
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #1d4ed8;
}
}
}
// ── Cards row ─────────────────────────────────────────────────────────────────
.ps-cards-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
@media (width <= 680px) {
grid-template-columns: 1fr;
}
}
.ps-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 2px 8px rgb(0 0 0 / 7%);
&__header {
display: flex;
align-items: center;
gap: 9px;
padding: 12px 22px;
background: #1e293b;
color: #94a3b8;
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
&__body {
padding: 28px;
display: flex;
align-items: center;
justify-content: center;
min-height: 280px;
&--sig {
background: repeating-linear-gradient(-45deg, #f8fafc, #f8fafc 4px, #fff 4px, #fff 12px);
}
}
}
// ── Image display ──────────────────────────────────────────────────────────────
.ps-img-wrap {
position: relative;
cursor: zoom-in;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px rgb(0 0 0 / 12%);
max-width: 240px;
max-height: 240px;
&--sig {
max-width: 320px;
max-height: 160px;
background: #fff;
padding: 12px;
border: 1.5px solid #e2e8f0;
}
&:hover .ps-img__overlay {
opacity: 1;
}
}
.ps-img {
display: block;
width: 240px;
height: 240px;
object-fit: cover;
&--sig {
width: 100%;
height: 140px;
object-fit: contain;
}
}
.ps-img__overlay {
position: absolute;
inset: 0;
background: rgb(15 23 42 / 55%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: #fff;
font-size: 12px;
font-weight: 600;
opacity: 0;
transition: opacity 0.2s;
backdrop-filter: blur(2px);
}
.ps-no-img {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #94a3b8;
font-size: 13px;
font-weight: 500;
}
// ── Lightbox ──────────────────────────────────────────────────────────────────
.ps-lightbox {
position: fixed;
inset: 0;
background: rgb(2 6 23 / 82%);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(6px);
&__inner {
background: #fff;
border-radius: 16px;
overflow: hidden;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 24px 80px rgb(0 0 0 / 50%);
}
&__bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
background: #1e293b;
color: #94a3b8;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
}
&__close {
background: none;
border: none;
color: #94a3b8;
font-size: 16px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
line-height: 1;
transition: color 0.15s;
&:hover {
color: #fff;
}
}
&__img {
display: block;
max-width: 90vw;
max-height: calc(90vh - 48px);
object-fit: contain;
}
}
@@ -0,0 +1,187 @@
/**
* 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 { Router } from '@angular/router';
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
@Component({
selector: 'mifosx-photo-signature',
standalone: true,
imports: [CommonModule],
styleUrl: './photo-signature.component.scss',
template: `
<div class="ps-page">
<!-- Hero -->
<div class="ps-hero">
<div class="ps-hero__icon-wrap">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
</svg>
</div>
<div class="ps-hero__text">
<h1 class="ps-hero__title">Photo &amp; Signature</h1>
@if (accountLoaded) {
<div class="ps-hero__meta">
<span class="ps-hero__ac">{{ accountNo }}</span>
@if (clientName) {
<span class="ps-hero__sep"></span>
<span class="ps-hero__name">{{ clientName }}</span>
}
</div>
}
</div>
<button class="ps-back-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) {
<!-- Empty state -->
<div class="ps-empty">
<div class="ps-empty__icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
</svg>
</div>
<p class="ps-empty__title">No Account Loaded</p>
<p class="ps-empty__sub">Search an account on the Clerk Dashboard first.</p>
<button class="ps-empty__btn" (click)="goBack()">Go to Dashboard</button>
</div>
} @else {
<div class="ps-cards-row">
<!-- Photo card -->
<div class="ps-card">
<div class="ps-card__header">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
Client Photo
</div>
<div class="ps-card__body">
@if (photoUrl) {
<div class="ps-img-wrap" (click)="preview(photoUrl, 'Client Photo')">
<img [src]="photoUrl" alt="Client Photo" class="ps-img" />
<div class="ps-img__overlay">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
<line x1="11" y1="8" x2="11" y2="14" />
<line x1="8" y1="11" x2="14" y2="11" />
</svg>
Click to enlarge
</div>
</div>
} @else {
<div class="ps-no-img">
<svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.2">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
</svg>
<span>No photo on file</span>
</div>
}
</div>
</div>
<!-- Signature card -->
<div class="ps-card">
<div class="ps-card__header">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
</svg>
Signature
</div>
<div class="ps-card__body ps-card__body--sig">
@if (signatureUrl) {
<div class="ps-img-wrap ps-img-wrap--sig" (click)="preview(signatureUrl, 'Signature')">
<img [src]="signatureUrl" alt="Signature" class="ps-img ps-img--sig" />
<div class="ps-img__overlay">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
<line x1="11" y1="8" x2="11" y2="14" />
<line x1="8" y1="11" x2="14" y2="11" />
</svg>
Click to enlarge
</div>
</div>
} @else {
<div class="ps-no-img">
<svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.2">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
</svg>
<span>No signature on file</span>
</div>
}
</div>
</div>
</div>
}
</div>
<!-- Lightbox preview -->
@if (previewSrc) {
<div class="ps-lightbox" (click)="closePreview()">
<div class="ps-lightbox__inner" (click)="$event.stopPropagation()">
<div class="ps-lightbox__bar">
<span>{{ previewLabel }}</span>
<button class="ps-lightbox__close" (click)="closePreview()">✕</button>
</div>
<img [src]="previewSrc" [alt]="previewLabel" class="ps-lightbox__img" />
</div>
</div>
}
`
})
export class PhotoSignatureComponent implements OnInit {
private clerkUiState = inject(ClerkUiStateService);
private router = inject(Router);
accountLoaded = false;
photoUrl: string | null = null;
signatureUrl: string | null = null;
accountNo = '';
clientName = '';
previewSrc: string | null = null;
previewLabel = '';
ngOnInit(): void {
const s = this.clerkUiState.snapshot;
this.accountLoaded = s.accountLoaded;
if (s.savedAccount) {
this.photoUrl = s.savedAccount.clientPhotoUrl;
this.signatureUrl = s.savedAccount.signatureUrl;
this.accountNo = s.savedAccount.accountDetails?.accountNo ?? s.savedRow?.acNo ?? '';
const cd = s.savedAccount.clientDetails;
this.clientName = cd ? `${cd.firstname ?? ''} ${cd.lastname ?? ''}`.trim() : '';
}
}
preview(src: string, label: string): void {
this.previewSrc = src;
this.previewLabel = label;
}
closePreview(): void {
this.previewSrc = null;
}
goBack(): void {
this.router.navigate(['/clerk/dashboard']);
}
}
@@ -0,0 +1,373 @@
/**
* 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;
}
.si-page {
min-height: calc(100vh - 68px);
background: #f0f4f8;
padding: 24px 28px;
display: flex;
flex-direction: column;
gap: 20px;
}
// ── Hero ──────────────────────────────────────────────────────────────────────
.si-hero {
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;
&::before {
content: '';
position: absolute;
top: -40px;
right: -40px;
width: 200px;
height: 200px;
background: rgb(255 255 255 / 5%);
border-radius: 50%;
pointer-events: none;
}
&__icon-wrap {
width: 56px;
height: 56px;
background: rgb(255 255 255 / 15%);
border: 1px solid rgb(255 255 255 / 25%);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
backdrop-filter: blur(8px);
}
&__text {
flex: 1;
}
&__title {
font-size: 22px;
font-weight: 800;
letter-spacing: -0.01em;
margin: 0 0 6px;
}
&__meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 12.5px;
color: rgb(255 255 255 / 75%);
flex-wrap: wrap;
}
&__ac {
font-weight: 700;
color: #bfdbfe;
font-family: monospace;
font-size: 13px;
}
&__sep {
width: 1px;
height: 13px;
background: rgb(255 255 255 / 30%);
}
&__name {
color: rgb(255 255 255 / 85%);
}
&__count {
color: rgb(255 255 255 / 75%);
strong {
color: #fff;
}
}
}
.si-back-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;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
backdrop-filter: blur(8px);
&:hover {
background: rgb(255 255 255 / 22%);
transform: translateX(-2px);
}
}
// ── Empty state ────────────────────────────────────────────────────────────────
.si-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: #2563eb;
border: none;
border-radius: 10px;
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #1d4ed8;
}
}
}
// ── Stats row ─────────────────────────────────────────────────────────────────
.si-stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.si-stat {
border-radius: 14px;
padding: 18px 22px;
display: flex;
align-items: center;
gap: 14px;
border: 1px solid;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
&__icon {
width: 42px;
height: 42px;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 18px;
font-weight: 700;
}
&__num {
font-size: 28px;
font-weight: 800;
line-height: 1;
display: block;
}
&__label {
font-size: 11.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
display: block;
margin-top: 3px;
opacity: 0.7;
}
&--blue {
background: linear-gradient(135deg, #eff6ff, #dbeafe);
border-color: #93c5fd;
color: #1d4ed8;
.si-stat__icon {
background: #bfdbfe;
}
}
&--green {
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
border-color: #86efac;
color: #15803d;
.si-stat__icon {
background: #bbf7d0;
}
}
&--grey {
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
border-color: #cbd5e1;
color: #475569;
.si-stat__icon {
background: #e2e8f0;
}
}
}
// ── Table card ─────────────────────────────────────────────────────────────────
.si-table-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 2px 8px rgb(0 0 0 / 7%);
&__header {
display: flex;
align-items: center;
gap: 9px;
padding: 12px 22px;
background: #1e293b;
color: #94a3b8;
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
}
.si-table-wrap {
overflow-x: auto;
}
.si-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
thead tr {
background: #f8fafc;
border-bottom: 2px solid #e2e8f0;
}
th {
padding: 10px 16px;
text-align: left;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #64748b;
white-space: nowrap;
}
td {
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
color: #334155;
vertical-align: middle;
}
tbody tr:last-child td {
border-bottom: none;
}
tbody tr:hover td {
background: #f8fafc;
}
}
.si-row {
&--active td {
background: #fafffe;
}
}
.si-cell--num {
color: #94a3b8;
font-size: 12px;
width: 40px;
}
.si-cell--name {
font-weight: 600;
color: #1e293b;
}
.si-cell--amt {
font-weight: 700;
color: #0f172a;
font-family: monospace;
}
.si-cell--ac {
font-family: monospace;
font-size: 12px;
color: #475569;
}
.si-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 700;
border: 1px solid;
&--active {
background: #f0fdf4;
border-color: #22c55e;
color: #15803d;
}
&--inactive {
background: #f8fafc;
border-color: #e2e8f0;
color: #94a3b8;
}
}
@@ -0,0 +1,196 @@
/**
* 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 { Router } from '@angular/router';
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
@Component({
selector: 'mifosx-standing-instructions',
standalone: true,
imports: [CommonModule],
styleUrl: './standing-instructions.component.scss',
template: `
<div class="si-page">
<!-- Hero -->
<div class="si-hero">
<div class="si-hero__icon-wrap">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
</div>
<div class="si-hero__text">
<h1 class="si-hero__title">Standing Instructions</h1>
@if (accountLoaded) {
<div class="si-hero__meta">
<span class="si-hero__ac">{{ accountNo }}</span>
@if (clientName) {
<span class="si-hero__sep"></span>
<span class="si-hero__name">{{ clientName }}</span>
}
<span class="si-hero__sep"></span>
<span class="si-hero__count">
<strong>{{ instructions.length }}</strong> instruction{{ instructions.length !== 1 ? 's' : '' }}
</span>
</div>
}
</div>
<button class="si-back-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="si-empty">
<div class="si-empty__icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
</div>
<p class="si-empty__title">No Account Loaded</p>
<p class="si-empty__sub">Search an account on the Clerk Dashboard first.</p>
<button class="si-empty__btn" (click)="goBack()">Go to Dashboard</button>
</div>
} @else if (instructions.length === 0) {
<div class="si-empty">
<div class="si-empty__icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
</div>
<p class="si-empty__title">No Standing Instructions</p>
<p class="si-empty__sub">This account has no standing instructions set up.</p>
</div>
} @else {
<!-- Stats row -->
<div class="si-stats-row">
<div class="si-stat si-stat--blue">
<div class="si-stat__icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
</div>
<div>
<span class="si-stat__num">{{ instructions.length }}</span>
<span class="si-stat__label">Total</span>
</div>
</div>
<div class="si-stat si-stat--green">
<div class="si-stat__icon">&#10003;</div>
<div>
<span class="si-stat__num">{{ activeCount }}</span>
<span class="si-stat__label">Active</span>
</div>
</div>
<div class="si-stat si-stat--grey">
<div class="si-stat__icon">&#8212;</div>
<div>
<span class="si-stat__num">{{ instructions.length - activeCount }}</span>
<span class="si-stat__label">Inactive</span>
</div>
</div>
</div>
<!-- Instructions table -->
<div class="si-table-card">
<div class="si-table-card__header">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
All Standing Instructions
</div>
<div class="si-table-wrap">
<table class="si-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Amount</th>
<th>From Account</th>
<th>To Account</th>
<th>Frequency</th>
<th>Valid Till</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (si of instructions; track si.id) {
<tr class="si-row" [class.si-row--active]="si.status?.value === 'Active'">
<td class="si-cell--num">{{ $index + 1 }}</td>
<td class="si-cell--name">{{ si.name ?? '&#8212;' }}</td>
<td class="si-cell--amt">&#8377;{{ si.amount ?? 0 | number: '1.2-2' }}</td>
<td class="si-cell--ac">{{ si.fromAccountId ?? '&#8212;' }}</td>
<td class="si-cell--ac">{{ si.toAccountId ?? '&#8212;' }}</td>
<td>{{ si.recurrenceType?.value ?? si.instructionType?.value ?? '&#8212;' }}</td>
<td>{{ si.validTill ?? si.endDate ?? '&#8212;' }}</td>
<td>
<span
class="si-badge"
[class.si-badge--active]="si.status?.value === 'Active'"
[class.si-badge--inactive]="si.status?.value !== 'Active'"
>
{{ si.status?.value ?? '&#8212;' }}
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
`
})
export class StandingInstructionsComponent implements OnInit {
private clerkUiState = inject(ClerkUiStateService);
private router = inject(Router);
accountLoaded = false;
instructions: any[] = [];
accountNo = '';
clientName = '';
get activeCount(): number {
return this.instructions.filter((si) => si.status?.value === 'Active').length;
}
ngOnInit(): void {
const s = this.clerkUiState.snapshot;
this.accountLoaded = s.accountLoaded;
if (s.savedAccount) {
this.instructions = s.savedAccount.standingInstructions ?? [];
this.accountNo = s.savedAccount.accountDetails?.accountNo ?? s.savedRow?.acNo ?? '';
const cd = s.savedAccount.clientDetails;
this.clientName = cd ? `${cd.firstname ?? ''} ${cd.lastname ?? ''}`.trim() : '';
}
}
goBack(): void {
this.router.navigate(['/clerk/dashboard']);
}
}
@@ -0,0 +1,395 @@
/**
* 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;
}
.ta-page {
min-height: calc(100vh - 68px);
background: #f0f4f8;
padding: 24px 28px;
display: flex;
flex-direction: column;
gap: 20px;
}
// ── Hero ──────────────────────────────────────────────────────────────────────
.ta-hero {
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;
&::before {
content: '';
position: absolute;
top: -40px;
right: -40px;
width: 200px;
height: 200px;
background: rgb(255 255 255 / 5%);
border-radius: 50%;
pointer-events: none;
}
&__icon-wrap {
width: 56px;
height: 56px;
background: rgb(255 255 255 / 15%);
border: 1px solid rgb(255 255 255 / 25%);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
backdrop-filter: blur(8px);
}
&__text {
flex: 1;
}
&__title {
font-size: 22px;
font-weight: 800;
letter-spacing: -0.01em;
margin: 0 0 6px;
}
&__meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 12.5px;
color: rgb(255 255 255 / 75%);
flex-wrap: wrap;
}
&__ac {
font-weight: 700;
color: #bfdbfe;
font-family: monospace;
font-size: 13px;
}
&__sep {
width: 1px;
height: 13px;
background: rgb(255 255 255 / 30%);
}
&__name {
color: rgb(255 255 255 / 85%);
}
&__count {
strong {
color: #fff;
}
}
}
.ta-back-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;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
backdrop-filter: blur(8px);
&:hover {
background: rgb(255 255 255 / 22%);
transform: translateX(-2px);
}
}
// ── Empty ──────────────────────────────────────────────────────────────────────
.ta-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: #2563eb;
border: none;
border-radius: 10px;
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #1d4ed8;
}
}
}
// ── AML Compliance row ────────────────────────────────────────────────────────
.ta-aml-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.ta-aml-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 18px 22px;
display: flex;
align-items: flex-start;
gap: 14px;
box-shadow: 0 1px 4px rgb(0 0 0 / 5%);
transition: border-color 0.2s;
&--alert {
border-color: #fca5a5;
background: #fff5f5;
}
&__icon {
width: 40px;
height: 40px;
background: #f1f5f9;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #475569;
}
&__body {
display: flex;
flex-direction: column;
gap: 3px;
}
&__label {
font-size: 11.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #64748b;
}
&__val {
font-size: 22px;
font-weight: 800;
color: #0f172a;
font-family: monospace;
&--alert {
color: #dc2626;
}
}
&__warn {
font-size: 11px;
color: #dc2626;
font-weight: 600;
}
}
// ── Table card ─────────────────────────────────────────────────────────────────
.ta-table-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 2px 8px rgb(0 0 0 / 7%);
&__header {
display: flex;
align-items: center;
gap: 9px;
padding: 12px 22px;
background: #1e293b;
color: #94a3b8;
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
}
.ta-badge {
margin-left: 6px;
background: #2563eb;
color: #fff;
font-size: 10.5px;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
}
.ta-no-txn {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 40px 20px;
color: #94a3b8;
font-size: 13px;
}
.ta-table-wrap {
overflow-x: auto;
}
.ta-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
thead tr {
background: #f8fafc;
border-bottom: 2px solid #e2e8f0;
}
th {
padding: 10px 16px;
text-align: left;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #64748b;
white-space: nowrap;
}
td {
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
color: #334155;
vertical-align: middle;
}
tbody tr:last-child td {
border-bottom: none;
}
tbody tr:hover td {
background: #f8fafc;
}
}
.ta-row--cr {
td {
background: rgb(240 253 244 / 50%);
}
}
.ta-row--dr {
td {
background: rgb(255 245 245 / 50%);
}
}
.ta-cell--num {
color: #94a3b8;
font-size: 12px;
width: 40px;
}
.ta-cell--date {
color: #64748b;
font-size: 12px;
}
.ta-cell--ref {
font-family: monospace;
font-size: 12px;
color: #94a3b8;
}
.ta-cell--amt {
font-weight: 700;
font-family: monospace;
}
.ta-cell--cr {
color: #16a34a;
}
.ta-cell--dr {
color: #dc2626;
}
.ta-type-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 700;
border: 1px solid;
&--cr {
background: #f0fdf4;
border-color: #22c55e;
color: #15803d;
}
&--dr {
background: #fff5f5;
border-color: #fca5a5;
color: #dc2626;
}
}
@@ -0,0 +1,206 @@
/**
* 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 { Router } from '@angular/router';
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
@Component({
selector: 'mifosx-today-activity',
standalone: true,
imports: [CommonModule],
styleUrl: './today-activity.component.scss',
template: `
<div class="ta-page">
<!-- Hero -->
<div class="ta-hero">
<div class="ta-hero__icon-wrap">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div class="ta-hero__text">
<h1 class="ta-hero__title">Today's Activity</h1>
@if (accountLoaded) {
<div class="ta-hero__meta">
<span class="ta-hero__ac">{{ accountNo }}</span>
@if (clientName) {
<span class="ta-hero__sep"></span>
<span class="ta-hero__name">{{ clientName }}</span>
}
@if (transactions.length > 0) {
<span class="ta-hero__sep"></span>
<span class="ta-hero__count"
><strong>{{ transactions.length }}</strong> txn today</span
>
}
</div>
}
</div>
<button class="ta-back-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="ta-empty">
<div class="ta-empty__icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<p class="ta-empty__title">No Account Loaded</p>
<p class="ta-empty__sub">Search an account on the Clerk Dashboard first.</p>
<button class="ta-empty__btn" (click)="goBack()">Go to Dashboard</button>
</div>
} @else {
<!-- AML Compliance cards -->
<div class="ta-aml-row">
<div class="ta-aml-card" [class.ta-aml-card--alert]="neftRtgs > 200000">
<div class="ta-aml-card__icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
</svg>
</div>
<div class="ta-aml-card__body">
<span class="ta-aml-card__label">Today's NEFT / RTGS</span>
<span class="ta-aml-card__val" [class.ta-aml-card__val--alert]="neftRtgs > 200000">
&#8377;{{ neftRtgs | number: '1.2-2' }}
</span>
@if (neftRtgs > 200000) {
<span class="ta-aml-card__warn">Exceeds &#8377;2,00,000 limit</span>
}
</div>
</div>
<div class="ta-aml-card" [class.ta-aml-card--alert]="chequeClearing > 0">
<div class="ta-aml-card__icon">
<svg width="18" height="18" 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>
</div>
<div class="ta-aml-card__body">
<span class="ta-aml-card__label">Cheque In Clearing</span>
<span class="ta-aml-card__val" [class.ta-aml-card__val--alert]="chequeClearing > 0">
&#8377;{{ chequeClearing | number: '1.2-2' }}
</span>
</div>
</div>
</div>
<!-- Transactions table -->
<div class="ta-table-card">
<div class="ta-table-card__header">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
Transactions Today
@if (transactions.length > 0) {
<span class="ta-badge">{{ transactions.length }}</span>
}
</div>
@if (transactions.length === 0) {
<div class="ta-no-txn">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.3">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>No transactions today</span>
</div>
} @else {
<div class="ta-table-wrap">
<table class="ta-table">
<thead>
<tr>
<th>#</th>
<th>Type</th>
<th>Amount</th>
<th>Date</th>
<th>Reference</th>
</tr>
</thead>
<tbody>
@for (t of transactions; track t.id) {
<tr
[class.ta-row--cr]="t.transactionType?.deposit"
[class.ta-row--dr]="!t.transactionType?.deposit"
>
<td class="ta-cell--num">{{ $index + 1 }}</td>
<td>
<span
class="ta-type-badge"
[class.ta-type-badge--cr]="t.transactionType?.deposit"
[class.ta-type-badge--dr]="!t.transactionType?.deposit"
>
{{ t.transactionType?.value ?? '&#8212;' }}
</span>
</td>
<td
class="ta-cell--amt"
[class.ta-cell--cr]="t.transactionType?.deposit"
[class.ta-cell--dr]="!t.transactionType?.deposit"
>
{{ t.transactionType?.deposit ? '+' : '&#8722;' }}&#8377;{{ t.amount ?? 0 | number: '1.2-2' }}
</td>
<td class="ta-cell--date">{{ t.date ?? '&#8212;' }}</td>
<td class="ta-cell--ref">{{ t.id ?? '&#8212;' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
</div>
`
})
export class TodayActivityComponent implements OnInit {
private clerkUiState = inject(ClerkUiStateService);
private router = inject(Router);
accountLoaded = false;
transactions: any[] = [];
neftRtgs = 0;
chequeClearing = 0;
accountNo = '';
clientName = '';
ngOnInit(): void {
const s = this.clerkUiState.snapshot;
this.accountLoaded = s.accountLoaded;
if (s.savedAccount) {
this.transactions = s.savedAccount.todayTransactions ?? [];
this.neftRtgs = s.savedAccount.sameDayNeftRtgs ?? 0;
this.chequeClearing = s.savedAccount.sameDayChequeClearing ?? 0;
this.accountNo = s.savedAccount.accountDetails?.accountNo ?? s.savedRow?.acNo ?? '';
const cd = s.savedAccount.clientDetails;
this.clientName = cd ? `${cd.firstname ?? ''} ${cd.lastname ?? ''}`.trim() : '';
}
}
goBack(): void {
this.router.navigate(['/clerk/dashboard']);
}
}
@@ -388,6 +388,14 @@ export class AuthenticationService {
return JSON.parse(this.storage.getItem(this.credentialsStorageKey));
}
/** Returns true when the logged-in user has the "Clerk" role. */
isClerkUser(): boolean {
const creds = this.getCredentials();
if (!creds || !creds.roles) return false;
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
return roleNames.some((n) => n === 'clerk');
}
/**
* Returns true ONLY for regular cashier role (not Head Cashier).
* Regular cashier → full-screen transaction entry, no nav.
@@ -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/.
*/
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import { AuthenticationService } from './authentication.service';
/**
* Restricts /cashier/transaction-entry to users with the Cashier role only.
* Clerk and other roles are redirected to /home.
*/
@Injectable({ providedIn: 'root' })
export class CashierOnlyGuard {
private router = inject(Router);
private authService = inject(AuthenticationService);
canActivate(): boolean {
if (this.authService.isCashierUser()) {
return true;
}
this.router.navigate(['/home'], { replaceUrl: true });
return false;
}
}
+140 -66
View File
@@ -57,73 +57,138 @@
<!-- Toolbar always visible (shows CBSNexora, Branch, Session info) -->
<mifosx-toolbar [sidenav]="sidenav" (collapse)="toggleCollapse($event)"></mifosx-toolbar>
<!-- Cashier simplified menu bar — visible only in cashier full-screen mode -->
<!-- Simplified menu bar — cashier OR clerk fullscreen mode -->
@if (hideShellLayout) {
<nav class="cashier-nav">
<a
class="cashier-nav__item cashier-nav__item--primary"
[routerLink]="['/cashier/transaction-entry']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="keyboard"></fa-icon> Transaction Entry
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/share-collection']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="hand-holding-usd"></fa-icon> Share Collection
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/cash-exchange']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="exchange"></fa-icon> Cash Exchange
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/exchange-detail']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="globe"></fa-icon> Exchange Detail
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/inter-cashier']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="exchange-alt"></fa-icon> Inter-Cashier
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/excess-report']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="exclamation-circle"></fa-icon> Excess Report
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/cashier-position']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="wallet"></fa-icon> Position
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/cash-history']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="history"></fa-icon> Cash History
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/closing-report']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="file-alt"></fa-icon> Closing Report
</a>
</nav>
<!-- Clerk Dashboard fullscreen nav — only visible at /clerk/dashboard -->
@if (currentUrl.includes('/clerk/')) {
<nav class="cashier-nav cashier-nav--clerk">
<a
class="cashier-nav__item cashier-nav__item--primary"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/dashboard')"
[routerLink]="['/clerk/dashboard']"
>
<fa-icon icon="id-card"></fa-icon> Clerk Dashboard
</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>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/photo-signature')"
[routerLink]="['/clerk/photo-signature']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
</svg>
Photo &amp; Signature
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/standing-instructions')"
[routerLink]="['/clerk/standing-instructions']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
Standing Instructions
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/today-activity')"
[routerLink]="['/clerk/today-activity']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
Today's Activity
</a>
}
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
</nav>
} @else {
<!-- Cashier fullscreen nav -->
<nav class="cashier-nav">
<a
class="cashier-nav__item cashier-nav__item--primary"
[routerLink]="['/cashier/transaction-entry']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="keyboard"></fa-icon> Transaction Entry
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/share-collection']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="hand-holding-usd"></fa-icon> Share Collection
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/cash-exchange']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="exchange"></fa-icon> Cash Exchange
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/exchange-detail']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="globe"></fa-icon> Exchange Detail
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/inter-cashier']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="exchange-alt"></fa-icon> Inter-Cashier
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/excess-report']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="exclamation-circle"></fa-icon> Excess Report
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/cashier-position']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="wallet"></fa-icon> Position
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/cash-history']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="history"></fa-icon> Cash History
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/closing-report']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="file-alt"></fa-icon> Closing Report
</a>
</nav>
}
}
<!-- Live KPI ticker items — referenced twice below for seamless marquee loop -->
@@ -300,6 +365,15 @@
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
routerLink="/clerk/dashboard"
(click)="hideShellLayout = true"
*mifosxHasPermission="'MENU_CLERK'"
>
<fa-icon icon="id-card" class="ni-ico"></fa-icon>Clerk
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="clearingMenu" *mifosxHasPermission="'MENU_CLEARING'">
<fa-icon icon="exchange" class="ni-ico"></fa-icon>Clearing
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
+43
View File
@@ -135,6 +135,49 @@ $color: #e2e4ec;
background: #1a6ac8;
}
}
&--sec {
font-size: 11.5px;
padding: 4px 10px;
border: 1px solid rgb(255 255 255 / 12%);
cursor: pointer;
}
}
&__esc-hint {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: #475569;
cursor: pointer;
padding: 4px 10px;
border-radius: 4px;
border: 1px solid #2a3a5e;
transition:
color 0.15s,
border-color 0.15s;
kbd {
background: #0f172a;
border: 1px solid #334155;
border-radius: 4px;
padding: 1px 5px;
font-size: 10px;
font-weight: 700;
color: #94a3b8;
font-family: monospace;
}
&:hover {
color: #e2e8f0;
border-color: #0ea5e9;
}
}
&--clerk {
border-bottom-color: #e87722;
}
}
+41 -3
View File
@@ -8,10 +8,11 @@
/** Angular Imports */
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { ChangeDetectorRef, Component, OnDestroy, OnInit, inject } from '@angular/core';
import { ChangeDetectorRef, Component, HostListener, OnDestroy, OnInit, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router, NavigationEnd } from '@angular/router';
import { AuthenticationService } from '../authentication/authentication.service';
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
/** rxjs Imports */
import { Observable, Subscription } from 'rxjs';
@@ -64,6 +65,7 @@ export class ShellComponent implements OnInit, OnDestroy {
private dialog = inject(MatDialog);
private router = inject(Router);
private authService = inject(AuthenticationService);
private clerkUiStateService = inject(ClerkUiStateService);
/** Subscription to breakpoint observer for handset. */
isHandset$: Observable<boolean> = this.breakpointObserver
@@ -79,6 +81,11 @@ export class ShellComponent implements OnInit, OnDestroy {
hideShellLayout = false;
private routeSub: Subscription;
/** Account Facilities — populated from ClerkUiStateService when account loads */
clerkAccountLoaded = false;
clerkFacilities: { label: string; active: boolean }[] = [];
private clerkStateSub: Subscription;
/** Sub Ledger mega menu state */
subLedgerOpen = false;
subLedgerSearch = '';
@@ -118,13 +125,27 @@ export class ShellComponent implements OnInit, OnDestroy {
this.cdr.detectChanges();
});
// Cashier users: always hide shell layout (role-based, not URL-based)
// Cashier-only users: hide shell. Clerk/dashboard: also fullscreen by default.
const checkLayout = () => {
this.hideShellLayout = this.authService.isCashierUser();
const url = this.router.url;
const isCashierOnly = this.authService.isCashierUser() && !this.authService.isClerkUser();
const isClerkRoute =
url.includes('/clerk/dashboard') ||
url.includes('/clerk/account-facilities') ||
url.includes('/clerk/photo-signature') ||
url.includes('/clerk/standing-instructions') ||
url.includes('/clerk/today-activity');
this.hideShellLayout = isCashierOnly || isClerkRoute;
this.cdr.detectChanges();
};
this.routeSub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => checkLayout());
setTimeout(() => checkLayout(), 0);
this.clerkStateSub = this.clerkUiStateService.state$.subscribe((s) => {
this.clerkAccountLoaded = s.accountLoaded;
this.clerkFacilities = s.facilities;
this.cdr.detectChanges();
});
}
/**
@@ -141,6 +162,20 @@ export class ShellComponent implements OnInit, OnDestroy {
this.cdr.detectChanges();
}
/** Expose current URL to template for route-based conditionals. */
get currentUrl(): string {
return this.router.url;
}
/** ESC key — exit fullscreen at Clerk Dashboard, restoring full shell header. */
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.hideShellLayout && this.router.url.includes('/clerk/')) {
this.hideShellLayout = false;
this.cdr.detectChanges();
}
}
openConfigWizard(): void {
// Placeholder — config wizard is handled by toolbar component
}
@@ -275,5 +310,8 @@ export class ShellComponent implements OnInit, OnDestroy {
if (this.routeSub) {
this.routeSub.unsubscribe();
}
if (this.clerkStateSub) {
this.clerkStateSub.unsubscribe();
}
}
}
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260608',
'hash': 'f6404708'
'version': '260609',
'hash': 'f7d9259e'
},
'allow_switching_backend_instance': true
};