776 lines
30 KiB
TypeScript
776 lines
30 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/.
|
|
*/
|
|
|
|
/** 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<ItemDecisionDialogComponent>>(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<HTMLElement>;
|
|
@ViewChild('chequeStage') chequeStageEl?: ElementRef<HTMLElement>;
|
|
@ViewChild('chequeImageWrap') chequeImageWrapEl?: ElementRef<HTMLElement>;
|
|
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: `<div class="lg-caption"><h4>Cheque ${side}</h4></div>` }];
|
|
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: `<div class="lg-caption"><h4>${signer.name} — stored signature</h4></div>`
|
|
}
|
|
];
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|