diff --git a/src/app/clearing-network/signatory.service.ts b/src/app/clearing-network/signatory.service.ts new file mode 100644 index 000000000..ab896cc80 --- /dev/null +++ b/src/app/clearing-network/signatory.service.ts @@ -0,0 +1,88 @@ +/** + * 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 { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; + +export interface JointHolder { + clientId: number; + cifNumber?: string; + displayName: string; + sharePercentage?: number; + signingAuthority?: string; + role?: string; + isPrimary?: boolean; + isMandatory?: boolean; +} + +export interface AccountSignatoryInfo { + modeOfOperation: string | null; + primaryClientId: number | null; + primaryName: string | null; + jointHolders: JointHolder[]; + availableBalance: number | null; + onHoldAmount: number | null; +} + +/** + * Fetches account-holder/signatory data needed to verify a cheque's signature — reusing the exact APIs and + * conventions already established in the cashier module's joint-holder detail view + * (`transaction-entry.component.ts`), so this isn't a second, divergent implementation of the same lookup. + */ +@Injectable({ + providedIn: 'root' +}) +export class SignatoryService { + private http = inject(HttpClient); + + /** + * Fetches the savings or current account (with joint holders and mode of operation embedded via + * `associations=all` — the same Fineract convention the cashier module already relies on). + */ + fetchAccountSignatories(accountType: string, accountId: number | string): Observable { + const path = + accountType?.toUpperCase() === 'CURRENT' ? `/currentaccounts/${accountId}` : `/savingsaccounts/${accountId}`; + return this.http.get(path, { params: new HttpParams().set('associations', 'all') }).pipe( + map((acc: any) => ({ + modeOfOperation: acc?.modeOfOperation ?? null, + primaryClientId: acc?.clientId ?? null, + primaryName: acc?.clientName ?? acc?.name ?? null, + jointHolders: Array.isArray(acc?.jointHolders) ? acc.jointHolders : [], + availableBalance: acc?.summary?.availableBalance ?? null, + onHoldAmount: acc?.summary?.totalSavingsAmountOnHold ?? acc?.onHoldFundsDerived ?? null + })) + ); + } + + /** The client's stored signature (a document tagged name === 'clientSignature'), as a blob object URL, or null if none uploaded. */ + fetchClientSignature(clientId: number | string): Observable { + return this.http.get(`/clients/${clientId}/documents`).pipe( + switchMap((docs: any[]) => { + const sigDoc = (docs || []).find( + (d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature') + ); + if (!sigDoc) { + return of(null); + } + return this.http.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' }).pipe( + map((blob: Blob) => URL.createObjectURL(blob)), + catchError(() => of(null)) + ); + }), + catchError(() => of(null)) + ); + } + + /** The client's photo (already base64/text from this API), or null if none uploaded. */ + fetchClientPhoto(clientId: number | string): Observable { + return this.http.get(`/clients/${clientId}/images`, { responseType: 'text' }).pipe(catchError(() => of(null))); + } +} diff --git a/src/app/ctsclearing/ctsclearing.service.ts b/src/app/ctsclearing/ctsclearing.service.ts index 80ab73695..0bf421b03 100644 --- a/src/app/ctsclearing/ctsclearing.service.ts +++ b/src/app/ctsclearing/ctsclearing.service.ts @@ -82,6 +82,15 @@ export class CtsClearingService { }); } + /** Records the clerk's manual signature-verification decision — required before passInwardItem will succeed. */ + verifyInwardItemSignature(id: string | number, signatureOk: boolean): Observable { + return this.http.post( + `/ctsclearing/inward-items/${id}`, + { signatureOk, locale: 'en' }, + { params: new HttpParams().set('command', 'verifySignature') } + ); + } + /** Uploads a real scanned cheque image (front|back) for an inward item. */ uploadInwardItemImage(id: string | number, side: 'front' | 'back', file: File): Observable { const formData = new FormData(); diff --git a/src/app/ctsclearing/inward-batch-detail/inward-batch-detail.component.html b/src/app/ctsclearing/inward-batch-detail/inward-batch-detail.component.html index ba5f1ba2f..bb99accff 100644 --- a/src/app/ctsclearing/inward-batch-detail/inward-batch-detail.component.html +++ b/src/app/ctsclearing/inward-batch-detail/inward-batch-detail.component.html @@ -89,6 +89,9 @@ @if (it.instrumentDate) { Dated {{ it.instrumentDate | dateFormat }} } + @if (it.instrumentTypeDescription || it.instrumentType) { + {{ it.instrumentTypeDescription || it.instrumentType }} + } @@ -98,6 +101,12 @@ @if (it.clientName) { {{ it.clientName }} } + @if (it.drawerAccountNumberMasked) { + Drawer a/c {{ it.drawerAccountNumberMasked }} + } + @if (it.draweeBankName) { + {{ it.draweeBankName }} + } @@ -167,6 +176,9 @@ @if (it.iqaDetail) {
{{ it.iqaDetail }}
} + @if (it.iqaSubChecks) { +
{{ it.iqaSubChecks }}
+ }
@@ -202,6 +214,9 @@ @if (it.depositDate) { Deposited {{ it.depositDate | dateFormat }} } + @if (it.depositAccountNumber) { + Deposit a/c {{ it.depositAccountNumber }} + } @if (it.presentingBranchCode) { Branch {{ it.presentingBranchCode }} } diff --git a/src/app/ctsclearing/inward-batch-detail/inward-batch-detail.component.ts b/src/app/ctsclearing/inward-batch-detail/inward-batch-detail.component.ts index 8a59b9761..14da7f8cc 100644 --- a/src/app/ctsclearing/inward-batch-detail/inward-batch-detail.component.ts +++ b/src/app/ctsclearing/inward-batch-detail/inward-batch-detail.component.ts @@ -237,7 +237,12 @@ export class InwardBatchDetailComponent implements OnInit, OnDestroy { } decide(item: any): void { - const dialogRef = this.dialog.open(ItemDecisionDialogComponent, { data: { item }, width: '480px' }); + const dialogRef = this.dialog.open(ItemDecisionDialogComponent, { + data: { item }, + width: '760px', + maxWidth: '95vw', + maxHeight: '90vh' + }); dialogRef.afterClosed().subscribe((result: any) => { if (result?.actioned) { this.loadAll(); diff --git a/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.html b/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.html index 4b989ece5..55a4e89cb 100644 --- a/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.html +++ b/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.html @@ -22,8 +22,186 @@ {{ 'labels.inputs.Account' | translate }}{{ item.accountNo || item.accountId || '–' }} +
+ Hold Placed{{ item.holdPlaced ? (item.holdAmount ?? item.amount) : '–' }} +
+ @if (accountInfo?.availableBalance !== null && accountInfo?.availableBalance !== undefined) { +
+ Available Balance{{ accountInfo!.availableBalance }} +
+ } +

Cheque Image

+
+ Click the cheque to go fullscreen, then drag any signer's stored signature from the shelf onto it and resize for a + close match. +
+
+
+
+ @if (frontImageUrl) { + front + } @else { +
No front image
+ } + @if (fullscreenActive) { + @for (signer of requiredSigners; track $index) { + @if (signer.overlayVisible && signer.signatureUrl) { +
+
+ {{ signer.name }} + + +
+ signature overlay +
+
+ } + } + } +
+ @if (fullscreenActive) { + +
+ + {{ chequeZoom * 100 | number: '1.0-0' }}% + + @if (chequeZoom !== 1) { + + } +
+
+ @for (signer of requiredSigners; track $index) { + @if (signer.signatureUrl) { +
+ signature + {{ signer.name }} +
+ } + } +
+ } +
+ @if (backImageUrl) { + back + } @else { +
No back image
+ } +
+ +

Automatic Validation

+
+ @for (rule of validationRules; track rule.key) { + {{ rule.label }} + } +
+ @if (item.validationDetail) { +
{{ item.validationDetail }}
+ } + +

Signature Verification

+ @if (loadingAccountInfo) { +
Loading account signatories…
+ } @else { + @if (accountInfo) { +
Mode of Operation: {{ modeOfOperationLabel }}
+ } + @if (showManualOverride) { +
+ + Account/signatory details unavailable — I have manually verified this signature + +
+ } @else { +
+ {{ + signerSelectionMode === 'any' + ? 'Any one signatory below may sign — tick the one that matches.' + : 'Every mandatory signatory below must be individually confirmed.' + }} +
+ @for (signer of requiredSigners; track $index) { +
+
+ @if (signer.loadingSignature) { +
Loading…
+ } @else if (signer.signatureUrl) { + signature + } @else { +
No signature on file
+ } +
+
+
+ {{ signer.name }} + {{ signer.role }} +
+
+ Signature Matches +
+ } + } + } +
@@ -70,7 +253,7 @@ mat-raised-button [color]="mode === 'RETURN' ? 'warn' : 'primary'" (click)="submit()" - [disabled]="busy || (mode === 'RETURN' && returnForm.invalid)" + [disabled]="busy || (mode === 'RETURN' ? returnForm.invalid : !signatureVerified)" > {{ (mode === 'PASS' ? 'labels.buttons.Pass' : 'labels.buttons.Return') | translate }} diff --git a/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.scss b/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.scss index 78cebfbef..cf95aacaa 100644 --- a/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.scss +++ b/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.scss @@ -29,7 +29,7 @@ .mode-toggle { display: flex; gap: 8px; - margin-bottom: 12px; + margin: 16px 0 12px; button.active { background: rgb(25 118 210 / 12%); @@ -39,3 +39,340 @@ mat-form-field { width: 100%; } + +.section-title { + margin: 20px 0 8px; + font-size: 0.95rem; +} + +.muted { + color: rgb(0 0 0 / 50%); + font-size: 0.82rem; + margin-bottom: 8px; +} + +.image-row { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.cheque-image { + max-width: 260px; + max-height: 140px; + object-fit: contain; + border: 1px solid #ddd; + border-radius: 4px; + cursor: pointer; + background: #fafafa; +} + +// the overlay boxes are positioned absolute relative to THIS wrapper, not the (possibly flex-centered) +// .cheque-stage — otherwise, once the stage centers the image via flexbox in fullscreen mode, the overlay's +// left/top coordinates (measured from the stage's own top-left corner) land nowhere near the actual image +.cheque-image-wrap { + position: relative; + display: inline-block; + line-height: 0; + transform-origin: center center; +} + +.cheque-stage { + position: relative; + display: inline-block; + line-height: 0; + + // fullscreen compare mode: the stage itself becomes a fixed, full-viewport backdrop so the cheque and + // every draggable signature overlay are shown as large as possible for a real side-by-side comparison + &.fullscreen-stage { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + width: 100vw; + height: 100vh; + max-width: none; + overflow: auto; + background: rgb(0 0 0 / 92%); + + .cheque-image { + width: auto; + max-width: 82vw; + max-height: 88vh; + cursor: zoom-in; + } + } + + // matches the native fullscreen state directly (independent of Angular's class-binding timing), so the + // stage fills correctly the instant the browser promotes it to the top layer + &:fullscreen { + width: 100vw; + height: 100vh; + max-width: none; + display: flex; + align-items: center; + justify-content: center; + background: rgb(0 0 0 / 92%); + } +} + +.fullscreen-close { + position: absolute; + top: 16px; + right: 16px; + z-index: 2010; + width: 36px; + height: 36px; + border-radius: 50%; + border: none; + background: rgb(255 255 255 / 15%); + color: #fff; + font-size: 16px; + cursor: pointer; + + &:hover { + background: rgb(255 255 255 / 25%); + } +} + +.fullscreen-zoom { + position: absolute; + top: 16px; + left: 50%; + transform: translateX(-50%); + z-index: 2010; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 20px; + background: rgb(255 255 255 / 15%); + color: #fff; + font-size: 12px; + + span { + min-width: 40px; + text-align: center; + font-variant-numeric: tabular-nums; + } + + button { + width: 26px; + height: 26px; + border-radius: 50%; + border: none; + background: rgb(255 255 255 / 15%); + color: #fff; + font-size: 14px; + line-height: 1; + cursor: pointer; + + &:hover { + background: rgb(255 255 255 / 25%); + } + } +} + +.fullscreen-shelf { + position: absolute; + top: 16px; + left: 16px; + z-index: 2010; + display: flex; + flex-direction: column; + gap: 8px; + max-height: 88vh; + overflow-y: auto; + padding-right: 4px; +} + +.shelf-item { + width: 150px; + padding: 4px; + border: 2px solid transparent; + border-radius: 4px; + background: #fff; + cursor: pointer; + text-align: center; + + &.active { + border-color: #1976d2; + } + + img { + display: block; + width: 100%; + height: 54px; + object-fit: contain; + } + + span { + display: block; + margin-top: 2px; + color: #333; + font-size: 10px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.sig-overlay { + position: absolute; + // must beat the fullscreen shelf/close-button (z-index: 2010) — they share the same stacking context + // (the fullscreen-stage), so a low z-index here meant the overlay silently rendered BEHIND the shelf + // wherever the two visually overlapped, e.g. when dragging it toward the top-left where the shelf sits + z-index: 2020; + box-sizing: border-box; + border: 1px dashed rgb(25 118 210 / 70%); + background: rgb(255 255 255 / 40%); + cursor: move; + user-select: none; +} + +.sig-overlay-handle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + padding: 1px 4px; + background: rgb(25 118 210 / 85%); + color: #fff; + font-size: 10px; + line-height: 1.6; + cursor: move; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sig-overlay-close { + background: none; + border: none; + color: #fff; + cursor: pointer; + font-size: 11px; + line-height: 1; + padding: 0 2px; +} + +.sig-overlay-img { + display: block; + width: 100%; + height: auto; + pointer-events: none; +} + +.sig-overlay-resize { + position: absolute; + right: 0; + bottom: 0; + width: 12px; + height: 12px; + cursor: nwse-resize; + background: repeating-linear-gradient(135deg, rgb(25 118 210 / 80%) 0 2px, transparent 2px 4px); +} + +.sig-overlay-opacity { + width: 44px; + height: 10px; + margin: 0 4px; + cursor: pointer; +} + +.no-image { + padding: 12px; + border: 1px dashed #ccc; + border-radius: 4px; + color: rgb(0 0 0 / 40%); + font-size: 0.82rem; + + &.small { + padding: 6px 10px; + width: 120px; + } +} + +.check-badges { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 6px; +} + +.validation-detail { + margin-bottom: 8px; +} + +.mode-label { + font-weight: 500; +} + +.signer-row { + display: flex; + align-items: center; + gap: 14px; + padding: 8px 0; + border-bottom: 1px solid rgb(0 0 0 / 6%); + + &:last-of-type { + border-bottom: none; + } +} + +.signer-signature { + flex-shrink: 0; +} + +.signature-image { + width: 140px; + height: 60px; + object-fit: contain; + border: 1px solid #ddd; + border-radius: 4px; + cursor: pointer; + background: #fafafa; +} + +.signer-info { + flex: 1; + min-width: 0; +} + +.signer-name { + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; +} + +.gate-warning { + color: #d32f2f; + font-size: 0.82rem; + margin-bottom: 8px; +} + +.pill { + font-size: 11px; + padding: 2px 8px; + border-radius: 6px; + font-weight: 600; + white-space: nowrap; + + &.ok { + background: #dff5e8; + color: #28c07a; + } + + &.bad { + background: #fde3e3; + color: #d32f2f; + } + + &.neutral { + background: #e0e0e0; + color: #757575; + } +} diff --git a/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.ts b/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.ts index b30528940..0853bd33e 100644 --- a/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.ts +++ b/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.ts @@ -7,8 +7,8 @@ */ /** Angular Imports */ -import { Component, OnInit, inject } from '@angular/core'; -import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; +import { FormsModule, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { MatDialogRef, MAT_DIALOG_DATA, @@ -18,17 +18,48 @@ import { } from '@angular/material/dialog'; import { CdkScrollable } from '@angular/cdk/scrolling'; import { MatButtonModule } from '@angular/material/button'; +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 } from '../../../clearing-network/clearing-status'; +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; + /** - * Manual decision on a single inward CTS cheque — PASS (with an optional note) or RETURN (with a return - * reason code loaded from the CTS reason-code master, plus a free-text reason). + * 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', @@ -36,21 +67,33 @@ import { clearingError } from '../../../clearing-network/clearing-status'; styleUrls: ['./item-decision-dialog.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, + FormsModule, MatDialogTitle, CdkScrollable, MatDialogContent, MatDialogActions, - MatButtonModule + MatButtonModule, + MatCheckboxModule ] }) -export class ItemDecisionDialogComponent implements OnInit { +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); + @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 }); } @@ -60,6 +103,39 @@ export class ItemDecisionDialogComponent implements OnInit { 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; + + /** 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; @@ -81,6 +157,434 @@ export class ItemDecisionDialogComponent implements OnInit { 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(); + } + + /** 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'; + } + + 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 + }; + } + + 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 { @@ -92,17 +596,29 @@ export class ItemDecisionDialogComponent implements OnInit { return; } if (this.mode === 'PASS') { + if (!this.signatureVerified) { + this.notify('Confirm the required signature(s) before passing this cheque', true); + return; + } this.busy = true; - const payload = { note: this.passForm.value.note || undefined, locale: 'en' }; - this.ctsService.passInwardItem(this.item.id, payload).subscribe({ + this.ctsService.verifyInwardItemSignature(this.item.id, true).subscribe({ next: () => { - this.busy = false; - this.notify('Cheque passed'); - this.dialogRef.close({ actioned: true }); + const payload = { note: this.passForm.value.note || undefined, locale: 'en' }; + this.ctsService.passInwardItem(this.item.id, payload).subscribe({ + next: () => { + this.busy = false; + 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 pass the cheque'), true); + this.notify(clearingError(err, 'Could not record signature verification'), true); } }); } else { diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 087e4f2b6..a61e35393 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260720', - 'hash': 'ed2a40a8' + 'version': '260721', + 'hash': '6d205c0e' }, 'allow_switching_backend_instance': true };