done
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-08-30 11:09:14 +05:30
parent 07a4c8b8e3
commit 9f95d01196
57 changed files with 5705 additions and 681 deletions
@@ -0,0 +1,144 @@
<!--
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/.
-->
<div class="pg">
<header class="pg-head vv-no-print">
<div>
<div class="pg-eyebrow">CASHIER · VOUCHER</div>
<h1 class="pg-title">Voucher</h1>
<p class="pg-sub">
Look up a voucher by its number to see the entry behind it — which accounts were debited, which were credited,
and for how much.
</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
<span>Voucher No</span>
<input
type="text"
[(ngModel)]="voucherNo"
(keyup.enter)="search()"
placeholder="CR/2708/0001"
autocomplete="off"
/>
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="search()" [disabled]="loading">
<mat-icon>search</mat-icon> Find
</button>
@if (voucher) {
<button type="button" class="pg-btn" (click)="print()">
<mat-icon>print</mat-icon> Print
</button>
}
</div>
</header>
@if (error) {
<div class="pg-alert pg-alert--error vv-no-print">{{ error }}</div>
}
@if (loading) {
<div class="pg-empty vv-no-print">Loading…</div>
}
@if (voucher && !loading) {
<div class="pg-card vv-slip">
<div class="vv-slip-head">
<div>
<div class="vv-no">{{ voucher.voucherNo }}</div>
<div class="vv-meta">
{{ voucher.voucherType || '—' }}
@if (voucher.branchName) {
· {{ voucher.branchName }}
}
</div>
</div>
<div class="vv-slip-head-right">
<div class="vv-amount">₹{{ voucher.amount | number: '1.2-2' }}</div>
<div class="vv-meta">
@if (voucher.createdAt) {
{{ voucher.createdAt | date: 'dd MMM yyyy, HH:mm' }}
}
@if (voucher.createdBy) {
· {{ voucher.createdBy }}
}
</div>
</div>
</div>
@if (voucher.verificationStatus) {
<div class="vv-status" [class.vv-status--exception]="voucher.verificationStatus === 'EXCEPTION'">
<mat-icon>{{ voucher.verificationStatus === 'EXCEPTION' ? 'error_outline' : 'verified' }}</mat-icon>
{{ voucher.verificationStatus === 'EXCEPTION' ? 'Exception raised against this voucher' : 'Verified against the slip' }}
</div>
}
<!-- A voucher raised before its sides were recorded: the number is valid, the posting behind it
simply was never captured, so it is shown as an older record rather than as an error. -->
@if (hasNoLines) {
<div class="pg-empty">
No debit or credit detail was recorded for this voucher. It was raised before the entry sides were kept.
</div>
} @else {
<table class="pg-table vv-lines">
<thead>
<tr>
<th>Particulars</th>
<th>Account</th>
<th class="num">Debit</th>
<th class="num">Credit</th>
</tr>
</thead>
<tbody>
@for (line of debitLines; track line.lineNo) {
<tr>
<td>
<div class="vv-party">{{ line.partyName || '—' }}</div>
@if (line.description) {
<div class="vv-desc">{{ line.description }}</div>
}
</td>
<td>{{ line.accountNo || '—' }}</td>
<td class="num">{{ line.amount | number: '1.2-2' }}</td>
<td class="num">—</td>
</tr>
}
@for (line of creditLines; track line.lineNo) {
<tr>
<td class="vv-indent">
<div class="vv-party">{{ line.partyName || '—' }}</div>
@if (line.description) {
<div class="vv-desc">{{ line.description }}</div>
}
</td>
<td>{{ line.accountNo || '—' }}</td>
<td class="num">—</td>
<td class="num">{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="2">Total</td>
<td class="num">{{ voucher.totalDebit | number: '1.2-2' }}</td>
<td class="num">{{ voucher.totalCredit | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
<!-- Stated rather than assumed: a voucher whose sides were written before the balance check
existed, or repaired by hand, should read as unbalanced rather than pass as a valid slip. -->
@if (!voucher.balanced) {
<div class="pg-alert pg-alert--error">
This voucher does not balance — debits and credits differ. Refer it to accounts before relying on it.
</div>
}
}
</div>
}
</div>
@@ -0,0 +1,109 @@
/**
* 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/.
*/
/* Page chrome and the table come from cashier-shared.scss. Only the slip itself is defined here. */
.vv-slip {
max-width: 860px;
}
.vv-slip-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 14px;
margin-bottom: 14px;
border-bottom: 1px solid #e5e7eb;
}
.vv-slip-head-right {
text-align: right;
}
.vv-no {
font-size: 18px;
font-weight: 600;
letter-spacing: 0.4px;
color: #111827;
}
.vv-amount {
font-size: 18px;
font-weight: 600;
color: #111827;
}
.vv-meta {
margin-top: 2px;
font-size: 12px;
color: #6b7280;
}
.vv-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin-bottom: 14px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-radius: 8px;
color: #065f46;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
/* An exception is a warning about this voucher, not a state of it — it has to stop the eye. */
&.vv-status--exception {
background: #fef2f2;
border-color: #fecaca;
color: #991b1b;
}
}
.vv-lines {
/* Credits sit under their debits the way they do on a written slip, so the indent carries the
relationship that the DR/CR columns alone would leave to the reader to work out. */
.vv-indent {
padding-left: 28px;
}
.vv-party {
color: #111827;
}
.vv-desc {
margin-top: 2px;
font-size: 11px;
color: #6b7280;
}
tfoot td {
font-weight: 600;
border-top: 2px solid #e5e7eb;
}
}
/* Printing a voucher should put the slip on paper and nothing else — the search box and buttons are
how you reached it, not part of it. */
@media print {
.vv-no-print {
display: none !important;
}
.vv-slip {
max-width: none;
border: none;
box-shadow: none;
}
}
@@ -0,0 +1,143 @@
/**
* 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/.
*/
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
/** One side of a voucher: a debit or a credit against a single account. */
interface VoucherLine {
lineNo: number;
entryType: 'DR' | 'CR';
glAccountId: number | null;
savingsAccountId: number | null;
loanAccountId: number | null;
partyName: string | null;
accountNo: string | null;
amount: number;
description: string | null;
}
interface Voucher {
voucherNo: string;
voucherType: string | null;
referenceType: string | null;
referenceId: number | null;
amount: number | null;
branchOfficeId: number | null;
branchName: string | null;
createdBy: string | null;
createdAt: string | null;
verificationStatus: string | null;
lines: VoucherLine[];
totalDebit: number;
totalCredit: number;
balanced: boolean;
}
/**
* One voucher, as a slip: which accounts were debited, which credited, and for how much.
*
* Looked up by voucher number rather than listed, because that is how a voucher is reached — from a
* paper slip in someone's hand, or from a number quoted on a statement.
*/
@Component({
selector: 'mifosx-voucher-view',
templateUrl: './voucher-view.component.html',
styleUrls: [
'../cashier-shared.scss',
'./voucher-view.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class VoucherViewComponent {
private service = inject(CashierService);
private route = inject(ActivatedRoute);
private router = inject(Router);
voucherNo = '';
voucher: Voucher | null = null;
loading = false;
/** Shown in place of the slip — a bad number and a server error read differently to the user. */
error: string | null = null;
constructor() {
// A voucher number in the URL means someone arrived from a link or a statement rather than by
// typing, so the slip is fetched straight away instead of showing them an empty search box.
const fromUrl = this.route.snapshot.queryParamMap.get('voucherNo');
if (fromUrl) {
this.voucherNo = fromUrl;
this.search();
}
}
search(): void {
const trimmed = (this.voucherNo || '').trim();
if (!trimmed) {
this.error = 'Enter a voucher number.';
this.voucher = null;
return;
}
this.loading = true;
this.error = null;
// Kept in the URL so the slip can be linked to and survives a refresh.
this.router.navigate([], {
relativeTo: this.route,
queryParams: { voucherNo: trimmed },
replaceUrl: true
});
this.service
.voucherDetail(trimmed)
.pipe(
catchError((err: any) => {
this.error =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
`Voucher ${trimmed} could not be loaded.`;
return of(null);
})
)
.subscribe((res: Voucher | null) => {
this.loading = false;
this.voucher = res;
});
}
/** Debits first, then credits — the order a slip is read in. */
get debitLines(): VoucherLine[] {
return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'DR');
}
get creditLines(): VoucherLine[] {
return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'CR');
}
/**
* A voucher raised before its sides were recorded. The voucher is real and its number valid — only
* the posting behind it was never captured — so this reads as an older record, not a broken one.
*/
get hasNoLines(): boolean {
return !!this.voucher && this.voucher.lines.length === 0;
}
print(): void {
window.print();
}
}