Files
cbsnexora/src/app/shared/search-select/search-select.component.ts
T
niteshandClaude Sonnet 5 cc5cd4c6ab
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
feat(shares): add Share Withdrawal maker-checker workflow with GL settlement
Clerk submits a governed share redemption request (shares, Board Resolution
No., Meeting Date, settlement Payment Type); Bank Manager reviews with a
live backend-validated checklist (account/GL state, not just submit-time
snapshot) before approving, which redeems the shares and settles the
linked savings account in one transaction. Adds a Passbook tab to the
Share Account view, a reusable search-select control, and a Savings
Product "Map Payment Types to Specific Funds" fund-source override (only
Loan Products had this before) so settlement can be GL-mapped correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 16:55:21 +05:30

237 lines
6.6 KiB
TypeScript

/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, ElementRef, forwardRef, HostListener, Input, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
export interface SearchSelectOption {
value: string;
label: string;
/** What typed text is matched against — defaults to `label` when omitted, but a caller can
* pass a richer string (e.g. account no + customer name + branch) so a search hits fields
* that aren't all visible in the collapsed label. */
searchText?: string;
}
/**
* Drop-in replacement for a plain `<select>` when the option list is long enough that scanning
* it is impractical (e.g. hundreds of accounts) — a text box that filters the option list as you
* type, instead of a native dropdown you can only scroll through. Selection only commits on
* click; typing without picking an option reverts the box to the last committed selection on
* blur, so the bound ngModel value never drifts from an actual, valid option.
*
* Usage: <mifosx-search-select [options]="opts" [(ngModel)]="selectedValue"></mifosx-search-select>
*/
@Component({
selector: 'mifosx-search-select',
standalone: true,
imports: [
CommonModule,
FormsModule
],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SearchSelectComponent),
multi: true
}
],
template: `
<div class="inv-search-select">
<input
#inputEl
type="text"
class="inv-search-select__input"
[value]="query"
[placeholder]="placeholder"
[disabled]="disabled"
(input)="onInput($event)"
(focus)="onFocus()"
(blur)="onBlur()"
/>
@if (open) {
<ul
class="inv-search-select__list"
[style.top.px]="listTop"
[style.left.px]="listLeft"
[style.width.px]="listWidth"
(mousedown)="$event.preventDefault()"
>
@for (o of filteredOptions; track o.value) {
<li
class="inv-search-select__option"
[class.inv-search-select__option--active]="o.value === value"
(click)="selectOption(o)"
>
{{ o.label }}
</li>
} @empty {
<li class="inv-search-select__empty">No matches</li>
}
</ul>
}
</div>
`,
styles: [
`
/* Angular view encapsulation stops the host page's shared styles from reaching inside this
* component's own template, so its look is re-declared here rather than inherited. */
:host {
display: block;
width: 100%;
}
.inv-search-select {
position: relative;
width: 100%;
}
.inv-search-select__input {
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: #fff;
box-sizing: border-box;
}
/* position: fixed, anchored via JS to the input's own screen coords (see
* updateListPosition()) rather than absolute inside this component — most of this app's
* cards use overflow: hidden for rounded corners, which would otherwise clip a dropdown
* that extends past the card's edge. */
.inv-search-select__list {
position: fixed;
z-index: 2000;
max-height: 260px;
overflow-y: auto;
margin: 0;
padding: 4px;
list-style: none;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(13, 27, 42, 0.18);
}
.inv-search-select__option {
padding: 8px 10px;
border-radius: 6px;
font-size: 13px;
color: #0d1b2a;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.inv-search-select__option:hover,
.inv-search-select__option--active {
background: #eef2ff;
}
.inv-search-select__empty {
padding: 8px 10px;
font-size: 12px;
color: #94a3b8;
}
`
]
})
export class SearchSelectComponent implements ControlValueAccessor {
@Input() options: SearchSelectOption[] = [];
@Input() placeholder = 'Search…';
@ViewChild('inputEl') private inputEl?: ElementRef<HTMLInputElement>;
value = '';
query = '';
open = false;
disabled = false;
listTop = 0;
listLeft = 0;
listWidth = 0;
private onChangeFn: (v: string) => void = () => {};
onTouched: () => void = () => {};
get filteredOptions(): SearchSelectOption[] {
const q = this.query.trim().toLowerCase();
if (!q) {
return this.options;
}
return this.options.filter((o) => (o.searchText || o.label).toLowerCase().includes(q));
}
private labelFor(value: string): string {
return this.options.find((o) => o.value === value)?.label ?? '';
}
writeValue(v: string): void {
this.value = v ?? '';
this.query = this.labelFor(this.value);
}
registerOnChange(fn: (v: string) => void): void {
this.onChangeFn = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
/** Anchors the fixed-position list to the input's current on-screen box. */
private updateListPosition(): void {
const el = this.inputEl?.nativeElement;
if (!el) {
return;
}
const rect = el.getBoundingClientRect();
this.listTop = rect.bottom + 4;
this.listLeft = rect.left;
this.listWidth = rect.width;
}
@HostListener('window:scroll')
@HostListener('window:resize')
onWindowChange(): void {
if (this.open) {
this.updateListPosition();
}
}
onInput(event: Event): void {
this.query = (event.target as HTMLInputElement).value;
this.open = true;
this.updateListPosition();
}
onFocus(): void {
this.open = true;
this.updateListPosition();
}
onBlur(): void {
this.open = false;
this.onTouched();
// Discard any uncommitted typed text — only a clicked option ever changes `value`.
this.query = this.labelFor(this.value);
}
selectOption(o: SearchSelectOption): void {
this.value = o.value;
this.query = o.label;
this.open = false;
this.onChangeFn(this.value);
this.onTouched();
}
}