/** * 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, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; import { FormsModule, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { MatDialog, MatDialogRef, MAT_DIALOG_DATA, MatDialogTitle, MatDialogContent, MatDialogActions, MatDialogClose } from '@angular/material/dialog'; import { CdkScrollable } from '@angular/cdk/scrolling'; import { MatButtonModule } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatSnackBar } from '@angular/material/snack-bar'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import lightGallery from 'lightgallery'; import lgZoom from 'lightgallery/plugins/zoom'; import lgFullscreen from 'lightgallery/plugins/fullscreen'; import type { LightGallery } from 'lightgallery/lightgallery'; import type { GalleryItem } from 'lightgallery/lg-utils'; /** Custom Services */ import { CtsClearingService } from '../../ctsclearing.service'; import { ClearingService } from '../../../clearing-network/clearing.service'; import { SignatoryService, AccountSignatoryInfo, JointHolder } from '../../../clearing-network/signatory.service'; import { clearingError, pillClass } from '../../../clearing-network/clearing-status'; import { ValidationDetailDialogComponent } from './validation-detail-dialog/validation-detail-dialog.component'; interface RequiredSigner { clientId: number; name: string; role: string; mandatory: boolean; isPrimary: boolean; signatureUrl: string | null; loadingSignature: boolean; checked: boolean; /** Draggable/resizable overlay state — lets the clerk place the stored signature directly over the * cheque's own signature area for a close visual comparison, at whatever size suits the cheque image. */ overlayVisible: boolean; overlayX: number; overlayY: number; overlayWidth: number; overlayOpacity: number; } const OVERLAY_MIN_WIDTH = 60; const OVERLAY_MAX_WIDTH = 420; const OVERLAY_DEFAULT_WIDTH = 160; /** * Single-panel cheque verification for the clerk: the cheque image, every automatic validation result the * backend already computes, the account's mode of operation and mandatory signatories (with their stored * signature images to visually compare against the cheque), and finally the PASS/RETURN decision. PASS is * blocked until the applicable signature rule is satisfied — see {@link signatureVerified}. */ @Component({ selector: 'mifosx-item-decision-dialog', templateUrl: './item-decision-dialog.component.html', styleUrls: ['./item-decision-dialog.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, FormsModule, MatDialogTitle, CdkScrollable, MatDialogContent, MatDialogActions, MatDialogClose, MatButtonModule, MatCheckboxModule, MatTooltipModule ] }) export class ItemDecisionDialogComponent implements OnInit, OnDestroy { dialogRef = inject>(MatDialogRef); data = inject(MAT_DIALOG_DATA); private formBuilder = inject(UntypedFormBuilder); private ctsService = inject(CtsClearingService); private clearingService = inject(ClearingService); private signatoryService = inject(SignatoryService); private snackBar = inject(MatSnackBar); private dialog = inject(MatDialog); @ViewChild('lightboxRoot', { static: true }) lightboxRoot: ElementRef; @ViewChild('chequeStage') chequeStageEl?: ElementRef; @ViewChild('chequeImageWrap') chequeImageWrapEl?: ElementRef; private lightboxInstance: LightGallery | null = null; private readonly lightboxPlugins = [ lgZoom, lgFullscreen ]; private notify(message: string, isError = false): void { this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 }); } item: any = this.data?.item || {}; mode: 'PASS' | 'RETURN' = 'PASS'; busy = false; reasonCodes: any[] = []; /** Fullscreen compare mode: the cheque takes over the whole viewport with a shelf of every mandatory * signer's stored signature down the side, so any one of them can be dragged onto the cheque at a big, * workable size instead of squinting at a small inline image. */ fullscreenActive = false; /** Zoom level applied to the cheque image itself while fullscreen — the clerk needs to zoom into fine * detail (MICR line, ink texture) independent of dragging a signature overlay around. */ chequeZoom = 1; private static readonly ZOOM_MIN = 1; private static readonly ZOOM_MAX = 4; private static readonly ZOOM_STEP = 0.25; frontImageUrl: string | null = null; backImageUrl: string | null = null; loadingAccountInfo = false; accountInfo: AccountSignatoryInfo | null = null; requiredSigners: RequiredSigner[] = []; /** 'any' = mode of operation lets any one signer sign (Either/Anyone or Survivor); 'all' = every mandatory signer must sign. */ signerSelectionMode: 'any' | 'all' = 'any'; manualOverrideChecked = false; flagAsSuspicious = false; markAsWhitelisted = false; handleComplianceActions(): void { if (this.flagAsSuspicious) { const existingStrData = localStorage.getItem('fiu_str_cases'); let cases: any[] = []; if (existingStrData) { try { cases = JSON.parse(existingStrData); } catch (e) { cases = []; } } const newRef = 101 + cases.length; const newCase: any = { reportType: 'STR', reportRefNo: newRef, branchCode: this.item.presentingBranchCode || '1', accountNumber: this.item.accountNo || this.item.accountId || '110001101000838', txnBranchCode: this.item.presentingBranchCode || '1', depositOrWithdrawal: 'Withdrawal', txnId: this.item.id, txnDate: new Date().toLocaleDateString('en-GB').replace(/\//g, '-'), txnTime: new Date().toLocaleTimeString('en-US', { hour12: false }), txnAmount: this.item.amount, instrumentType: 'Cheque', instrumentId: this.item.chequeNumber, nonCustomerRef: '', relationshipFlag: 'Customer', thirdPartyPan: '', declaration: 'NA', customerName: this.item.clientName || 'UNKNOWN CLIENT', detectedOn: new Date().toLocaleDateString('en-GB').replace(/\//g, '-'), flaggedBy: 'CTS Clearing Officer', riskCategory: 'unusual-pattern', narrative: `CTS Cheque #${this.item.chequeNumber} for amount ₹${this.item.amount} flagged as suspicious by Clearing Officer during inward presentment review. Action: ${this.mode}. Reason/Notes: ${this.mode === 'RETURN' ? this.returnForm.value.reason : this.passForm.value.note || 'Manual compliance escalation'}`, status: 'po-review' }; cases.unshift(newCase); localStorage.setItem('fiu_str_cases', JSON.stringify(cases)); const storedCtr = localStorage.getItem('fiu_ctr_txns'); if (storedCtr) { try { const ctrList = JSON.parse(storedCtr); const matching = ctrList.find((t: any) => t.accountNumber === newCase.accountNumber); if (matching) { matching.complianceStatus = 'SUSPICIOUS'; localStorage.setItem('fiu_ctr_txns', JSON.stringify(ctrList)); } } catch (e) {} } } else if (this.markAsWhitelisted) { const existingWhitelist = localStorage.getItem('compliance_whitelisted_accounts'); let whitelisted: string[] = []; if (existingWhitelist) { try { whitelisted = JSON.parse(existingWhitelist); } catch (e) { whitelisted = []; } } const account = this.item.accountNo || this.item.accountId; if (account && !whitelisted.includes(account.toString())) { whitelisted.push(account.toString()); localStorage.setItem('compliance_whitelisted_accounts', JSON.stringify(whitelisted)); } const storedCtr = localStorage.getItem('fiu_ctr_txns'); if (storedCtr) { try { const ctrList = JSON.parse(storedCtr); const matching = ctrList.find((t: any) => t.accountNumber === (account ? account.toString() : '')); if (matching) { matching.complianceStatus = 'WHITELISTED'; localStorage.setItem('fiu_ctr_txns', JSON.stringify(ctrList)); } } catch (e) {} } } } /** Every automatic validation rule InwardClearingValidationService already computes, shown so the clerk sees WHY, not just a decision. */ readonly validationRules: { key: string; label: string; goodWhenTrue: boolean }[] = [ { key: 'accountCheckPassed', label: 'Account', goodWhenTrue: true }, { key: 'balanceCheckPassed', label: 'Balance', goodWhenTrue: true }, { key: 'duplicateCheckPassed', label: 'Duplicate', goodWhenTrue: true }, { key: 'stopPaymentCheckPassed', label: 'Stop Payment', goodWhenTrue: true }, { key: 'amountWordsFiguresMatch', label: 'Amount Match', goodWhenTrue: true }, { key: 'staleOrPostdated', label: 'Stale / Postdated', goodWhenTrue: false }, { key: 'positivePayCheck', label: 'Positive Pay', goodWhenTrue: true } ]; passForm: UntypedFormGroup; returnForm: UntypedFormGroup; constructor() { this.passForm = this.formBuilder.group({ note: [''] }); this.returnForm = this.formBuilder.group({ returnReasonCode: [ '', Validators.required ], reason: [''] }); } ngOnInit(): void { this.clearingService.getReturnReasonCodes('CTS').subscribe((res: any) => { const list = res?.pageItems ?? (Array.isArray(res) ? res : []); this.reasonCodes = list.filter((c: any) => c.active !== false); }); this.loadImages(); this.loadAccountInfo(); } ngOnDestroy(): void { this.destroyLightbox(); } /** Shared status-string -> pill-color mapping, same one the batch list table already uses for iqaStatus/decision. */ pillClass = pillClass; /** ok / bad / neutral for a single boolean validation rule, respecting whether true is the good outcome. */ ruleClass(rule: { key: string; goodWhenTrue: boolean }): 'ok' | 'bad' | 'neutral' { const value = this.item?.[rule.key]; if (value === null || value === undefined) { return 'neutral'; } const good = rule.goodWhenTrue ? value === true : value === false; return good ? 'ok' : 'bad'; } /** item.decision is NEVER null — InwardClearingItem.java sets it to the literal string "PENDING" at creation, * then "PASS"/"RETURN" once decided. A plain truthy check on it is always true, which silently made this * dialog show the read-only decision summary (and hide the Pass/Return form) for every item, including ones * still genuinely PENDING. */ get isDecided(): boolean { return this.item?.decision === 'PASS' || this.item?.decision === 'RETURN'; } /** What each automatic rule actually compared (amount vs words, instrument date vs the valid-until window, * balance vs amount, etc.) instead of just the pass/fail pill — opened on demand, not always-on clutter. */ openValidationDetail(): void { this.dialog.open(ValidationDetailDialogComponent, { data: { item: this.item, accountInfo: this.accountInfo }, width: '560px', maxWidth: '95vw', maxHeight: '85vh' }); } private loadImages(): void { this.ctsService.getInwardItemImage(this.item.id, 'front').subscribe({ next: (url: string) => (this.frontImageUrl = url), error: () => { /* no image uploaded for this side yet */ } }); this.ctsService.getInwardItemImage(this.item.id, 'back').subscribe({ next: (url: string) => (this.backImageUrl = url), error: () => { /* no image uploaded for this side yet */ } }); } /** Opens the front/back cheque image in a zoomable, pannable lightbox — same lightgallery setup used app-wide. */ openImageZoom(side: 'front' | 'back'): void { const url = side === 'front' ? this.frontImageUrl : this.backImageUrl; if (!url) { return; } this.destroyLightbox(); const items: GalleryItem[] = [ { src: url, thumb: url, subHtml: `

Cheque ${side}

` }]; this.lightboxInstance = lightGallery(this.lightboxRoot.nativeElement, { dynamic: true, dynamicEl: items, plugins: this.lightboxPlugins, download: false, closable: true, escKey: true, zoomFromOrigin: true }); this.lightboxInstance.openGallery(0); } /** Opens a signer's stored signature image in the same zoomable lightbox, for a closer visual comparison. */ openSignatureZoom(signer: RequiredSigner): void { if (!signer.signatureUrl) { return; } this.destroyLightbox(); const items: GalleryItem[] = [ { src: signer.signatureUrl, thumb: signer.signatureUrl, subHtml: `

${signer.name} — stored signature

` } ]; this.lightboxInstance = lightGallery(this.lightboxRoot.nativeElement, { dynamic: true, dynamicEl: items, plugins: this.lightboxPlugins, download: false, closable: true, escKey: true, zoomFromOrigin: true }); this.lightboxInstance.openGallery(0); } private destroyLightbox(): void { if (this.lightboxInstance) { this.lightboxInstance.destroy(); this.lightboxInstance = null; } } private loadAccountInfo(): void { if (!this.item.accountType || !this.item.accountId) { return; } this.loadingAccountInfo = true; this.signatoryService.fetchAccountSignatories(this.item.accountType, this.item.accountId).subscribe({ next: (info: AccountSignatoryInfo) => { this.accountInfo = info; this.buildRequiredSigners(info); this.loadingAccountInfo = false; }, error: () => { this.loadingAccountInfo = false; } }); } /** * The required-signer list, built from the account's already-captured mode of operation and each joint * holder's mandatory flag: Either/Anyone-or-Survivor needs any ONE signer; anything else (Jointly, Former * or Survivor) needs every mandatory signer individually checked. */ private buildRequiredSigners(info: AccountSignatoryInfo): void { const anyMode = !info.modeOfOperation || info.modeOfOperation === 'EITHER_OR_SURVIVOR' || info.modeOfOperation === 'ANYONE_OR_SURVIVOR'; this.signerSelectionMode = anyMode ? 'any' : 'all'; const jointHolders = info.jointHolders || []; // for a joint account the primary holder is already one of the joint-holder rows (isPrimary: true) — // only fall back to the account-level clientId/name when no joint-holder rows exist at all (plain // individual account), otherwise the primary would be added twice const primaryAlreadyListed = jointHolders.some((h) => h.isPrimary); // mandatory-ness is recorded two ways depending on how the account was set up: the isMandatory boolean // column, or the signingAuthority text (the field the existing cashier joint-holder view keys off) — // trust either signal rather than assuming both were populated consistently const holderIsMandatory = (h: JointHolder): boolean => !!h.isMandatory || (h.signingAuthority ?? '').toLowerCase() === 'mandatory'; const signers: RequiredSigner[] = []; if (!primaryAlreadyListed && info.primaryClientId) { signers.push({ clientId: info.primaryClientId, name: info.primaryName || 'Primary Holder', role: 'Primary Holder', mandatory: true, isPrimary: true, signatureUrl: null, loadingSignature: true, checked: false, ...this.defaultOverlayPosition(signers.length) }); } jointHolders .filter((h) => (this.signerSelectionMode === 'all' ? holderIsMandatory(h) : true)) .forEach((h) => { signers.push({ clientId: h.clientId, name: h.displayName, role: h.role || (holderIsMandatory(h) ? 'Mandatory' : 'Optional'), mandatory: holderIsMandatory(h), isPrimary: !!h.isPrimary, signatureUrl: null, loadingSignature: true, checked: false, ...this.defaultOverlayPosition(signers.length) }); }); this.requiredSigners = signers; signers.forEach((s) => { this.signatoryService.fetchClientSignature(s.clientId).subscribe({ next: (url: string | null) => { s.signatureUrl = url; s.loadingSignature = false; }, error: () => { s.loadingSignature = false; } }); }); } /** Staggers each signer's initial overlay position so several stacked on the same cheque don't start exactly on top of each other. */ private defaultOverlayPosition(index: number): { overlayVisible: boolean; overlayX: number; overlayY: number; overlayWidth: number; overlayOpacity: number; } { return { overlayVisible: false, overlayX: 12 + index * 28, overlayY: 12 + index * 28, overlayWidth: OVERLAY_DEFAULT_WIDTH, overlayOpacity: 0.75 }; } toggleOverlay(signer: RequiredSigner): void { signer.overlayVisible = !signer.overlayVisible; } private dragState: { signer: RequiredSigner; startClientX: number; startClientY: number; startX: number; startY: number; elWidth: number; elHeight: number; } | null = null; private resizeState: { signer: RequiredSigner; startClientX: number; startWidth: number } | null = null; /** Drag begins anywhere on the overlay box (header, image, or body). Captures the overlay's own rendered * size so the move handler below can keep it from being dragged off the cheque entirely. */ startOverlayDrag(event: MouseEvent, signer: RequiredSigner): void { event.preventDefault(); event.stopPropagation(); const rect = (event.currentTarget as HTMLElement)?.getBoundingClientRect(); this.dragState = { signer, startClientX: event.clientX, startClientY: event.clientY, startX: signer.overlayX, startY: signer.overlayY, elWidth: rect?.width ?? signer.overlayWidth, elHeight: rect?.height ?? 80 }; } startOverlayResize(event: MouseEvent, signer: RequiredSigner): void { event.preventDefault(); event.stopPropagation(); this.resizeState = { signer, startClientX: event.clientX, startWidth: signer.overlayWidth }; } /** * The overlay's left/top are CSS-relative to .cheque-image-wrap (its positioned ancestor), but the clerk * should be able to drag it above/left/right/below the cheque image too — not just within its own * rectangle — so the true bound is the full fullscreen stage, not the (smaller) image. This converts the * wrap-relative coordinate into the stage's frame, clamps against the stage's actual size, then converts * back — allowing free placement anywhere on screen while still keeping it reachable/visible. */ private clampToStage(value: number, elSize: number, wrapOffset: number, stageSize: number | undefined): number { if (stageSize === undefined) { return value; } const min = -wrapOffset; const max = stageSize - wrapOffset - elSize; return Math.min(Math.max(max, min), Math.max(min, value)); } @HostListener('document:mousemove', ['$event']) onDocumentMouseMove(event: MouseEvent): void { if (this.dragState) { const signer = this.dragState.signer; const wrapRect = this.chequeImageWrapEl?.nativeElement.getBoundingClientRect(); const stageRect = this.chequeStageEl?.nativeElement.getBoundingClientRect(); const offsetX = wrapRect && stageRect ? wrapRect.left - stageRect.left : 0; const offsetY = wrapRect && stageRect ? wrapRect.top - stageRect.top : 0; const rawX = this.dragState.startX + (event.clientX - this.dragState.startClientX); const rawY = this.dragState.startY + (event.clientY - this.dragState.startClientY); signer.overlayX = this.clampToStage(rawX, this.dragState.elWidth, offsetX, stageRect?.width); signer.overlayY = this.clampToStage(rawY, this.dragState.elHeight, offsetY, stageRect?.height); } else if (this.resizeState) { const signer = this.resizeState.signer; signer.overlayWidth = Math.min( OVERLAY_MAX_WIDTH, Math.max(OVERLAY_MIN_WIDTH, this.resizeState.startWidth + (event.clientX - this.resizeState.startClientX)) ); } } @HostListener('document:mouseup') onDocumentMouseUp(): void { this.dragState = null; this.resizeState = null; } /** * Uses the real browser Fullscreen API (not just a CSS position:fixed simulation) — a CSS-only "fullscreen" * div nested inside the Material dialog's animated container can end up sized/positioned relative to that * ancestor instead of the true viewport (the dialog applies a CSS transform for its open/close animation, * which becomes the containing block for any position:fixed descendant). Element.requestFullscreen() instead * promotes the stage to the browser's top layer, fully independent of any ancestor transform. */ toggleFullscreen(): void { if (this.fullscreenActive) { this.exitFullscreen(); return; } const el = this.chequeStageEl?.nativeElement; if (el?.requestFullscreen) { el.requestFullscreen().catch(() => this.enterFallbackFullscreen()); } else { this.enterFallbackFullscreen(); } } private enterFallbackFullscreen(): void { this.fullscreenActive = true; this.resetOverlaysForFullscreenEntry(); this.chequeZoom = 1; } private exitFullscreen(): void { if (document.fullscreenElement) { document.exitFullscreen(); } else { this.fullscreenActive = false; this.chequeZoom = 1; } } /** Keeps fullscreenActive in sync with real browser fullscreen state, including when the user exits via Esc * or the browser's own controls rather than our close button. */ @HostListener('document:fullscreenchange') onFullscreenChange(): void { const isNativeFullscreen = document.fullscreenElement === this.chequeStageEl?.nativeElement; if (isNativeFullscreen) { this.fullscreenActive = true; this.resetOverlaysForFullscreenEntry(); this.chequeZoom = 1; } else if (this.fullscreenActive) { this.fullscreenActive = false; this.chequeZoom = 1; } } /** Ctrl/pinch-free scroll-to-zoom on the cheque itself — independent of dragging a signature overlay. */ onChequeWheel(event: WheelEvent): void { if (!this.fullscreenActive) { return; } event.preventDefault(); const delta = event.deltaY > 0 ? -ItemDecisionDialogComponent.ZOOM_STEP : ItemDecisionDialogComponent.ZOOM_STEP; this.chequeZoom = Math.min( ItemDecisionDialogComponent.ZOOM_MAX, Math.max(ItemDecisionDialogComponent.ZOOM_MIN, Math.round((this.chequeZoom + delta) * 100) / 100) ); } zoomIn(): void { this.chequeZoom = Math.min( ItemDecisionDialogComponent.ZOOM_MAX, this.chequeZoom + ItemDecisionDialogComponent.ZOOM_STEP ); } zoomOut(): void { this.chequeZoom = Math.max( ItemDecisionDialogComponent.ZOOM_MIN, this.chequeZoom - ItemDecisionDialogComponent.ZOOM_STEP ); } resetZoom(): void { this.chequeZoom = 1; } /** Escape only needs handling here for the CSS-fallback path — native fullscreen already exits itself on * Escape and reports back through fullscreenchange above. */ @HostListener('document:keydown.escape') onEscapeKey(): void { if (this.fullscreenActive && !document.fullscreenElement) { this.fullscreenActive = false; } } /** Fullscreen always starts with nothing placed on the cheque — only the shelf of stored signatures on the * side. A signature only appears on the cheque once it's actually dragged there from the shelf. */ private resetOverlaysForFullscreenEntry(): void { this.requiredSigners.forEach((s) => { s.overlayVisible = false; }); } /** * Starting to drag a shelf thumbnail places that signature onto the cheque, right under the cursor, and * immediately continues the same drag so it follows the mouse into position in one motion — rather than a * separate "add" click followed by a second drag. Clicking an already-placed shelf item (no drag) just * removes it instead, since there's nothing left to place. */ onShelfItemMouseDown(event: MouseEvent, signer: RequiredSigner): void { if (signer.overlayVisible) { this.toggleOverlay(signer); return; } event.preventDefault(); const wrapRect = this.chequeImageWrapEl?.nativeElement.getBoundingClientRect(); const stageRect = this.chequeStageEl?.nativeElement.getBoundingClientRect(); const offsetX = wrapRect && stageRect ? wrapRect.left - stageRect.left : 0; const offsetY = wrapRect && stageRect ? wrapRect.top - stageRect.top : 0; // the overlay doesn't exist in the DOM yet at this point (overlayVisible is only just being set below), // so its rendered size can't be measured — a signature image's own aspect ratio plus the header bar height // gives a reasonable estimate for keeping the initial drop clamped to the stage like every drag after it const estimatedHeight = Math.round(signer.overlayWidth / 2.5) + 20; const x = wrapRect ? event.clientX - wrapRect.left - signer.overlayWidth / 2 : 0; const y = wrapRect ? event.clientY - wrapRect.top - 14 : 0; signer.overlayVisible = true; signer.overlayX = this.clampToStage(x, signer.overlayWidth, offsetX, stageRect?.width); signer.overlayY = this.clampToStage(y, estimatedHeight, offsetY, stageRect?.height); this.dragState = { signer, startClientX: event.clientX, startClientY: event.clientY, startX: signer.overlayX, startY: signer.overlayY, elWidth: signer.overlayWidth, elHeight: estimatedHeight }; } /** null when the account's balance couldn't be resolved at all — the automatic "Balance" check pill already * covers that outcome, this just makes the actual sufficiency legible at a glance rather than requiring * the clerk to mentally subtract Available Balance minus Amount themselves. */ get balanceSufficient(): boolean | null { const available = this.accountInfo?.availableBalance; if ( available === null || available === undefined || this.item?.amount === null || this.item?.amount === undefined ) { return null; } return available >= this.item.amount; } get modeOfOperationLabel(): string { const labels: { [key: string]: string } = { EITHER_OR_SURVIVOR: 'Either or Survivor — any one signatory', ANYONE_OR_SURVIVOR: 'Anyone or Survivor — any one signatory', FORMER_OR_SURVIVOR: 'Former or Survivor — the mandatory signatory', JOINTLY: 'Jointly — all mandatory signatories' }; const mode = this.accountInfo?.modeOfOperation; return (mode && labels[mode]) || mode || 'Not captured on the account'; } /** True once account/signatory data couldn't be resolved at all — falls back to a manual override rather than a dead end. */ get showManualOverride(): boolean { return !this.loadingAccountInfo && (!this.accountInfo || this.requiredSigners.length === 0); } get signatureVerified(): boolean { if (this.showManualOverride) { return this.manualOverrideChecked; } if (!this.requiredSigners.length) { return false; } if (this.signerSelectionMode === 'any') { return this.requiredSigners.some((s) => s.checked); } return this.requiredSigners.every((s) => s.checked); } setMode(mode: 'PASS' | 'RETURN'): void { this.mode = mode; } submit(): void { if (this.busy) { return; } if (this.mode === 'PASS') { if (!this.signatureVerified) { this.notify('Confirm the required signature(s) before passing this cheque', true); return; } this.busy = true; this.ctsService.verifyInwardItemSignature(this.item.id, true).subscribe({ next: () => { const payload = { note: this.passForm.value.note || undefined, locale: 'en' }; this.ctsService.passInwardItem(this.item.id, payload).subscribe({ next: () => { this.busy = false; this.handleComplianceActions(); this.notify('Cheque passed'); this.dialogRef.close({ actioned: true }); }, error: (err: any) => { this.busy = false; this.notify(clearingError(err, 'Could not pass the cheque'), true); } }); }, error: (err: any) => { this.busy = false; this.notify(clearingError(err, 'Could not record signature verification'), true); } }); } else { if (this.returnForm.invalid) { return; } this.busy = true; const raw = this.returnForm.value; const payload = { returnReasonCode: raw.returnReasonCode, reason: raw.reason || undefined, locale: 'en' }; this.ctsService.returnInwardItem(this.item.id, payload).subscribe({ next: () => { this.busy = false; this.handleComplianceActions(); this.notify('Cheque returned'); this.dialogRef.close({ actioned: true }); }, error: (err: any) => { this.busy = false; this.notify(clearingError(err, 'Could not return the cheque'), true); } }); } } }