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:
co-authored by
Claude Opus 4.7
parent
33b01d667d
commit
918a90368c
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user