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