done
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user