Locker module UI: dashboard, class master, locker list, waiting list, allotment form

This commit is contained in:
tarunmalviya
2026-10-05 15:30:12 +05:30
parent 885b39d970
commit 7d1025f5ce
23 changed files with 5403 additions and 0 deletions
+2
View File
@@ -37,6 +37,7 @@ import { GroupsModule } from './groups/groups.module';
import { CentersModule } from './centers/centers.module';
import { AccountingModule } from './accounting/accounting.module';
import { ChequeBookModule } from './chequebook/chequebook.module';
import { LockerModule } from './locker/locker.module';
import { CtsClearingModule } from './ctsclearing/ctsclearing.module';
import { NachClearingModule } from './nachclearing/nachclearing.module';
import { SystemModule } from './system/system.module';
@@ -154,6 +155,7 @@ export function HttpLoaderFactory(http: HttpClient) {
CentersModule,
AccountingModule,
ChequeBookModule,
LockerModule,
CtsClearingModule,
NachClearingModule,
SystemModule,
@@ -0,0 +1,830 @@
<!--
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>
{{ (mode === 'create' ? 'New Locker Allotment' : 'Locker Application') | translate }}
<span class="tag"
>{{ 'Application No' | translate }}: {{ allotment?.applicationNo ?? ('Auto' | translate) }}</span
>
@if (allotment) {
<span
class="pill status-pill"
[ngClass]="
allotment.status === 'ACTIVE'
? 'ok'
: allotment.status === 'PENDING_APPROVAL'
? 'warn'
: allotment.status === 'REJECTED'
? 'danger'
: 'neutral'
"
>{{ statusLabel(allotment.status) | translate }}</span
>
}
</h2>
<div class="header-actions">
<button mat-raised-button [routerLink]="['/locker', 'allotment']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
@if (loadFailed) {
<mat-card class="notice error">
<mat-card-content>{{ 'Could not load this locker application.' }}</mat-card-content>
</mat-card>
} @else {
@if (allotment?.status === 'REJECTED' && allotment?.rejectionReason) {
<mat-card class="notice error">
<mat-card-content>
<strong>{{ 'Rejected' | translate }}:</strong> {{ allotment.rejectionReason }}
</mat-card-content>
</mat-card>
}
<!-- Approver bar: only while pending -->
@if (isPending) {
<mat-card class="notice approve-bar">
<mat-card-content>
<span class="approve-text">
<fa-icon icon="hourglass-half"></fa-icon>&nbsp;{{
'This application is waiting for Branch Manager approval. The locker is reserved until then.'
}}
</span>
<span class="approve-actions">
@if (!rejecting) {
<button
mat-raised-button
color="primary"
[disabled]="busy"
(click)="approve()"
*mifosxHasPermission="'APPROVE_LOCKERALLOTMENT'"
>
<fa-icon icon="check"></fa-icon>&nbsp;{{ 'Approve' | translate }}
</button>
<button
mat-raised-button
color="warn"
[disabled]="busy"
(click)="rejecting = true"
*mifosxHasPermission="'REJECT_LOCKERALLOTMENT'"
>
<fa-icon icon="ban"></fa-icon>&nbsp;{{ 'Reject' | translate }}
</button>
}
</span>
@if (rejecting) {
<div class="reject-box">
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'Reason for rejection' | translate }}</mat-label>
<textarea
matInput
rows="2"
[(ngModel)]="rejectionReason"
[ngModelOptions]="{ standalone: true }"
></textarea>
</mat-form-field>
<div class="form-actions">
<button mat-raised-button type="button" (click)="rejecting = false">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
color="warn"
type="button"
[disabled]="busy || !rejectionReason.trim()"
(click)="confirmReject()"
>
{{ 'Confirm Reject' | translate }}
</button>
</div>
</div>
}
</mat-card-content>
</mat-card>
}
<mat-card>
<mat-card-content>
<mat-tab-group [(selectedIndex)]="selectedTab" animationDuration="0ms">
<!-- ===================== TAB 1: APPLICATION MASTER ===================== -->
<!-- Laid out like the legacy screen: left = application + address information, right = locker rent,
reference account, contact and KYC. Fields filled from the client are read-only. -->
<mat-tab>
<ng-template mat-tab-label>
<span [class.tab-error]="tab1Errors.length && mainForm.touched">{{
'Application Master Entry' | translate
}}</span>
</ng-template>
<div class="tab-body legacy-layout" [formGroup]="mainForm">
<!-- ================= LEFT ================= -->
<div class="col">
<div class="panel">
<div class="panel-title">{{ 'Application' | translate }}</div>
<div class="grid-2">
<mat-form-field appearance="fill">
<mat-label>{{ 'Branch Code' | translate }}</mat-label>
<input matInput [value]="allotment?.officeName ?? ownOfficeName" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Application No' | translate }}</mat-label>
<input matInput [value]="allotment?.applicationNo ?? ('Auto' | translate)" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Client Id' | translate }}</mat-label>
<input matInput [value]="clientId ?? ''" readonly required />
<mat-hint>{{ 'Filled when the name is picked' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Transaction Date' | translate }}</mat-label>
<input matInput [value]="allotment?.transactionDate ?? transactionDate | dateFormat" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Locker No' | translate }}</mat-label>
<mat-select formControlName="lockerId" required>
@for (l of lockers; track l.id) {
<mat-option [value]="l.id">{{ l.classCode }}-{{ l.lockerNo }}</mat-option>
}
</mat-select>
@if (!mainForm.get('classCode')?.value) {
<mat-hint>{{ 'Choose Class Type first' | translate }}</mat-hint>
} @else if (lockers.length === 0) {
<mat-hint class="hint-warn">{{ 'No vacant locker in this class' | translate }}</mat-hint>
} @else {
<mat-hint>{{ 'Only vacant lockers' | translate }}</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Key No' | translate }}</mat-label>
<input matInput formControlName="keyNo" maxlength="20" required />
</mat-form-field>
</div>
<mifosx-locker-client-picker
label="Name"
[required]="true"
[disabled]="mode !== 'create'"
[clientId]="clientId"
[clientName]="clientName"
(picked)="onClientPicked($event)"
></mifosx-locker-client-picker>
<div class="grid-2">
<mat-form-field appearance="fill">
<mat-label>{{ 'Freeze' | translate }}</mat-label>
<mat-select formControlName="freeze">
<mat-option [value]="false">{{ 'Un Freeze' | translate }}</mat-option>
<mat-option [value]="true">{{ 'Freeze' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Risk' | translate }}</mat-label>
<mat-select formControlName="risk">
<mat-option value="">-</mat-option>
@for (r of risks; track r) {
<mat-option [value]="r">{{ r }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Freeze Remark' | translate }}</mat-label>
<textarea matInput rows="1" formControlName="freezeRemark"></textarea>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Remarks' | translate }}</mat-label>
<input matInput formControlName="remarks" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Op. Date' }}</mat-label>
<!-- As on the legacy screen: the transaction date until approval sets the real opening date. -->
<input
matInput
[value]="allotment?.openingDate ?? allotment?.transactionDate ?? transactionDate | dateFormat"
readonly
/>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Close Date' | translate }}</mat-label>
<input
matInput
[value]="allotment?.closeDate ? (allotment.closeDate | dateFormat) : ''"
readonly
/>
</mat-form-field>
</div>
</div>
<div class="panel">
<div class="panel-title">{{ 'Address Information' | translate }}</div>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'Address' | translate }}</mat-label>
<input matInput [value]="clientDetails?.addressLine1 ?? ''" readonly required />
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'Address Line 2' | translate }}</mat-label>
<input matInput [value]="clientDetails?.addressLine2 ?? ''" readonly />
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'Address Line 3' | translate }}</mat-label>
<input matInput [value]="clientDetails?.addressLine3 ?? ''" readonly />
</mat-form-field>
<div class="grid-2">
<mat-form-field appearance="fill">
<mat-label>{{ 'Pay Mode' | translate }}</mat-label>
<mat-select formControlName="payMode" required>
@for (p of payModes; track p.value) {
<mat-option [value]="p.value">{{ p.label | translate }}</mat-option>
}
</mat-select>
@if (needsJointHolder) {
<mat-hint>{{ 'Add joint holders on the Joint Member tab' | translate }}</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Email Active' | translate }}</mat-label>
<mat-select formControlName="emailActive">
<mat-option [value]="false">{{ 'No' | translate }}</mat-option>
<mat-option [value]="true">{{ 'Yes' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Occupation' | translate }}</mat-label>
<input matInput [value]="clientDetails?.occupation ?? ''" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'SMS Active' | translate }}</mat-label>
<mat-select formControlName="smsActive">
<mat-option [value]="false">{{ 'No' | translate }}</mat-option>
<mat-option [value]="true">{{ 'Yes' | translate }}</mat-option>
</mat-select>
</mat-form-field>
@if (mode !== 'view') {
<mat-form-field appearance="fill">
<mat-label>{{ 'Password' | translate }}</mat-label>
<input
matInput
type="password"
formControlName="password"
autocomplete="new-password"
[required]="mode === 'create'"
/>
@if (mode === 'edit') {
<mat-hint>{{ 'Blank keeps the current password' | translate }}</mat-hint>
}
</mat-form-field>
} @else {
<mat-form-field appearance="fill">
<mat-label>{{ 'Password' | translate }}</mat-label>
<input matInput value="••••••" readonly />
</mat-form-field>
}
<mat-form-field appearance="fill">
<mat-label>{{ 'Gender' | translate }}</mat-label>
<input matInput [value]="clientDetails?.gender ?? ''" readonly />
</mat-form-field>
@if (mode !== 'view') {
<mat-form-field appearance="fill">
<mat-label>{{ 'Confirm Password' | translate }}</mat-label>
<input matInput type="password" formControlName="confirmPassword" autocomplete="new-password" />
@if (mainForm.hasError('passwordMismatch')) {
<mat-hint class="hint-warn">{{ 'Passwords do not match' | translate }}</mat-hint>
}
</mat-form-field>
}
<mat-form-field appearance="fill">
<mat-label>{{ 'Dual Auth Mode' | translate }}</mat-label>
<mat-select formControlName="dualAuth">
<mat-option [value]="false">{{ 'No' | translate }}</mat-option>
<mat-option [value]="true">{{ 'Yes' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Inquiry Sr No' | translate }}</mat-label>
<mat-select formControlName="waitingListId" (selectionChange)="onWaitingEntryChange()">
<mat-option value="">{{ 'None' | translate }}</mat-option>
@for (w of waitingEntries; track w.id) {
<mat-option [value]="w.id">
{{ w.id }} · {{ w.customerName }} · {{ w.preferredClasses?.join(', ') }}
@if (w.clientId && w.clientId === clientId) {
· ★
}
</mat-option>
}
@if (allotment?.waitingListId && !selectedWaitingEntry) {
<mat-option [value]="allotment.waitingListId">{{ allotment.waitingListId }}</mat-option>
}
</mat-select>
<mat-hint>{{ 'Waiting List entry, if any' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Inquiry Date' | translate }}</mat-label>
<input
matInput
[value]="selectedWaitingEntry ? (selectedWaitingEntry.inquiryDate | dateFormat) : ''"
readonly
/>
</mat-form-field>
<mat-form-field appearance="fill" class="span-2">
<mat-label>{{ 'Inquirer Name' | translate }}</mat-label>
<input matInput [value]="selectedWaitingEntry?.customerName ?? ''" readonly />
</mat-form-field>
</div>
</div>
</div>
<!-- ================= RIGHT ================= -->
<div class="col">
<div class="panel">
<div class="panel-title">{{ 'Locker Rent' | translate }}</div>
<div class="grid-2">
<mat-form-field appearance="fill">
<mat-label>{{ 'Class Type' | translate }}</mat-label>
<mat-select formControlName="classCode" (selectionChange)="onClassChange()" required>
@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>{{ 'Category' | translate }}</mat-label>
<mat-select formControlName="category" required>
@for (c of categories; track c.value) {
<mat-option [value]="c.value">{{ c.label | translate }}</mat-option>
}
</mat-select>
@if (clientDetails) {
<mat-hint>{{
(clientDetails.member ? 'Client is a member' : 'Client is not a member') | translate
}}</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Class Rent' | translate }}</mat-label>
<input matInput class="num" [value]="classRent | number: '1.2-2'" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Rent With GST' | translate }}</mat-label>
<input matInput class="num" [value]="rentWithGst | number: '1.2-2'" readonly />
<mat-hint>{{ 'GST 18%' | translate }}: {{ gstAmount | number: '1.2-2' }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Total Rent' | translate }}</mat-label>
<input matInput class="num" [value]="totalPayable | number: '1.2-2'" readonly />
<mat-hint>{{ 'Rent with GST + Deposit' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Depo Amt.' }}</mat-label>
<input matInput class="num" [value]="requiredDeposit | number: '1.2-2'" readonly />
</mat-form-field>
</div>
</div>
<div class="panel">
<div class="panel-title">{{ 'Reference Account Details' | translate }}</div>
<div class="grid-2">
<mat-form-field appearance="fill">
<mat-label>{{ 'Branch' | translate }}</mat-label>
<input matInput [value]="allotment?.officeName ?? ownOfficeName" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'A/c Type' | translate }}</mat-label>
<mat-select
[value]="rentAccountType"
(selectionChange)="onRentAccountTypeChange($event.value)"
[disabled]="mode === 'view' || mainForm.get('rentExemption')?.value"
>
@for (t of rentAccountTypes; track t) {
<mat-option [value]="t">{{ t }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="span-2">
<mat-label>{{ 'A/c No.' }}</mat-label>
<mat-select formControlName="rentAccountId" [required]="!mainForm.get('rentExemption')?.value">
@for (a of rentAccountsOfType; track a.id) {
<mat-option [value]="a.id"
>{{ a.accountNo }} · {{ 'Bal' | translate }} {{ a.balance | number: '1.2-2' }}</mat-option
>
}
</mat-select>
@if (mainForm.get('rentExemption')?.value) {
<mat-hint>{{ 'Not needed — rent is exempted' | translate }}</mat-hint>
} @else if (clientDetails && !clientDetails.savingsAccounts?.length) {
<mat-hint class="hint-warn">{{ 'Client has no active savings account' | translate }}</mat-hint>
} @else {
<mat-hint>{{ 'Locker rent is debited from this account' | translate }}</mat-hint>
}
</mat-form-field>
</div>
</div>
<div class="panel">
<div class="panel-title">{{ 'Contact and KYC' | translate }}</div>
<div class="grid-2">
<mat-form-field appearance="fill">
<mat-label>{{ 'City' | translate }}</mat-label>
<input matInput [value]="clientDetails?.city ?? ''" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Phone-1' | translate }}</mat-label>
<input matInput [value]="clientDetails?.mobileNo ?? ''" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Phone-2' | translate }}</mat-label>
<input matInput formControlName="phone2" maxlength="15" />
@if (mainForm.get('phone2')?.hasError('pattern')) {
<mat-error>{{ '10 to 15 digits' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Pincode' | translate }}</mat-label>
<input matInput [value]="clientDetails?.pincode ?? ''" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Rent Up To Date' | translate }}</mat-label>
<input
matInput
[value]="allotment?.rentUpToDate ? (allotment.rentUpToDate | dateFormat) : ''"
readonly
/>
<mat-hint>{{ 'Updated by Rent Entry' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Exemption Of Rent' | translate }}</mat-label>
<mat-select formControlName="rentExemption">
<mat-option [value]="false">{{ 'NO' | translate }}</mat-option>
<mat-option [value]="true">{{ 'YES' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'P.A.N. No.' }}</mat-label>
<input matInput [value]="clientDetails?.panNumber ?? ''" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Aadhaar No.' }}</mat-label>
<input matInput [value]="clientDetails?.aadhaarMasked ?? ''" readonly />
</mat-form-field>
</div>
</div>
<div class="panel">
<div class="panel-title">{{ 'Documents' | translate }}</div>
<div class="grid-2">
<mat-form-field appearance="fill">
<mat-label>{{ 'Photo Received' | translate }}</mat-label>
<mat-select formControlName="photoReceived">
<mat-option [value]="false">{{ 'No' | translate }}</mat-option>
<mat-option [value]="true">{{ 'Yes' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Address Proof Received' | translate }}</mat-label>
<mat-select formControlName="addressProofReceived">
<mat-option [value]="false">{{ 'No' | translate }}</mat-option>
<mat-option [value]="true">{{ 'Yes' | translate }}</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
@if (clientId && clientDetailsFailed) {
<div class="form-error">{{ 'Could not load the client details.' }}</div>
}
</div>
</div>
</mat-tab>
<!-- ===================== TAB 2: JOINT MEMBERS ===================== -->
<mat-tab>
<ng-template mat-tab-label>
<span [class.tab-error]="tab2Errors.length && mainForm.touched">
{{ 'Joint Member Detail' | translate }}
@if (jointHolders.length) {
({{ jointHolders.length }})
}
</span>
</ng-template>
<div class="tab-body">
<div class="form-note">
{{ 'Pay Mode' | translate }}:
<strong>{{ labelOf(payModes, mainForm.get('payMode')?.value) | translate }}</strong>
—
{{
(needsJointHolder
? 'at least one joint holder is required'
: 'joint holders are optional; mandate holders can still be added'
) | translate
}}
</div>
@for (j of jointHolders; track $index; let i = $index) {
<div class="row-card">
<span class="row-no">{{ i + 1 }}</span>
<div class="row-grid">
<mifosx-locker-client-picker
label="Client Name"
[required]="true"
[disabled]="mode === 'view'"
[clientId]="j.clientId"
[clientName]="j.clientName"
[excludeIds]="takenIds(i, jointHolders)"
(picked)="onJointPicked(i, $event)"
></mifosx-locker-client-picker>
<mat-form-field appearance="fill">
<mat-label>{{ 'Join Category' | translate }}</mat-label>
<mat-select
[(ngModel)]="j.joinCategory"
[ngModelOptions]="{ standalone: true }"
[disabled]="mode === 'view'"
>
@for (c of joinCategories; track c.value) {
<mat-option [value]="c.value">{{ c.label | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (mode !== 'view') {
<button
mat-icon-button
color="warn"
type="button"
[matTooltip]="'Remove' | translate"
(click)="removeJointHolder(i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
}
</div>
}
@if (jointHolders.length === 0) {
<div class="empty-state">{{ 'No joint holders' | translate }}</div>
}
@if (mode !== 'view') {
<button mat-stroked-button color="primary" type="button" (click)="addJointHolder()">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'Add Joint Holder' | translate }}
</button>
}
</div>
</mat-tab>
<!-- ===================== TAB 3: OTHER ENTRIES ===================== -->
<mat-tab>
<ng-template mat-tab-label>
<span [class.tab-error]="tab3Errors.length && mainForm.touched">{{ 'Other Entry(s)' | translate }}</span>
</ng-template>
<div class="tab-body">
<div class="section-label">{{ 'Security Deposit (Fixed Deposit)' | translate }}</div>
@if (!clientId) {
<div class="form-note">{{ 'Choose the client on the first tab to see their fixed deposits.' }}</div>
} @else if ((clientDetails?.fixedDeposits ?? []).length === 0) {
<div class="form-note">{{ 'Client has no active fixed deposit.' }}</div>
} @else {
<table class="simple-table">
<thead>
<tr>
<th></th>
<th>{{ 'A/c No' | translate }}</th>
<th>{{ 'Product' | translate }}</th>
<th class="num">{{ 'Depo. Amt' }}</th>
<th>{{ 'Depo Date' | translate }}</th>
<th>{{ 'Matu. Date' }}</th>
</tr>
</thead>
<tbody>
@for (f of clientDetails.fixedDeposits; track f.id) {
<tr>
<td>
<mat-checkbox
[checked]="selectedFdIds.has(f.id)"
[disabled]="mode === 'view' || (f.onHold && !selectedFdIds.has(f.id))"
(change)="toggleFd(f.id, $event.checked)"
></mat-checkbox>
</td>
<td>
{{ f.accountNo }}
@if (f.onHold && !selectedFdIds.has(f.id)) {
<span class="pill neutral">{{ 'Already under lien' | translate }}</span>
}
</td>
<td>{{ f.productName }}</td>
<td class="num">{{ f.depositAmount | number: '1.2-2' }}</td>
<td>{{ f.depositDate | dateFormat }}</td>
<td>{{ f.maturityDate | dateFormat }}</td>
</tr>
}
</tbody>
</table>
}
<div class="rate-strip">
<div>
<span class="k">{{ 'Deposit required' | translate }}</span
>{{ requiredDeposit | number: '1.2-2' }}
</div>
<div>
<span class="k">{{ 'FD selected' | translate }}</span
>{{ selectedFdTotal | number: '1.2-2' }}
</div>
@if (depositShortfall > 0) {
<div class="warn-text">
<fa-icon icon="exclamation-triangle"></fa-icon>&nbsp;{{ 'Short by' | translate }}
{{ depositShortfall | number: '1.2-2' }}
</div>
}
</div>
<div class="form-note">
{{ 'A lien is placed on the selected deposits when the application is approved.' }}
</div>
<div class="section-label">{{ 'Share / Membership' | translate }}</div>
@if (clientDetails) {
<div class="client-panel">
<div>
<span class="k">{{ 'Member' | translate }}</span
>{{ (clientDetails.member ? 'Yes' : 'No') | translate }}
</div>
<div>
<span class="k">{{ 'Membership No' | translate }}</span
>{{ clientDetails.membershipNo || '-' }}
</div>
<div>
<span class="k">{{ 'No Of Shares' | translate }}</span
>{{ clientDetails.totalShares ?? 0 }}
</div>
</div>
} @else {
<div class="form-note">{{ "Shown from the client's share account." }}</div>
}
<div class="section-label">{{ 'Guarantor' | translate }}</div>
@for (g of guarantors; track $index; let i = $index) {
<div class="row-card">
<span class="row-no">{{ i + 1 }}</span>
<div class="row-grid single">
<mifosx-locker-client-picker
label="Guarantor Name"
[required]="true"
[disabled]="mode === 'view'"
[clientId]="g.clientId"
[clientName]="g.clientName"
[excludeIds]="takenIds(i, guarantors)"
(picked)="onGuarantorPicked(i, $event)"
></mifosx-locker-client-picker>
</div>
@if (mode !== 'view') {
<button
mat-icon-button
color="warn"
type="button"
[matTooltip]="'Remove' | translate"
(click)="removeGuarantor(i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
}
</div>
}
@if (guarantors.length === 0) {
<div class="empty-state">{{ 'No guarantors' | translate }}</div>
}
@if (mode !== 'view') {
<button mat-stroked-button color="primary" type="button" (click)="addGuarantor()">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'Add Guarantor' | translate }}
</button>
}
</div>
</mat-tab>
<!-- ===================== TAB 4: NOMINEE ===================== -->
<mat-tab>
<ng-template mat-tab-label>
<span [class.tab-error]="tab4Errors.length && mainForm.touched">
{{ "Nominee's Detail" | translate }}
@if (nominees.length) {
({{ nominees.length }})
}
</span>
</ng-template>
<div class="tab-body" [formGroup]="nomineeForm">
<mat-checkbox formControlName="hasNominee" (change)="onHasNomineeChange()">{{
'Nominee' | translate
}}</mat-checkbox>
<div formArrayName="nominees">
@for (n of nominees.controls; track $index; let i = $index) {
<div class="row-card" [formGroupName]="i">
<span class="row-no">{{ i + 1 }}</span>
<div class="row-grid nominee">
<mat-form-field appearance="fill">
<mat-label>{{ 'Nom. Date' }}</mat-label>
<input matInput [matDatepicker]="nomPicker" formControlName="nominationDate" required />
<mat-datepicker-toggle matSuffix [for]="nomPicker"></mat-datepicker-toggle>
<mat-datepicker #nomPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Name' | translate }}</mat-label>
<input matInput formControlName="name" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Mobile No' | translate }}</mat-label>
<input matInput formControlName="mobileNo" maxlength="15" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Birth Dt.' }}</mat-label>
<input
matInput
[matDatepicker]="dobPicker"
formControlName="dateOfBirth"
[max]="transactionDate"
/>
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
<mat-datepicker #dobPicker></mat-datepicker>
@if (isMinor(i)) {
<mat-hint class="hint-warn">{{ 'Minor' | translate }}</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Relation' | translate }}</mat-label>
<mat-select formControlName="relation" required>
@for (r of relations; track r) {
<mat-option [value]="r">{{ r }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Gender' | translate }}</mat-label>
<mat-select formControlName="gender" required>
@for (g of genders; track g) {
<mat-option [value]="g">{{ g }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Guar. Name' }}</mat-label>
<input matInput formControlName="guardianName" [required]="isMinor(i)" />
@if (isMinor(i)) {
<mat-hint>{{ 'Required for a minor nominee' | translate }}</mat-hint>
}
</mat-form-field>
</div>
@if (mode !== 'view') {
<button
mat-icon-button
color="warn"
type="button"
[matTooltip]="'Remove' | translate"
(click)="removeNominee(i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
}
</div>
}
</div>
@if (nomineeForm.get('hasNominee')?.value && mode !== 'view') {
<button mat-stroked-button color="primary" type="button" (click)="addNominee()">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'Add Nominee' | translate }}
</button>
}
</div>
</mat-tab>
</mat-tab-group>
@if (mode !== 'view') {
@if (allErrors.length && mainForm.touched) {
<div class="error-list">
@for (e of allErrors; track e) {
<div><fa-icon icon="exclamation-circle"></fa-icon>&nbsp;{{ e | translate }}</div>
}
</div>
}
<div class="form-actions">
<button mat-raised-button type="button" [routerLink]="['/locker', 'allotment']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" type="button" [disabled]="busy" (click)="submit()">
{{ (mode === 'create' ? 'Submit for Approval' : 'Save Changes') | translate }}
</button>
</div>
}
</mat-card-content>
</mat-card>
}
</div>
@@ -0,0 +1,351 @@
/**
* 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;
h2 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin: 0;
}
}
.header-actions {
display: flex;
gap: 12px;
}
.tag {
padding: 2px 8px;
border-radius: 6px;
background: rgb(0 0 0 / 6%);
color: rgb(0 0 0 / 60%);
font-size: 0.8rem;
font-weight: 500;
}
.notice {
margin-bottom: 16px;
&.error {
border-left: 4px solid #c62828;
}
}
.approve-bar mat-card-content {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.approve-text {
color: #b45309;
font-weight: 500;
}
.approve-actions {
display: flex;
gap: 8px;
}
.reject-box {
flex-basis: 100%;
}
.tab-body {
padding: 16px 4px 8px;
}
/* Tab 1 keeps the legacy screen's sections and field order, stacked one below the other at full width. */
.legacy-layout {
display: flex;
flex-direction: column;
gap: 12px;
}
.col {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.panel {
padding: 12px 14px 4px;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 6px;
background: #fff;
}
.panel-title {
margin: 0 0 10px;
font-size: 0.85rem;
font-weight: 600;
color: #1976d2;
}
/* Fields flow across the full width, as many per row as fit. */
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0 16px;
.span-2 {
grid-column: span 2;
}
}
input.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.tab-error {
color: #d32f2f;
}
.section-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 55%);
margin: 16px 0 8px;
&:first-child {
margin-top: 0;
}
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
margin-bottom: 8px;
}
.span-2 {
grid-column: span 2;
}
mat-form-field,
.full-width {
width: 100%;
}
.info-box {
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 12px;
border-radius: 6px;
background: rgb(25 118 210 / 6%);
min-height: 56px;
}
.info-label {
font-size: 0.75rem;
color: rgb(0 0 0 / 60%);
}
.info-value {
font-size: 0.95rem;
font-weight: 600;
}
.client-panel {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px 20px;
padding: 12px 16px;
margin-bottom: 8px;
border-radius: 6px;
background: #fafafa;
border: 1px solid rgb(0 0 0 / 8%);
font-size: 0.9rem;
}
.k {
display: block;
font-size: 0.72rem;
color: rgb(0 0 0 / 55%);
}
.rate-strip {
display: flex;
flex-wrap: wrap;
gap: 12px 32px;
padding: 10px 16px;
margin: 4px 0 8px;
border-radius: 6px;
background: rgb(25 118 210 / 6%);
font-variant-numeric: tabular-nums;
}
.sub {
font-size: 0.75rem;
color: rgb(0 0 0 / 55%);
}
.warn-text {
color: #b45309;
font-weight: 600;
align-self: center;
}
.check-row {
display: flex;
flex-wrap: wrap;
gap: 4px 24px;
margin-bottom: 8px;
}
.check-cell {
display: flex;
align-items: center;
}
.row-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 8px 8px 0;
margin-bottom: 8px;
border: 1px solid rgb(0 0 0 / 10%);
border-radius: 6px;
}
.row-no {
min-width: 24px;
padding-top: 18px;
font-weight: 600;
color: rgb(0 0 0 / 55%);
}
.row-grid {
flex: 1;
display: grid;
grid-template-columns: 2fr 1fr;
gap: 0 16px;
&.single {
grid-template-columns: 1fr;
}
&.nominee {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
}
.simple-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 8px;
th,
td {
padding: 6px 8px;
border-bottom: 1px solid rgb(0 0 0 / 8%);
text-align: left;
}
th {
font-size: 0.8rem;
color: rgb(0 0 0 / 60%);
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
.form-note {
color: rgb(0 0 0 / 60%);
font-size: 0.85rem;
margin: 0 0 8px;
}
.form-error {
color: #d32f2f;
font-size: 0.85rem;
margin: 0 0 8px;
}
.hint-warn {
color: #b45309;
}
.error-list {
margin-top: 12px;
padding: 10px 14px;
border-radius: 6px;
background: #fdecea;
color: #c62828;
font-size: 0.85rem;
div + div {
margin-top: 4px;
}
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.empty-state {
padding: 16px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.warn {
background: #fdf1dc;
color: #b45309;
}
&.danger {
background: #fde2e1;
color: #c62828;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.status-pill {
font-size: 0.75rem;
}
@@ -0,0 +1,857 @@
/**
* 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,
FormsModule,
UntypedFormArray,
UntypedFormBuilder,
UntypedFormGroup,
ValidationErrors,
ValidatorFn,
Validators
} from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatTabsModule } from '@angular/material/tabs';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
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 {
LockerClientPickerComponent,
PickedClient
} from '../../shared/locker-client-picker/locker-client-picker.component';
/** Locker operation password and its confirmation must match. */
const passwordMatchValidator: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password && confirm && password !== confirm ? { passwordMismatch: true } : null;
};
/** GST on locker rent: 9% CGST + 9% SGST (or 18% IGST) — the total rate shown on the form. */
const GST_RATE = 0.18;
/** One joint holder / mandate holder row on tab 2. */
interface JointHolderRow {
clientId: number | null;
clientName: string | null;
joinCategory: string;
}
/** One guarantor row on tab 3. */
interface GuarantorRow {
clientId: number | null;
clientName: string | null;
}
/**
* Locker allotment — the legacy "A/c Master Maintenance" screen as four tabs: Application Master, Joint Members,
* Other Entries (FD security deposit, membership, guarantors) and Nominees. Saving submits the application for the
* Branch Manager's approval; only an approved application allots the locker. The same screen opens an existing
* application — editable while pending, read-only afterwards — with Approve / Reject for the approver.
*/
@Component({
selector: 'mifosx-locker-allotment-form',
templateUrl: './allotment-form.component.html',
styleUrls: ['./allotment-form.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
RouterLink,
MatButtonModule,
MatTabsModule,
MatSnackBarModule,
MatTooltipModule,
FaIconComponent,
LockerClientPickerComponent
]
})
export class AllotmentFormComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private lockerService = inject(LockerService);
private settingsService = inject(SettingsService);
private authenticationService = inject(AuthenticationService);
private dateUtils = inject(Dates);
private route = inject(ActivatedRoute);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
private serverMessage(err: any, fallback: string): string {
const body = err?.error;
return body?.errors?.[0]?.defaultUserMessage || body?.defaultUserMessage || fallback;
}
// ----- Mode -----
/** create: new application; edit: pending and the user may change it; view: everything else (read-only). */
mode: 'create' | 'edit' | 'view' = 'create';
allotmentId: number | null = null;
allotment: any = null;
busy = false;
loadFailed = false;
selectedTab = 0;
// ----- Reference data -----
ownOfficeName = '';
transactionDate: Date = new Date();
permissions: string[] = [];
/** Active class master rows of the branch; rates are taken from the row the chosen locker belongs to. */
classRows: any[] = [];
classCodes: string[] = [];
/** Vacant lockers of the chosen class (plus this application's own locker when it is opened again). */
lockers: any[] = [];
/** Pending waiting-list entries — this client's first. */
waitingEntries: any[] = [];
// ----- Client (tab 1) -----
clientId: number | null = null;
clientName: string | null = null;
clientDetails: any = null;
clientDetailsFailed = false;
// ----- Tabs 2–4 -----
jointHolders: JointHolderRow[] = [];
guarantors: GuarantorRow[] = [];
selectedFdIds = new Set<number>();
readonly payModes = [
{ value: 'SELF', label: 'Self' },
{ value: 'EITHER_OR_SURVIVOR', label: 'Either or Survivor' },
{ value: 'FORMER_OR_SURVIVOR', label: 'Former or Survivor' },
{ value: 'ANYONE_OR_SURVIVOR', label: 'Anyone or Survivor' },
{ value: 'JOINTLY', label: 'Jointly' }
];
readonly categories = [
{ value: 'MEMBER', label: 'Member' },
{ value: 'NON_MEMBER', label: 'Non-Member' },
{ value: 'STAFF', label: 'Staff' }
];
readonly risks = [
'LOW',
'MEDIUM',
'HIGH'
];
readonly joinCategories = [
{ value: 'JOINT_HOLDER', label: 'Joint Holder' },
{ value: 'MANDATE_HOLDER', label: 'Mandate Holder' },
{ value: 'AUTHORISED_SIGNATORY', label: 'Authorised Signatory' }
];
readonly relations = [
'SPOUSE',
'FATHER',
'MOTHER',
'SON',
'DAUGHTER',
'BROTHER',
'SISTER',
'OTHER'
];
readonly genders = [
'MALE',
'FEMALE',
'OTHER'
];
mainForm: UntypedFormGroup;
nomineeForm: UntypedFormGroup;
rejecting = false;
rejectionReason = '';
constructor() {
this.mainForm = this.formBuilder.group(
{
classCode: [
'',
Validators.required
],
lockerId: [
'',
Validators.required
],
keyNo: [
'',
[
Validators.required,
Validators.maxLength(20)
]
],
category: [
'NON_MEMBER',
Validators.required
],
payMode: [
'SELF',
Validators.required
],
password: [
'',
[
Validators.minLength(4),
Validators.maxLength(20)
]
],
confirmPassword: [''],
dualAuth: [false],
smsActive: [true],
emailActive: [false],
rentExemption: [false],
rentAccountId: [''],
risk: [''],
freeze: [false],
freezeRemark: [
'',
Validators.maxLength(200)
],
waitingListId: [''],
phone2: [
'',
Validators.pattern(/^\d{10,15}$/)
],
photoReceived: [false],
addressProofReceived: [false],
remarks: [
'',
Validators.maxLength(500)
]
},
{ validators: passwordMatchValidator }
);
this.nomineeForm = this.formBuilder.group({
hasNominee: [false],
nominees: this.formBuilder.array([])
});
}
get nominees(): UntypedFormArray {
return this.nomineeForm.get('nominees') as UntypedFormArray;
}
ngOnInit(): void {
const credentials = this.authenticationService.getCredentials();
this.permissions = credentials?.permissions ?? [];
this.ownOfficeName = credentials?.officeName ?? '';
const businessDate = this.settingsService.businessDate;
this.transactionDate = businessDate && !isNaN(businessDate.getTime()) ? businessDate : new Date();
this.loadClasses();
this.loadWaitingEntries();
const id = this.route.snapshot.paramMap.get('id');
if (id && id !== 'new') {
this.allotmentId = Number(id);
this.loadAllotment();
} else {
this.mode = 'create';
this.applyPasswordRule();
}
}
// ----- Loading -----
private loadAllotment(): void {
this.lockerService.getAllotment(this.allotmentId!).subscribe({
next: (data: any) => {
this.allotment = data;
const editable = data.status === 'PENDING_APPROVAL' && this.hasPermission('UPDATE_LOCKERALLOTMENT');
this.mode = editable ? 'edit' : 'view';
this.fillFromAllotment(data);
this.applyPasswordRule();
if (this.mode === 'view') {
this.mainForm.disable({ emitEvent: false });
this.nomineeForm.disable({ emitEvent: false });
}
},
error: () => (this.loadFailed = true)
});
}
private fillFromAllotment(data: any): void {
this.clientId = data.clientId;
this.clientName = data.clientName;
this.loadClientDetails(data.clientId);
this.mainForm.patchValue(
{
classCode: data.classCode,
lockerId: data.lockerId,
keyNo: data.keyNo,
category: data.category,
payMode: data.payMode,
dualAuth: !!data.dualAuth,
smsActive: !!data.smsActive,
emailActive: !!data.emailActive,
rentExemption: !!data.rentExemption,
rentAccountId: data.rentAccountId ?? '',
risk: data.risk ?? '',
freeze: !!data.freeze,
freezeRemark: data.freezeRemark ?? '',
waitingListId: data.waitingListId ?? '',
phone2: data.phone2 ?? '',
photoReceived: !!data.photoReceived,
addressProofReceived: !!data.addressProofReceived,
remarks: data.remarks ?? ''
},
{ emitEvent: false }
);
this.loadLockers(data.classCode, { id: data.lockerId, lockerNo: data.lockerNo, lockerClassId: data.lockerClassId });
this.jointHolders = (data.jointHolders ?? []).map((j: any) => ({
clientId: j.clientId,
clientName: j.clientName,
joinCategory: j.joinCategory
}));
this.guarantors = (data.guarantors ?? []).map((g: any) => ({ clientId: g.clientId, clientName: g.clientName }));
this.selectedFdIds = new Set((data.fixedDeposits ?? []).map((f: any) => f.accountId));
this.nominees.clear();
(data.nominees ?? []).forEach((n: any) => this.nominees.push(this.buildNominee(n)));
this.nomineeForm.get('hasNominee')?.setValue(this.nominees.length > 0, { emitEvent: false });
}
private loadClasses(): void {
this.lockerService.getClasses('ACTIVE', 0, 500).subscribe({
next: (res: any) => {
this.classRows = res?.pageItems ?? [];
this.classCodes = Array.from(new Set<string>(this.classRows.map((c: any) => c.classCode))).sort();
},
error: () => {
this.classRows = [];
this.classCodes = [];
}
});
}
/** Vacant lockers of the class; the application's own (reserved) locker is kept in the list when reopened. */
loadLockers(classCode: string, keep?: { id: number; lockerNo: number; lockerClassId: number }): void {
this.lockers = [];
if (!classCode) {
return;
}
this.lockerService.getLockers({ classCode, status: 'VACANT' }, 0, 500).subscribe({
next: (res: any) => {
const rows = res?.pageItems ?? [];
if (keep && !rows.some((l: any) => l.id === keep.id)) {
rows.unshift({ id: keep.id, lockerNo: keep.lockerNo, lockerClassId: keep.lockerClassId, classCode });
}
this.lockers = rows;
},
error: () => (this.lockers = keep ? [{ ...keep, classCode }] : [])
});
}
private loadWaitingEntries(): void {
this.lockerService.getWaitingList('PENDING', 0, 500).subscribe({
next: (res: any) => {
this.waitingEntries = res?.pageItems ?? [];
this.sortWaitingEntries();
},
error: () => (this.waitingEntries = [])
});
}
/** This client's own inquiries first, then queue order. */
private sortWaitingEntries(): void {
this.waitingEntries.sort((a: any, b: any) => {
const aMine = a.clientId && a.clientId === this.clientId ? 0 : 1;
const bMine = b.clientId && b.clientId === this.clientId ? 0 : 1;
return aMine - bMine || a.id - b.id;
});
}
private loadClientDetails(clientId: number): void {
this.clientDetails = null;
this.clientDetailsFailed = false;
this.lockerService.getAllotmentClientDetails(clientId).subscribe({
next: (details: any) => {
this.clientDetails = details;
if (this.mode === 'create') {
// Category follows membership; staff is chosen by hand.
this.mainForm.get('category')?.setValue(details?.member ? 'MEMBER' : 'NON_MEMBER');
if (details?.riskRating && !this.mainForm.get('risk')?.value) {
this.mainForm.get('risk')?.setValue(details.riskRating);
}
const accounts = details?.savingsAccounts ?? [];
if (accounts.length === 1 && !this.mainForm.get('rentAccountId')?.value) {
this.mainForm.get('rentAccountId')?.setValue(accounts[0].id);
}
}
this.syncRentAccountType();
},
error: () => (this.clientDetailsFailed = true)
});
}
// ----- Tab 1 events -----
onClientPicked(client: PickedClient | null): void {
this.clientId = client?.clientId ?? null;
this.clientName = client?.displayName ?? null;
this.clientDetails = null;
this.selectedFdIds.clear();
this.rentAccountType = '';
this.mainForm.get('rentAccountId')?.setValue('');
// The applicant cannot also be their own joint holder or guarantor.
this.jointHolders = this.jointHolders.filter((j) => j.clientId !== this.clientId);
this.guarantors = this.guarantors.filter((g) => g.clientId !== this.clientId);
if (this.clientId) {
this.loadClientDetails(this.clientId);
}
this.sortWaitingEntries();
}
onClassChange(): void {
this.mainForm.get('lockerId')?.setValue('');
this.loadLockers(this.mainForm.get('classCode')?.value);
}
/** Picking a waiting-list entry pre-selects the first class that inquiry asked for. */
onWaitingEntryChange(): void {
const entry = this.selectedWaitingEntry;
const wanted = entry?.preferredClasses?.find((code: string) => this.classCodes.includes(code));
if (wanted && !this.mainForm.get('classCode')?.value) {
this.mainForm.get('classCode')?.setValue(wanted);
this.onClassChange();
}
}
get selectedWaitingEntry(): any {
const id = Number(this.mainForm.get('waitingListId')?.value);
return id ? this.waitingEntries.find((w: any) => w.id === id) : null;
}
// ----- Rates -----
/** Class master row the chosen locker was created from. */
get selectedClassRow(): any {
const lockerId = Number(this.mainForm.get('lockerId')?.value);
const locker = this.lockers.find((l: any) => l.id === lockerId);
const classId = locker?.lockerClassId;
return classId ? this.classRows.find((c: any) => c.id === classId) : null;
}
get isMemberRate(): boolean {
return this.mainForm.get('category')?.value === 'MEMBER';
}
get classRent(): number {
const row = this.selectedClassRow;
if (!row) return 0;
return Number(this.isMemberRate ? row.memberRent : row.rent) || 0;
}
get gstAmount(): number {
return Math.round(this.classRent * GST_RATE * 100) / 100;
}
get rentWithGst(): number {
return Math.round((this.classRent + this.gstAmount) * 100) / 100;
}
get requiredDeposit(): number {
const row = this.selectedClassRow;
if (!row) return 0;
return Number(this.isMemberRate ? row.memberDeposit : row.deposit) || 0;
}
/** First payment at allotment: a year's rent with GST plus the security deposit. */
get totalPayable(): number {
return Math.round((this.rentWithGst + this.requiredDeposit) * 100) / 100;
}
// ----- Reference account (legacy A/c Type → A/c No) -----
/** Savings product of the rent account, as the legacy "A/c Type" picker. */
rentAccountType = '';
get rentAccountTypes(): string[] {
return Array.from(new Set<string>((this.clientDetails?.savingsAccounts ?? []).map((a: any) => a.productName)));
}
get rentAccountsOfType(): any[] {
return (this.clientDetails?.savingsAccounts ?? []).filter(
(a: any) => !this.rentAccountType || a.productName === this.rentAccountType
);
}
onRentAccountTypeChange(type: string): void {
this.rentAccountType = type;
const accounts = this.rentAccountsOfType;
this.mainForm.get('rentAccountId')?.setValue(accounts.length === 1 ? accounts[0].id : '');
}
/** Picks the type of the chosen account, or the only type the client has. */
private syncRentAccountType(): void {
const accounts = this.clientDetails?.savingsAccounts ?? [];
const chosen = accounts.find((a: any) => a.id === Number(this.mainForm.get('rentAccountId')?.value));
this.rentAccountType = chosen?.productName ?? (this.rentAccountTypes.length === 1 ? this.rentAccountTypes[0] : '');
}
get selectedFdTotal(): number {
return (this.clientDetails?.fixedDeposits ?? [])
.filter((f: any) => this.selectedFdIds.has(f.id))
.reduce((sum: number, f: any) => sum + (Number(f.depositAmount) || 0), 0);
}
get depositShortfall(): number {
return Math.max(0, this.requiredDeposit - this.selectedFdTotal);
}
// ----- Tab 2: joint holders -----
/** Any mode other than SELF means more than one person operates the locker. */
get needsJointHolder(): boolean {
return this.mainForm.get('payMode')?.value !== 'SELF';
}
addJointHolder(): void {
this.jointHolders.push({ clientId: null, clientName: null, joinCategory: 'JOINT_HOLDER' });
}
removeJointHolder(index: number): void {
this.jointHolders.splice(index, 1);
}
onJointPicked(index: number, client: PickedClient | null): void {
this.jointHolders[index].clientId = client?.clientId ?? null;
this.jointHolders[index].clientName = client?.displayName ?? null;
}
/** Ids already used on this application, so the same person cannot be picked twice. */
takenIds(exceptIndex: number, rows: { clientId: number | null }[]): number[] {
const ids = rows
.filter((_, i) => i !== exceptIndex)
.map((r) => r.clientId)
.filter((id): id is number => !!id);
return this.clientId ? [
this.clientId,
...ids
] : ids;
}
// ----- Tab 3: deposits and guarantors -----
toggleFd(id: number, checked: boolean): void {
if (checked) {
this.selectedFdIds.add(id);
} else {
this.selectedFdIds.delete(id);
}
}
addGuarantor(): void {
this.guarantors.push({ clientId: null, clientName: null });
}
removeGuarantor(index: number): void {
this.guarantors.splice(index, 1);
}
onGuarantorPicked(index: number, client: PickedClient | null): void {
this.guarantors[index].clientId = client?.clientId ?? null;
this.guarantors[index].clientName = client?.displayName ?? null;
}
// ----- Tab 4: nominees -----
private buildNominee(n: any = {}): UntypedFormGroup {
return this.formBuilder.group({
nominationDate: [
n.nominationDate ? this.dateUtils.parseDate(n.nominationDate) : this.transactionDate,
Validators.required
],
name: [
n.name ?? '',
[
Validators.required,
Validators.maxLength(100)
]
],
mobileNo: [
n.mobileNo ?? '',
Validators.pattern(/^\d{10,15}$/)
],
dateOfBirth: [n.dateOfBirth ? this.dateUtils.parseDate(n.dateOfBirth) : ''],
relation: [
n.relation ?? '',
Validators.required
],
gender: [
n.gender ?? '',
Validators.required
],
guardianName: [
n.guardianName ?? '',
Validators.maxLength(100)
]
});
}
onHasNomineeChange(): void {
if (this.nomineeForm.get('hasNominee')?.value) {
if (this.nominees.length === 0) {
this.addNominee();
}
} else {
this.nominees.clear();
}
}
addNominee(): void {
this.nominees.push(this.buildNominee());
}
removeNominee(index: number): void {
this.nominees.removeAt(index);
if (this.nominees.length === 0) {
this.nomineeForm.get('hasNominee')?.setValue(false);
}
}
/** Under 18 on the transaction date. */
isMinor(index: number): boolean {
const dob = this.nominees.at(index).get('dateOfBirth')?.value;
if (!dob) return false;
const birth = new Date(dob);
const eighteenth = new Date(birth.getFullYear() + 18, birth.getMonth(), birth.getDate());
return eighteenth > this.transactionDate;
}
// ----- Validation per tab (drives the red tab labels and the submit button) -----
get tab1Errors(): string[] {
const errors: string[] = [];
if (!this.clientId) errors.push('Choose the client');
const f = this.mainForm;
if (f.get('classCode')?.invalid) errors.push('Choose the class');
if (f.get('lockerId')?.invalid) errors.push('Choose a vacant locker');
if (f.get('keyNo')?.invalid) errors.push('Enter the key number');
if (f.get('payMode')?.invalid) errors.push('Choose the pay mode');
if (f.get('category')?.invalid) errors.push('Choose the category');
if (this.mode === 'create' && !f.get('password')?.value) errors.push('Set the locker password');
if (f.get('password')?.invalid) errors.push('Password must be 4 to 20 characters');
if (f.hasError('passwordMismatch')) errors.push('Passwords do not match');
if (!f.get('rentExemption')?.value && !f.get('rentAccountId')?.value) errors.push('Choose the rent debit account');
if (f.get('phone2')?.invalid) errors.push('Phone-2 must be 10 to 15 digits');
return errors;
}
get tab2Errors(): string[] {
const errors: string[] = [];
if (this.needsJointHolder && this.jointHolders.length === 0) {
errors.push('This pay mode needs at least one joint holder');
}
if (this.jointHolders.some((j) => !j.clientId)) errors.push('Pick a client on every joint holder row');
return errors;
}
get tab3Errors(): string[] {
return this.guarantors.some((g) => !g.clientId) ? ['Pick a client on every guarantor row'] : [];
}
get tab4Errors(): string[] {
const errors: string[] = [];
if (this.nomineeForm.get('hasNominee')?.value && this.nominees.length === 0) errors.push('Add the nominee');
this.nominees.controls.forEach((group, i) => {
if (group.invalid) errors.push(`Complete nominee ${i + 1}`);
if (this.isMinor(i) && !group.get('guardianName')?.value) {
errors.push(`Nominee ${i + 1} is a minor — enter the guardian name`);
}
});
return errors;
}
get allErrors(): string[] {
return [
...this.tab1Errors,
...this.tab2Errors,
...this.tab3Errors,
...this.tab4Errors
];
}
// ----- Save -----
submit(): void {
if (this.mode === 'view' || this.busy) {
return;
}
if (this.allErrors.length) {
this.mainForm.markAllAsTouched();
this.nomineeForm.markAllAsTouched();
this.selectedTab = this.tab1Errors.length ? 0 : this.tab2Errors.length ? 1 : this.tab3Errors.length ? 2 : 3;
this.notify(this.allErrors[0], true);
return;
}
this.busy = true;
const raw = this.mainForm.getRawValue();
const dateFormat = this.settingsService.dateFormat;
const payload: any = {
clientId: this.clientId,
lockerId: Number(raw.lockerId),
keyNo: raw.keyNo,
category: raw.category,
payMode: raw.payMode,
dualAuth: !!raw.dualAuth,
smsActive: !!raw.smsActive,
emailActive: !!raw.emailActive,
rentExemption: !!raw.rentExemption,
rentAccountId: raw.rentExemption || !raw.rentAccountId ? null : Number(raw.rentAccountId),
risk: raw.risk || null,
freeze: !!raw.freeze,
freezeRemark: raw.freeze ? raw.freezeRemark || null : null,
waitingListId: raw.waitingListId ? Number(raw.waitingListId) : null,
phone2: raw.phone2 || null,
photoReceived: !!raw.photoReceived,
addressProofReceived: !!raw.addressProofReceived,
remarks: raw.remarks || null,
transactionDate: this.dateUtils.formatDate(this.transactionDate, dateFormat),
jointHolders: this.jointHolders.map((j) => ({ clientId: j.clientId, joinCategory: j.joinCategory })),
fixedDepositIds: [...this.selectedFdIds],
guarantors: this.guarantors.map((g) => ({ clientId: g.clientId })),
nominees: this.nominees.getRawValue().map((n: any) => ({
nominationDate: this.dateUtils.formatDate(n.nominationDate, dateFormat),
name: n.name,
mobileNo: n.mobileNo || null,
dateOfBirth: n.dateOfBirth ? this.dateUtils.formatDate(n.dateOfBirth, dateFormat) : null,
relation: n.relation,
gender: n.gender,
guardianName: n.guardianName || null
})),
dateFormat,
locale: this.settingsService.language.code
};
// On edit a blank password keeps the one already set.
if (raw.password) {
payload.password = raw.password;
}
const request =
this.mode === 'create'
? this.lockerService.createAllotment(payload)
: this.lockerService.updateAllotment(this.allotmentId!, payload);
request.subscribe({
next: (res: any) => {
this.busy = false;
this.notify(
this.mode === 'create'
? 'Application submitted for Branch Manager approval'
: 'Application updated — still awaiting approval'
);
this.router.navigate([
'/locker',
'allotment',
res?.resourceId ?? this.allotmentId
]);
if (this.mode === 'edit') {
this.loadAllotment();
}
},
error: (err: any) => {
this.busy = false;
this.notify(this.serverMessage(err, 'Failed to save the application'), true);
}
});
}
// ----- Approval -----
get isPending(): boolean {
return this.allotment?.status === 'PENDING_APPROVAL';
}
approve(): void {
if (!this.allotmentId || this.busy) return;
this.busy = true;
this.lockerService.approveAllotment(this.allotmentId).subscribe({
next: () => {
this.busy = false;
this.notify('Locker allotted');
this.loadAllotment();
},
error: (err: any) => {
this.busy = false;
this.notify(this.serverMessage(err, 'Failed to approve'), true);
}
});
}
confirmReject(): void {
if (!this.allotmentId || this.busy || !this.rejectionReason.trim()) return;
this.busy = true;
this.lockerService.rejectAllotment(this.allotmentId, this.rejectionReason.trim()).subscribe({
next: () => {
this.busy = false;
this.rejecting = false;
this.rejectionReason = '';
this.notify('Application rejected — the locker is vacant again');
this.loadAllotment();
},
error: (err: any) => {
this.busy = false;
this.notify(this.serverMessage(err, 'Failed to reject'), true);
}
});
}
// ----- Helpers -----
/** Password is required only for a new application; on edit a blank one means "keep". */
private applyPasswordRule(): void {
const password = this.mainForm.get('password');
const validators = [
Validators.minLength(4),
Validators.maxLength(20)
];
password?.setValidators(
this.mode === 'create' ? [
Validators.required,
...validators
] : validators
);
password?.updateValueAndValidity({ emitEvent: false });
}
hasPermission(code: string): boolean {
return this.permissions.includes('ALL_FUNCTIONS') || this.permissions.includes(code);
}
labelOf(list: { value: string; label: string }[], value: string): string {
return list.find((x) => x.value === value)?.label ?? value;
}
statusLabel(status: string): string {
switch (status) {
case 'PENDING_APPROVAL':
return 'Pending Approval';
case 'ACTIVE':
return 'Active';
case 'REJECTED':
return 'Rejected';
case 'CLOSED':
return 'Closed';
default:
return status;
}
}
}
@@ -0,0 +1,103 @@
<!--
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 Allotment' | translate }}</h2>
<div class="header-actions">
<button
mat-raised-button
color="primary"
[routerLink]="['/locker', 'allotment', 'new']"
*mifosxHasPermission="'CREATE_LOCKERALLOTMENT'"
>
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'New Allotment' | translate }}
</button>
<button mat-raised-button [routerLink]="['/locker']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
<div class="filter-row">
@for (s of statuses; track s.value) {
<button mat-stroked-button [class.active]="statusFilter === s.value" (click)="filterByStatus(s.value)">
{{ s.label | translate }}
</button>
}
</div>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="allotments">
<ng-container matColumnDef="applicationNo">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Application No' | translate }}</th>
<td mat-cell *matCellDef="let a" class="num">
<strong>{{ a.applicationNo ?? a.id }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="office">
<th mat-header-cell *matHeaderCellDef>{{ 'Branch' | translate }}</th>
<td mat-cell *matCellDef="let a">{{ a.officeName }}</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>{{ 'Client' | translate }}</th>
<td mat-cell *matCellDef="let a">
{{ a.clientName }}
<br /><span class="sub">{{ 'Client ID' | translate }} {{ a.clientId }}</span>
</td>
</ng-container>
<ng-container matColumnDef="locker">
<th mat-header-cell *matHeaderCellDef>{{ 'Locker' | translate }}</th>
<td mat-cell *matCellDef="let a">
<strong>{{ a.classCode }}-{{ a.lockerNo }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="keyNo">
<th mat-header-cell *matHeaderCellDef>{{ 'Key No' | translate }}</th>
<td mat-cell *matCellDef="let a">{{ a.keyNo }}</td>
</ng-container>
<ng-container matColumnDef="payMode">
<th mat-header-cell *matHeaderCellDef>{{ 'Pay Mode' | translate }}</th>
<td mat-cell *matCellDef="let a">{{ a.payMode }}</td>
</ng-container>
<ng-container matColumnDef="openingDate">
<th mat-header-cell *matHeaderCellDef>{{ 'Op. Date' }}</th>
<td mat-cell *matCellDef="let a">{{ a.openingDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let a">
<span class="pill" [ngClass]="statusClass(a.status)">{{ statusLabel(a.status) | translate }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let a" class="actions-cell">
<button mat-icon-button color="primary" [matTooltip]="'Open' | translate" (click)="open(a)">
<fa-icon icon="eye"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns" class="clickable" (click)="open(row)"></tr>
</table>
@if (loadFailed) {
<div class="empty-state">{{ 'Could not load locker applications.' }}</div>
} @else if (allotments.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</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,99 @@
/**
* 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;
}
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
tr.clickable {
cursor: pointer;
&:hover {
background: rgb(25 118 210 / 4%);
}
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.sub {
font-size: 0.8rem;
color: rgb(0 0 0 / 55%);
}
.actions-cell {
white-space: nowrap;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.warn {
background: #fdf1dc;
color: #b45309;
}
&.danger {
background: #fde2e1;
color: #c62828;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -0,0 +1,134 @@
/**
* 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 { Router, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { LockerService } from '../../locker.service';
/**
* Locker applications of the branch by status. A new application waits for the Branch Manager's approval; only
* then is the locker allotted. Opening a row shows the full application (and Approve / Reject while pending).
*/
@Component({
selector: 'mifosx-locker-allotment-list',
templateUrl: './allotment-list.component.html',
styleUrls: ['./allotment-list.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatTooltipModule,
FaIconComponent
]
})
export class AllotmentListComponent implements OnInit {
private lockerService = inject(LockerService);
private router = inject(Router);
allotments: any[] = [];
loadFailed = false;
statusFilter = 'PENDING_APPROVAL';
pageSize = 10;
pageIndex = 0;
totalRecords = 0;
pageSizeOptions = [
10,
25,
50,
100
];
statuses = [
{ value: 'PENDING_APPROVAL', label: 'Pending Approval' },
{ value: 'ACTIVE', label: 'Active' },
{ value: 'REJECTED', label: 'Rejected' },
{ value: 'CLOSED', label: 'Closed' }
];
columns = [
'applicationNo',
'office',
'clientName',
'locker',
'keyNo',
'payMode',
'openingDate',
'status',
'actions'
];
ngOnInit(): void {
this.loadAllotments();
}
loadAllotments(): void {
this.loadFailed = false;
this.lockerService
.getAllotments(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize)
.subscribe({
next: (res: any) => {
this.allotments = res?.pageItems ?? [];
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
},
error: () => {
this.allotments = [];
this.totalRecords = 0;
this.loadFailed = true;
}
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.loadAllotments();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadAllotments();
}
open(row: any): void {
this.router.navigate([
'/locker',
'allotment',
row.id
]);
}
statusLabel(status: string): string {
return this.statuses.find((s) => s.value === status)?.label ?? status;
}
statusClass(status: string): string {
switch (status) {
case 'ACTIVE':
return 'ok';
case 'PENDING_APPROVAL':
return 'warn';
case 'REJECTED':
return 'danger';
default:
return 'neutral';
}
}
}
@@ -0,0 +1,281 @@
<!--
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>{{ 'Class Master' | translate }}</h2>
<div class="header-actions">
<button mat-raised-button color="primary" (click)="openAddForm()" *mifosxHasPermission="'CREATE_LOCKERCLASS'">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'Add Locker Class' | translate }}
</button>
<button mat-raised-button [routerLink]="['/locker']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
@if (showForm) {
<mat-card class="add-form">
<mat-card-content>
<h3 class="form-title">
{{ (editingId === null ? 'New Locker Class' : 'Edit Locker Class') | translate }}
<span class="sr-no">{{ 'Sr No' | translate }}: {{ editingId ?? ('Auto' | translate) }}</span>
</h3>
<form [formGroup]="classForm" (ngSubmit)="submit()">
<div class="section-label">{{ 'Class' | translate }}</div>
<div class="form-grid">
@if (isSuperUser) {
<mat-form-field appearance="fill">
<mat-label>{{ 'Branch' | translate }}</mat-label>
<mat-select formControlName="officeId" required>
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
</mat-form-field>
} @else {
<div class="total-box">
<span class="total-label">{{ 'Branch' | translate }}</span>
<span class="branch-value">{{ ownOfficeName }}</span>
</div>
}
<mat-form-field appearance="fill">
<mat-label>{{ 'Class' | translate }}</mat-label>
<input matInput formControlName="classCode" placeholder="A1" required />
<mat-hint>{{ 'Letters and digits, e.g. A1, B2' }}</mat-hint>
@if (classForm.get('classCode')?.hasError('pattern')) {
<mat-error>{{ 'Only letters and digits' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Class Name' | translate }}</mat-label>
<mat-select formControlName="className" required>
@for (name of classNames; track name) {
<mat-option [value]="name">{{ name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Area' | translate }}</mat-label>
<input matInput type="number" formControlName="area" required />
<mat-hint>{{ 'Locker size' | translate }}</mat-hint>
</mat-form-field>
</div>
<div class="section-label">{{ 'Rent and Deposit' | translate }}</div>
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'Rent (yearly)' | translate }}</mat-label>
<input matInput type="number" formControlName="rent" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Member Rent (yearly)' | translate }}</mat-label>
<input matInput type="number" formControlName="memberRent" />
<mat-hint>{{ 'Blank = same as Rent' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Deposit' | translate }}</mat-label>
<input matInput type="number" formControlName="deposit" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Member Deposit' | translate }}</mat-label>
<input matInput type="number" formControlName="memberDeposit" />
<mat-hint>{{ 'Blank = same as Deposit' | translate }}</mat-hint>
</mat-form-field>
</div>
<div class="section-label">{{ 'Locker Numbers' | translate }}</div>
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'Start Locker No' | translate }}</mat-label>
<input matInput type="number" formControlName="startLockerNo" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'End Locker No' | translate }}</mat-label>
<input matInput type="number" formControlName="endLockerNo" required />
</mat-form-field>
<div class="total-box">
<span class="total-label">{{ 'Total Lockers' | translate }}</span>
<span class="total-value">{{ totalLockers }}</span>
</div>
</div>
@if (classForm.hasError('endBeforeStart')) {
<div class="form-error">{{ 'End Locker No cannot be less than Start Locker No' | translate }}</div>
}
@if (overlappingRange; as clash) {
<div class="form-error">
{{ 'Class' | translate }} {{ clash.classCode }} {{ 'already has lockers' | translate }}
{{ clash.startLockerNo }}–{{ clash.endLockerNo }}
</div>
}
@if (editingId !== null) {
<div class="form-note">
{{ 'Class and locker numbers cannot be changed. Add a new row for a different range.' }}
</div>
}
<div class="section-label">{{ 'Effective Period' | translate }}</div>
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'Effective From' | translate }}</mat-label>
<input matInput [matDatepicker]="fromPicker" formControlName="effectiveFromDate" required />
<mat-datepicker-toggle matSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Effective To' | translate }}</mat-label>
<input
matInput
[matDatepicker]="toPicker"
formControlName="effectiveToDate"
[min]="classForm.get('effectiveFromDate')?.value"
/>
<mat-datepicker-toggle matSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
<mat-hint>{{ 'Blank = no end date' | translate }}</mat-hint>
</mat-form-field>
</div>
@if (classForm.hasError('toBeforeFrom')) {
<div class="form-error">{{ 'Effective To cannot be before Effective From' | translate }}</div>
}
<div class="form-actions">
<button mat-raised-button type="button" (click)="cancel()">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="classForm.invalid || overlappingRange || busy"
>
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
<div class="filter-row">
@for (s of ['ACTIVE', 'INACTIVE']; track s) {
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
}
</div>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="classList">
<ng-container matColumnDef="srNo">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Sr No' | translate }}</th>
<td mat-cell *matCellDef="let c" class="num">{{ c.id }}</td>
</ng-container>
<ng-container matColumnDef="office">
<th mat-header-cell *matHeaderCellDef>{{ 'Branch' | translate }}</th>
<td mat-cell *matCellDef="let c">{{ c.officeName }}</td>
</ng-container>
<ng-container matColumnDef="classCode">
<th mat-header-cell *matHeaderCellDef>{{ 'Class' | translate }}</th>
<td mat-cell *matCellDef="let c">
<strong>{{ c.classCode }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="className">
<th mat-header-cell *matHeaderCellDef>{{ 'Class Name' | translate }}</th>
<td mat-cell *matCellDef="let c">
<span class="pill neutral">{{ c.className }}</span>
</td>
</ng-container>
<ng-container matColumnDef="area">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Area' | translate }}</th>
<td mat-cell *matCellDef="let c" class="num">{{ c.area }}</td>
</ng-container>
<ng-container matColumnDef="rent">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Rent / Member' | translate }}</th>
<td mat-cell *matCellDef="let c" class="num">
{{ c.rent | number: '1.0-2' }}<br />
<span class="sub">{{ c.memberRent | number: '1.0-2' }}</span>
</td>
</ng-container>
<ng-container matColumnDef="lockerRange">
<th mat-header-cell *matHeaderCellDef>{{ 'Locker No' | translate }}</th>
<td mat-cell *matCellDef="let c">{{ c.startLockerNo }} – {{ c.endLockerNo }}</td>
</ng-container>
<ng-container matColumnDef="totalLockers">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Total' | translate }}</th>
<td mat-cell *matCellDef="let c" class="num">{{ c.endLockerNo - c.startLockerNo + 1 }}</td>
</ng-container>
<ng-container matColumnDef="occupied">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Occupied' | translate }}</th>
<td mat-cell *matCellDef="let c" class="num">{{ c.occupied ?? 0 }}</td>
</ng-container>
<ng-container matColumnDef="deposit">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Deposit / Member' | translate }}</th>
<td mat-cell *matCellDef="let c" class="num">
{{ c.deposit | number: '1.0-2' }}<br />
<span class="sub">{{ c.memberDeposit | number: '1.0-2' }}</span>
</td>
</ng-container>
<ng-container matColumnDef="effectiveFrom">
<th mat-header-cell *matHeaderCellDef>{{ 'Effective From' | translate }}</th>
<td mat-cell *matCellDef="let c">{{ c.effectiveFromDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="effectiveTo">
<th mat-header-cell *matHeaderCellDef>{{ 'Effective To' | translate }}</th>
<td mat-cell *matCellDef="let c">{{ c.effectiveToDate ? (c.effectiveToDate | dateFormat) : '-' }}</td>
</ng-container>
<ng-container matColumnDef="active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let c">
<span class="pill" [ngClass]="c.active ? 'ok' : 'neutral'">{{ c.active ? 'Active' : 'Inactive' }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let c" class="actions-cell">
@if (c.active) {
<button
mat-icon-button
color="primary"
[matTooltip]="'Edit' | translate"
(click)="edit(c)"
*mifosxHasPermission="'UPDATE_LOCKERCLASS'"
>
<fa-icon icon="pen"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
[matTooltip]="'Deactivate' | translate"
(click)="deactivate(c)"
*mifosxHasPermission="'DELETE_LOCKERCLASS'"
>
<fa-icon icon="trash"></fa-icon>
</button>
}
</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 locker classes.' }}
</div>
} @else if (classList.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</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,166 @@
/**
* 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;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.add-form {
margin-bottom: 16px;
}
.form-title {
margin: 0 0 12px;
font-size: 1.05rem;
font-weight: 600;
}
.sr-no {
margin-left: 12px;
padding: 2px 8px;
border-radius: 6px;
background: rgb(0 0 0 / 6%);
color: rgb(0 0 0 / 60%);
font-size: 0.8rem;
font-weight: 500;
}
.section-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 55%);
margin: 12px 0 8px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
margin-bottom: 8px;
}
mat-form-field {
width: 100%;
}
.total-box {
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 12px;
border-radius: 6px;
background: rgb(25 118 210 / 6%);
min-height: 56px;
}
.total-label {
font-size: 0.75rem;
color: rgb(0 0 0 / 60%);
}
.total-value {
font-size: 1.3rem;
font-weight: 600;
color: #1976d2;
font-variant-numeric: tabular-nums;
}
.branch-value {
font-size: 1rem;
font-weight: 600;
}
.form-error {
color: #d32f2f;
font-size: 0.85rem;
margin: 0 0 8px;
}
.form-note {
color: rgb(0 0 0 / 60%);
font-size: 0.85rem;
margin: 0 0 8px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.sub {
font-size: 0.8rem;
color: rgb(0 0 0 / 55%);
}
.actions-cell {
white-space: nowrap;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -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();
}
}
@@ -0,0 +1,45 @@
<!--
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' | translate }}</h2>
</div>
@for (group of groups; track group.title) {
<h3 class="group-title">{{ group.title | translate }}</h3>
<div class="cards-grid">
@for (screen of group.screens; track screen.label) {
@if (screen.ready) {
<mat-card class="lk-card" [routerLink]="screen.route">
<mat-card-content>
<div class="card-head">
<span class="card-icon">
<fa-icon [icon]="screen.icon"></fa-icon>
</span>
<span class="card-title">{{ screen.label | translate }}</span>
</div>
</mat-card-content>
</mat-card>
} @else {
<mat-card class="lk-card lk-card--disabled" aria-disabled="true">
<mat-card-content>
<div class="card-head">
<span class="card-icon">
<fa-icon [icon]="screen.icon"></fa-icon>
</span>
<span class="card-title">{{ screen.label | translate }}</span>
<span class="soon-badge">{{ 'Coming soon' | translate }}</span>
</div>
</mat-card-content>
</mat-card>
}
}
</div>
}
</div>
@@ -0,0 +1,95 @@
/**
* 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;
}
.group-title {
font-size: 0.95rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 55%);
margin: 24px 0 12px;
&:first-of-type {
margin-top: 0;
}
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.lk-card {
cursor: pointer;
transition:
box-shadow 0.2s ease,
transform 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
transform: translateY(-2px);
}
}
.lk-card--disabled {
cursor: default;
opacity: 0.6;
&:hover {
box-shadow: none;
transform: none;
}
}
.card-head {
display: flex;
align-items: center;
gap: 12px;
}
.card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 10px;
flex: 0 0 auto;
background: rgb(25 118 210 / 10%);
color: #1976d2;
font-size: 1.15rem;
}
.card-title {
font-size: 1.05rem;
font-weight: 600;
line-height: 1.2;
}
.soon-badge {
margin-left: auto;
padding: 2px 8px;
border-radius: 10px;
background: rgb(0 0 0 / 8%);
color: rgb(0 0 0 / 60%);
font-size: 0.7rem;
font-weight: 600;
white-space: nowrap;
}
@@ -0,0 +1,162 @@
/**
* 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 } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** One dashboard card. `ready` stays false until that screen is built, so the card cannot route to a missing page. */
interface LockerScreen {
route: string[];
label: string;
icon: string;
ready: boolean;
}
/** A titled row of cards on the dashboard. */
interface LockerScreenGroup {
title: string;
screens: LockerScreen[];
}
/**
* Safe Deposit Locker landing page — one card per screen, grouped as one-time setup, daily counter work and
* occasional lifecycle actions.
*/
@Component({
selector: 'mifosx-locker-dashboard',
templateUrl: './locker-dashboard.component.html',
styleUrls: ['./locker-dashboard.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatCardModule,
FaIconComponent
]
})
export class LockerDashboardComponent {
groups: LockerScreenGroup[] = [
{
title: 'Setup',
screens: [
{
route: [
'/locker',
'class-master'
],
label: 'Class Master',
icon: 'layer-group',
ready: true
},
{
route: [
'/locker',
'lockers'
],
label: 'Locker List',
icon: 'th-large',
ready: true
},
{
route: [
'/locker',
'waiting-list'
],
label: 'Waiting List',
icon: 'hourglass-half',
ready: true
},
{
route: [
'/locker',
'gl-mapping'
],
label: 'GL Mapping',
icon: 'book-open',
ready: false
}
]
},
{
title: 'Daily Work',
screens: [
{
route: [
'/locker',
'allotment'
],
label: 'Allotment',
icon: 'file-signature',
ready: true
},
{
route: [
'/locker',
'rent-entry'
],
label: 'Rent Entry',
icon: 'receipt',
ready: false
},
{
route: [
'/locker',
'in-out-entry'
],
label: 'In / Out Entry',
icon: 'door-open',
ready: false
}
]
},
{
title: 'Others',
screens: [
{
route: [
'/locker',
'key-change'
],
label: 'Key Change',
icon: 'key',
ready: false
},
{
route: [
'/locker',
'freeze'
],
label: 'Freeze / Unfreeze',
icon: 'ban',
ready: false
},
{
route: [
'/locker',
'surrender'
],
label: 'Surrender',
icon: 'box-open',
ready: false
},
{
route: [
'/locker',
'reports'
],
label: 'Reports',
icon: 'chart-bar',
ready: false
}
]
}
];
}
@@ -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>&nbsp;{{ '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 = [])
});
}
}
+75
View File
@@ -0,0 +1,75 @@
/**
* 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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
/** Routing Imports */
import { Route } from '../core/route/route.service';
/** Custom Components */
import { LockerDashboardComponent } from './locker-dashboard/locker-dashboard.component';
import { ClassMasterComponent } from './class-master/class-master.component';
import { LockerListComponent } from './locker-list/locker-list.component';
import { WaitingListComponent } from './waiting-list/waiting-list.component';
import { AllotmentListComponent } from './allotment/allotment-list/allotment-list.component';
import { AllotmentFormComponent } from './allotment/allotment-form/allotment-form.component';
/** Safe Deposit Locker Routes */
const routes: Routes = [
Route.withShell([
{
path: 'locker',
data: { title: 'Locker', breadcrumb: 'Locker' },
children: [
{
path: '',
component: LockerDashboardComponent
},
{
path: 'class-master',
component: ClassMasterComponent,
data: { title: 'Class Master', breadcrumb: 'Class Master' }
},
{
path: 'lockers',
component: LockerListComponent,
data: { title: 'Locker List', breadcrumb: 'Locker List' }
},
{
path: 'waiting-list',
component: WaitingListComponent,
data: { title: 'Waiting List', breadcrumb: 'Waiting List' }
},
{
path: 'allotment',
component: AllotmentListComponent,
data: { title: 'Locker Allotment', breadcrumb: 'Allotment' }
},
{
// Separate from ':id' so saving a new application loads the saved one in a fresh component.
path: 'allotment/new',
component: AllotmentFormComponent,
data: { title: 'New Locker Allotment', breadcrumb: 'New Allotment' }
},
{
path: 'allotment/:id',
component: AllotmentFormComponent,
data: { title: 'Locker Application', routeParamBreadcrumb: 'id' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LockerRoutingModule {}
+46
View File
@@ -0,0 +1,46 @@
/**
* 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 { NgModule } from '@angular/core';
/** Custom Modules */
import { SharedModule } from '../shared/shared.module';
import { DirectivesModule } from '../directives/directives.module';
import { PipesModule } from 'app/pipes/pipes.module';
import { LockerRoutingModule } from './locker-routing.module';
/** Custom Components (standalone) */
import { LockerDashboardComponent } from './locker-dashboard/locker-dashboard.component';
import { ClassMasterComponent } from './class-master/class-master.component';
import { LockerListComponent } from './locker-list/locker-list.component';
import { WaitingListComponent } from './waiting-list/waiting-list.component';
import { AllotmentListComponent } from './allotment/allotment-list/allotment-list.component';
import { AllotmentFormComponent } from './allotment/allotment-form/allotment-form.component';
/**
* Safe Deposit Locker Module.
*
* Class master with member / non-member rates, locker inventory, waiting list, allotment, rent collection,
* operation register and key / break / freeze / surrender lifecycle. Lockers are allotted only to existing clients.
*/
@NgModule({
imports: [
SharedModule,
DirectivesModule,
PipesModule,
LockerRoutingModule,
LockerDashboardComponent,
ClassMasterComponent,
LockerListComponent,
WaitingListComponent,
AllotmentListComponent,
AllotmentFormComponent
]
})
export class LockerModule {}
+152
View File
@@ -0,0 +1,152 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* Safe Deposit Locker — class master (member / non-member rent and deposit per locker number range), locker
* inventory, waiting list, allotment, rent, operation register and lifecycle.
*/
@Injectable({
providedIn: 'root'
})
export class LockerService {
private http = inject(HttpClient);
// ----- Class Master -----
/**
* status: 'ACTIVE' | 'INACTIVE'. Server-side paginated — returns { pageItems, totalFilteredRecords }.
* The server confines everyone but the Super user to their own branch, whatever officeId is sent.
*/
getClasses(status?: string, offset = 0, limit = 20, officeId?: number): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
if (officeId) params = params.set('officeId', officeId);
return this.http.get('/lockerclasses', { params });
}
createClass(payload: any): Observable<any> {
return this.http.post('/lockerclasses', payload);
}
updateClass(id: string | number, payload: any): Observable<any> {
return this.http.put(`/lockerclasses/${id}`, payload);
}
/** Refused by the server while any locker of the class is allotted. */
deactivateClass(id: string | number): Observable<any> {
return this.http.delete(`/lockerclasses/${id}`);
}
// ----- Locker inventory (read-only) -----
/** status: 'VACANT' | 'ALLOTTED' | 'BLOCKED'. Server-side paginated — returns { pageItems, totalFilteredRecords }. */
getLockers(
filters: { officeId?: number; classCode?: string; status?: string; lockerNo?: number },
offset = 0,
limit = 20
): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (filters.officeId) params = params.set('officeId', filters.officeId);
if (filters.classCode) params = params.set('classCode', filters.classCode);
if (filters.status) params = params.set('status', filters.status);
if (filters.lockerNo) params = params.set('lockerNo', filters.lockerNo);
return this.http.get('/lockers', { params });
}
/** { total, vacant, allotted, blocked } for the branches the user may see. */
getLockerSummary(officeId?: number): Observable<any> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', officeId);
return this.http.get('/lockers/summary', { params });
}
// ----- Waiting List -----
/** status: 'PENDING' | 'ALLOTTED' | 'CANCELLED'. Oldest inquiry first. */
getWaitingList(status?: string, offset = 0, limit = 20, officeId?: number): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
if (officeId) params = params.set('officeId', officeId);
return this.http.get('/lockerwaitinglist', { params });
}
createWaitingListEntry(payload: any): Observable<any> {
return this.http.post('/lockerwaitinglist', payload);
}
updateWaitingListEntry(id: string | number, payload: any): Observable<any> {
return this.http.put(`/lockerwaitinglist/${id}`, payload);
}
/** Clients whose name contains the text, case-insensitive — one row per account, so callers de-duplicate by clientId. */
searchClientsByName(name: string): Observable<any[]> {
return this.http.get<any[]>('/customer-search', { params: new HttpParams().set('name', name) });
}
/** Name, contact and first address of a client, to pre-fill an inquiry. Limited to the user's branch scope. */
getWaitingListClientContact(clientId: number): Observable<any> {
return this.http.get(`/lockerwaitinglist/clientcontact/${clientId}`);
}
cancelWaitingListEntry(id: string | number, additionalRemarks?: string): Observable<any> {
const body: any = {};
if (additionalRemarks) body.additionalRemarks = additionalRemarks;
return this.http.post(`/lockerwaitinglist/${id}?command=cancel`, body);
}
// ----- Allotment -----
/** status: 'PENDING_APPROVAL' | 'ACTIVE' | 'REJECTED' | 'CLOSED'. Server-side paginated. */
getAllotments(status?: string, offset = 0, limit = 20, officeId?: number): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
if (officeId) params = params.set('officeId', officeId);
return this.http.get('/lockerallotments', { params });
}
getAllotment(id: string | number): Observable<any> {
return this.http.get(`/lockerallotments/${id}`);
}
/**
* Everything the allotment form fills from the client: contact, address, gender, occupation, PAN, masked Aadhaar,
* membership (decides member rates), active savings accounts (rent debit) and fixed deposits (security deposit).
*/
getAllotmentClientDetails(clientId: number): Observable<any> {
return this.http.get(`/lockerallotments/clientdetails/${clientId}`);
}
createAllotment(payload: any): Observable<any> {
return this.http.post('/lockerallotments', payload);
}
updateAllotment(id: string | number, payload: any): Observable<any> {
return this.http.put(`/lockerallotments/${id}`, payload);
}
/** Branch Manager's decision on a pending application; reject needs a reason. */
approveAllotment(id: string | number): Observable<any> {
return this.http.post(`/lockerallotments/${id}?command=approve`, {});
}
rejectAllotment(id: string | number, reason: string): Observable<any> {
return this.http.post(`/lockerallotments/${id}?command=reject`, { rejectionReason: reason });
}
// ----- Shared -----
/** Branches the logged-in user may see — used for the Super user's branch picker. */
getOffices(): Observable<any> {
return this.http.get('/offices');
}
}
@@ -0,0 +1,178 @@
/**
* 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,
DestroyRef,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
inject
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { LockerService } from '../../locker.service';
/** What a pick hands back — enough to show the client and link them. */
export interface PickedClient {
clientId: number;
displayName: string;
mobileNo?: string | null;
officeName?: string | null;
}
/**
* Client name box with type-ahead (case-insensitive, from 2 letters) over existing clients. Emits the picked client,
* and emits null as soon as the text is changed away from it, so a stale client id is never kept for another name.
*/
@Component({
selector: 'mifosx-locker-client-picker',
template: `
<mat-form-field appearance="fill" class="picker-field">
<mat-label>{{ label | translate }}</mat-label>
<input
matInput
[formControl]="nameControl"
[matAutocomplete]="auto"
[required]="required"
autocomplete="off"
[readonly]="disabled"
/>
<mat-autocomplete #auto="matAutocomplete" [displayWith]="displayName" (optionSelected)="onSelected($event)">
@for (c of suggestions; track c.clientId) {
<mat-option [value]="c">
<span class="opt-name">{{ c.displayName }}</span>
<span class="opt-meta">
{{ 'ID' | translate }} {{ c.clientId }}
@if (c.mobileNo) {
· {{ c.mobileNo }}
}
@if (c.officeName) {
· {{ c.officeName }}
}
</span>
</mat-option>
}
</mat-autocomplete>
@if (pickedId) {
<mat-hint class="picked">{{ 'Client ID' | translate }} {{ pickedId }}</mat-hint>
} @else if (!disabled) {
<mat-hint>{{ 'Type 2+ letters and pick a client' | translate }}</mat-hint>
}
</mat-form-field>
`,
styles: [
`
.picker-field {
width: 100%;
}
.opt-name {
display: block;
font-weight: 500;
line-height: 1.2;
}
.opt-meta {
display: block;
font-size: 0.75rem;
color: rgb(0 0 0 / 55%);
line-height: 1.2;
}
.picked {
color: #1976d2;
font-weight: 600;
}
`
],
imports: [
...STANDALONE_SHARED_IMPORTS,
ReactiveFormsModule,
MatAutocompleteModule
]
})
export class LockerClientPickerComponent implements OnInit, OnChanges {
private lockerService = inject(LockerService);
private destroyRef = inject(DestroyRef);
@Input() label = 'Client';
@Input() required = false;
@Input() disabled = false;
/** Pre-selected client, e.g. when an existing application is opened. */
@Input() clientId: number | null = null;
@Input() clientName: string | null = null;
/** Client ids that may not be picked here (e.g. the main applicant in the joint-holder list). */
@Input() excludeIds: number[] = [];
@Output() picked = new EventEmitter<PickedClient | null>();
nameControl = new FormControl<any>('');
suggestions: PickedClient[] = [];
pickedId: number | null = null;
private pickedName = '';
ngOnInit(): void {
this.nameControl.valueChanges
.pipe(
// A picked option puts the object in the box; only typed text counts as a change.
filter((value: any): value is string => typeof value === 'string'),
debounceTime(300),
map((value: string) => value.trim()),
distinctUntilChanged(),
switchMap((name: string) => {
if (this.pickedId && name !== this.pickedName) {
this.pickedId = null;
this.pickedName = '';
this.picked.emit(null);
}
if (name.length < 2 || this.pickedId) {
return of([]);
}
return this.lockerService.searchClientsByName(name).pipe(catchError(() => of([])));
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((rows: any[]) => {
// The search returns one row per account; keep one per client.
const seen = new Set<number>();
this.suggestions = (rows ?? [])
.filter(
(r: any) =>
r?.clientId && !this.excludeIds.includes(r.clientId) && !seen.has(r.clientId) && seen.add(r.clientId)
)
.slice(0, 10);
});
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['clientId'] || changes['clientName']) {
this.pickedId = this.clientId ?? null;
this.pickedName = this.clientName ?? '';
this.nameControl.setValue(this.pickedName, { emitEvent: false });
}
}
onSelected(event: MatAutocompleteSelectedEvent): void {
const client: PickedClient = event.option.value;
this.pickedId = client.clientId;
this.pickedName = client.displayName;
this.suggestions = [];
this.nameControl.setValue(client.displayName, { emitEvent: false });
this.picked.emit(client);
}
displayName = (value: any): string => (typeof value === 'string' ? value : (value?.displayName ?? ''));
}
@@ -0,0 +1,293 @@
<!--
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>{{ 'Waiting List' | translate }}</h2>
<div class="header-actions">
<button
mat-raised-button
color="primary"
(click)="openAddForm()"
*mifosxHasPermission="'CREATE_LOCKERWAITINGLIST'"
>
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'Add to Waiting List' | translate }}
</button>
<button mat-raised-button [routerLink]="['/locker']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
@if (showForm) {
<mat-card class="add-form">
<mat-card-content>
<h3 class="form-title">
{{ (editingId === null ? 'New Inquiry' : 'Edit Inquiry') | translate }}
<span class="sr-no">{{ 'Waiting No' | translate }}: {{ editingId ?? ('Auto' | translate) }}</span>
</h3>
<form [formGroup]="entryForm" (ngSubmit)="submit()">
<div class="section-label">{{ 'Inquiry' | translate }}</div>
<div class="form-grid">
@if (isSuperUser) {
<mat-form-field appearance="fill">
<mat-label>{{ 'Branch' | translate }}</mat-label>
<mat-select formControlName="officeId" (selectionChange)="onOfficeChange()" required>
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
</mat-form-field>
} @else {
<div class="info-box">
<span class="info-label">{{ 'Branch' | translate }}</span>
<span class="info-value">{{ ownOfficeName }}</span>
</div>
}
<mat-form-field appearance="fill">
<mat-label>{{ 'Inquiry Date' | translate }}</mat-label>
<input matInput [matDatepicker]="inqPicker" formControlName="inquiryDate" [max]="maxDate" required />
<mat-datepicker-toggle matSuffix [for]="inqPicker"></mat-datepicker-toggle>
<mat-datepicker #inqPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Customer Name' | translate }}</mat-label>
<input
matInput
formControlName="customerName"
[matAutocomplete]="clientAuto"
autocomplete="off"
required
/>
<mat-autocomplete
#clientAuto="matAutocomplete"
[displayWith]="displayClientName"
(optionSelected)="onClientSelected($event)"
>
@for (c of clientSuggestions; track c.clientId) {
<mat-option [value]="c">
<span class="opt-name">{{ c.displayName }}</span>
<span class="opt-meta">
{{ 'ID' | translate }} {{ c.clientId }}
@if (c.mobileNo) {
· {{ c.mobileNo }}
}
@if (c.officeName) {
· {{ c.officeName }}
}
</span>
</mat-option>
}
</mat-autocomplete>
<mat-hint>{{ 'Type 2+ letters to pick an existing client, or enter a new name' | translate }}</mat-hint>
</mat-form-field>
</div>
@if (linkedClient) {
<div class="client-bar">
<span class="client-chip">
<fa-icon icon="user"></fa-icon>&nbsp;{{ 'Existing client' | translate }}: {{ linkedClient.name }} ({{
'Client ID' | translate
}}
{{ linkedClient.id }})
<button
mat-icon-button
type="button"
class="chip-remove"
[matTooltip]="'Remove client link' | translate"
(click)="unlinkClient()"
>
<fa-icon icon="times"></fa-icon>
</button>
</span>
</div>
}
<div class="section-label">{{ 'Address and Contact' | translate }}</div>
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'Address Line 1' | translate }}</mat-label>
<input matInput formControlName="addressLine1" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Address Line 2' | translate }}</mat-label>
<input matInput formControlName="addressLine2" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Address Line 3' | translate }}</mat-label>
<input matInput formControlName="addressLine3" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'City' | translate }}</mat-label>
<input matInput formControlName="city" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Pincode' | translate }}</mat-label>
<input matInput formControlName="pincode" maxlength="6" required />
@if (entryForm.get('pincode')?.hasError('pattern')) {
<mat-error>{{ 'Pincode must be 6 digits' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Phone' | translate }}</mat-label>
<input matInput formControlName="phone" maxlength="15" />
@if (entryForm.get('phone')?.hasError('pattern')) {
<mat-error>{{ 'Phone must be 10 to 15 digits' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Email' | translate }}</mat-label>
<input matInput formControlName="email" />
@if (entryForm.get('email')?.hasError('email')) {
<mat-error>{{ 'Email is not valid' | translate }}</mat-error>
}
</mat-form-field>
</div>
<div class="section-label">{{ 'Locker Class' | translate }} *</div>
@if (classCodes.length === 0) {
<div class="form-note">{{ 'No active locker class at this branch. Add one in Class Master first.' }}</div>
} @else {
<div class="class-checks">
@for (code of classCodes; track code) {
<mat-checkbox [checked]="selectedClasses.has(code)" (change)="toggleClass(code, $event.checked)">{{
code
}}</mat-checkbox>
}
</div>
@if (selectedClasses.size === 0) {
<div class="form-error">{{ 'Choose at least one locker class' | translate }}</div>
}
}
<div class="section-label">{{ 'Remarks' | translate }}</div>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'Remarks' | translate }}</mat-label>
<textarea matInput rows="2" formControlName="remarks"></textarea>
</mat-form-field>
<div class="form-actions">
<button mat-raised-button type="button" (click)="cancelForm()">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="entryForm.invalid || selectedClasses.size === 0 || busy"
>
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
<div class="filter-row">
@for (s of statuses; track s) {
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
}
</div>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="entries">
<ng-container matColumnDef="waitingNo">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Waiting No' | translate }}</th>
<td mat-cell *matCellDef="let e" class="num">
<strong>{{ e.id }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="office">
<th mat-header-cell *matHeaderCellDef>{{ 'Branch' | translate }}</th>
<td mat-cell *matCellDef="let e">{{ e.officeName }}</td>
</ng-container>
<ng-container matColumnDef="inquiryDate">
<th mat-header-cell *matHeaderCellDef>{{ 'Inquiry Date' | translate }}</th>
<td mat-cell *matCellDef="let e">{{ e.inquiryDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="customerName">
<th mat-header-cell *matHeaderCellDef>{{ 'Customer Name' | translate }}</th>
<td mat-cell *matCellDef="let e">
{{ e.customerName }}
@if (e.clientId) {
<span class="pill info client-tag" [matTooltip]="'Existing client' | translate"
>{{ 'Client' | translate }} #{{ e.clientId }}</span
>
}
@if (e.remarks) {
<br /><span class="sub">{{ e.remarks }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="city">
<th mat-header-cell *matHeaderCellDef>{{ 'City' | translate }}</th>
<td mat-cell *matCellDef="let e">{{ e.city }}</td>
</ng-container>
<ng-container matColumnDef="phone">
<th mat-header-cell *matHeaderCellDef>{{ 'Phone' | translate }}</th>
<td mat-cell *matCellDef="let e">{{ e.phone || '-' }}</td>
</ng-container>
<ng-container matColumnDef="classes">
<th mat-header-cell *matHeaderCellDef>{{ 'Locker Class' | translate }}</th>
<td mat-cell *matCellDef="let e">
@for (code of e.preferredClasses; track code) {
<span class="pill neutral class-pill">{{ code }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let e">
<span class="pill" [ngClass]="e.status === 'PENDING' ? 'warn' : e.status === 'ALLOTTED' ? 'ok' : 'neutral'">{{
e.status
}}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let e" class="actions-cell">
@if (e.status === 'PENDING') {
<button
mat-icon-button
color="primary"
[matTooltip]="'Edit' | translate"
(click)="edit(e)"
*mifosxHasPermission="'UPDATE_LOCKERWAITINGLIST'"
>
<fa-icon icon="pen"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
[matTooltip]="'Cancel' | translate"
(click)="cancelEntry(e)"
*mifosxHasPermission="'CANCEL_LOCKERWAITINGLIST'"
>
<fa-icon icon="ban"></fa-icon>
</button>
}
</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 the waiting list.' }}</div>
} @else if (entries.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</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,226 @@
/**
* 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;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.add-form {
margin-bottom: 16px;
}
.form-title {
margin: 0 0 12px;
font-size: 1.05rem;
font-weight: 600;
}
.sr-no {
margin-left: 12px;
padding: 2px 8px;
border-radius: 6px;
background: rgb(0 0 0 / 6%);
color: rgb(0 0 0 / 60%);
font-size: 0.8rem;
font-weight: 500;
}
.section-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 55%);
margin: 12px 0 8px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
margin-bottom: 8px;
}
mat-form-field {
width: 100%;
}
.info-box {
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 12px;
border-radius: 6px;
background: rgb(25 118 210 / 6%);
min-height: 56px;
}
.info-label {
font-size: 0.75rem;
color: rgb(0 0 0 / 60%);
}
.info-value {
font-size: 1rem;
font-weight: 600;
}
.client-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 4px;
.form-note {
margin: 0;
}
}
.opt-name {
display: block;
font-weight: 500;
line-height: 1.2;
}
.opt-meta {
display: block;
font-size: 0.75rem;
color: rgb(0 0 0 / 55%);
line-height: 1.2;
}
.client-chip {
display: inline-flex;
align-items: center;
padding: 2px 4px 2px 12px;
border-radius: 16px;
background: #e3f0fc;
color: #1976d2;
font-weight: 600;
font-size: 0.85rem;
}
.chip-remove {
width: 32px;
height: 32px;
padding: 4px;
}
.client-tag {
margin-left: 6px;
}
.class-checks {
display: flex;
flex-wrap: wrap;
gap: 4px 24px;
margin-bottom: 8px;
}
.form-error {
color: #d32f2f;
font-size: 0.85rem;
margin: 0 0 8px;
}
.form-note {
color: rgb(0 0 0 / 60%);
font-size: 0.85rem;
margin: 0 0 8px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.sub {
font-size: 0.8rem;
color: rgb(0 0 0 / 55%);
}
.actions-cell {
white-space: nowrap;
}
.class-pill {
margin-right: 4px;
}
.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,447 @@
/**
* 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, DestroyRef, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';
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 { 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 { CancelDialogComponent } from 'app/shared/cancel-dialog/cancel-dialog.component';
/**
* Locker waiting list — inquiries taken at the counter, oldest first. The inquirer need not be a client; the entry
* number is the waiting number handed to them. Editable and cancellable only while PENDING; allotment will mark it
* ALLOTTED.
*/
@Component({
selector: 'mifosx-locker-waiting-list',
templateUrl: './waiting-list.component.html',
styleUrls: ['./waiting-list.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
MatTooltipModule,
MatAutocompleteModule,
FaIconComponent
]
})
export class WaitingListComponent implements OnInit {
private destroyRef = inject(DestroyRef);
private formBuilder = inject(UntypedFormBuilder);
private lockerService = inject(LockerService);
private settingsService = inject(SettingsService);
private authenticationService = inject(AuthenticationService);
private dateUtils = inject(Dates);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
private serverMessage(err: any, fallback: string): string {
const body = err?.error;
return body?.errors?.[0]?.defaultUserMessage || body?.defaultUserMessage || fallback;
}
entries: any[] = [];
showForm = false;
busy = false;
loadFailed = false;
/** Waiting number of the entry being edited; null while adding. */
editingId: number | null = null;
isSuperUser = false;
ownOfficeId: number | null = null;
ownOfficeName = '';
offices: any[] = [];
/** The existing client the inquiry is linked to, if the inquirer already banks here; null for a walk-in. */
linkedClient: { id: number; name: string } | null = null;
/** Clients matching what is typed in Customer Name, one per client. */
clientSuggestions: any[] = [];
/** Active class codes of the branch the form is for. */
classCodes: string[] = [];
selectedClasses = new Set<string>();
statusFilter = 'PENDING';
pageSize = 10;
pageIndex = 0;
totalRecords = 0;
pageSizeOptions = [
10,
25,
50,
100
];
statuses = [
'PENDING',
'ALLOTTED',
'CANCELLED'
];
/** The bank's business date, not the PC clock — the server rejects an inquiry dated after it. */
maxDate: Date = this.businessDate();
entryForm: UntypedFormGroup;
columns = [
'waitingNo',
'office',
'inquiryDate',
'customerName',
'city',
'phone',
'classes',
'status',
'actions'
];
constructor() {
this.entryForm = this.formBuilder.group({
officeId: [''],
inquiryDate: [
this.maxDate,
Validators.required
],
customerName: [
'',
[
Validators.required,
Validators.maxLength(100)
]
],
addressLine1: [
'',
[
Validators.required,
Validators.maxLength(150)
]
],
addressLine2: [
'',
Validators.maxLength(150)
],
addressLine3: [
'',
Validators.maxLength(150)
],
city: [
'',
[
Validators.required,
Validators.maxLength(60)
]
],
pincode: [
'',
[
Validators.required,
Validators.pattern(/^\d{6}$/)
]
],
phone: [
'',
Validators.pattern(/^\d{10,15}$/)
],
email: [
'',
[
Validators.email,
Validators.maxLength(100)
]
],
remarks: [
'',
Validators.maxLength(500)
]
});
}
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.watchCustomerName();
this.loadEntries();
}
loadEntries(): void {
this.loadFailed = false;
this.lockerService
.getWaitingList(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize)
.subscribe({
next: (res: any) => {
this.entries = res?.pageItems ?? [];
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
},
error: () => {
this.entries = [];
this.totalRecords = 0;
this.loadFailed = true;
}
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.loadEntries();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadEntries();
}
/** Class checkboxes follow the branch picked on the form (always the user's own for non-Super users). */
loadClassCodes(officeId: number | null): void {
this.lockerService.getClasses('ACTIVE', 0, 500, officeId || undefined).subscribe({
next: (res: any) => {
const rows = (res?.pageItems ?? []).filter((c: any) => !officeId || c.officeId === officeId);
this.classCodes = Array.from(new Set<string>(rows.map((c: any) => c.classCode))).sort();
// A class that is no longer offered stays ticked only if it is still available.
this.selectedClasses = new Set([...this.selectedClasses].filter((code) => this.classCodes.includes(code)));
},
error: () => (this.classCodes = [])
});
}
onOfficeChange(): void {
this.selectedClasses.clear();
this.loadClassCodes(Number(this.entryForm.get('officeId')?.value) || null);
}
toggleClass(code: string, checked: boolean): void {
if (checked) {
this.selectedClasses.add(code);
} else {
this.selectedClasses.delete(code);
}
}
openAddForm(): void {
this.editingId = null;
this.maxDate = this.businessDate();
this.entryForm.reset({ officeId: this.ownOfficeId ?? '', inquiryDate: this.maxDate });
if (this.isSuperUser) {
this.entryForm.get('officeId')?.enable();
} else {
this.entryForm.get('officeId')?.disable();
}
this.selectedClasses.clear();
this.linkedClient = null;
this.clientSuggestions = [];
this.loadClassCodes(this.ownOfficeId);
this.showForm = true;
}
/**
* Typing in Customer Name looks up existing clients (case-insensitive, from 2 letters). Changing the name away from
* a picked client drops the link, so the entry never carries a client id for someone else's name.
*/
private watchCustomerName(): void {
this.entryForm
.get('customerName')!
.valueChanges.pipe(
// Picking a suggestion puts the option object in the field; only typed text counts as a name change,
// otherwise a fast client lookup would be undone by this very selection.
filter((value: any): value is string => typeof value === 'string'),
debounceTime(300),
map((value: string) => value.trim()),
distinctUntilChanged(),
switchMap((name: string) => {
if (this.linkedClient && name !== this.linkedClient.name) {
this.linkedClient = null;
}
if (name.length < 2 || this.linkedClient) {
return of([]);
}
return this.lockerService.searchClientsByName(name).pipe(catchError(() => of([])));
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((rows: any[]) => {
// The search returns one row per account; keep the first row of each client.
const seen = new Set<number>();
this.clientSuggestions = (rows ?? [])
.filter((r: any) => r?.clientId && !seen.has(r.clientId) && seen.add(r.clientId))
.slice(0, 10);
});
}
/** Fills name, address and contact from the picked client and links the inquiry to it. */
onClientSelected(event: MatAutocompleteSelectedEvent): void {
const picked = event.option.value;
this.clientSuggestions = [];
this.lockerService.getWaitingListClientContact(picked.clientId).subscribe({
next: (client: any) => {
this.linkedClient = { id: client.clientId, name: client.displayName };
// Only overwrite what the client record actually has, so anything already typed is kept otherwise.
const patch: any = {};
if (client.addressLine1) patch.addressLine1 = client.addressLine1;
if (client.addressLine2) patch.addressLine2 = client.addressLine2;
if (client.addressLine3) patch.addressLine3 = client.addressLine3;
if (client.city) patch.city = client.city;
if (client.pincode) patch.pincode = client.pincode;
if (client.mobileNo) patch.phone = client.mobileNo;
if (client.email) patch.email = client.email;
this.entryForm.patchValue(patch);
this.entryForm.get('customerName')?.setValue(client.displayName, { emitEvent: false });
if (!client.addressLine1 || !client.city || !client.pincode) {
this.notify('Client has no complete address on record — please fill the missing fields.');
}
},
error: (err: any) => {
this.linkedClient = null;
this.notify(this.serverMessage(err, 'Could not load the client details'), true);
}
});
}
/** The option value is the search row; the field must show just the name. */
displayClientName = (value: any): string => (typeof value === 'string' ? value : (value?.displayName ?? ''));
/** Keeps the typed details but stops treating the inquirer as that client. */
unlinkClient(): void {
this.linkedClient = null;
}
edit(row: any): void {
this.editingId = row.id;
this.entryForm.reset({
officeId: row.officeId,
inquiryDate: row.inquiryDate ? this.dateUtils.parseDate(row.inquiryDate) : this.maxDate,
customerName: row.customerName,
addressLine1: row.addressLine1,
addressLine2: row.addressLine2 ?? '',
addressLine3: row.addressLine3 ?? '',
city: row.city,
pincode: row.pincode,
phone: row.phone ?? '',
email: row.email ?? '',
remarks: row.remarks ?? ''
});
// The branch an inquiry was taken at never changes.
this.entryForm.get('officeId')?.disable();
// Keyed to the name shown in the field, so the link survives until that name is actually changed.
this.linkedClient = row.clientId ? { id: row.clientId, name: row.customerName } : null;
this.clientSuggestions = [];
this.selectedClasses = new Set(row.preferredClasses ?? []);
this.loadClassCodes(row.officeId);
this.showForm = true;
}
cancelForm(): void {
this.showForm = false;
this.editingId = null;
}
submit(): void {
if (this.entryForm.invalid || this.selectedClasses.size === 0 || this.busy) {
return;
}
this.busy = true;
const raw = this.entryForm.getRawValue();
const dateFormat = this.settingsService.dateFormat;
const payload: any = {
// Sent as null on edit too, so removing the link is saved.
clientId: this.linkedClient?.id ?? null,
inquiryDate: this.dateUtils.formatDate(raw.inquiryDate, dateFormat),
customerName: this.displayClientName(raw.customerName),
addressLine1: raw.addressLine1,
addressLine2: raw.addressLine2 || null,
addressLine3: raw.addressLine3 || null,
city: raw.city,
pincode: raw.pincode,
phone: raw.phone || null,
email: raw.email || null,
preferredClasses: [...this.selectedClasses],
remarks: raw.remarks || null,
dateFormat,
locale: this.settingsService.language.code
};
if (this.editingId == null && this.isSuperUser && raw.officeId) {
payload.officeId = Number(raw.officeId);
}
const request =
this.editingId == null
? this.lockerService.createWaitingListEntry(payload)
: this.lockerService.updateWaitingListEntry(this.editingId, payload);
request.subscribe({
next: (res: any) => {
this.busy = false;
this.notify(
this.editingId == null
? `Added to waiting list — Waiting No ${res?.resourceId}`
: 'Waiting list entry updated'
);
this.cancelForm();
this.loadEntries();
},
error: (err: any) => {
this.busy = false;
this.notify(this.serverMessage(err, 'Failed to save waiting list entry'), true);
}
});
}
/** Falls back to the PC date only when the business date has not been loaded yet. */
private businessDate(): Date {
const date = this.settingsService.businessDate;
return date && !isNaN(date.getTime()) ? date : new Date();
}
cancelEntry(row: any): void {
const dialogRef = this.dialog.open(CancelDialogComponent, {
data: { cancelContext: `waiting list entry ${row.id} (${row.customerName})` }
});
dialogRef.afterClosed().subscribe((response: any) => {
if (!response?.cancel) {
return;
}
this.lockerService.cancelWaitingListEntry(row.id).subscribe({
next: () => {
this.notify('Waiting list entry cancelled');
this.loadEntries();
},
error: (err: any) => this.notify(this.serverMessage(err, 'Failed to cancel waiting list entry'), true)
});
});
}
}