feat(ctsclearing): single-panel signature verification for inward cheques
Publish Image in Docker Hub / build (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

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:
Jaanu423
2026-07-22 12:25:20 +05:30
co-authored by Claude Sonnet 5
parent 6d205c0ed2
commit 0207cf85a3
8 changed files with 1170 additions and 17 deletions
@@ -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>