410 lines
12 KiB
TypeScript
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();
|
|
}
|
|
}
|