feat: Denomination Master admin UI (modern fintech design)

Standalone Angular page to manage currency notes & coins: hero + stat
tiles, notes/coins card grids, slide-in drawer for add/edit. Supports
CRUD, enable/disable, and client-side duplicate (denomination+kind)
validation with snackbar feedback. Added route and Admin menu link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-05-27 11:15:47 +05:30
co-authored by Claude Opus 4.7
parent 33b01d667d
commit 918a90368c
6 changed files with 1347 additions and 0 deletions
+4
View File
@@ -404,6 +404,10 @@
<fa-icon icon="key" class="menu-item-icon" style="color: #7c3aed"></fa-icon> <fa-icon icon="key" class="menu-item-icon" style="color: #7c3aed"></fa-icon>
OTP Audit Logs OTP Audit Logs
</button> </button>
<button mat-menu-item routerLink="/system/denomination-master" tabindex="0">
<fa-icon icon="coins" class="menu-item-icon" style="color: #7c3aed"></fa-icon>
Denomination Master
</button>
</div> </div>
<!-- Audit & Compliance --> <!-- Audit & Compliance -->
@@ -0,0 +1,289 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="denom-page">
<!-- LEFT CONTENT COLUMN -->
<div class="denom-content">
<!-- HERO -->
<header class="hero">
<div class="hero-text">
<div class="eyebrow">CURRENCY · CONFIGURATION</div>
<h1 class="hero-title">Denomination master</h1>
<p class="hero-sub">
Define which currency notes and coins the bank accepts. Disable a denomination to phase it out of new
transactions without losing history.
</p>
</div>
<button class="hero-add-btn" (click)="openAdd()" aria-label="Add denomination">
<mat-icon>add</mat-icon>
Add denomination
</button>
</header>
<!-- STAT TILES -->
<section class="stat-grid">
<div class="stat-tile">
<div class="stat-head">
<span class="stat-icon ic-dark"><mat-icon>account_balance_wallet</mat-icon></span>
<span class="stat-label">Total denominations</span>
</div>
<div class="stat-value">{{ total }}</div>
<div class="stat-foot">Across notes and coins</div>
</div>
<div class="stat-tile">
<div class="stat-head">
<span class="stat-icon ic-note"><mat-icon>payments</mat-icon></span>
<span class="stat-label">Note types</span>
</div>
<div class="stat-value">{{ notes.length }}</div>
<div class="stat-foot">{{ activeNotes }} currently active</div>
</div>
<div class="stat-tile">
<div class="stat-head">
<span class="stat-icon ic-coin"><mat-icon>monetization_on</mat-icon></span>
<span class="stat-label">Coin types</span>
</div>
<div class="stat-value">{{ coins.length }}</div>
<div class="stat-foot">{{ activeCoins }} currently active</div>
</div>
<div class="stat-tile">
<div class="stat-head">
<span class="stat-icon ic-ok"><mat-icon>check_circle</mat-icon></span>
<span class="stat-label">Active</span>
</div>
<div class="stat-value">{{ activeCount }}</div>
<div class="stat-foot">{{ disabledCount }} disabled</div>
</div>
</section>
<!-- CURRENCY NOTES -->
<section class="denom-section" *ngIf="notes.length > 0">
<div class="section-head">
<h2 class="section-title">Currency notes</h2>
<span class="section-count">{{ sectionCount(activeNotes, notes.length) }}</span>
</div>
<div class="card-grid">
<article class="denom-card" *ngFor="let item of notes" [class.inactive]="!item.isActive">
<div class="card-top">
<div class="bill-swatch" [style.background]="noteColor(item.denomination)">₹{{ item.denomination }}</div>
<div class="card-info">
<div class="card-desc">{{ item.description || item.denomination + ' Rupee Note' }}</div>
<div class="card-meta">Introduced {{ item.introducedOn || '—' }}</div>
</div>
</div>
<div class="card-actions">
<span
class="status-pill"
[class.is-active]="item.isActive"
[class.is-inactive]="!item.isActive"
[attr.aria-label]="'Status: ' + (item.isActive ? 'Active' : 'Discontinued')"
>
<span class="status-dot"></span>{{ item.isActive ? 'Active' : 'Discontinued' }}
</span>
<div class="icon-btns">
<button class="icon-btn" (click)="openEdit(item)" aria-label="Edit denomination">
<mat-icon>edit</mat-icon>
</button>
<div class="menu-wrap">
<button class="icon-btn" (click)="toggleMenu(item, $event)" aria-label="More actions">
<mat-icon>more_horiz</mat-icon>
</button>
<div class="ctx-menu" *ngIf="openMenuId === item.id" (click)="$event.stopPropagation()">
<button class="ctx-item" (click)="toggleStatus(item)">
<mat-icon>{{ item.isActive ? 'block' : 'check_circle' }}</mat-icon>
{{ item.isActive ? 'Disable' : 'Enable' }}
</button>
<button class="ctx-item ctx-danger" (click)="deleteDenomination(item)">
<mat-icon>delete_outline</mat-icon>
Delete
</button>
</div>
</div>
</div>
</div>
<div class="disc-row" *ngIf="item.discontinuedOn">
<span class="disc-label">Discontinued</span>
<span class="disc-value">{{ item.discontinuedOn }}</span>
</div>
</article>
</div>
</section>
<!-- COINS -->
<section class="denom-section" *ngIf="coins.length > 0">
<div class="section-head">
<h2 class="section-title">Coins</h2>
<span class="section-count">{{ sectionCount(activeCoins, coins.length) }}</span>
</div>
<div class="card-grid">
<article class="denom-card" *ngFor="let item of coins" [class.inactive]="!item.isActive">
<div class="card-top">
<div class="coin-disc" [style.background]="coinColor(item.denomination)">₹{{ item.denomination }}</div>
<div class="card-info">
<div class="card-desc">{{ item.description || item.denomination + ' Rupee Coin' }}</div>
<div class="card-meta">Introduced {{ item.introducedOn || '—' }}</div>
</div>
</div>
<div class="card-actions">
<span
class="status-pill"
[class.is-active]="item.isActive"
[class.is-inactive]="!item.isActive"
[attr.aria-label]="'Status: ' + (item.isActive ? 'Active' : 'Discontinued')"
>
<span class="status-dot"></span>{{ item.isActive ? 'Active' : 'Discontinued' }}
</span>
<div class="icon-btns">
<button class="icon-btn" (click)="openEdit(item)" aria-label="Edit denomination">
<mat-icon>edit</mat-icon>
</button>
<div class="menu-wrap">
<button class="icon-btn" (click)="toggleMenu(item, $event)" aria-label="More actions">
<mat-icon>more_horiz</mat-icon>
</button>
<div class="ctx-menu" *ngIf="openMenuId === item.id" (click)="$event.stopPropagation()">
<button class="ctx-item" (click)="toggleStatus(item)">
<mat-icon>{{ item.isActive ? 'block' : 'check_circle' }}</mat-icon>
{{ item.isActive ? 'Disable' : 'Enable' }}
</button>
<button class="ctx-item ctx-danger" (click)="deleteDenomination(item)">
<mat-icon>delete_outline</mat-icon>
Delete
</button>
</div>
</div>
</div>
</div>
<div class="disc-row" *ngIf="item.discontinuedOn">
<span class="disc-label">Discontinued</span>
<span class="disc-value">{{ item.discontinuedOn }}</span>
</div>
</article>
</div>
</section>
</div>
<!-- DRAWER BACKDROP -->
<div class="drawer-backdrop" *ngIf="drawerOpen" (click)="closeDrawer()"></div>
<!-- RIGHT DRAWER -->
<aside class="drawer" [class.open]="drawerOpen" role="dialog" aria-modal="true" aria-label="Add denomination">
<div class="drawer-head">
<div>
<h3 class="drawer-title">{{ editingId ? 'Edit denomination' : 'Add denomination' }}</h3>
<p class="drawer-sub">{{ editingId ? 'Update an existing note or coin' : 'Define a new note or coin' }}</p>
</div>
<button class="icon-btn close-btn" (click)="closeDrawer()" aria-label="Close"><mat-icon>close</mat-icon></button>
</div>
<ng-container *ngIf="drawerOpen">
<form class="drawer-form" (ngSubmit)="save()">
<!-- Type segmented control -->
<div class="field">
<label class="field-label">Type</label>
<div class="segmented" role="radiogroup" aria-label="Denomination type">
<button
type="button"
class="seg-btn"
role="radio"
[attr.aria-checked]="model.kind === 'note'"
[class.active]="model.kind === 'note'"
(click)="setType('note')"
>
<mat-icon>payments</mat-icon> Note
</button>
<button
type="button"
class="seg-btn"
role="radio"
[attr.aria-checked]="model.kind === 'coin'"
[class.active]="model.kind === 'coin'"
(click)="setType('coin')"
>
<mat-icon>monetization_on</mat-icon> Coin
</button>
</div>
</div>
<!-- Denomination -->
<div class="field">
<label class="field-label" for="denomInput">Denomination (₹)</label>
<input
id="denomInput"
class="text-input"
type="number"
min="1"
step="1"
placeholder="e.g. 500"
[(ngModel)]="model.denomination"
name="denomination"
/>
</div>
<!-- Description -->
<div class="field">
<label class="field-label" for="descInput">Description</label>
<input
id="descInput"
class="text-input"
type="text"
placeholder="500 Rupee Note"
[(ngModel)]="model.description"
name="description"
/>
</div>
<!-- Introduced on -->
<div class="field">
<label class="field-label" for="dateInput">Introduced on</label>
<input
id="dateInput"
class="text-input"
type="date"
[max]="today"
[(ngModel)]="model.introducedOn"
name="introducedOn"
/>
</div>
<!-- Active toggle -->
<div class="toggle-block">
<div class="toggle-text">
<span class="toggle-title">Active</span>
<span class="toggle-help">Available for transactions</span>
</div>
<button
type="button"
class="pill-toggle"
[class.on]="model.isActive"
(click)="toggleActive()"
role="switch"
[attr.aria-checked]="model.isActive"
aria-label="Active"
>
<span class="pill-dot"></span>
</button>
</div>
<!-- Actions -->
<div class="drawer-actions">
<button type="button" class="btn btn-cancel" (click)="closeDrawer()">Cancel</button>
<button type="submit" class="btn btn-save" [disabled]="!formValid || saving">
{{ editingId ? 'Update' : 'Save' }}
</button>
</div>
</form>
</ng-container>
</aside>
</div>
@@ -0,0 +1,747 @@
/**
* 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 url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
:host {
/* Colors */
--bg-page: #faf8f4;
--bg-card: #fff;
--bg-soft: #f2ede0;
--border: #ece6d8;
--border-strong: #e5e0d6;
--text-primary: #1a1a1a;
--text-secondary: #5f5c57;
--text-muted: #8a867f;
--accent-dark: #1a1a1a;
--accent-eyebrow: #0f4f3c;
--status-active-bg: #e8f3e8;
--status-active-text: #1f6f3d;
--status-inactive-bg: #f4ede0;
--status-inactive-text: #8a6a2e;
--icon-bg-dark: #1a1a1a;
--icon-fg-dark: #faf8f4;
--icon-bg-note: #eef0f8;
--icon-fg-note: #3b4f8f;
--icon-bg-coin: #fbf1d6;
--icon-fg-coin: #8a6a2e;
--icon-bg-ok: #e8f3e8;
--icon-fg-ok: #1f6f3d;
--r-sm: 8px;
--r-md: 10px;
--r-lg: 14px;
--r-xl: 16px;
--r-pill: 999px;
display: block;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
.denom-page {
position: relative;
min-height: 100%;
background: var(--bg-page);
padding: 36px 40px;
overflow-x: clip;
}
.denom-content {
width: 100%;
}
/* ---------- HERO ---------- */
.hero {
margin-bottom: 32px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.hero-text {
min-width: 0;
}
.hero-add-btn {
flex: 0 0 auto;
align-self: flex-start;
width: auto;
height: 38px;
margin-top: 4px;
padding: 0 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
font-family: inherit;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
border: none;
border-radius: var(--r-pill);
cursor: pointer;
color: #fff;
background: linear-gradient(135deg, #1f7a4d 0%, #0f4f3c 100%);
box-shadow: 0 4px 14px -4px rgb(15 79 60 / 45%);
transition:
transform 0.15s ease,
box-shadow 0.15s ease,
filter 0.15s ease;
}
.hero-add-btn:hover {
transform: translateY(-1px);
box-shadow: 0 8px 22px -6px rgb(15 79 60 / 55%);
filter: brightness(1.06);
}
.hero-add-btn:active {
transform: translateY(0);
}
.hero-add-btn mat-icon {
font-size: 17px;
width: 17px;
height: 17px;
line-height: 17px;
}
.eyebrow {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.12em;
color: var(--accent-eyebrow);
margin-bottom: 12px;
}
.hero-title {
font-size: 34px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--text-primary);
margin: 0 0 12px;
line-height: 1.1;
}
.hero-sub {
font-size: 15px;
color: var(--text-secondary);
max-width: 520px;
line-height: 1.5;
margin: 0;
}
/* ---------- STAT TILES ---------- */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 32px;
}
.stat-tile {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-xl);
padding: 20px;
min-height: 120px;
display: flex;
flex-direction: column;
gap: 12px;
}
.stat-head {
display: flex;
align-items: center;
gap: 12px;
}
.stat-icon {
width: 28px;
height: 28px;
border-radius: var(--r-sm);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 1;
flex: 0 0 auto;
}
.stat-icon mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
}
.ic-dark {
background: var(--icon-bg-dark);
color: var(--icon-fg-dark);
}
.ic-note {
background: var(--icon-bg-note);
color: var(--icon-fg-note);
}
.ic-coin {
background: var(--icon-bg-coin);
color: var(--icon-fg-coin);
}
.ic-ok {
background: var(--icon-bg-ok);
color: var(--icon-fg-ok);
}
.stat-label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
}
.stat-value {
font-size: 40px;
font-weight: 500;
letter-spacing: -0.03em;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.stat-foot {
font-size: 12px;
color: var(--text-muted);
}
/* ---------- SECTION ---------- */
.denom-section {
margin-bottom: 32px;
}
.section-head {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 16px;
}
.section-title {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text-primary);
margin: 0;
}
.section-count {
font-size: 13px;
color: var(--text-muted);
}
/* ---------- CARD GRID ---------- */
.card-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.denom-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 18px;
display: flex;
flex-direction: column;
gap: 12px;
transition: opacity 0.15s ease;
}
.denom-card.inactive {
opacity: 0.65;
}
.card-top {
display: flex;
align-items: flex-start;
gap: 14px;
}
.bill-swatch {
width: 64px;
height: 36px;
border-radius: 6px;
border: 1px solid rgb(0 0 0 / 8%);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #2a2a2a;
flex: 0 0 auto;
}
.coin-disc {
width: 46px;
height: 46px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
color: #5c3a00;
flex: 0 0 auto;
box-shadow:
inset 0 -2px 0 rgb(0 0 0 / 10%),
0 1px 3px rgb(0 0 0 / 8%);
}
.card-info {
min-width: 0;
padding-top: 2px;
}
.card-desc {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.3;
}
.card-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
.card-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
padding: 3px 9px;
border-radius: var(--r-pill);
line-height: 1.4;
}
.status-pill.is-active {
background: var(--status-active-bg);
color: var(--status-active-text);
}
.status-pill.is-inactive {
background: var(--status-inactive-bg);
color: var(--status-inactive-text);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentcolor;
display: inline-block;
flex: 0 0 auto;
}
.icon-btns {
display: flex;
align-items: center;
gap: 6px;
}
.icon-btn {
width: 28px;
height: 28px;
border: 1px solid var(--border);
background: var(--bg-page);
color: var(--text-secondary);
border-radius: var(--r-sm);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
cursor: pointer;
padding: 0;
transition:
background 0.12s ease,
color 0.12s ease;
}
.icon-btn:hover {
background: var(--bg-soft);
color: var(--text-primary);
}
.icon-btn mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
}
.menu-wrap {
position: relative;
}
.ctx-menu {
position: absolute;
top: 34px;
right: 0;
z-index: 30;
background: var(--bg-card);
border: 1px solid var(--border-strong);
border-radius: var(--r-md);
box-shadow: 0 8px 24px -8px rgb(0 0 0 / 18%);
padding: 4px;
min-width: 140px;
display: flex;
flex-direction: column;
}
.ctx-item {
display: flex;
align-items: center;
gap: 8px;
text-align: left;
background: transparent;
border: none;
padding: 8px 10px;
font-size: 13px;
color: var(--text-primary);
border-radius: var(--r-sm);
cursor: pointer;
font-family: inherit;
}
.ctx-item:hover {
background: var(--bg-soft);
}
.ctx-item mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
}
.ctx-danger {
color: #c0392b;
}
.ctx-danger:hover {
background: #fbe9e7;
}
.disc-row {
border-top: 1px solid #f2ede0;
padding-top: 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
.disc-label {
font-size: 12px;
color: var(--text-muted);
}
.disc-value {
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
/* ---------- DRAWER ---------- */
.drawer-backdrop {
position: fixed;
inset: 0;
background: rgb(26 26 26 / 18%);
z-index: 1000;
animation: fade-in 0.18s ease;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 380px;
background: var(--bg-card);
border-left: 1px solid var(--border);
padding: 32px 28px;
box-shadow: -20px 0 40px -20px rgb(0 0 0 / 18%);
overflow-y: auto;
z-index: 1001;
transform: translateX(100%);
transition: transform 0.24s cubic-bezier(0.4, 0, 0.2, 1);
}
.drawer.open {
transform: translateX(0);
}
.drawer-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.drawer-title {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.drawer-sub {
font-size: 13px;
color: var(--text-secondary);
margin: 4px 0 0;
}
.close-btn {
width: 32px;
height: 32px;
}
.drawer-form {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 24px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.text-input {
box-sizing: border-box;
height: 42px;
padding: 0 14px;
border: 1px solid var(--border-strong);
border-radius: var(--r-md);
background: var(--bg-page);
font-size: 14px;
color: var(--text-primary);
font-family: inherit;
width: 100%;
}
.text-input:focus {
outline: none;
border-color: var(--accent-dark);
background: var(--bg-card);
}
.text-input::placeholder {
color: var(--text-muted);
}
/* segmented control */
.segmented {
box-sizing: border-box;
display: flex;
gap: 2px;
padding: 3px;
background: var(--bg-soft);
border: 1px solid var(--border-strong);
border-radius: var(--r-md);
}
.seg-btn {
flex: 1;
height: 36px;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: 7px;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.seg-btn mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
}
.seg-btn.active {
background: var(--bg-card);
color: var(--text-primary);
box-shadow: 0 1px 2px rgb(0 0 0 / 6%);
}
/* active toggle block */
.toggle-block {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-page);
border: 1px solid var(--border-strong);
border-radius: var(--r-md);
padding: 12px 14px;
}
.toggle-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.toggle-title {
font-size: 13px;
font-weight: 500;
}
.toggle-help {
font-size: 12px;
color: var(--text-muted);
}
.pill-toggle {
width: 36px;
height: 20px;
border-radius: var(--r-pill);
background: #d9d3c5;
border: none;
position: relative;
cursor: pointer;
padding: 0;
flex: 0 0 auto;
transition: background 0.16s ease;
}
.pill-toggle.on {
background: var(--accent-dark);
}
.pill-dot {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
transition: transform 0.16s ease;
}
.pill-toggle.on .pill-dot {
transform: translateX(16px);
}
/* drawer actions */
.drawer-actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
.btn {
flex: 1;
height: 42px;
padding: 0 18px;
border-radius: var(--r-pill);
font-size: 14px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.btn mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
}
.btn-cancel {
background: var(--bg-card);
border: 1px solid var(--border-strong);
color: var(--text-primary);
}
.btn-save {
background: var(--accent-dark);
border: 1px solid var(--accent-dark);
color: var(--bg-page);
}
.btn-save:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ---------- RESPONSIVE ---------- */
@media (width <= 1100px) {
.drawer {
width: min(380px, 90vw);
}
}
@media (width <= 820px) {
.hero {
flex-direction: column;
}
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
.card-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -0,0 +1,258 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, HostListener, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatIconModule } from '@angular/material/icon';
import { DenominationMasterService } from './denomination-master.service';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
interface Denomination {
id: number;
denomination: number;
kind: 'note' | 'coin';
description: string;
isActive: boolean;
introducedOn: string | null;
discontinuedOn: string | null;
}
const NOTE_COLORS: { [k: number]: string } = {
2000: '#E8C6F4',
1000: '#F5C6C6',
500: '#C9D5F2',
200: '#F5DDA9',
100: '#C7DCC7',
50: '#D9C8F2',
20: '#F5C9A3',
10: '#C7D4DC'
};
const COIN_COLORS: { [k: number]: string } = {
20: '#E5C04A',
10: '#D4A949',
5: '#C9A24F',
2: '#BD9B58',
1: '#B89460'
};
@Component({
selector: 'mifosx-denomination-master',
templateUrl: './denomination-master.component.html',
styleUrls: ['./denomination-master.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatSnackBarModule,
MatIconModule
]
})
export class DenominationMasterComponent implements OnInit {
private service = inject(DenominationMasterService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
notes: Denomination[] = [];
coins: Denomination[] = [];
allItems: Denomination[] = [];
drawerOpen = false;
editingId: number | null = null;
saving = false;
openMenuId: number | null = null;
model = {
kind: 'note' as 'note' | 'coin',
denomination: null as number | null,
description: '',
introducedOn: '',
isActive: true
};
ngOnInit(): void {
this.load();
}
load(): void {
this.service.getAll().subscribe((data: Denomination[]) => {
this.allItems = data || [];
this.notes = this.allItems.filter((d) => d.kind === 'note').sort((a, b) => b.denomination - a.denomination);
this.coins = this.allItems.filter((d) => d.kind === 'coin').sort((a, b) => b.denomination - a.denomination);
});
}
// ---- derived stats ----
get total(): number {
return this.allItems.length;
}
get activeNotes(): number {
return this.notes.filter((n) => n.isActive).length;
}
get activeCoins(): number {
return this.coins.filter((c) => c.isActive).length;
}
get activeCount(): number {
return this.allItems.filter((i) => i.isActive).length;
}
get disabledCount(): number {
return this.allItems.filter((i) => !i.isActive).length;
}
// ---- colors ----
noteColor(value: number): string {
return NOTE_COLORS[value] || '#E5E0D6';
}
coinColor(value: number): string {
return COIN_COLORS[value] || '#D4A949';
}
sectionCount(active: number, count: number): string {
if (count > 0 && active === count) {
return `${count} types · all active`;
}
return `${count} types · ${active} active`;
}
// ---- drawer ----
openAdd(): void {
this.editingId = null;
this.model = { kind: 'note', denomination: null, description: '', introducedOn: '', isActive: true };
this.drawerOpen = true;
}
openEdit(item: Denomination): void {
this.editingId = item.id;
this.openMenuId = null;
this.model = {
kind: item.kind,
denomination: item.denomination,
description: item.description || '',
introducedOn: item.introducedOn || '',
isActive: item.isActive
};
this.drawerOpen = true;
}
closeDrawer(): void {
this.drawerOpen = false;
this.editingId = null;
}
setType(kind: 'note' | 'coin'): void {
this.model.kind = kind;
}
toggleActive(): void {
this.model.isActive = !this.model.isActive;
}
get today(): string {
return new Date().toISOString().split('T')[0];
}
get formValid(): boolean {
const v = this.model.denomination;
const denomOk = v != null && Number.isInteger(+v) && +v > 0;
const descOk = this.model.description.trim().length > 0;
const dateOk = !!this.model.introducedOn && this.model.introducedOn <= this.today;
return denomOk && descOk && dateOk;
}
save(): void {
if (!this.formValid || this.saving) {
return;
}
const denom = +this.model.denomination!;
const kind = this.model.kind;
// duplicate guard: same denomination + same kind cannot exist twice
const duplicate = this.allItems.find((i) => i.kind === kind && i.denomination === denom && i.id !== this.editingId);
if (duplicate) {
const kindLabel = kind === 'note' ? 'note' : 'coin';
this.snackBar.open(`A ₹${denom} ${kindLabel} already exists. Duplicate denominations are not allowed.`, 'Close', {
duration: 5000
});
return;
}
this.saving = true;
const payload = {
denomination: this.model.denomination,
kind: this.model.kind,
description: this.model.description.trim(),
isActive: this.model.isActive,
introducedOn: this.model.introducedOn
};
const obs = this.editingId ? this.service.update(this.editingId, payload) : this.service.create(payload);
const wasEdit = this.editingId != null;
obs.subscribe({
next: () => {
this.snackBar.open(wasEdit ? 'Denomination updated' : 'Denomination added', 'Close', { duration: 3000 });
this.closeDrawer();
this.load();
this.saving = false;
},
error: () => {
this.saving = false;
}
});
}
// ---- card actions ----
toggleMenu(item: Denomination, event: MouseEvent): void {
event.stopPropagation();
this.openMenuId = this.openMenuId === item.id ? null : item.id;
}
@HostListener('document:keydown.escape')
onEsc(): void {
if (this.openMenuId != null) {
this.openMenuId = null;
} else if (this.drawerOpen) {
this.closeDrawer();
}
}
@HostListener('document:click')
onDocumentClick(): void {
if (this.openMenuId != null) {
this.openMenuId = null;
}
}
closeMenu(): void {
this.openMenuId = null;
}
toggleStatus(item: Denomination): void {
this.openMenuId = null;
const obs = item.isActive ? this.service.deactivate(item.id) : this.service.activate(item.id);
obs.subscribe(() => {
this.snackBar.open(item.isActive ? 'Denomination disabled' : 'Denomination enabled', 'Close', { duration: 3000 });
this.load();
});
}
deleteDenomination(item: Denomination): void {
this.openMenuId = null;
const ref = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `₹${item.denomination} ${item.kind}` }
});
ref.afterClosed().subscribe((res: any) => {
if (res?.delete) {
this.service.delete(item.id).subscribe(() => {
this.snackBar.open('Denomination deleted', 'Close', { duration: 3000 });
this.load();
});
}
});
}
}
@@ -0,0 +1,38 @@
/**
* 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 { HttpClient } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class DenominationMasterService {
private http = inject(HttpClient);
getAll() {
return this.http.get<any[]>('/denominationmaster');
}
create(data: any) {
return this.http.post('/denominationmaster', data);
}
update(id: number, data: any) {
return this.http.put(`/denominationmaster/${id}`, data);
}
activate(id: number) {
return this.http.put(`/denominationmaster/${id}?command=activate`, {});
}
deactivate(id: number) {
return this.http.put(`/denominationmaster/${id}?command=deactivate`, {});
}
delete(id: number) {
return this.http.delete(`/denominationmaster/${id}`);
}
}
+11
View File
@@ -56,6 +56,7 @@ import { ViewRoleComponent } from './roles-and-permissions/view-role/view-role.c
import { SystemComponent } from './system.component'; import { SystemComponent } from './system.component';
import { SystemInformationComponent } from './system-information/system-information.component'; import { SystemInformationComponent } from './system-information/system-information.component';
import { AboutUsComponent } from './about-us/about-us.component'; import { AboutUsComponent } from './about-us/about-us.component';
import { DenominationMasterComponent } from './denomination-master/denomination-master.component';
/** Custom Resolvers */ /** Custom Resolvers */
import { AccountNumberPreferencesResolver } from './account-number-preferences/account-number-preferences.resolver'; import { AccountNumberPreferencesResolver } from './account-number-preferences/account-number-preferences.resolver';
@@ -614,6 +615,16 @@ const routes: Routes = [
} }
} }
] ]
},
{
path: 'denomination-master',
data: { title: 'Denomination Master', breadcrumb: 'Denomination Master' },
children: [
{
path: '',
component: DenominationMasterComponent
}
]
} }
] ]
} }