Files
cbsnexora/src/app/locker/class-master/class-master.component.ts
T

410 lines
12 KiB
TypeScript

/**
* 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();
}
}