This commit is contained in:
2026-09-23 15:51:10 +05:30
parent cda7c52d11
commit 08a427467a
123 changed files with 20687 additions and 4156 deletions
@@ -6,109 +6,116 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { MatRadioModule } from '@angular/material/radio';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Subject, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { of } from 'rxjs';
/** Custom Services */
import { SharesService } from 'app/shares/shares.service';
import { CashierService } from 'app/cashier/cashier.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ReceiptOption, receiptFromOwn, receiptFromUnattached, receiptGlLabel } from 'app/shares/share-receipt-option';
/** Where the money for this tranche comes from. */
export type FundingSource = 'CASH' | 'SAVINGS';
/**
* Apply Additional Shares.
*
* A member buying a further tranche of shares, which — like the account's first application — is
* only a request until it is approved. Two things have to be captured that the bare share count
* does not carry:
*
* <ul>
* <li>the board resolution authorising this particular tranche, which is its own meeting and its
* own number, not the one the account was opened under;</li>
* <li>where the money is coming from, because a tranche nobody has paid for cannot be approved.</li>
* </ul>
* A member buying a further tranche, which — like the account's first application — is only a
* request until it is approved. Two things have to be captured that the bare share count does not
* carry: the board resolution authorising this particular tranche (its own meeting, its own number,
* not the one the account was opened under), and where the money is coming from, because a tranche
* nobody has paid for cannot be approved.
*
* Both funding routes converge on the Share Sundry holding account, so approval posts the same
* Dr Sundry / Cr Share Capital pair either way:
*
* <ul>
* <li><b>Cash</b> — the member already paid at the counter; the cashier's receipts put the money
* into Sundry at that point. Submitting only reserves those receipts against this tranche.
* More than one can be ticked, because a member often pays in instalments and no single
* receipt covers the tranche. They are checked here rather than at approval, while the member
* is still at the counter to make up any shortfall.</li>
* <li><b>Cash</b> — the member already paid at the counter and the cashier's receipt put the money
* into Sundry then. Submitting only reserves that receipt against this tranche. One receipt
* funds one tranche; it is checked here rather than at approval, while the member is still at
* the counter to make up any shortfall.</li>
* <li><b>Savings</b> — the member's savings are debited into Sundry on submit, so their balance
* drops immediately. Approval then moves it out to share capital.</li>
* </ul>
*
* Deliberately the same screen as the first application's Apply Collection dialog — same numbered
* steps, same receipt list, same voucher search — because it settles the same receipts for the same
* person, and two different designs for that were only ever two things to learn.
*/
@Component({
selector: 'mifosx-apply-shares',
templateUrl: './apply-shares.component.html',
styleUrls: ['./apply-shares.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FormsModule,
MatRadioModule,
MatProgressSpinnerModule,
MatIconModule
MatDialogModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
]
})
export class ApplySharesComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private sharesService = inject(SharesService);
private cashierService = inject(CashierService);
private dateUtils = inject(Dates);
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
/** Shares account data. */
sharesAccountData: any;
public dialogRef = inject(MatDialogRef<ApplySharesComponent>);
public data = inject<{
shareAccountId: number;
clientId: number;
accountNo: string;
unitPrice: number;
}>(MAT_DIALOG_DATA);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
maxDate = new Date();
/** Apply Share Account form. */
applySharesForm: UntypedFormGroup;
/** Shares Account Id */
accountId: any;
/** Submission in flight, so the form cannot be posted twice. */
submitting = false;
/** Anything that stopped the submission, shown above the buttons. */
errorMsg = '';
/**
* The member's unapplied Share Sundry receipts, and which of them fund this tranche.
*
* Several, because a member often pays one tranche in instalments — ₹600 today, the ₹400 balance
* tomorrow — and neither receipt covers it alone. They are submitted together as one settlement
* rather than sending the member back to the counter for a single combined receipt.
*/
vouchers: any[] = [];
selectedVoucherIds: number[] = [];
loadingVouchers = false;
lookingUpVoucher = false;
voucherError = '';
// ── The tranche ─────────────────────────────────────────────────────────────────────────────
/** GL account the deposit was posted to — required, and always picked by hand. Never resolved
* from a share product's accounting setup, so a broken or missing product mapping can never
* make a genuine voucher unreachable. */
glAccountNo = '';
glAccounts: any[] = [];
loadingGlAccounts = true;
/** How many shares this tranche is for. */
requestedShares: number | null = null;
/** The product's current market price. Read-only: the tranche's value and its GL posting must
* never be able to disagree because somebody typed over the price. */
unitPrice = 0;
/** Both as yyyy-MM-dd, the value a native date input reads and writes. */
requestedDate = '';
resolutionDate = '';
resolutionNumber = '';
/** Shares x unit price — what this tranche is worth, and what has to be funded. */
get amount(): number {
return (Number(this.requestedShares) || 0) * this.unitPrice;
}
// ── Funding ─────────────────────────────────────────────────────────────────────────────────
funding: FundingSource | null = null;
/** Every Share Collection receipt that can fund this tranche, from both server lists, in one pool. */
available: ReceiptOption[] = [];
loadingReceipts = false;
/** The one receipt funding this tranche. One receipt, one tranche. */
receipt: ReceiptOption | null = null;
/** Key of the receipt currently being attached to this member, so only its own row shows a spinner. */
attaching: string | null = null;
/** What the member's savings can cover, for the savings route. */
savingsEligibility: {
savings: {
eligible: boolean;
reason: string | null;
savingsAccountNo: string | null;
@@ -116,19 +123,24 @@ export class ApplySharesComponent implements OnInit {
} | null = null;
checkingSavings = false;
constructor() {
this.accountId = this.route.parent.snapshot.params['shareAccountId'];
this.route.data.subscribe((data: { shareAccountActionData: any }) => {
this.sharesAccountData = data.shareAccountActionData;
/** Re-checks the savings verdict as the share count settles, rather than on every keystroke of
* "1000" — which asks four times and lets the answers land out of order. */
private sharesChanged = new Subject<number>();
ngOnInit(): void {
this.unitPrice = Number(this.data.unitPrice) || 0;
// Opens on the business date rather than blank: a tranche is recorded the day it is taken, and
// asking a clerk to type that in is asking them to retype what the system already knows.
this.requestedDate = this.isoOf(this.settingsService.businessDate);
this.sharesChanged.pipe(debounceTime(400), distinctUntilChanged()).subscribe(() => {
if (this.funding === 'SAVINGS') {
this.checkSavings();
}
});
}
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.createApplySharesAccountForm();
this.applySharesForm.get('unitPrice').patchValue(this.sharesAccountData.currentMarketPrice || '');
this.applySharesForm.get('amount').patchValue(this.amount || '');
// Needed only by the voucher search, which is the exception path -- loaded quietly in the
// background so opening that popup never waits on a request.
this.cashierService
.getShareAdvanceGlAccounts()
.pipe(catchError(() => of(null)))
@@ -138,191 +150,109 @@ export class ApplySharesComponent implements OnInit {
});
}
/**
* Creates the apply shares form.
*
* The request date opens on the business date rather than blank: an additional-share request is
* recorded the day it is taken, and a clerk who has to fill that in by hand is being asked to
* retype what the system already knows.
*/
createApplySharesAccountForm() {
this.applySharesForm = this.formBuilder.group({
requestedDate: [
this.settingsService.businessDate,
Validators.required
],
requestedShares: [
'',
Validators.required
],
unitPrice: [{ value: '', disabled: true }],
// Disabled, not readonly: the amount is derived from shares x price and must never be a
// figure somebody typed, or the tranche's value and its GL posting could disagree.
amount: [{ value: '', disabled: true }],
resolutionNumber: [
'',
Validators.required
],
resolutionDate: [
'',
Validators.required
],
paymentMode: [
'',
Validators.required
]
});
// Recompute as the share count is typed, so the clerk can read the amount back to the member
// before committing to a funding route.
this.applySharesForm.get('requestedShares').valueChanges.subscribe(() => {
this.applySharesForm.get('amount').patchValue(this.amount || '');
// A savings verdict is cleared the moment the amount changes rather than when the re-check
// returns, so a stale "can cover it" is never on screen beside a larger amount. The ticked
// vouchers stay: they are the receipts the member actually holds, and the running total below
// shows against the new amount whether they still cover it.
this.voucherError = '';
this.savingsEligibility = null;
});
// Debounced separately from the clearing above: the check is a server round-trip, and firing it
// on every keystroke of "1000" asks four times and lets the answers land out of order.
this.applySharesForm
.get('requestedShares')
.valueChanges.pipe(debounceTime(400), distinctUntilChanged())
.subscribe(() => {
if (this.paymentMode === 'SAVINGS') {
this.checkSavings();
}
});
this.applySharesForm.get('paymentMode').valueChanges.subscribe((mode: string) => {
this.vouchers = [];
this.selectedVoucherIds = [];
this.voucherError = '';
this.errorMsg = '';
this.savingsEligibility = null;
if (mode === 'SAVINGS') {
this.checkSavings();
} else if (mode === 'CASH') {
this.loadVouchers();
}
});
onSharesChange(): void {
// The verdict is cleared the moment the amount changes rather than when the re-check returns, so
// a stale "can cover it" is never on screen beside a larger amount. The chosen receipt stays --
// it is the receipt the member actually holds, and the shortfall line below re-reads against the
// new amount on its own.
this.savings = null;
this.errorMsg = '';
this.sharesChanged.next(Number(this.requestedShares) || 0);
}
/** Shares x unit price — what this tranche is worth, and what has to be funded. */
get amount(): number {
const shares = Number(this.applySharesForm?.get('requestedShares')?.value) || 0;
const price = Number(this.applySharesForm?.get('unitPrice')?.value) || 0;
return shares * price;
}
get paymentMode(): string {
return this.applySharesForm?.get('paymentMode')?.value || '';
}
/**
* Loads the member's unapplied Share Sundry receipts so they can be ticked off.
*
* A single receipt is ticked for them: with only one candidate there is nothing to choose, and
* making the clerk tick it adds a step that can only be got wrong by forgetting it.
*/
private loadVouchers(): void {
this.loadingVouchers = true;
this.cashierService
.getUnappliedShareAdvances(this.sharesAccountData.clientId)
.pipe(
catchError((err: any) => {
this.voucherError = this.readError(err, 'Could not load the member’s receipts.');
this.loadingVouchers = false;
return of(null);
})
)
.subscribe((res: any) => {
this.loadingVouchers = false;
if (!res) {
return;
}
this.vouchers = res.unappliedCollections || [];
if (this.vouchers.length === 1) {
this.selectedVoucherIds = [this.vouchers[0].id];
}
});
}
isVoucherSelected(id: number): boolean {
return this.selectedVoucherIds.includes(id);
}
toggleVoucher(id: number): void {
this.selectedVoucherIds = this.isVoucherSelected(id) ? this.selectedVoucherIds.filter((x) => x !== id) : [
...this.selectedVoucherIds,
id
];
this.voucherError = '';
}
/**
* What the ticked receipts come to.
*
* Shown live, because the submission only goes through when the total covers the tranche, and
* adding the rows up by eye is exactly what leads to a rejected submission.
*/
get selectedVoucherTotal(): number {
return this.vouchers.filter((v) => this.isVoucherSelected(v.id)).reduce((sum, v) => sum + Number(v.amount || 0), 0);
}
/**
* Resolves a voucher number the member quotes and adds it to the list.
*
* Kept alongside the tick list rather than replacing it: the member usually quotes the number on
* the slip in their hand, which is faster than finding it among their receipts. The lookup is
* deliberately not restricted to this member, so a voucher belonging to somebody else is
* surfaced here as a plain mismatch rather than silently ignored.
*/
lookupVoucher(voucherNo: string): void {
const trimmed = (voucherNo || '').trim();
const glAccountNo = this.glAccountNo.trim();
if (!trimmed || !glAccountNo || this.lookingUpVoucher) {
chooseFunding(source: FundingSource): void {
if (this.funding === source) {
return;
}
this.lookingUpVoucher = true;
this.voucherError = '';
this.funding = source;
this.receipt = null;
this.savings = null;
this.errorMsg = '';
if (source === 'SAVINGS') {
this.checkSavings();
} else if (!this.available.length) {
this.loadReceipts();
}
}
/**
* Loads every Share Collection receipt that could fund this tranche — the member's own, and the
* ones still belonging to nobody — as one list.
*
* Nothing is pre-selected, not even when a single receipt is listed: which receipt funds the
* tranche is a choice the person applying it makes, not one they inherit from a pre-ticked row
* they never looked at.
*/
private loadReceipts(): void {
this.loadingReceipts = true;
this.cashierService
.getShareAdvanceByVoucher(trimmed, glAccountNo, this.sharesAccountData.clientId)
.getShareCollectionReceipts(this.data.clientId)
.pipe(
catchError((err: any) => {
this.voucherError = this.readError(err, `Voucher ${trimmed} could not be found.`);
this.lookingUpVoucher = false;
this.errorMsg = this.readError(err, 'Could not load the available receipts.');
this.loadingReceipts = false;
return of(null);
})
)
.subscribe((res: any) => {
this.lookingUpVoucher = false;
this.loadingReceipts = false;
if (!res) {
return;
}
if (Number(res.client_id) !== Number(this.sharesAccountData.clientId)) {
this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`;
return;
}
// Added to the selection rather than replacing it — a looked-up voucher is usually the
// second half of a part-payment, not a change of mind about the first. No amount check
// here: one instalment is not meant to cover the tranche on its own.
this.vouchers = [
res,
...this.vouchers.filter((v) => v.id !== res.id)
this.available = [
...(res.own || []).map((row: any) => receiptFromOwn(row)),
...(res.rows || []).map((row: any) => receiptFromUnattached(row))
];
if (!this.isVoucherSelected(res.id)) {
this.selectedVoucherIds = [
...this.selectedVoucherIds,
res.id
];
}
});
}
/**
* Chooses the receipt funding this tranche, attaching it to this member first if it is still only
* a voucher number.
*
* A receipt paid straight into a GL account names no member — a GL account is nobody's account —
* so it has to be attributed before it can fund anything. That attribution is exactly the
* by-voucher lookup a hand-typed number already runs; doing it on the pick makes it one deliberate
* choice against a member on screen, rather than an Attach in one table and a tick in another.
*/
pick(option: ReceiptOption): void {
if (this.attaching || this.submitting) {
return;
}
if (this.receipt?.key === option.key) {
this.receipt = null;
return;
}
if (option.collectionId !== null) {
this.receipt = option;
return;
}
this.attaching = option.key;
this.errorMsg = '';
this.findVoucher(option.voucherNo, option.glAccountNo).subscribe((res) => {
this.attaching = null;
if (!res) {
return;
}
// Attached now, so it takes its real row's place in the pool -- picking it again must never
// run the attach a second time.
const attached = receiptFromOwn(res);
this.available = this.available.map((entry) => (entry.key === option.key ? attached : entry));
this.receipt = attached;
});
}
clearReceipt(): void {
this.receipt = null;
}
/** What the chosen receipt is still missing, zero once it covers the tranche. */
get shortfall(): number {
const short = this.amount - Number(this.receipt?.amount || 0);
return this.receipt && short > 0 ? short : 0;
}
/**
* Whether the member's savings can cover this tranche.
*
@@ -331,104 +261,215 @@ export class ApplySharesComponent implements OnInit {
* submission will be valued at. Deciding it in the browser would let the two drift apart.
*/
private checkSavings(): void {
const shares = Number(this.applySharesForm.get('requestedShares').value) || 0;
const shares = Number(this.requestedShares) || 0;
this.checkingSavings = true;
this.sharesService
.getAdditionalSharesSavingsEligibility(this.accountId, shares)
.getAdditionalSharesSavingsEligibility(String(this.data.shareAccountId), shares)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.checkingSavings = false;
if (!res) {
this.savingsEligibility = {
eligible: false,
reason: 'Could not check the savings account linked to this share account.',
savingsAccountNo: null,
savingsBalance: null
};
return;
}
this.savingsEligibility = {
eligible: !!res.eligible,
reason: res.reason ?? null,
savingsAccountNo: res.savingsAccountNo ?? null,
savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance)
};
this.savings = res
? {
eligible: !!res.eligible,
reason: res.reason ?? null,
savingsAccountNo: res.savingsAccountNo ?? null,
savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance)
}
: {
eligible: false,
reason: 'Could not check the savings account linked to this share account.',
savingsAccountNo: null,
savingsBalance: null
};
});
}
/**
* Whether the chosen funding route is ready — receipts that between them cover the tranche, or
* savings that can cover it.
* Whether the chosen funding route is ready — a receipt that covers the tranche, or savings that
* can.
*
* The total is checked here rather than only on submit, so a part-paid tranche is visibly short
* at the counter instead of being refused after the member has left.
* Checked here rather than only on submit, so a part-paid tranche is visibly short at the counter
* instead of being refused after the member has left.
*/
get fundingReady(): boolean {
if (this.paymentMode === 'CASH') {
return this.selectedVoucherIds.length > 0 && this.selectedVoucherTotal >= this.amount;
if (this.funding === 'CASH') {
return !!this.receipt && this.shortfall === 0;
}
if (this.paymentMode === 'SAVINGS') {
return !!this.savingsEligibility?.eligible;
if (this.funding === 'SAVINGS') {
return !!this.savings?.eligible;
}
return false;
}
/** The shortfall to tell the member about, or zero when the ticked receipts cover the tranche. */
get voucherShortfall(): number {
const short = this.amount - this.selectedVoucherTotal;
return short > 0 ? short : 0;
get canSubmit(): boolean {
return (
this.amount > 0 &&
!!this.requestedDate &&
!!this.resolutionNumber.trim() &&
!!this.resolutionDate &&
this.fundingReady &&
!this.submitting
);
}
get canSubmit(): boolean {
return this.applySharesForm.valid && this.amount > 0 && this.fundingReady && !this.submitting;
// ── Voucher search, for a receipt the list does not carry ───────────────────────────────────
searchOpen = false;
searchVoucherNo = '';
searchGlAccountNo = '';
searching = false;
/** GL accounts, for the search only — a voucher is found by number *and* the account it sits in. */
glAccounts: any[] = [];
loadingGlAccounts = true;
openSearch(): void {
this.searchOpen = true;
this.searchVoucherNo = '';
this.searchGlAccountNo = this.glAccounts.length === 1 ? this.glAccounts[0].gl_code : '';
this.errorMsg = '';
}
closeSearch(): void {
this.searchOpen = false;
this.errorMsg = '';
}
runSearch(): void {
const voucher = this.searchVoucherNo.trim();
const glAccountNo = this.searchGlAccountNo.trim();
// Both fields are genuinely needed: a blank GL account here means nothing has been chosen yet,
// not that none is required.
if (!voucher || !glAccountNo || this.searching) {
return;
}
this.searching = true;
this.errorMsg = '';
this.findVoucher(voucher, glAccountNo).subscribe((res) => {
this.searching = false;
if (!res) {
return;
}
const found = receiptFromOwn(res);
this.available = [
found,
...this.available.filter((entry) => entry.key !== found.key && entry.voucherNo !== found.voucherNo)
];
this.receipt = found;
this.searchOpen = false;
});
}
private findVoucher(voucher: string, glAccountNo: string) {
// No client-match check: any receipt can fund any share account, regardless of which member it
// was first recorded against.
return this.cashierService.getShareAdvanceByVoucher(voucher, glAccountNo, this.data.clientId).pipe(
catchError((err: any) => {
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found in that GL account.`);
return of(null);
})
);
}
// ── A receipt's own GL entries ──────────────────────────────────────────────────────────────
viewing: ReceiptOption | null = null;
/** The receipt's own real Dr/Cr legs, read under its own transaction id — never the GL account
* picked in the search box, which is unrelated screen state naming a different account. */
viewingGlEntries: any[] | null = null;
loadingGlEntries = false;
openDetail(option: ReceiptOption, event: Event): void {
event.stopPropagation();
if (option.collectionId === null) {
return;
}
this.viewing = option;
this.viewingGlEntries = null;
this.loadingGlEntries = true;
this.cashierService
.getShareAdvanceGlEntries(option.collectionId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.viewingGlEntries = res?.entries || [];
this.loadingGlEntries = false;
});
}
closeDetail(): void {
this.viewing = null;
this.viewingGlEntries = null;
}
// ── Submission ──────────────────────────────────────────────────────────────────────────────
/**
* Records the request. Nothing is issued here — the tranche sits pending until it is approved,
* which is what moves the money out of Sundry into share capital.
*/
submit() {
submit(): void {
if (!this.canSubmit) {
return;
}
this.submitting = true;
this.errorMsg = '';
const formValue = this.applySharesForm.value;
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const data: any = {
requestedShares: formValue.requestedShares,
requestedDate: this.dateUtils.formatDate(formValue.requestedDate, dateFormat),
unitPrice: this.applySharesForm.get('unitPrice').value,
resolutionNumber: formValue.resolutionNumber,
meetingDate: this.dateUtils.formatDate(formValue.resolutionDate, dateFormat),
fundingSource: formValue.paymentMode,
const payload: any = {
requestedShares: this.requestedShares,
requestedDate: this.dateUtils.formatDate(this.dateOf(this.requestedDate), dateFormat),
unitPrice: this.unitPrice,
resolutionNumber: this.resolutionNumber.trim(),
meetingDate: this.dateUtils.formatDate(this.dateOf(this.resolutionDate), dateFormat),
fundingSource: this.funding,
dateFormat,
locale
locale: this.settingsService.language.code
};
if (formValue.paymentMode === 'CASH') {
// First receipt leads, the rest ride along; the server settles them as one funding.
const [
leadId,
...additionalCollectionIds
] = this.selectedVoucherIds;
data.advanceCollectionId = leadId;
if (additionalCollectionIds.length > 0) {
data.additionalCollectionIds = additionalCollectionIds;
}
if (this.funding === 'CASH' && this.receipt) {
payload.advanceCollectionId = this.receipt.collectionId;
}
this.sharesService.executeSharesAccountCommand(this.accountId, 'applyadditionalshares', data).subscribe({
next: () => {
this.router.navigate(['../../'], { relativeTo: this.route });
},
error: (err: any) => {
this.submitting = false;
this.errorMsg = this.readError(err, 'The additional-share request could not be submitted.');
}
});
this.sharesService
.executeSharesAccountCommand(String(this.data.shareAccountId), 'applyadditionalshares', payload)
.subscribe({
next: (res: any) => {
this.dialogRef.close(res || true);
},
error: (err: any) => {
this.submitting = false;
this.errorMsg = this.readError(err, 'The additional-share request could not be submitted.');
}
});
}
// ── Helpers ─────────────────────────────────────────────────────────────────────────────────
/** The GL account a receipt sits in, named from the list the voucher search already loaded. */
glLabel(option: ReceiptOption): string {
return receiptGlLabel(option, this.glAccounts);
}
/** A Date as the yyyy-MM-dd a native date input reads, in local time. */
private isoOf(date: Date): string {
const local = new Date(date);
return `${local.getFullYear()}-${String(local.getMonth() + 1).padStart(2, '0')}-${String(local.getDate()).padStart(2, '0')}`;
}
/**
* A yyyy-MM-dd back into a local Date.
*
* Built from the parts rather than {@code new Date(iso)}, which reads the string as UTC midnight
* and so lands on the previous day for anyone west of Greenwich — a request dated a day early is
* not something a date picker should be able to produce.
*/
private dateOf(iso: string): Date {
const [
year,
month,
day
] = iso.split('-').map(Number);
return new Date(year, month - 1, day);
}
private readError(err: any, fallback: string): string {