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
+12
View File
@@ -21,6 +21,8 @@ import { ReconciliationComponent } from './reconciliation/reconciliation.compone
import { CashHistoryComponent } from './cash-history/cash-history.component';
import { DayBookComponent } from './day-book/day-book.component';
import { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component';
import { VoucherViewComponent } from './voucher-view/voucher-view.component';
import { VoucherReportComponent } from './voucher-report/voucher-report.component';
import { BranchDayComponent } from './branch-day/branch-day.component';
import { VaultPositionComponent } from './vault-position/vault-position.component';
import { AccountRegisterComponent } from './account-register/account-register.component';
@@ -221,6 +223,16 @@ const routes: Routes = [
component: VoucherExceptionsComponent,
data: { title: 'Voucher Exceptions', breadcrumb: 'Voucher Exceptions' }
},
{
path: 'voucher',
component: VoucherViewComponent,
data: { title: 'Voucher', breadcrumb: 'Voucher' }
},
{
path: 'voucher-report',
component: VoucherReportComponent,
data: { title: 'Voucher Report', breadcrumb: 'Voucher Report' }
},
{
path: 'closing-report',
component: ClosingCashReportComponent,
+29
View File
@@ -186,6 +186,35 @@ export class CashierService {
return this.http.get('/vouchers/exceptions', { params });
}
/**
* The vouchers raised over a period, one row each, with what was debited and what credited.
*
* Who appears is decided by the server from the caller's own roles — a cashier sees only their
* own work whatever they ask for — so `username` is a filter for those allowed to use it, not a
* way to choose whose report this is.
*/
voucherReport(from: string, to: string, username?: string, voucherType?: string): Observable<any> {
let params = new HttpParams().set('from', from).set('to', to);
if (username) {
params = params.set('username', username);
}
if (voucherType) {
params = params.set('voucherType', voucherType);
}
return this.http.get('/vouchers/report', { params });
}
/**
* One voucher and the debit and credit sides it is made of — what a printed voucher shows.
*
* The voucher number travels as a query parameter, not a path segment: numbers are formatted
* CR/2708/0001 and the server rejects the encoded slashes in a path.
*/
voucherDetail(voucherNo: string): Observable<any> {
const params = new HttpParams().set('voucherNo', voucherNo);
return this.http.get('/vouchers/detail', { params });
}
/** Closes an exception. Manager / head cashier only, and a supporting document must exist. */
resolveVoucherException(voucherNo: string, resolutionStatus: string, resolutionNotes: string): Observable<any> {
return this.http.post('/vouchers/exceptions/resolve', { voucherNo, resolutionStatus, resolutionNotes });
@@ -0,0 +1,183 @@
<!--
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 vr-no-print">
<div>
<div class="pg-eyebrow">CASHIER · REPORT</div>
<h1 class="pg-title">Voucher Report</h1>
<p class="pg-sub">
Every voucher raised over a day or a month — the number, what was debited, what was credited, and who raised it.
</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
<span>Period</span>
<select [(ngModel)]="period" (change)="load()">
<option value="DAY">Day</option>
<option value="MONTH">Month</option>
</select>
</label>
<label class="pg-field">
<span>{{ period === 'DAY' ? 'Date' : 'Month of' }}</span>
<input type="date" [(ngModel)]="anchor" (change)="load()" />
</label>
@if (report?.canSeeAllUsers) {
<label class="pg-field">
<span>User</span>
<input
type="text"
[(ngModel)]="usernameFilter"
(keyup.enter)="load()"
placeholder="All users"
autocomplete="off"
/>
</label>
}
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Run
</button>
@if (report && report.count > 0) {
<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 vr-no-print">{{ error }}</div>
}
@if (loading) {
<div class="pg-empty vr-no-print">Loading…</div>
}
@if (report && !loading) {
<div class="vr-period">
{{ report.from | date: 'dd MMM yyyy' }}
@if (report.from !== report.to) {
— {{ report.to | date: 'dd MMM yyyy' }}
}
· {{ report.count }} voucher{{ report.count === 1 ? '' : 's' }}
· ₹{{ report.totalAmount | number: '1.2-2' }}
@if (report.username) {
· {{ report.username }}
}
<!-- Said plainly rather than left to be inferred from a missing filter: a cashier should know
this is their own work and not the branch's. -->
@if (!report.canSeeAllUsers) {
<span class="vr-scope-note">Your own vouchers</span>
}
</div>
@if (report.count === 0) {
<div class="pg-empty">No vouchers were raised in this period.</div>
} @else {
<div class="vr-summaries">
<div class="pg-card">
<div class="pos-section-label">By type</div>
<table class="pg-table">
<thead>
<tr>
<th>Type</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (row of byType; track row.type) {
<tr>
<td>{{ row.type }}</td>
<td class="num">{{ row.count }}</td>
<td class="num">{{ row.total | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Only worth showing to someone who can see more than one person's work. -->
@if (report.canSeeAllUsers) {
<div class="pg-card">
<div class="pos-section-label">By user</div>
<table class="pg-table">
<thead>
<tr>
<th>User</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (row of byUser; track row.username) {
<tr>
<td>{{ row.username }}</td>
<td class="num">{{ row.count }}</td>
<td class="num">{{ row.total | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<div class="pg-card">
<div class="pos-section-label">Vouchers</div>
<table class="pg-table vr-rows">
<thead>
<tr>
<th>Voucher No</th>
<th>Type</th>
<th>Debit</th>
<th>Credit</th>
<th class="num">Amount</th>
<th>User</th>
<th>Time</th>
</tr>
</thead>
<tbody>
@for (row of report.vouchers; track row.voucherNo) {
<tr>
<td>
<a class="vr-link" [routerLink]="['/cashier/voucher']" [queryParams]="{ voucherNo: row.voucherNo }">
{{ row.voucherNo }}
</a>
@if (row.verificationStatus === 'EXCEPTION') {
<mat-icon class="vr-flag" title="An exception was raised against this voucher">error_outline</mat-icon>
}
</td>
<td>{{ row.voucherType || '—' }}</td>
<td>
<div class="vr-party">{{ row.debitParty || '—' }}</div>
@if (row.debitAccount) {
<div class="vr-acct">{{ row.debitAccount }}</div>
}
</td>
<td>
<div class="vr-party">{{ row.creditParty || '—' }}</div>
@if (row.creditAccount) {
<div class="vr-acct">{{ row.creditAccount }}</div>
}
<!-- A DD with charges, or a compound journal, has sides this row does not show. -->
@if (row.extraLines > 0) {
<div class="vr-more">+{{ row.extraLines }} more on voucher</div>
}
</td>
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
<td>{{ row.username || '—' }}</td>
<td>{{ row.createdAt | date: 'dd MMM, HH:mm' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
@@ -0,0 +1,80 @@
/**
* 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, cards and tables come from cashier-shared.scss. Only the register itself is here. */
.vr-period {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
font-size: 13px;
color: #374151;
}
.vr-scope-note {
padding: 2px 8px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 999px;
color: #1e40af;
font-size: 11px;
}
/* The two summaries sit side by side where there is room and stack where there is not, so a
manager reads the totals before the rows on any screen. */
.vr-summaries {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
margin-bottom: 14px;
}
.vr-rows {
.vr-link {
color: #1d4ed8;
text-decoration: none;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
.vr-flag {
margin-left: 4px;
font-size: 15px;
width: 15px;
height: 15px;
color: #b91c1c;
vertical-align: text-bottom;
}
.vr-party {
color: #111827;
}
.vr-acct,
.vr-more {
margin-top: 2px;
font-size: 11px;
color: #6b7280;
}
}
/* Printing a report should put the register on paper — the filters are how you reached it. */
@media print {
.vr-no-print {
display: none !important;
}
.vr-link {
color: inherit;
text-decoration: none;
}
}
@@ -0,0 +1,159 @@
/**
* 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, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
interface VoucherRow {
voucherNo: string;
voucherType: string | null;
amount: number | null;
createdAt: string | null;
userId: number | null;
username: string | null;
branchName: string | null;
verificationStatus: string | null;
debitParty: string | null;
debitAccount: string | null;
creditParty: string | null;
creditAccount: string | null;
/** Sides beyond the one debit and one credit shown — a DD's charges, a compound journal. */
extraLines: number;
}
interface VoucherReport {
from: string;
to: string;
username: string | null;
/** Set by the server from the caller's roles; a cashier is never offered the user filter. */
canSeeAllUsers: boolean;
vouchers: VoucherRow[];
count: number;
totalAmount: number;
}
/**
* The vouchers raised over a day or a month, one row each.
*
* A cashier sees their own work; a clerk, head cashier or manager sees everyone's and can narrow to
* one user. That is decided by the server, not here — this screen only shows or hides the filter.
*/
@Component({
selector: 'mifosx-voucher-report',
templateUrl: './voucher-report.component.html',
styleUrls: [
'../cashier-shared.scss',
'./voucher-report.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterLink,
MatIconModule
]
})
export class VoucherReportComponent implements OnInit {
private service = inject(CashierService);
/** Day is the working default; a month is what you ask for at a close. */
period: 'DAY' | 'MONTH' = 'DAY';
/** Anchor date — the day itself, or any day inside the month wanted. */
anchor = this.todayIso();
usernameFilter = '';
typeFilter = '';
report: VoucherReport | null = null;
loading = false;
error: string | null = null;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.error = null;
const { from, to } = this.range();
this.service
.voucherReport(from, to, this.usernameFilter.trim() || undefined, this.typeFilter.trim() || undefined)
.pipe(
catchError((err: any) => {
this.error =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
'The voucher report could not be loaded.';
return of(null);
})
)
.subscribe((res: VoucherReport | null) => {
this.loading = false;
this.report = res;
});
}
/** The period the anchor date names: that day, or the whole calendar month around it. */
private range(): { from: string; to: string } {
if (this.period === 'DAY') {
return { from: this.anchor, to: this.anchor };
}
const [
year,
month
] = this.anchor.split('-').map(Number);
// Day 0 of the next month is the last day of this one, which is how a 28/30/31-day month is
// ended correctly without a table of month lengths.
const lastDay = new Date(year, month, 0).getDate();
const mm = String(month).padStart(2, '0');
return { from: `${year}-${mm}-01`, to: `${year}-${mm}-${String(lastDay).padStart(2, '0')}` };
}
private todayIso(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
/** Totals per user, for the summary a manager reads before the rows. */
get byUser(): { username: string; count: number; total: number }[] {
const rows = this.report?.vouchers ?? [];
const tally = new Map<string, { username: string; count: number; total: number }>();
for (const row of rows) {
const key = row.username ?? '—';
const entry = tally.get(key) ?? { username: key, count: 0, total: 0 };
entry.count += 1;
entry.total += row.amount ?? 0;
tally.set(key, entry);
}
return [...tally.values()].sort((a, b) => b.total - a.total);
}
/** Totals per voucher type — deposits against withdrawals against journals. */
get byType(): { type: string; count: number; total: number }[] {
const rows = this.report?.vouchers ?? [];
const tally = new Map<string, { type: string; count: number; total: number }>();
for (const row of rows) {
const key = row.voucherType ?? '—';
const entry = tally.get(key) ?? { type: key, count: 0, total: 0 };
entry.count += 1;
entry.total += row.amount ?? 0;
tally.set(key, entry);
}
return [...tally.values()].sort((a, b) => b.total - a.total);
}
print(): void {
window.print();
}
}
@@ -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();
}
}