feat(ctsclearing): single-panel signature verification for inward cheques
Adds a rich decide dialog for clerks verifying inward CTS cheques: cheque images, all automatic validation results, and a mandatory signature-check gate built from the account's mode of operation and joint holders (backed by a new SignatoryService reusing the existing signature/document fetch pattern from the cashier module). Also adds a fullscreen compare mode: the cheque opens large with a shelf of every mandatory signer's stored signature, which can be dragged onto the cheque, resized, made semi-transparent, and zoomed, for a real side-by-side signature match instead of squinting at a thumbnail. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
6d205c0ed2
commit
0207cf85a3
@@ -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<AccountSignatoryInfo> {
|
||||
const path =
|
||||
accountType?.toUpperCase() === 'CURRENT' ? `/currentaccounts/${accountId}` : `/savingsaccounts/${accountId}`;
|
||||
return this.http.get<any>(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<string | null> {
|
||||
return this.http.get<any[]>(`/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<string | null> {
|
||||
return this.http.get(`/clients/${clientId}/images`, { responseType: 'text' }).pipe(catchError(() => of(null)));
|
||||
}
|
||||
}
|
||||
@@ -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<any> {
|
||||
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<any> {
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
@if (it.instrumentDate) {
|
||||
<span class="muted">Dated {{ it.instrumentDate | dateFormat }}</span>
|
||||
}
|
||||
@if (it.instrumentTypeDescription || it.instrumentType) {
|
||||
<span class="muted">{{ it.instrumentTypeDescription || it.instrumentType }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="drawer">
|
||||
@@ -98,6 +101,12 @@
|
||||
@if (it.clientName) {
|
||||
<span class="muted">{{ it.clientName }}</span>
|
||||
}
|
||||
@if (it.drawerAccountNumberMasked) {
|
||||
<span class="muted">Drawer a/c {{ it.drawerAccountNumberMasked }}</span>
|
||||
}
|
||||
@if (it.draweeBankName) {
|
||||
<span class="muted">{{ it.draweeBankName }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="payeeName">
|
||||
@@ -167,6 +176,9 @@
|
||||
@if (it.iqaDetail) {
|
||||
<div class="muted">{{ it.iqaDetail }}</div>
|
||||
}
|
||||
@if (it.iqaSubChecks) {
|
||||
<div class="muted">{{ it.iqaSubChecks }}</div>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="decision">
|
||||
@@ -202,6 +214,9 @@
|
||||
@if (it.depositDate) {
|
||||
<span class="muted">Deposited {{ it.depositDate | dateFormat }}</span>
|
||||
}
|
||||
@if (it.depositAccountNumber) {
|
||||
<span class="muted">Deposit a/c {{ it.depositAccountNumber }}</span>
|
||||
}
|
||||
@if (it.presentingBranchCode) {
|
||||
<span class="muted">Branch {{ it.presentingBranchCode }}</span>
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
+184
-1
@@ -22,8 +22,186 @@
|
||||
<span class="k">{{ 'labels.inputs.Account' | translate }}</span
|
||||
><span>{{ item.accountNo || item.accountId || '–' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">Hold Placed</span><span>{{ item.holdPlaced ? (item.holdAmount ?? item.amount) : '–' }}</span>
|
||||
</div>
|
||||
@if (accountInfo?.availableBalance !== null && accountInfo?.availableBalance !== undefined) {
|
||||
<div>
|
||||
<span class="k">Available Balance</span><span>{{ accountInfo!.availableBalance }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">Cheque Image</h3>
|
||||
<div class="muted">
|
||||
Click the cheque to go fullscreen, then drag any signer's stored signature from the shelf onto it and resize for a
|
||||
close match.
|
||||
</div>
|
||||
<div class="image-row">
|
||||
<div #chequeStage class="cheque-stage" [class.fullscreen-stage]="fullscreenActive">
|
||||
<div
|
||||
#chequeImageWrap
|
||||
class="cheque-image-wrap"
|
||||
[style.transform]="fullscreenActive ? 'scale(' + chequeZoom + ')' : null"
|
||||
(wheel)="onChequeWheel($event)"
|
||||
>
|
||||
@if (frontImageUrl) {
|
||||
<img
|
||||
[src]="frontImageUrl"
|
||||
class="cheque-image"
|
||||
alt="front"
|
||||
matTooltip="Click to compare fullscreen"
|
||||
(click)="toggleFullscreen()"
|
||||
/>
|
||||
} @else {
|
||||
<div class="no-image">No front image</div>
|
||||
}
|
||||
@if (fullscreenActive) {
|
||||
@for (signer of requiredSigners; track $index) {
|
||||
@if (signer.overlayVisible && signer.signatureUrl) {
|
||||
<div
|
||||
class="sig-overlay"
|
||||
[style.left.px]="signer.overlayX"
|
||||
[style.top.px]="signer.overlayY"
|
||||
[style.width.px]="signer.overlayWidth"
|
||||
[style.opacity]="signer.overlayOpacity"
|
||||
(mousedown)="startOverlayDrag($event, signer)"
|
||||
>
|
||||
<div class="sig-overlay-handle">
|
||||
<span>{{ signer.name }}</span>
|
||||
<input
|
||||
type="range"
|
||||
class="sig-overlay-opacity"
|
||||
min="0.3"
|
||||
max="1"
|
||||
step="0.05"
|
||||
[(ngModel)]="signer.overlayOpacity"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
matTooltip="Overlay opacity"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="sig-overlay-close"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
(click)="toggleOverlay(signer)"
|
||||
matTooltip="Hide overlay"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<img [src]="signer.signatureUrl" class="sig-overlay-img" alt="signature overlay" draggable="false" />
|
||||
<div
|
||||
class="sig-overlay-resize"
|
||||
(mousedown)="startOverlayResize($event, signer)"
|
||||
matTooltip="Drag to resize"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (fullscreenActive) {
|
||||
<button type="button" class="fullscreen-close" (click)="toggleFullscreen()" matTooltip="Exit fullscreen (Esc)">
|
||||
✕
|
||||
</button>
|
||||
<div class="fullscreen-zoom">
|
||||
<button type="button" (click)="zoomOut()" matTooltip="Zoom out">−</button>
|
||||
<span>{{ chequeZoom * 100 | number: '1.0-0' }}%</span>
|
||||
<button type="button" (click)="zoomIn()" matTooltip="Zoom in">+</button>
|
||||
@if (chequeZoom !== 1) {
|
||||
<button type="button" (click)="resetZoom()" matTooltip="Reset zoom">⟲</button>
|
||||
}
|
||||
</div>
|
||||
<div class="fullscreen-shelf">
|
||||
@for (signer of requiredSigners; track $index) {
|
||||
@if (signer.signatureUrl) {
|
||||
<div
|
||||
class="shelf-item"
|
||||
[class.active]="signer.overlayVisible"
|
||||
(mousedown)="onShelfItemMouseDown($event, signer)"
|
||||
matTooltip="Drag onto the cheque"
|
||||
>
|
||||
<img [src]="signer.signatureUrl" alt="signature" draggable="false" />
|
||||
<span>{{ signer.name }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (backImageUrl) {
|
||||
<img
|
||||
[src]="backImageUrl"
|
||||
class="cheque-image"
|
||||
alt="back"
|
||||
matTooltip="Back — click to zoom"
|
||||
(click)="openImageZoom('back')"
|
||||
/>
|
||||
} @else {
|
||||
<div class="no-image">No back image</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">Automatic Validation</h3>
|
||||
<div class="check-badges">
|
||||
@for (rule of validationRules; track rule.key) {
|
||||
<span class="pill" [ngClass]="ruleClass(rule)" [title]="rule.label">{{ rule.label }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (item.validationDetail) {
|
||||
<div class="validation-detail muted">{{ item.validationDetail }}</div>
|
||||
}
|
||||
|
||||
<h3 class="section-title">Signature Verification</h3>
|
||||
@if (loadingAccountInfo) {
|
||||
<div class="muted">Loading account signatories…</div>
|
||||
} @else {
|
||||
@if (accountInfo) {
|
||||
<div class="mode-label muted">Mode of Operation: {{ modeOfOperationLabel }}</div>
|
||||
}
|
||||
@if (showManualOverride) {
|
||||
<div class="signer-row">
|
||||
<mat-checkbox [(ngModel)]="manualOverrideChecked">
|
||||
Account/signatory details unavailable — I have manually verified this signature
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="muted">
|
||||
{{
|
||||
signerSelectionMode === 'any'
|
||||
? 'Any one signatory below may sign — tick the one that matches.'
|
||||
: 'Every mandatory signatory below must be individually confirmed.'
|
||||
}}
|
||||
</div>
|
||||
@for (signer of requiredSigners; track $index) {
|
||||
<div class="signer-row">
|
||||
<div class="signer-signature">
|
||||
@if (signer.loadingSignature) {
|
||||
<div class="no-image small">Loading…</div>
|
||||
} @else if (signer.signatureUrl) {
|
||||
<img
|
||||
[src]="signer.signatureUrl"
|
||||
class="signature-image"
|
||||
alt="signature"
|
||||
matTooltip="Click to zoom"
|
||||
(click)="openSignatureZoom(signer)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="no-image small">No signature on file</div>
|
||||
}
|
||||
</div>
|
||||
<div class="signer-info">
|
||||
<div class="signer-name">
|
||||
{{ signer.name }}
|
||||
<span class="pill" [ngClass]="signer.mandatory ? 'bad' : 'neutral'">{{ signer.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<mat-checkbox [(ngModel)]="signer.checked">Signature Matches</mat-checkbox>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="mode-toggle">
|
||||
<button mat-stroked-button type="button" [class.active]="mode === 'PASS'" color="primary" (click)="setMode('PASS')">
|
||||
{{ 'labels.buttons.Pass' | translate }}
|
||||
@@ -41,6 +219,9 @@
|
||||
|
||||
@if (mode === 'PASS') {
|
||||
<form [formGroup]="passForm">
|
||||
@if (!signatureVerified) {
|
||||
<div class="gate-warning">Signature verification required above before this cheque can be passed.</div>
|
||||
}
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
|
||||
<input matInput formControlName="note" />
|
||||
@@ -62,6 +243,8 @@
|
||||
</mat-form-field>
|
||||
</form>
|
||||
}
|
||||
|
||||
<div #lightboxRoot style="display: none"></div>
|
||||
</div>
|
||||
|
||||
<div mat-dialog-actions align="end">
|
||||
@@ -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 }}
|
||||
</button>
|
||||
|
||||
+338
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+528
-12
@@ -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<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);
|
||||
|
||||
@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 });
|
||||
}
|
||||
@@ -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: `<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
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260720',
|
||||
'hash': 'ed2a40a8'
|
||||
'version': '260721',
|
||||
'hash': '6d205c0e'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user