From 7d1025f5ce2cac2c42b1e1520a7d9d81e60a3a50 Mon Sep 17 00:00:00 2001 From: tarunmalviya Date: Mon, 5 Oct 2026 15:30:12 +0530 Subject: [PATCH] Locker module UI: dashboard, class master, locker list, waiting list, allotment form --- src/app/app.module.ts | 2 + .../allotment-form.component.html | 830 +++++++++++++++++ .../allotment-form.component.scss | 351 +++++++ .../allotment-form.component.ts | 857 ++++++++++++++++++ .../allotment-list.component.html | 103 +++ .../allotment-list.component.scss | 99 ++ .../allotment-list.component.ts | 134 +++ .../class-master/class-master.component.html | 281 ++++++ .../class-master/class-master.component.scss | 166 ++++ .../class-master/class-master.component.ts | 409 +++++++++ .../locker-dashboard.component.html | 45 + .../locker-dashboard.component.scss | 95 ++ .../locker-dashboard.component.ts | 162 ++++ .../locker-list/locker-list.component.html | 146 +++ .../locker-list/locker-list.component.scss | 135 +++ .../locker-list/locker-list.component.ts | 171 ++++ src/app/locker/locker-routing.module.ts | 75 ++ src/app/locker/locker.module.ts | 46 + src/app/locker/locker.service.ts | 152 ++++ .../locker-client-picker.component.ts | 178 ++++ .../waiting-list/waiting-list.component.html | 293 ++++++ .../waiting-list/waiting-list.component.scss | 226 +++++ .../waiting-list/waiting-list.component.ts | 447 +++++++++ 23 files changed, 5403 insertions(+) create mode 100644 src/app/locker/allotment/allotment-form/allotment-form.component.html create mode 100644 src/app/locker/allotment/allotment-form/allotment-form.component.scss create mode 100644 src/app/locker/allotment/allotment-form/allotment-form.component.ts create mode 100644 src/app/locker/allotment/allotment-list/allotment-list.component.html create mode 100644 src/app/locker/allotment/allotment-list/allotment-list.component.scss create mode 100644 src/app/locker/allotment/allotment-list/allotment-list.component.ts create mode 100644 src/app/locker/class-master/class-master.component.html create mode 100644 src/app/locker/class-master/class-master.component.scss create mode 100644 src/app/locker/class-master/class-master.component.ts create mode 100644 src/app/locker/locker-dashboard/locker-dashboard.component.html create mode 100644 src/app/locker/locker-dashboard/locker-dashboard.component.scss create mode 100644 src/app/locker/locker-dashboard/locker-dashboard.component.ts create mode 100644 src/app/locker/locker-list/locker-list.component.html create mode 100644 src/app/locker/locker-list/locker-list.component.scss create mode 100644 src/app/locker/locker-list/locker-list.component.ts create mode 100644 src/app/locker/locker-routing.module.ts create mode 100644 src/app/locker/locker.module.ts create mode 100644 src/app/locker/locker.service.ts create mode 100644 src/app/locker/shared/locker-client-picker/locker-client-picker.component.ts create mode 100644 src/app/locker/waiting-list/waiting-list.component.html create mode 100644 src/app/locker/waiting-list/waiting-list.component.scss create mode 100644 src/app/locker/waiting-list/waiting-list.component.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 4136ae258..9d49f634c 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -37,6 +37,7 @@ import { GroupsModule } from './groups/groups.module'; import { CentersModule } from './centers/centers.module'; import { AccountingModule } from './accounting/accounting.module'; import { ChequeBookModule } from './chequebook/chequebook.module'; +import { LockerModule } from './locker/locker.module'; import { CtsClearingModule } from './ctsclearing/ctsclearing.module'; import { NachClearingModule } from './nachclearing/nachclearing.module'; import { SystemModule } from './system/system.module'; @@ -154,6 +155,7 @@ export function HttpLoaderFactory(http: HttpClient) { CentersModule, AccountingModule, ChequeBookModule, + LockerModule, CtsClearingModule, NachClearingModule, SystemModule, diff --git a/src/app/locker/allotment/allotment-form/allotment-form.component.html b/src/app/locker/allotment/allotment-form/allotment-form.component.html new file mode 100644 index 000000000..967b0b5c0 --- /dev/null +++ b/src/app/locker/allotment/allotment-form/allotment-form.component.html @@ -0,0 +1,830 @@ + + +
+ + + @if (loadFailed) { + + {{ 'Could not load this locker application.' }} + + } @else { + @if (allotment?.status === 'REJECTED' && allotment?.rejectionReason) { + + + {{ 'Rejected' | translate }}: {{ allotment.rejectionReason }} + + + } + + + @if (isPending) { + + + +  {{ + 'This application is waiting for Branch Manager approval. The locker is reserved until then.' + }} + + + @if (!rejecting) { + + + } + + @if (rejecting) { +
+ + {{ 'Reason for rejection' | translate }} + + +
+ + +
+
+ } +
+
+ } + + + + + + + + + {{ + 'Application Master Entry' | translate + }} + +
+ +
+
+
{{ 'Application' | translate }}
+
+ + {{ 'Branch Code' | translate }} + + + + {{ 'Application No' | translate }} + + + + + {{ 'Client Id' | translate }} + + {{ 'Filled when the name is picked' | translate }} + + + {{ 'Transaction Date' | translate }} + + + + + {{ 'Locker No' | translate }} + + @for (l of lockers; track l.id) { + {{ l.classCode }}-{{ l.lockerNo }} + } + + @if (!mainForm.get('classCode')?.value) { + {{ 'Choose Class Type first' | translate }} + } @else if (lockers.length === 0) { + {{ 'No vacant locker in this class' | translate }} + } @else { + {{ 'Only vacant lockers' | translate }} + } + + + {{ 'Key No' | translate }} + + +
+ + + +
+ + {{ 'Freeze' | translate }} + + {{ 'Un Freeze' | translate }} + {{ 'Freeze' | translate }} + + + + {{ 'Risk' | translate }} + + - + @for (r of risks; track r) { + {{ r }} + } + + + + + {{ 'Freeze Remark' | translate }} + + + + {{ 'Remarks' | translate }} + + + + + {{ 'Op. Date' }} + + + + + {{ 'Close Date' | translate }} + + +
+
+ +
+
{{ 'Address Information' | translate }}
+ + {{ 'Address' | translate }} + + + + {{ 'Address Line 2' | translate }} + + + + {{ 'Address Line 3' | translate }} + + + +
+ + {{ 'Pay Mode' | translate }} + + @for (p of payModes; track p.value) { + {{ p.label | translate }} + } + + @if (needsJointHolder) { + {{ 'Add joint holders on the Joint Member tab' | translate }} + } + + + {{ 'Email Active' | translate }} + + {{ 'No' | translate }} + {{ 'Yes' | translate }} + + + + + {{ 'Occupation' | translate }} + + + + {{ 'SMS Active' | translate }} + + {{ 'No' | translate }} + {{ 'Yes' | translate }} + + + + @if (mode !== 'view') { + + {{ 'Password' | translate }} + + @if (mode === 'edit') { + {{ 'Blank keeps the current password' | translate }} + } + + } @else { + + {{ 'Password' | translate }} + + + } + + {{ 'Gender' | translate }} + + + + @if (mode !== 'view') { + + {{ 'Confirm Password' | translate }} + + @if (mainForm.hasError('passwordMismatch')) { + {{ 'Passwords do not match' | translate }} + } + + } + + {{ 'Dual Auth Mode' | translate }} + + {{ 'No' | translate }} + {{ 'Yes' | translate }} + + + + + {{ 'Inquiry Sr No' | translate }} + + {{ 'None' | translate }} + @for (w of waitingEntries; track w.id) { + + {{ w.id }} · {{ w.customerName }} · {{ w.preferredClasses?.join(', ') }} + @if (w.clientId && w.clientId === clientId) { + · ★ + } + + } + @if (allotment?.waitingListId && !selectedWaitingEntry) { + {{ allotment.waitingListId }} + } + + {{ 'Waiting List entry, if any' | translate }} + + + {{ 'Inquiry Date' | translate }} + + + + + {{ 'Inquirer Name' | translate }} + + +
+
+
+ + +
+
+
{{ 'Locker Rent' | translate }}
+
+ + {{ 'Class Type' | translate }} + + @for (code of classCodes; track code) { + {{ code }} + } + + + + {{ 'Category' | translate }} + + @for (c of categories; track c.value) { + {{ c.label | translate }} + } + + @if (clientDetails) { + {{ + (clientDetails.member ? 'Client is a member' : 'Client is not a member') | translate + }} + } + + + + {{ 'Class Rent' | translate }} + + + + {{ 'Rent With GST' | translate }} + + {{ 'GST 18%' | translate }}: {{ gstAmount | number: '1.2-2' }} + + + + {{ 'Total Rent' | translate }} + + {{ 'Rent with GST + Deposit' | translate }} + + + {{ 'Depo Amt.' }} + + +
+
+ +
+
{{ 'Reference Account Details' | translate }}
+
+ + {{ 'Branch' | translate }} + + + + {{ 'A/c Type' | translate }} + + @for (t of rentAccountTypes; track t) { + {{ t }} + } + + + + {{ 'A/c No.' }} + + @for (a of rentAccountsOfType; track a.id) { + {{ a.accountNo }} · {{ 'Bal' | translate }} {{ a.balance | number: '1.2-2' }} + } + + @if (mainForm.get('rentExemption')?.value) { + {{ 'Not needed — rent is exempted' | translate }} + } @else if (clientDetails && !clientDetails.savingsAccounts?.length) { + {{ 'Client has no active savings account' | translate }} + } @else { + {{ 'Locker rent is debited from this account' | translate }} + } + +
+
+ +
+
{{ 'Contact and KYC' | translate }}
+
+ + {{ 'City' | translate }} + + + + {{ 'Phone-1' | translate }} + + + + + {{ 'Phone-2' | translate }} + + @if (mainForm.get('phone2')?.hasError('pattern')) { + {{ '10 to 15 digits' | translate }} + } + + + {{ 'Pincode' | translate }} + + + + + {{ 'Rent Up To Date' | translate }} + + {{ 'Updated by Rent Entry' | translate }} + + + {{ 'Exemption Of Rent' | translate }} + + {{ 'NO' | translate }} + {{ 'YES' | translate }} + + + + + {{ 'P.A.N. No.' }} + + + + {{ 'Aadhaar No.' }} + + +
+
+ +
+
{{ 'Documents' | translate }}
+
+ + {{ 'Photo Received' | translate }} + + {{ 'No' | translate }} + {{ 'Yes' | translate }} + + + + {{ 'Address Proof Received' | translate }} + + {{ 'No' | translate }} + {{ 'Yes' | translate }} + + +
+
+ + @if (clientId && clientDetailsFailed) { +
{{ 'Could not load the client details.' }}
+ } +
+
+
+ + + + + + {{ 'Joint Member Detail' | translate }} + @if (jointHolders.length) { + ({{ jointHolders.length }}) + } + + +
+
+ {{ 'Pay Mode' | translate }}: + {{ labelOf(payModes, mainForm.get('payMode')?.value) | translate }} + — + {{ + (needsJointHolder + ? 'at least one joint holder is required' + : 'joint holders are optional; mandate holders can still be added' + ) | translate + }} +
+ @for (j of jointHolders; track $index; let i = $index) { +
+ {{ i + 1 }} +
+ + + {{ 'Join Category' | translate }} + + @for (c of joinCategories; track c.value) { + {{ c.label | translate }} + } + + +
+ @if (mode !== 'view') { + + } +
+ } + @if (jointHolders.length === 0) { +
{{ 'No joint holders' | translate }}
+ } + @if (mode !== 'view') { + + } +
+
+ + + + + {{ 'Other Entry(s)' | translate }} + +
+ + @if (!clientId) { +
{{ 'Choose the client on the first tab to see their fixed deposits.' }}
+ } @else if ((clientDetails?.fixedDeposits ?? []).length === 0) { +
{{ 'Client has no active fixed deposit.' }}
+ } @else { + + + + + + + + + + + + + @for (f of clientDetails.fixedDeposits; track f.id) { + + + + + + + + + } + +
{{ 'A/c No' | translate }}{{ 'Product' | translate }}{{ 'Depo. Amt' }}{{ 'Depo Date' | translate }}{{ 'Matu. Date' }}
+ + + {{ f.accountNo }} + @if (f.onHold && !selectedFdIds.has(f.id)) { + {{ 'Already under lien' | translate }} + } + {{ f.productName }}{{ f.depositAmount | number: '1.2-2' }}{{ f.depositDate | dateFormat }}{{ f.maturityDate | dateFormat }}
+ } +
+
+ {{ 'Deposit required' | translate }}{{ requiredDeposit | number: '1.2-2' }} +
+
+ {{ 'FD selected' | translate }}{{ selectedFdTotal | number: '1.2-2' }} +
+ @if (depositShortfall > 0) { +
+  {{ 'Short by' | translate }} + {{ depositShortfall | number: '1.2-2' }} +
+ } +
+
+ {{ 'A lien is placed on the selected deposits when the application is approved.' }} +
+ + + @if (clientDetails) { +
+
+ {{ 'Member' | translate }}{{ (clientDetails.member ? 'Yes' : 'No') | translate }} +
+
+ {{ 'Membership No' | translate }}{{ clientDetails.membershipNo || '-' }} +
+
+ {{ 'No Of Shares' | translate }}{{ clientDetails.totalShares ?? 0 }} +
+
+ } @else { +
{{ "Shown from the client's share account." }}
+ } + + + @for (g of guarantors; track $index; let i = $index) { +
+ {{ i + 1 }} +
+ +
+ @if (mode !== 'view') { + + } +
+ } + @if (guarantors.length === 0) { +
{{ 'No guarantors' | translate }}
+ } + @if (mode !== 'view') { + + } +
+
+ + + + + + {{ "Nominee's Detail" | translate }} + @if (nominees.length) { + ({{ nominees.length }}) + } + + +
+ {{ + 'Nominee' | translate + }} +
+ @for (n of nominees.controls; track $index; let i = $index) { +
+ {{ i + 1 }} +
+ + {{ 'Nom. Date' }} + + + + + + {{ 'Name' | translate }} + + + + {{ 'Mobile No' | translate }} + + + + {{ 'Birth Dt.' }} + + + + @if (isMinor(i)) { + {{ 'Minor' | translate }} + } + + + {{ 'Relation' | translate }} + + @for (r of relations; track r) { + {{ r }} + } + + + + {{ 'Gender' | translate }} + + @for (g of genders; track g) { + {{ g }} + } + + + + {{ 'Guar. Name' }} + + @if (isMinor(i)) { + {{ 'Required for a minor nominee' | translate }} + } + +
+ @if (mode !== 'view') { + + } +
+ } +
+ @if (nomineeForm.get('hasNominee')?.value && mode !== 'view') { + + } +
+
+
+ + @if (mode !== 'view') { + @if (allErrors.length && mainForm.touched) { +
+ @for (e of allErrors; track e) { +
 {{ e | translate }}
+ } +
+ } +
+ + +
+ } +
+
+ } +
diff --git a/src/app/locker/allotment/allotment-form/allotment-form.component.scss b/src/app/locker/allotment/allotment-form/allotment-form.component.scss new file mode 100644 index 000000000..96989bf5c --- /dev/null +++ b/src/app/locker/allotment/allotment-form/allotment-form.component.scss @@ -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; +} diff --git a/src/app/locker/allotment/allotment-form/allotment-form.component.ts b/src/app/locker/allotment/allotment-form/allotment-form.component.ts new file mode 100644 index 000000000..92c2611b0 --- /dev/null +++ b/src/app/locker/allotment/allotment-form/allotment-form.component.ts @@ -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(); + + 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(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((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; + } + } +} diff --git a/src/app/locker/allotment/allotment-list/allotment-list.component.html b/src/app/locker/allotment/allotment-list/allotment-list.component.html new file mode 100644 index 000000000..191a500d5 --- /dev/null +++ b/src/app/locker/allotment/allotment-list/allotment-list.component.html @@ -0,0 +1,103 @@ + + +
+ + +
+ @for (s of statuses; track s.value) { + + } +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'Application No' | translate }} + {{ a.applicationNo ?? a.id }} + {{ 'Branch' | translate }}{{ a.officeName }}{{ 'Client' | translate }} + {{ a.clientName }} +
{{ 'Client ID' | translate }} {{ a.clientId }} +
{{ 'Locker' | translate }} + {{ a.classCode }}-{{ a.lockerNo }} + {{ 'Key No' | translate }}{{ a.keyNo }}{{ 'Pay Mode' | translate }}{{ a.payMode }}{{ 'Op. Date' }}{{ a.openingDate | dateFormat }}{{ 'labels.inputs.Status' | translate }} + {{ statusLabel(a.status) | translate }} + + +
+ @if (loadFailed) { +
{{ 'Could not load locker applications.' }}
+ } @else if (allotments.length === 0) { +
{{ 'labels.text.No records to display' | translate }}
+ } + @if (totalRecords > 0) { + + } +
+
diff --git a/src/app/locker/allotment/allotment-list/allotment-list.component.scss b/src/app/locker/allotment/allotment-list/allotment-list.component.scss new file mode 100644 index 000000000..4fa733bb4 --- /dev/null +++ b/src/app/locker/allotment/allotment-list/allotment-list.component.scss @@ -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%); +} diff --git a/src/app/locker/allotment/allotment-list/allotment-list.component.ts b/src/app/locker/allotment/allotment-list/allotment-list.component.ts new file mode 100644 index 000000000..3e75e612e --- /dev/null +++ b/src/app/locker/allotment/allotment-list/allotment-list.component.ts @@ -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'; + } + } +} diff --git a/src/app/locker/class-master/class-master.component.html b/src/app/locker/class-master/class-master.component.html new file mode 100644 index 000000000..7d311502e --- /dev/null +++ b/src/app/locker/class-master/class-master.component.html @@ -0,0 +1,281 @@ + + +
+ + + @if (showForm) { + + +

+ {{ (editingId === null ? 'New Locker Class' : 'Edit Locker Class') | translate }} + {{ 'Sr No' | translate }}: {{ editingId ?? ('Auto' | translate) }} +

+
+ +
+ @if (isSuperUser) { + + {{ 'Branch' | translate }} + + @for (office of offices; track office.id) { + {{ office.name }} + } + + + } @else { +
+ {{ 'Branch' | translate }} + {{ ownOfficeName }} +
+ } + + {{ 'Class' | translate }} + + {{ 'Letters and digits, e.g. A1, B2' }} + @if (classForm.get('classCode')?.hasError('pattern')) { + {{ 'Only letters and digits' | translate }} + } + + + {{ 'Class Name' | translate }} + + @for (name of classNames; track name) { + {{ name }} + } + + + + {{ 'Area' | translate }} + + {{ 'Locker size' | translate }} + +
+ + +
+ + {{ 'Rent (yearly)' | translate }} + + + + {{ 'Member Rent (yearly)' | translate }} + + {{ 'Blank = same as Rent' | translate }} + + + {{ 'Deposit' | translate }} + + + + {{ 'Member Deposit' | translate }} + + {{ 'Blank = same as Deposit' | translate }} + +
+ + +
+ + {{ 'Start Locker No' | translate }} + + + + {{ 'End Locker No' | translate }} + + +
+ {{ 'Total Lockers' | translate }} + {{ totalLockers }} +
+
+ @if (classForm.hasError('endBeforeStart')) { +
{{ 'End Locker No cannot be less than Start Locker No' | translate }}
+ } + @if (overlappingRange; as clash) { +
+ {{ 'Class' | translate }} {{ clash.classCode }} {{ 'already has lockers' | translate }} + {{ clash.startLockerNo }}–{{ clash.endLockerNo }} +
+ } + @if (editingId !== null) { +
+ {{ 'Class and locker numbers cannot be changed. Add a new row for a different range.' }} +
+ } + + +
+ + {{ 'Effective From' | translate }} + + + + + + {{ 'Effective To' | translate }} + + + + {{ 'Blank = no end date' | translate }} + +
+ @if (classForm.hasError('toBeforeFrom')) { +
{{ 'Effective To cannot be before Effective From' | translate }}
+ } + +
+ + +
+
+
+
+ } + +
+ @for (s of ['ACTIVE', 'INACTIVE']; track s) { + + } +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'Sr No' | translate }}{{ c.id }}{{ 'Branch' | translate }}{{ c.officeName }}{{ 'Class' | translate }} + {{ c.classCode }} + {{ 'Class Name' | translate }} + {{ c.className }} + {{ 'Area' | translate }}{{ c.area }}{{ 'Rent / Member' | translate }} + {{ c.rent | number: '1.0-2' }}
+ {{ c.memberRent | number: '1.0-2' }} +
{{ 'Locker No' | translate }}{{ c.startLockerNo }} – {{ c.endLockerNo }}{{ 'Total' | translate }}{{ c.endLockerNo - c.startLockerNo + 1 }}{{ 'Occupied' | translate }}{{ c.occupied ?? 0 }}{{ 'Deposit / Member' | translate }} + {{ c.deposit | number: '1.0-2' }}
+ {{ c.memberDeposit | number: '1.0-2' }} +
{{ 'Effective From' | translate }}{{ c.effectiveFromDate | dateFormat }}{{ 'Effective To' | translate }}{{ c.effectiveToDate ? (c.effectiveToDate | dateFormat) : '-' }}{{ 'labels.inputs.Status' | translate }} + {{ c.active ? 'Active' : 'Inactive' }} + + @if (c.active) { + + + } +
+ @if (loadFailed) { +
+ {{ 'Could not load locker classes.' }} +
+ } @else if (classList.length === 0) { +
{{ 'labels.text.No records to display' | translate }}
+ } + @if (totalRecords > 0) { + + } +
+
diff --git a/src/app/locker/class-master/class-master.component.scss b/src/app/locker/class-master/class-master.component.scss new file mode 100644 index 000000000..aa377ea40 --- /dev/null +++ b/src/app/locker/class-master/class-master.component.scss @@ -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%); +} diff --git a/src/app/locker/class-master/class-master.component.ts b/src/app/locker/class-master/class-master.component.ts new file mode 100644 index 000000000..baaf52541 --- /dev/null +++ b/src/app/locker/class-master/class-master.component.ts @@ -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(); + } +} diff --git a/src/app/locker/locker-dashboard/locker-dashboard.component.html b/src/app/locker/locker-dashboard/locker-dashboard.component.html new file mode 100644 index 000000000..e36c09fe3 --- /dev/null +++ b/src/app/locker/locker-dashboard/locker-dashboard.component.html @@ -0,0 +1,45 @@ + + +
+ + + @for (group of groups; track group.title) { +

{{ group.title | translate }}

+
+ @for (screen of group.screens; track screen.label) { + @if (screen.ready) { + + +
+ + + + {{ screen.label | translate }} +
+
+
+ } @else { + + +
+ + + + {{ screen.label | translate }} + {{ 'Coming soon' | translate }} +
+
+
+ } + } +
+ } +
diff --git a/src/app/locker/locker-dashboard/locker-dashboard.component.scss b/src/app/locker/locker-dashboard/locker-dashboard.component.scss new file mode 100644 index 000000000..0db68ae75 --- /dev/null +++ b/src/app/locker/locker-dashboard/locker-dashboard.component.scss @@ -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; +} diff --git a/src/app/locker/locker-dashboard/locker-dashboard.component.ts b/src/app/locker/locker-dashboard/locker-dashboard.component.ts new file mode 100644 index 000000000..f0bfb3d7d --- /dev/null +++ b/src/app/locker/locker-dashboard/locker-dashboard.component.ts @@ -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 + } + ] + } + ]; +} diff --git a/src/app/locker/locker-list/locker-list.component.html b/src/app/locker/locker-list/locker-list.component.html new file mode 100644 index 000000000..7fab11602 --- /dev/null +++ b/src/app/locker/locker-list/locker-list.component.html @@ -0,0 +1,146 @@ + + +
+ + +
+ + + + +
+ +
+ @if (isSuperUser) { + + {{ 'Branch' | translate }} + + {{ 'All Branches' | translate }} + @for (office of offices; track office.id) { + {{ office.name }} + } + + + } + + {{ 'Class' | translate }} + + {{ 'All Classes' | translate }} + @for (code of classCodes; track code) { + {{ code }} + } + + + + {{ 'Status' | translate }} + + {{ 'All' | translate }} + @for (s of statuses; track s) { + {{ s }} + } + + + + {{ 'Locker No' | translate }} + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'Branch' | translate }}{{ l.officeName }}{{ 'Class' | translate }} + {{ l.classCode }} + {{ 'Class Name' | translate }} + {{ l.className }} + {{ 'Locker No' | translate }}{{ l.lockerNo }}{{ 'Key No' | translate }}{{ l.keyNo || '-' }}{{ 'labels.inputs.Status' | translate }} + {{ + l.status + }} +
+ @if (loadFailed) { +
{{ 'Could not load lockers.' }}
+ } @else if (lockers.length === 0) { +
+ {{ 'No lockers found. Lockers are created from Class Master.' }} +
+ } + @if (totalRecords > 0) { + + } +
+
diff --git a/src/app/locker/locker-list/locker-list.component.scss b/src/app/locker/locker-list/locker-list.component.scss new file mode 100644 index 000000000..48e5b1f09 --- /dev/null +++ b/src/app/locker/locker-list/locker-list.component.scss @@ -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%); +} diff --git a/src/app/locker/locker-list/locker-list.component.ts b/src/app/locker/locker-list/locker-list.component.ts new file mode 100644 index 000000000..7b7b9e372 --- /dev/null +++ b/src/app/locker/locker-list/locker-list.component.ts @@ -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(codes)).sort(); + if (this.classFilter && !this.classCodes.includes(this.classFilter)) { + this.classFilter = ''; + } + }, + error: () => (this.classCodes = []) + }); + } +} diff --git a/src/app/locker/locker-routing.module.ts b/src/app/locker/locker-routing.module.ts new file mode 100644 index 000000000..a04b2617b --- /dev/null +++ b/src/app/locker/locker-routing.module.ts @@ -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 {} diff --git a/src/app/locker/locker.module.ts b/src/app/locker/locker.module.ts new file mode 100644 index 000000000..05a44979a --- /dev/null +++ b/src/app/locker/locker.module.ts @@ -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 {} diff --git a/src/app/locker/locker.service.ts b/src/app/locker/locker.service.ts new file mode 100644 index 000000000..d3fcbb2dd --- /dev/null +++ b/src/app/locker/locker.service.ts @@ -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 { + 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 { + return this.http.post('/lockerclasses', payload); + } + + updateClass(id: string | number, payload: any): Observable { + return this.http.put(`/lockerclasses/${id}`, payload); + } + + /** Refused by the server while any locker of the class is allotted. */ + deactivateClass(id: string | number): Observable { + 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 { + 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 { + 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 { + 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 { + return this.http.post('/lockerwaitinglist', payload); + } + + updateWaitingListEntry(id: string | number, payload: any): Observable { + 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 { + return this.http.get('/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 { + return this.http.get(`/lockerwaitinglist/clientcontact/${clientId}`); + } + + cancelWaitingListEntry(id: string | number, additionalRemarks?: string): Observable { + 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 { + 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 { + 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 { + return this.http.get(`/lockerallotments/clientdetails/${clientId}`); + } + + createAllotment(payload: any): Observable { + return this.http.post('/lockerallotments', payload); + } + + updateAllotment(id: string | number, payload: any): Observable { + return this.http.put(`/lockerallotments/${id}`, payload); + } + + /** Branch Manager's decision on a pending application; reject needs a reason. */ + approveAllotment(id: string | number): Observable { + return this.http.post(`/lockerallotments/${id}?command=approve`, {}); + } + + rejectAllotment(id: string | number, reason: string): Observable { + 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 { + return this.http.get('/offices'); + } +} diff --git a/src/app/locker/shared/locker-client-picker/locker-client-picker.component.ts b/src/app/locker/shared/locker-client-picker/locker-client-picker.component.ts new file mode 100644 index 000000000..155de9487 --- /dev/null +++ b/src/app/locker/shared/locker-client-picker/locker-client-picker.component.ts @@ -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: ` + + {{ label | translate }} + + + @for (c of suggestions; track c.clientId) { + + {{ c.displayName }} + + {{ 'ID' | translate }} {{ c.clientId }} + @if (c.mobileNo) { + · {{ c.mobileNo }} + } + @if (c.officeName) { + · {{ c.officeName }} + } + + + } + + @if (pickedId) { + {{ 'Client ID' | translate }} {{ pickedId }} + } @else if (!disabled) { + {{ 'Type 2+ letters and pick a client' | translate }} + } + + `, + 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(); + + nameControl = new FormControl(''); + 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(); + 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 ?? '')); +} diff --git a/src/app/locker/waiting-list/waiting-list.component.html b/src/app/locker/waiting-list/waiting-list.component.html new file mode 100644 index 000000000..f56328dc8 --- /dev/null +++ b/src/app/locker/waiting-list/waiting-list.component.html @@ -0,0 +1,293 @@ + + +
+ + + @if (showForm) { + + +

+ {{ (editingId === null ? 'New Inquiry' : 'Edit Inquiry') | translate }} + {{ 'Waiting No' | translate }}: {{ editingId ?? ('Auto' | translate) }} +

+
+ +
+ @if (isSuperUser) { + + {{ 'Branch' | translate }} + + @for (office of offices; track office.id) { + {{ office.name }} + } + + + } @else { +
+ {{ 'Branch' | translate }} + {{ ownOfficeName }} +
+ } + + {{ 'Inquiry Date' | translate }} + + + + + + {{ 'Customer Name' | translate }} + + + @for (c of clientSuggestions; track c.clientId) { + + {{ c.displayName }} + + {{ 'ID' | translate }} {{ c.clientId }} + @if (c.mobileNo) { + · {{ c.mobileNo }} + } + @if (c.officeName) { + · {{ c.officeName }} + } + + + } + + {{ 'Type 2+ letters to pick an existing client, or enter a new name' | translate }} + +
+ @if (linkedClient) { +
+ +  {{ 'Existing client' | translate }}: {{ linkedClient.name }} ({{ + 'Client ID' | translate + }} + {{ linkedClient.id }}) + + +
+ } + + +
+ + {{ 'Address Line 1' | translate }} + + + + {{ 'Address Line 2' | translate }} + + + + {{ 'Address Line 3' | translate }} + + + + {{ 'City' | translate }} + + + + {{ 'Pincode' | translate }} + + @if (entryForm.get('pincode')?.hasError('pattern')) { + {{ 'Pincode must be 6 digits' | translate }} + } + + + {{ 'Phone' | translate }} + + @if (entryForm.get('phone')?.hasError('pattern')) { + {{ 'Phone must be 10 to 15 digits' | translate }} + } + + + {{ 'Email' | translate }} + + @if (entryForm.get('email')?.hasError('email')) { + {{ 'Email is not valid' | translate }} + } + +
+ + + @if (classCodes.length === 0) { +
{{ 'No active locker class at this branch. Add one in Class Master first.' }}
+ } @else { +
+ @for (code of classCodes; track code) { + {{ + code + }} + } +
+ @if (selectedClasses.size === 0) { +
{{ 'Choose at least one locker class' | translate }}
+ } + } + + + + {{ 'Remarks' | translate }} + + + +
+ + +
+
+
+
+ } + +
+ @for (s of statuses; track s) { + + } +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'Waiting No' | translate }} + {{ e.id }} + {{ 'Branch' | translate }}{{ e.officeName }}{{ 'Inquiry Date' | translate }}{{ e.inquiryDate | dateFormat }}{{ 'Customer Name' | translate }} + {{ e.customerName }} + @if (e.clientId) { + {{ 'Client' | translate }} #{{ e.clientId }} + } + @if (e.remarks) { +
{{ e.remarks }} + } +
{{ 'City' | translate }}{{ e.city }}{{ 'Phone' | translate }}{{ e.phone || '-' }}{{ 'Locker Class' | translate }} + @for (code of e.preferredClasses; track code) { + {{ code }} + } + {{ 'labels.inputs.Status' | translate }} + {{ + e.status + }} + + @if (e.status === 'PENDING') { + + + } +
+ @if (loadFailed) { +
{{ 'Could not load the waiting list.' }}
+ } @else if (entries.length === 0) { +
{{ 'labels.text.No records to display' | translate }}
+ } + @if (totalRecords > 0) { + + } +
+
diff --git a/src/app/locker/waiting-list/waiting-list.component.scss b/src/app/locker/waiting-list/waiting-list.component.scss new file mode 100644 index 000000000..2d01b676a --- /dev/null +++ b/src/app/locker/waiting-list/waiting-list.component.scss @@ -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%); +} diff --git a/src/app/locker/waiting-list/waiting-list.component.ts b/src/app/locker/waiting-list/waiting-list.component.ts new file mode 100644 index 000000000..f9db6d72c --- /dev/null +++ b/src/app/locker/waiting-list/waiting-list.component.ts @@ -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(); + + 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(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(); + 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) + }); + }); + } +}