Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d1025f5ce |
@@ -37,6 +37,7 @@ import { GroupsModule } from './groups/groups.module';
|
|||||||
import { CentersModule } from './centers/centers.module';
|
import { CentersModule } from './centers/centers.module';
|
||||||
import { AccountingModule } from './accounting/accounting.module';
|
import { AccountingModule } from './accounting/accounting.module';
|
||||||
import { ChequeBookModule } from './chequebook/chequebook.module';
|
import { ChequeBookModule } from './chequebook/chequebook.module';
|
||||||
|
import { LockerModule } from './locker/locker.module';
|
||||||
import { CtsClearingModule } from './ctsclearing/ctsclearing.module';
|
import { CtsClearingModule } from './ctsclearing/ctsclearing.module';
|
||||||
import { NachClearingModule } from './nachclearing/nachclearing.module';
|
import { NachClearingModule } from './nachclearing/nachclearing.module';
|
||||||
import { SystemModule } from './system/system.module';
|
import { SystemModule } from './system/system.module';
|
||||||
@@ -154,6 +155,7 @@ export function HttpLoaderFactory(http: HttpClient) {
|
|||||||
CentersModule,
|
CentersModule,
|
||||||
AccountingModule,
|
AccountingModule,
|
||||||
ChequeBookModule,
|
ChequeBookModule,
|
||||||
|
LockerModule,
|
||||||
CtsClearingModule,
|
CtsClearingModule,
|
||||||
NachClearingModule,
|
NachClearingModule,
|
||||||
SystemModule,
|
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> {{
|
||||||
|
'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> {{ 'Approve' | translate }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="warn"
|
||||||
|
[disabled]="busy"
|
||||||
|
(click)="rejecting = true"
|
||||||
|
*mifosxHasPermission="'REJECT_LOCKERALLOTMENT'"
|
||||||
|
>
|
||||||
|
<fa-icon icon="ban"></fa-icon> {{ '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> {{ '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> {{ '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> {{ '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> {{ '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> {{ 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> {{ '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> {{ '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> {{ '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 = [])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
@@ -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> {{ '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> {{ '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)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user