Locker module UI: dashboard, class master, locker list, waiting list, allotment form
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* 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 {
|
||||
AbstractControl,
|
||||
UntypedFormBuilder,
|
||||
UntypedFormGroup,
|
||||
ValidationErrors,
|
||||
ValidatorFn,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Custom Services */
|
||||
import { LockerService } from '../locker.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||
|
||||
/** End locker no must not be below the start, and effective-to must not be before effective-from. */
|
||||
const rangeValidator: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
|
||||
const errors: ValidationErrors = {};
|
||||
const start = Number(group.get('startLockerNo')?.value);
|
||||
const end = Number(group.get('endLockerNo')?.value);
|
||||
if (start && end && end < start) {
|
||||
errors['endBeforeStart'] = true;
|
||||
}
|
||||
const from = group.get('effectiveFromDate')?.value;
|
||||
const to = group.get('effectiveToDate')?.value;
|
||||
if (from && to && new Date(to) < new Date(from)) {
|
||||
errors['toBeforeFrom'] = true;
|
||||
}
|
||||
return Object.keys(errors).length ? errors : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Locker class master — one row per locker number range of a class (a class such as A1 can have several ranges),
|
||||
* with member / non-member rent and deposit and an effective period. Occupied is counted from allotments by the
|
||||
* server, never stored. Only a Branch Manager (own branch) or the Super user (any branch) may change it; the
|
||||
* server enforces both, the buttons here are only hidden to match.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-locker-class-master',
|
||||
templateUrl: './class-master.component.html',
|
||||
styleUrls: ['./class-master.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
RouterLink,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatSnackBarModule,
|
||||
MatTooltipModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ClassMasterComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private lockerService = inject(LockerService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
private notify(message: string, isError = false): void {
|
||||
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||
}
|
||||
|
||||
/** The server's own reason (overlapping range, allotted lockers, other branch) rather than a generic failure. */
|
||||
private serverMessage(err: any, fallback: string): string {
|
||||
const body = err?.error;
|
||||
return body?.errors?.[0]?.defaultUserMessage || body?.defaultUserMessage || fallback;
|
||||
}
|
||||
|
||||
/** Super user picks any branch; everyone else is fixed to their own. */
|
||||
isSuperUser = false;
|
||||
ownOfficeId: number | null = null;
|
||||
ownOfficeName = '';
|
||||
offices: any[] = [];
|
||||
|
||||
classList: any[] = [];
|
||||
showForm = false;
|
||||
busy = false;
|
||||
loadFailed = false;
|
||||
/** Id of the row being edited; null while adding. */
|
||||
editingId: number | null = null;
|
||||
|
||||
statusFilter = 'ACTIVE';
|
||||
pageSize = 10;
|
||||
pageIndex = 0;
|
||||
totalRecords = 0;
|
||||
pageSizeOptions = [
|
||||
10,
|
||||
25,
|
||||
50,
|
||||
100
|
||||
];
|
||||
|
||||
classNames = [
|
||||
'SMALL',
|
||||
'MEDIUM',
|
||||
'LARGE'
|
||||
];
|
||||
|
||||
classForm: UntypedFormGroup;
|
||||
|
||||
columns = [
|
||||
'srNo',
|
||||
'office',
|
||||
'classCode',
|
||||
'className',
|
||||
'area',
|
||||
'rent',
|
||||
'lockerRange',
|
||||
'totalLockers',
|
||||
'occupied',
|
||||
'deposit',
|
||||
'effectiveFrom',
|
||||
'effectiveTo',
|
||||
'active',
|
||||
'actions'
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.classForm = this.formBuilder.group(
|
||||
{
|
||||
officeId: [''],
|
||||
classCode: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(10),
|
||||
Validators.pattern(/^[A-Za-z0-9]+$/)
|
||||
]
|
||||
],
|
||||
className: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
area: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(1)
|
||||
]
|
||||
],
|
||||
rent: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(0)
|
||||
]
|
||||
],
|
||||
memberRent: [
|
||||
'',
|
||||
Validators.min(0)
|
||||
],
|
||||
deposit: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(0)
|
||||
]
|
||||
],
|
||||
memberDeposit: [
|
||||
'',
|
||||
Validators.min(0)
|
||||
],
|
||||
startLockerNo: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(1),
|
||||
Validators.pattern(/^\d+$/)
|
||||
]
|
||||
],
|
||||
endLockerNo: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(1),
|
||||
Validators.pattern(/^\d+$/)
|
||||
]
|
||||
],
|
||||
effectiveFromDate: [
|
||||
new Date(),
|
||||
Validators.required
|
||||
],
|
||||
effectiveToDate: ['']
|
||||
},
|
||||
{ validators: rangeValidator }
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const credentials = this.authenticationService.getCredentials();
|
||||
this.isSuperUser = !!credentials?.permissions?.includes('ALL_FUNCTIONS');
|
||||
this.ownOfficeId = credentials?.officeId ?? null;
|
||||
this.ownOfficeName = credentials?.officeName ?? '';
|
||||
if (this.isSuperUser) {
|
||||
this.lockerService.getOffices().subscribe({
|
||||
next: (offices: any[]) => (this.offices = offices ?? []),
|
||||
error: () => (this.offices = [])
|
||||
});
|
||||
}
|
||||
this.loadClasses();
|
||||
}
|
||||
|
||||
/** Lockers the entered range will create — shown live so the user can check it before saving. */
|
||||
get totalLockers(): number {
|
||||
const start = Number(this.classForm.get('startLockerNo')?.value);
|
||||
const end = Number(this.classForm.get('endLockerNo')?.value);
|
||||
return start && end && end >= start ? end - start + 1 : 0;
|
||||
}
|
||||
|
||||
/** Another active range of the same class already covering any of the entered locker numbers (on the loaded page). */
|
||||
get overlappingRange(): any | null {
|
||||
const code = String(this.classForm.get('classCode')?.value || '').toUpperCase();
|
||||
const start = Number(this.classForm.get('startLockerNo')?.value);
|
||||
const end = Number(this.classForm.get('endLockerNo')?.value);
|
||||
const officeId = Number(this.classForm.get('officeId')?.value || this.ownOfficeId);
|
||||
if (!code || !start || !end || end < start) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
this.classList.find(
|
||||
(c) =>
|
||||
c.active &&
|
||||
c.id !== this.editingId &&
|
||||
c.officeId === officeId &&
|
||||
String(c.classCode).toUpperCase() === code &&
|
||||
start <= c.endLockerNo &&
|
||||
end >= c.startLockerNo
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
loadClasses(): void {
|
||||
this.loadFailed = false;
|
||||
this.lockerService
|
||||
.getClasses(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize)
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
this.classList = res?.pageItems ?? [];
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
},
|
||||
error: () => {
|
||||
this.classList = [];
|
||||
this.totalRecords = 0;
|
||||
this.loadFailed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
filterByStatus(status: string): void {
|
||||
this.statusFilter = this.statusFilter === status ? '' : status;
|
||||
this.pageIndex = 0;
|
||||
this.loadClasses();
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageSize = event.pageSize;
|
||||
this.pageIndex = event.pageIndex;
|
||||
this.loadClasses();
|
||||
}
|
||||
|
||||
openAddForm(): void {
|
||||
this.editingId = null;
|
||||
this.resetForm();
|
||||
this.showForm = true;
|
||||
}
|
||||
|
||||
/** Class code and locker range stay fixed once saved — a different range is added as a new row. */
|
||||
edit(row: any): void {
|
||||
this.editingId = row.id;
|
||||
this.classForm.reset({
|
||||
officeId: row.officeId,
|
||||
classCode: row.classCode,
|
||||
className: row.className,
|
||||
area: row.area,
|
||||
rent: row.rent,
|
||||
memberRent: row.memberRent,
|
||||
deposit: row.deposit,
|
||||
memberDeposit: row.memberDeposit,
|
||||
startLockerNo: row.startLockerNo,
|
||||
endLockerNo: row.endLockerNo,
|
||||
effectiveFromDate: row.effectiveFromDate ? this.dateUtils.parseDate(row.effectiveFromDate) : '',
|
||||
effectiveToDate: row.effectiveToDate ? this.dateUtils.parseDate(row.effectiveToDate) : ''
|
||||
});
|
||||
this.classForm.get('officeId')?.disable();
|
||||
this.classForm.get('classCode')?.disable();
|
||||
this.classForm.get('startLockerNo')?.disable();
|
||||
this.classForm.get('endLockerNo')?.disable();
|
||||
this.showForm = true;
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.showForm = false;
|
||||
this.editingId = null;
|
||||
this.resetForm();
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.classForm.invalid || this.overlappingRange || this.busy) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
const raw = this.classForm.getRawValue();
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
// Member rate falls back to the normal rate when left blank, as in the legacy class master.
|
||||
const payload: any = {
|
||||
className: raw.className,
|
||||
area: Number(raw.area),
|
||||
rent: Number(raw.rent),
|
||||
memberRent: raw.memberRent !== '' && raw.memberRent != null ? Number(raw.memberRent) : Number(raw.rent),
|
||||
deposit: Number(raw.deposit),
|
||||
memberDeposit:
|
||||
raw.memberDeposit !== '' && raw.memberDeposit != null ? Number(raw.memberDeposit) : Number(raw.deposit),
|
||||
effectiveFromDate: this.dateUtils.formatDate(raw.effectiveFromDate, dateFormat),
|
||||
dateFormat,
|
||||
locale: this.settingsService.language.code
|
||||
};
|
||||
if (raw.effectiveToDate) {
|
||||
payload.effectiveToDate = this.dateUtils.formatDate(raw.effectiveToDate, dateFormat);
|
||||
} else if (this.editingId != null) {
|
||||
// Sent blank on edit so a previously set end date can be cleared.
|
||||
payload.effectiveToDate = null;
|
||||
}
|
||||
if (this.editingId == null) {
|
||||
if (this.isSuperUser && raw.officeId) {
|
||||
payload.officeId = Number(raw.officeId);
|
||||
}
|
||||
payload.classCode = String(raw.classCode).toUpperCase();
|
||||
payload.startLockerNo = Number(raw.startLockerNo);
|
||||
payload.endLockerNo = Number(raw.endLockerNo);
|
||||
}
|
||||
|
||||
const request =
|
||||
this.editingId == null
|
||||
? this.lockerService.createClass(payload)
|
||||
: this.lockerService.updateClass(this.editingId, payload);
|
||||
request.subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.notify(this.editingId == null ? 'Locker class saved' : 'Locker class updated');
|
||||
this.cancel();
|
||||
this.loadClasses();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.busy = false;
|
||||
this.notify(this.serverMessage(err, 'Failed to save locker class'), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deactivate(row: any): void {
|
||||
const dialogRef = this.dialog.open(DeleteDialogComponent, {
|
||||
data: { deleteContext: `locker class ${row.classCode} (lockers ${row.startLockerNo}-${row.endLockerNo})` }
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (!response?.delete) {
|
||||
return;
|
||||
}
|
||||
this.lockerService.deactivateClass(row.id).subscribe({
|
||||
next: () => {
|
||||
this.notify('Locker class deactivated');
|
||||
this.loadClasses();
|
||||
},
|
||||
error: (err: any) => this.notify(this.serverMessage(err, 'Failed to deactivate locker class'), true)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private resetForm(): void {
|
||||
this.classForm.reset({
|
||||
officeId: this.ownOfficeId ?? '',
|
||||
className: '',
|
||||
effectiveFromDate: new Date(),
|
||||
effectiveToDate: ''
|
||||
});
|
||||
if (this.isSuperUser) {
|
||||
this.classForm.get('officeId')?.enable();
|
||||
} else {
|
||||
this.classForm.get('officeId')?.disable();
|
||||
}
|
||||
this.classForm.get('classCode')?.enable();
|
||||
this.classForm.get('startLockerNo')?.enable();
|
||||
this.classForm.get('endLockerNo')?.enable();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user