Locker module UI: dashboard, class master, locker list, waiting list, allotment form
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>{{ 'Locker List' | translate }}</h2>
|
||||
<div class="header-actions">
|
||||
<button mat-raised-button [routerLink]="['/locker']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tiles">
|
||||
<button type="button" class="tile" [class.active]="statusFilter === ''" (click)="filterByStatus('')">
|
||||
<span class="tile-label">{{ 'Total' | translate }}</span>
|
||||
<span class="tile-value">{{ summary.total }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tile vacant"
|
||||
[class.active]="statusFilter === 'VACANT'"
|
||||
(click)="filterByStatus('VACANT')"
|
||||
>
|
||||
<span class="tile-label">{{ 'Vacant' | translate }}</span>
|
||||
<span class="tile-value">{{ summary.vacant }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tile allotted"
|
||||
[class.active]="statusFilter === 'ALLOTTED'"
|
||||
(click)="filterByStatus('ALLOTTED')"
|
||||
>
|
||||
<span class="tile-label">{{ 'Allotted' | translate }}</span>
|
||||
<span class="tile-value">{{ summary.allotted }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tile blocked"
|
||||
[class.active]="statusFilter === 'BLOCKED'"
|
||||
(click)="filterByStatus('BLOCKED')"
|
||||
>
|
||||
<span class="tile-label">{{ 'Blocked' | translate }}</span>
|
||||
<span class="tile-value">{{ summary.blocked }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
@if (isSuperUser) {
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'Branch' | translate }}</mat-label>
|
||||
<mat-select [(ngModel)]="officeFilter" (selectionChange)="refresh()">
|
||||
<mat-option value="">{{ 'All Branches' | translate }}</mat-option>
|
||||
@for (office of offices; track office.id) {
|
||||
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'Class' | translate }}</mat-label>
|
||||
<mat-select [(ngModel)]="classFilter" (selectionChange)="applyFilters()">
|
||||
<mat-option value="">{{ 'All Classes' | translate }}</mat-option>
|
||||
@for (code of classCodes; track code) {
|
||||
<mat-option [value]="code">{{ code }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'Status' | translate }}</mat-label>
|
||||
<mat-select [(ngModel)]="statusFilter" (selectionChange)="applyFilters()">
|
||||
<mat-option value="">{{ 'All' | translate }}</mat-option>
|
||||
@for (s of statuses; track s) {
|
||||
<mat-option [value]="s">{{ s }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'Locker No' | translate }}</mat-label>
|
||||
<input matInput type="number" min="1" [(ngModel)]="lockerNoFilter" (keyup.enter)="applyFilters()" />
|
||||
</mat-form-field>
|
||||
<button mat-raised-button color="primary" (click)="applyFilters()">
|
||||
<fa-icon icon="search"></fa-icon> {{ 'Search' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button (click)="clearFilters()">{{ 'Clear' | translate }}</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper mat-elevation-z1">
|
||||
<table mat-table [dataSource]="lockers">
|
||||
<ng-container matColumnDef="office">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'Branch' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let l">{{ l.officeName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="classCode">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'Class' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let l">
|
||||
<strong>{{ l.classCode }}</strong>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="className">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'Class Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let l">
|
||||
<span class="pill neutral">{{ l.className }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="lockerNo">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Locker No' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let l" class="num">{{ l.lockerNo }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="keyNo">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'Key No' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let l">{{ l.keyNo || '-' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let l">
|
||||
<span class="pill" [ngClass]="l.status === 'VACANT' ? 'ok' : l.status === 'ALLOTTED' ? 'info' : 'warn'">{{
|
||||
l.status
|
||||
}}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="columns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: columns"></tr>
|
||||
</table>
|
||||
@if (loadFailed) {
|
||||
<div class="empty-state">{{ 'Could not load lockers.' }}</div>
|
||||
} @else if (lockers.length === 0) {
|
||||
<div class="empty-state">
|
||||
{{ 'No lockers found. Lockers are created from Class Master.' }}
|
||||
</div>
|
||||
}
|
||||
@if (totalRecords > 0) {
|
||||
<mat-paginator
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="pageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgb(0 0 0 / 12%);
|
||||
border-left: 4px solid #1976d2;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
|
||||
&.vacant {
|
||||
border-left-color: #28c07a;
|
||||
}
|
||||
|
||||
&.allotted {
|
||||
border-left-color: #1976d2;
|
||||
}
|
||||
|
||||
&.blocked {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgb(25 118 210 / 8%);
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 10%);
|
||||
}
|
||||
}
|
||||
|
||||
.tile-label {
|
||||
font-size: 0.75rem;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.tile-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
mat-form-field {
|
||||
width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td.num,
|
||||
th.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
|
||||
&.ok {
|
||||
background: #dff5e8;
|
||||
color: #28c07a;
|
||||
}
|
||||
|
||||
&.info {
|
||||
background: #e3f0fc;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
background: #fdf1dc;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&.neutral {
|
||||
background: #e0e0e0;
|
||||
color: #757575;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Custom Services */
|
||||
import { LockerService } from '../locker.service';
|
||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||
|
||||
/**
|
||||
* Read-only locker inventory — every box created from the class master, with its status. The tiles on top are the
|
||||
* status counts and double as a status filter. Allotment happens on its own screen, not here.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-locker-list',
|
||||
templateUrl: './locker-list.component.html',
|
||||
styleUrls: ['./locker-list.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
MatButtonModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class LockerListComponent implements OnInit {
|
||||
private lockerService = inject(LockerService);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
|
||||
lockers: any[] = [];
|
||||
summary = { total: 0, vacant: 0, allotted: 0, blocked: 0 };
|
||||
loadFailed = false;
|
||||
|
||||
isSuperUser = false;
|
||||
offices: any[] = [];
|
||||
classCodes: string[] = [];
|
||||
|
||||
officeFilter: number | '' = '';
|
||||
classFilter = '';
|
||||
statusFilter = '';
|
||||
lockerNoFilter: number | null = null;
|
||||
|
||||
pageSize = 25;
|
||||
pageIndex = 0;
|
||||
totalRecords = 0;
|
||||
pageSizeOptions = [
|
||||
25,
|
||||
50,
|
||||
100
|
||||
];
|
||||
|
||||
statuses = [
|
||||
'VACANT',
|
||||
'ALLOTTED',
|
||||
'BLOCKED'
|
||||
];
|
||||
|
||||
columns = [
|
||||
'office',
|
||||
'classCode',
|
||||
'className',
|
||||
'lockerNo',
|
||||
'keyNo',
|
||||
'status'
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
const credentials = this.authenticationService.getCredentials();
|
||||
this.isSuperUser = !!credentials?.permissions?.includes('ALL_FUNCTIONS');
|
||||
if (this.isSuperUser) {
|
||||
this.lockerService.getOffices().subscribe({
|
||||
next: (offices: any[]) => (this.offices = offices ?? []),
|
||||
error: () => (this.offices = [])
|
||||
});
|
||||
}
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** Reloads the class choices, the counts and the first page — everything the branch filter affects. */
|
||||
refresh(): void {
|
||||
this.pageIndex = 0;
|
||||
this.loadClassCodes();
|
||||
this.loadSummary();
|
||||
this.loadLockers();
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
this.pageIndex = 0;
|
||||
this.loadLockers();
|
||||
}
|
||||
|
||||
filterByStatus(status: string): void {
|
||||
this.statusFilter = this.statusFilter === status ? '' : status;
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.classFilter = '';
|
||||
this.statusFilter = '';
|
||||
this.lockerNoFilter = null;
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageSize = event.pageSize;
|
||||
this.pageIndex = event.pageIndex;
|
||||
this.loadLockers();
|
||||
}
|
||||
|
||||
private loadLockers(): void {
|
||||
this.loadFailed = false;
|
||||
this.lockerService
|
||||
.getLockers(
|
||||
{
|
||||
officeId: this.officeFilter || undefined,
|
||||
classCode: this.classFilter || undefined,
|
||||
status: this.statusFilter || undefined,
|
||||
lockerNo: this.lockerNoFilter || undefined
|
||||
},
|
||||
this.pageIndex * this.pageSize,
|
||||
this.pageSize
|
||||
)
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
this.lockers = res?.pageItems ?? [];
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
},
|
||||
error: () => {
|
||||
this.lockers = [];
|
||||
this.totalRecords = 0;
|
||||
this.loadFailed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadSummary(): void {
|
||||
this.lockerService.getLockerSummary(this.officeFilter || undefined).subscribe({
|
||||
next: (res: any) => (this.summary = res ?? this.summary),
|
||||
error: () => (this.summary = { total: 0, vacant: 0, allotted: 0, blocked: 0 })
|
||||
});
|
||||
}
|
||||
|
||||
/** Class choices come from the active class master rows of the branch being viewed. */
|
||||
private loadClassCodes(): void {
|
||||
this.lockerService.getClasses('ACTIVE', 0, 500, this.officeFilter || undefined).subscribe({
|
||||
next: (res: any) => {
|
||||
const codes = (res?.pageItems ?? []).map((c: any) => c.classCode);
|
||||
this.classCodes = Array.from(new Set<string>(codes)).sort();
|
||||
if (this.classFilter && !this.classCodes.includes(this.classFilter)) {
|
||||
this.classFilter = '';
|
||||
}
|
||||
},
|
||||
error: () => (this.classCodes = [])
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user