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>
237 lines
6.6 KiB
TypeScript
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();
|
|
}
|
|
}
|