feat: cash-only registers, day book sections and DD amendments
This commit is contained in:
@@ -65,42 +65,6 @@
|
||||
@if (errorMsg) {
|
||||
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
|
||||
}
|
||||
|
||||
<!-- Exceptions surface at the top of the day, not only as a badge buried in their own row: a
|
||||
flag raised for investigation is useless if someone has to scroll to find it. -->
|
||||
@if (!loading && exceptionRows.length > 0) {
|
||||
<div class="db-exceptions">
|
||||
<div class="db-exceptions__head">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<strong>
|
||||
{{ exceptionRows.length }} voucher{{ exceptionRows.length === 1 ? '' : 's' }} awaiting investigation
|
||||
</strong>
|
||||
@if (resolvedExceptionCount > 0) {
|
||||
<span class="db-muted">({{ resolvedExceptionCount }} already closed)</span>
|
||||
}
|
||||
<button type="button" class="pg-btn pg-btn--warn" (click)="toggleExceptionsOnly()">
|
||||
{{ exceptionsOnly ? 'Show all vouchers' : 'Show only these' }}
|
||||
</button>
|
||||
</div>
|
||||
@for (e of exceptionRows; track e.voucherNo) {
|
||||
<div class="db-exceptions__item">
|
||||
<span class="pg-chip">{{ e.voucherNo }}</span>
|
||||
<span class="db-exceptions__amt">
|
||||
System ₹{{ e.amount | number: '1.2-2' }}
|
||||
@if (e.slipAmount != null && e.slipAmount !== e.amount) {
|
||||
· Slip ₹{{ e.slipAmount | number: '1.2-2' }}
|
||||
<strong class="db-exceptions__diff"
|
||||
>(diff ₹{{ e.slipAmount - (e.amount ?? 0) | number: '1.2-2' }})</strong
|
||||
>
|
||||
}
|
||||
</span>
|
||||
<span class="db-exceptions__why">{{ e.verifyRemarks }}</span>
|
||||
<span class="db-muted">— {{ e.verifiedByName }}, {{ timeOf(e.verifiedAt) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="pg-empty">Loading…</div>
|
||||
}
|
||||
@@ -139,252 +103,87 @@
|
||||
Totals count approved vouchers only — pending and rejected rows are listed but never move the money.
|
||||
</p>
|
||||
|
||||
<div class="pg-card">
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voucher No</th>
|
||||
<th>Dr/Cr</th>
|
||||
<th>Type</th>
|
||||
<th>Account</th>
|
||||
<th>Party</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Payment</th>
|
||||
<th>Status</th>
|
||||
<th>By</th>
|
||||
<th>Approved By</th>
|
||||
<th>Slip Check</th>
|
||||
<th>Time</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of filteredRows; track r.voucherNo ?? r.submittedAt) {
|
||||
<tr [class.db-row--cr]="r.side === 'CR'" [class.db-row--dr]="r.side === 'DR'">
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.voucherNo ?? '—' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="db-side" [class.db-side--cr]="r.side === 'CR'" [class.db-side--dr]="r.side === 'DR'">
|
||||
{{ r.side }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ r.kind }}</td>
|
||||
<td>
|
||||
{{ r.accountNo ?? '—' }}
|
||||
@if (r.accountType) {
|
||||
<span class="db-muted">· {{ r.accountType }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ r.party ?? '—' }}</td>
|
||||
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ r.paymentTypeName ?? '—' }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(r.status)">{{ r.status }}</span>
|
||||
</td>
|
||||
<td>{{ r.handledBy ?? '—' }}</td>
|
||||
<td>
|
||||
{{ r.approvedBy ?? '—' }}
|
||||
@if (r.approvedBy && r.actionedAt) {
|
||||
<span class="db-muted">· {{ timeOf(r.actionedAt) }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (r.verifyStatus === 'VERIFIED') {
|
||||
<span class="db-badge db-badge--ok" [title]="'Verified by ' + (r.verifiedByName ?? '')"
|
||||
>✓ MATCHED</span
|
||||
>
|
||||
} @else if (r.verifyStatus === 'EXCEPTION' && r.resolutionStatus) {
|
||||
<!-- Closed: the flag is history now, so it reads as settled rather than pending. -->
|
||||
<span
|
||||
class="db-badge db-badge--ok"
|
||||
[title]="resolutionLabel(r.resolutionStatus) + ' — ' + (r.resolutionNotes ?? '')"
|
||||
>
|
||||
✓ RESOLVED
|
||||
</span>
|
||||
} @else if (r.verifyStatus === 'EXCEPTION') {
|
||||
<span
|
||||
class="db-badge db-badge--rejected"
|
||||
[title]="'Slip ₹' + r.slipAmount + ' vs system ₹' + r.amount + ' — ' + (r.verifyRemarks ?? '')"
|
||||
>
|
||||
⚠ EXCEPTION
|
||||
</span>
|
||||
} @else {
|
||||
<span class="db-muted">Not checked</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ timeOf(r.submittedAt) }}</td>
|
||||
<td class="db-actions">
|
||||
<button type="button" class="db-icon-btn" (click)="openVerify(r)" title="Check against the paper slip">
|
||||
<mat-icon>fact_check</mat-icon>
|
||||
</button>
|
||||
<button type="button" class="db-icon-btn" (click)="openAttach(r)" title="Attach or view scanned slip">
|
||||
<mat-icon>attach_file</mat-icon>
|
||||
@if (r.attachmentCount > 0) {
|
||||
<span class="db-icon-btn__count">{{ r.attachmentCount }}</span>
|
||||
}
|
||||
</button>
|
||||
</td>
|
||||
<!-- One table per source register rather than one long list. The two read differently: the
|
||||
cashier's rows are cash that actually crossed the counter, the clerk's are requests nobody
|
||||
has actioned yet. Mixed together they invited the wrong sum. -->
|
||||
@for (s of sections; track s.key) {
|
||||
<div class="pg-card db-section">
|
||||
<div class="db-section__head">
|
||||
<strong>{{ s.label }}</strong>
|
||||
<span class="db-muted">{{ s.hint }}</span>
|
||||
<span class="db-section__count">{{ s.rows.length }} row{{ s.rows.length === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voucher No</th>
|
||||
<th>Dr/Cr</th>
|
||||
<th>Type</th>
|
||||
<th>Account</th>
|
||||
<th>Party</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Payment</th>
|
||||
<th>Status</th>
|
||||
<th>By</th>
|
||||
<th>Approved By</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
|
||||
<!-- A flagged voucher spells out its reason in the table itself. A tooltip is not
|
||||
enough: it cannot be printed, exported, or read without a mouse. -->
|
||||
@if (r.verifyStatus === 'EXCEPTION' && verifyRow !== r) {
|
||||
<tr class="db-exc-row" [class.db-exc-row--resolved]="r.resolutionStatus">
|
||||
<td colspan="13">
|
||||
<mat-icon>{{ r.resolutionStatus ? 'task_alt' : 'warning' }}</mat-icon>
|
||||
<strong>Exception</strong>
|
||||
@if (r.slipAmount != null && r.slipAmount !== r.amount) {
|
||||
— slip ₹{{ r.slipAmount | number: '1.2-2' }} vs system ₹{{ r.amount | number: '1.2-2' }} (difference
|
||||
₹{{ r.slipAmount - (r.amount ?? 0) | number: '1.2-2' }})
|
||||
}
|
||||
— {{ r.verifyRemarks }}
|
||||
<span class="db-muted">· raised by {{ r.verifiedByName }} at {{ timeOf(r.verifiedAt) }}</span>
|
||||
<!-- Both halves of the record: what the cashier found, and how it was settled. -->
|
||||
@if (r.resolutionStatus) {
|
||||
<div class="db-exc-row__resolution">
|
||||
<strong>Closed — {{ resolutionLabel(r.resolutionStatus) }}:</strong>
|
||||
{{ r.resolutionNotes }}
|
||||
<span class="db-muted">· by {{ r.resolvedByName }} at {{ timeOf(r.resolvedAt) }}</span>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of s.rows; track r.voucherNo ?? r.section + r.txnId) {
|
||||
<tr [class.db-row--cr]="r.side === 'CR'" [class.db-row--dr]="r.side === 'DR'">
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.voucherNo ?? '—' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="db-side" [class.db-side--cr]="r.side === 'CR'" [class.db-side--dr]="r.side === 'DR'">
|
||||
{{ r.side }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ r.kind }}</td>
|
||||
<td>
|
||||
{{ r.accountNo ?? '—' }}
|
||||
@if (r.accountType) {
|
||||
<span class="db-muted">· {{ r.accountType }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
<!-- Verification panel, opened inline under its own voucher so the two amounts sit
|
||||
next to the row they describe. -->
|
||||
@if (verifyRow === r) {
|
||||
<tr class="db-panel-row">
|
||||
<td colspan="13">
|
||||
<div class="db-panel">
|
||||
<div class="db-panel__head">
|
||||
<strong>Slip check — {{ r.voucherNo }}</strong>
|
||||
<button type="button" class="db-icon-btn" (click)="closeVerify()" title="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="db-panel__body">
|
||||
<div class="db-cmp">
|
||||
<span class="db-cmp__label">System recorded</span>
|
||||
<span class="db-cmp__value">₹{{ r.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<label class="pg-field">
|
||||
<span>Amount written on slip</span>
|
||||
<input type="number" step="0.01" [(ngModel)]="verifySlipAmount" />
|
||||
</label>
|
||||
@if (verifySlipBlank) {
|
||||
<div class="db-cmp">
|
||||
<span class="db-cmp__label">Difference</span>
|
||||
<span class="db-cmp__value">—</span>
|
||||
</div>
|
||||
} @else if (verifyMismatch) {
|
||||
<div class="db-cmp db-cmp--bad">
|
||||
<span class="db-cmp__label">Difference</span>
|
||||
<span class="db-cmp__value">₹{{ verifyDifference | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="db-cmp db-cmp--good">
|
||||
<span class="db-cmp__label">Match</span>
|
||||
<span class="db-cmp__value">Amounts agree</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<label class="pg-field db-panel__remarks">
|
||||
<span>{{ verifyMismatch ? 'What did you find? (required)' : 'Remarks (optional)' }}</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="verifyRemarks"
|
||||
placeholder="e.g. slip written as 500, system posted 400 — teller keyed it wrong"
|
||||
/>
|
||||
</label>
|
||||
@if (verifyMismatch) {
|
||||
<p class="db-panel__warn">
|
||||
Saving this marks the voucher as an <strong>exception</strong> for investigation — it does not
|
||||
change the posted amount.
|
||||
</p>
|
||||
}
|
||||
<div class="db-panel__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
(click)="saveVerify()"
|
||||
[disabled]="verifySaving || verifySlipBlank"
|
||||
>
|
||||
{{ verifyMismatch ? 'Mark Exception' : 'Mark Verified' }}
|
||||
</button>
|
||||
<!-- Raising an exception on an already-matched voucher stays available: a
|
||||
slip cleared in haste, or a difference noticed later, has to be
|
||||
recordable without erasing the earlier verdict. -->
|
||||
@if (!verifyMismatch) {
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--warn"
|
||||
(click)="raiseException()"
|
||||
[disabled]="verifySaving"
|
||||
>
|
||||
Raise Exception
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="pg-btn" (click)="closeVerify()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<td>{{ r.party ?? '—' }}</td>
|
||||
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ r.paymentTypeName ?? '—' }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(r.status)">{{ r.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
<!-- Attachment panel for this voucher. -->
|
||||
@if (attachRow === r) {
|
||||
<tr class="db-panel-row">
|
||||
<td colspan="13">
|
||||
<div class="db-panel">
|
||||
<div class="db-panel__head">
|
||||
<strong>Attachments — {{ r.voucherNo }}</strong>
|
||||
<button type="button" class="db-icon-btn" (click)="closeAttach()" title="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<label class="db-upload">
|
||||
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
|
||||
<span class="db-muted">Scanned slip, cheque image or ID proof · max 10 MB</span>
|
||||
</label>
|
||||
@if (attachUploading) {
|
||||
<p class="db-muted">Uploading…</p>
|
||||
}
|
||||
@if (attachLoading) {
|
||||
<p class="db-muted">Loading…</p>
|
||||
}
|
||||
@if (!attachLoading && attachments.length === 0) {
|
||||
<p class="db-muted">Nothing attached to this voucher yet.</p>
|
||||
}
|
||||
@for (a of attachments; track a.id) {
|
||||
<div class="db-file">
|
||||
<mat-icon>description</mat-icon>
|
||||
<span class="db-file__name">{{ a.fileName }}</span>
|
||||
<span class="db-muted">{{ a.fileSize }} bytes · {{ a.uploadedByName }}</span>
|
||||
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
|
||||
<mat-icon>download</mat-icon>
|
||||
</button>
|
||||
<button type="button" class="db-icon-btn" (click)="deleteAttachment(a)" title="Delete">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<td>{{ r.handledBy ?? '—' }}</td>
|
||||
<td>
|
||||
{{ r.approvedBy ?? '—' }}
|
||||
@if (r.approvedBy && r.actionedAt) {
|
||||
<span class="db-muted">· {{ timeOf(r.actionedAt) }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ timeOf(r.submittedAt) }}</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5">Showing {{ filteredRows.length }} of {{ rows.length }} voucher(s)</td>
|
||||
<td class="num">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</td>
|
||||
<td colspan="7"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5">{{ s.label }} subtotal</td>
|
||||
<td class="num">
|
||||
@if (s.credit > 0 && s.debit > 0) {
|
||||
₹{{ s.credit | number: '1.2-2' }} CR · ₹{{ s.debit | number: '1.2-2' }} DR
|
||||
} @else if (s.credit > 0) {
|
||||
₹{{ s.credit | number: '1.2-2' }} CR
|
||||
} @else if (s.debit > 0) {
|
||||
₹{{ s.debit | number: '1.2-2' }} DR
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td colspan="5"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!loading && loaded && rows.length > 0 && filteredRows.length === 0) {
|
||||
|
||||
@@ -32,6 +32,34 @@
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* One card per source register. The heading says which register, and why its rows read differently
|
||||
from the others, so the three are not added up as though they were one list. */
|
||||
.db-section {
|
||||
margin-bottom: 14px;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 12px;
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
}
|
||||
|
||||
&__count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dr/Cr marker — the one thing the eye should catch when scanning a day's vouchers. */
|
||||
.db-side {
|
||||
display: inline-block;
|
||||
@@ -169,41 +197,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* The reason spelled out under its own flagged row. */
|
||||
.db-exc-row td {
|
||||
background: #fef2f2 !important;
|
||||
border-bottom: 1px solid #fecaca !important;
|
||||
color: #7f1d1d;
|
||||
font-size: 12px;
|
||||
padding: 8px 14px !important;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
vertical-align: -3px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* A closed exception stays on the row as history, but drops the alarm colouring — it is no longer
|
||||
something anyone has to act on. */
|
||||
.db-exc-row--resolved td {
|
||||
background: #f0fdf4 !important;
|
||||
border-bottom: 1px solid #bbf7d0 !important;
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
.db-exc-row__resolution {
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px dotted #bbf7d0;
|
||||
}
|
||||
|
||||
.db-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Raising an exception is a deliberate, consequential act — styled to read that way without
|
||||
looking destructive, since nothing is deleted. */
|
||||
.pg-btn--warn {
|
||||
@@ -352,38 +345,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.db-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.db-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-table tfoot td {
|
||||
background: #f8fafc;
|
||||
font-weight: 800;
|
||||
|
||||
@@ -13,8 +13,18 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
|
||||
/**
|
||||
* Which part of the day's work a row came from. Set by the server, one per source register.
|
||||
*
|
||||
* Only counter business appears in the day book. Cash moved between the vault and the drawers is
|
||||
* the bank shifting its own money, not a customer transaction, and it is read on the Cashier
|
||||
* Position and Closing Cash Report where the drawer itself is the subject.
|
||||
*/
|
||||
type DayBookSection = 'CASHIER' | 'CLERK';
|
||||
|
||||
interface DayBookRow {
|
||||
voucherNo: string | null;
|
||||
section: DayBookSection;
|
||||
side: 'CR' | 'DR';
|
||||
kind: string;
|
||||
accountNo: string | null;
|
||||
@@ -29,18 +39,6 @@ interface DayBookRow {
|
||||
txnId: string | null;
|
||||
submittedAt: string | null;
|
||||
actionedAt: string | null;
|
||||
/** null until someone has physically checked the slip. */
|
||||
verifyStatus: 'VERIFIED' | 'EXCEPTION' | null;
|
||||
slipAmount: number | null;
|
||||
verifyRemarks: string | null;
|
||||
verifiedByName: string | null;
|
||||
verifiedAt: string | null;
|
||||
/** Set once a clerk or manager has closed the exception; null while it is still open. */
|
||||
resolutionStatus: string | null;
|
||||
resolutionNotes: string | null;
|
||||
resolvedByName: string | null;
|
||||
resolvedAt: string | null;
|
||||
attachmentCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,48 +126,47 @@ export class DayBookComponent implements OnInit {
|
||||
this.load();
|
||||
}
|
||||
|
||||
/** Show only the flagged vouchers — set from the exception banner. */
|
||||
exceptionsOnly = false;
|
||||
|
||||
/**
|
||||
* Vouchers still awaiting a conclusion. A closed exception drops out of the banner — it stays
|
||||
* visible on its own row as history, but it is no longer something anyone has to act on.
|
||||
* The day's rows split by where they came from, in the order they are read.
|
||||
*
|
||||
* Cashier work first — that is the counter — then the clerk's outstanding requests. An empty
|
||||
* section is dropped rather than shown as a heading over nothing.
|
||||
*
|
||||
* A section is not "who typed it": a clerk raises a withdrawal that a cashier pays out, so the
|
||||
* heading says which register the entry belongs to, and the By column still names the person.
|
||||
*/
|
||||
get exceptionRows(): DayBookRow[] {
|
||||
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && !r.resolutionStatus);
|
||||
}
|
||||
|
||||
/** Closed exceptions, kept for the record rather than for action. */
|
||||
get resolvedExceptionCount(): number {
|
||||
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && r.resolutionStatus).length;
|
||||
}
|
||||
|
||||
/** Human label for a resolution code. */
|
||||
resolutionLabel(status: string | null): string {
|
||||
switch (status) {
|
||||
case 'RESOLVED_SYSTEM_ERROR':
|
||||
return 'System entry was wrong';
|
||||
case 'RESOLVED_SLIP_ERROR':
|
||||
return 'Slip was written wrong';
|
||||
case 'RESOLVED_NO_ACTION':
|
||||
return 'No action needed';
|
||||
default:
|
||||
return status ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Vouchers nobody has checked against a slip yet. */
|
||||
get uncheckedCount(): number {
|
||||
return this.rows.filter((r) => !r.verifyStatus).length;
|
||||
}
|
||||
|
||||
toggleExceptionsOnly(): void {
|
||||
this.exceptionsOnly = !this.exceptionsOnly;
|
||||
get sections(): {
|
||||
key: DayBookSection;
|
||||
label: string;
|
||||
hint: string;
|
||||
rows: DayBookRow[];
|
||||
credit: number;
|
||||
debit: number;
|
||||
}[] {
|
||||
const order: { key: DayBookSection; label: string; hint: string }[] = [
|
||||
{ key: 'CASHIER', label: 'Cashier', hint: 'Cash actually taken in and paid out over the counter' },
|
||||
{ key: 'CLERK', label: 'Clerk', hint: 'Requests raised but not yet actioned — no cash has moved' }
|
||||
];
|
||||
return order
|
||||
.map((s) => {
|
||||
const rows = this.filteredRows.filter((r) => r.section === s.key);
|
||||
return {
|
||||
...s,
|
||||
rows,
|
||||
credit: rows
|
||||
.filter((r) => r.side === 'CR' && this.countsAsMoney(r))
|
||||
.reduce((t, r) => t + Number(r.amount ?? 0), 0),
|
||||
debit: rows
|
||||
.filter((r) => r.side === 'DR' && this.countsAsMoney(r))
|
||||
.reduce((t, r) => t + Number(r.amount ?? 0), 0)
|
||||
};
|
||||
})
|
||||
.filter((s) => s.rows.length > 0);
|
||||
}
|
||||
|
||||
/** Free-text narrowing over the rows already fetched — no extra round trip per keystroke. */
|
||||
get filteredRows(): DayBookRow[] {
|
||||
const base = this.exceptionsOnly ? this.exceptionRows : this.rows;
|
||||
const base = this.rows;
|
||||
const q = this.search.trim().toLowerCase();
|
||||
if (!q) return base;
|
||||
return base.filter((r) =>
|
||||
@@ -231,236 +228,9 @@ export class DayBookComponent implements OnInit {
|
||||
return t ? t.slice(0, 5) : '—';
|
||||
}
|
||||
|
||||
// ── Physical slip verification ────────────────────────────────────────────
|
||||
|
||||
/** Row whose verification panel is open, or null when none is. */
|
||||
verifyRow: DayBookRow | null = null;
|
||||
verifySlipAmount: number | null = null;
|
||||
verifyRemarks = '';
|
||||
verifySaving = false;
|
||||
|
||||
openVerify(r: DayBookRow): void {
|
||||
this.verifyRow = r;
|
||||
// Pre-filled with the system's own figure: the common case is that the two agree, and when
|
||||
// they don't the cashier only has to change the digits that actually differ.
|
||||
this.verifySlipAmount = Number(r.amount ?? 0);
|
||||
this.verifyRemarks = r.verifyRemarks ?? '';
|
||||
this.attachRow = null;
|
||||
}
|
||||
|
||||
closeVerify(): void {
|
||||
this.verifyRow = null;
|
||||
this.verifySlipAmount = null;
|
||||
this.verifyRemarks = '';
|
||||
}
|
||||
|
||||
/** True when the amount typed off the slip differs from what the system recorded. */
|
||||
get verifyMismatch(): boolean {
|
||||
if (!this.verifyRow || this.verifySlipAmount == null) return false;
|
||||
return Number(this.verifySlipAmount) !== Number(this.verifyRow.amount ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the slip box is empty. Clearing the field must not read as "amounts agree" — an
|
||||
* unanswered question is not a match, so the panel shows neither verdict until a figure is typed.
|
||||
*/
|
||||
get verifySlipBlank(): boolean {
|
||||
return this.verifySlipAmount == null || String(this.verifySlipAmount).trim() === '';
|
||||
}
|
||||
|
||||
get verifyDifference(): number {
|
||||
if (!this.verifyRow || this.verifySlipAmount == null) return 0;
|
||||
return Number(this.verifySlipAmount) - Number(this.verifyRow.amount ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the verdict. The status is derived from the figures rather than picked by the user —
|
||||
* if the slip and the system disagree it is an exception, whatever anyone intended to click.
|
||||
*/
|
||||
saveVerify(): void {
|
||||
const row = this.verifyRow;
|
||||
if (!row?.voucherNo) return;
|
||||
|
||||
const isException = this.verifyMismatch;
|
||||
if (isException && !this.verifyRemarks.trim()) {
|
||||
this.errorMsg = 'The amounts differ — add a remark saying what you found before saving.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.verifySaving = true;
|
||||
this.errorMsg = '';
|
||||
this.service
|
||||
.verifyVoucher(
|
||||
row.voucherNo,
|
||||
isException ? 'EXCEPTION' : 'VERIFIED',
|
||||
isException ? Number(this.verifySlipAmount) : undefined,
|
||||
this.verifyRemarks.trim() || undefined
|
||||
)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not save the verification.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.verifySaving = false;
|
||||
if (res) {
|
||||
this.closeVerify();
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags a voucher whose amounts agree — the problem is something other than the figure: a
|
||||
* missing signature, a wrong account, a slip the member disputes. Deliberately not a "clear the
|
||||
* check" action: erasing a verdict would erase the evidence that anything was ever wrong, which
|
||||
* is exactly what an investigation needs.
|
||||
*/
|
||||
raiseException(): void {
|
||||
const row = this.verifyRow;
|
||||
if (!row?.voucherNo) return;
|
||||
|
||||
if (!this.verifyRemarks.trim()) {
|
||||
this.errorMsg = 'Say what the problem is before raising an exception.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.verifySaving = true;
|
||||
this.errorMsg = '';
|
||||
this.service
|
||||
// The slip amount is sent as-is: the figures match, so it doubles as the record of what
|
||||
// both the slip and the system said at the time the exception was raised.
|
||||
.verifyVoucher(
|
||||
row.voucherNo,
|
||||
'EXCEPTION',
|
||||
Number(this.verifySlipAmount ?? row.amount ?? 0),
|
||||
this.verifyRemarks.trim()
|
||||
)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not raise the exception.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.verifySaving = false;
|
||||
if (res) {
|
||||
this.closeVerify();
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Attachments ───────────────────────────────────────────────────────────
|
||||
|
||||
attachRow: DayBookRow | null = null;
|
||||
attachments: any[] = [];
|
||||
attachLoading = false;
|
||||
attachUploading = false;
|
||||
|
||||
openAttach(r: DayBookRow): void {
|
||||
this.attachRow = r;
|
||||
this.verifyRow = null;
|
||||
this.attachments = [];
|
||||
if (!r.voucherNo) return;
|
||||
this.attachLoading = true;
|
||||
this.service
|
||||
.listVoucherAttachments(r.voucherNo)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.attachLoading = false;
|
||||
this.attachments = res?.attachments ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
closeAttach(): void {
|
||||
this.attachRow = null;
|
||||
this.attachments = [];
|
||||
}
|
||||
|
||||
onFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
const row = this.attachRow;
|
||||
if (!file || !row?.voucherNo) return;
|
||||
|
||||
// 10 MB, matching the backend's own ceiling — rejecting here saves uploading the whole file
|
||||
// only to have it refused.
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
this.attachUploading = true;
|
||||
this.errorMsg = '';
|
||||
this.service
|
||||
.uploadVoucherAttachment(
|
||||
row.voucherNo!,
|
||||
file.name,
|
||||
file.type || 'application/octet-stream',
|
||||
String(reader.result)
|
||||
)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.attachUploading = false;
|
||||
input.value = '';
|
||||
if (res) {
|
||||
this.openAttach(row);
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetched through HttpClient rather than window.open: the download endpoint needs the auth
|
||||
* header the interceptor adds, which a bare browser navigation would not carry. The blob is
|
||||
* then handed to the browser as a normal save.
|
||||
*/
|
||||
downloadAttachment(a: any): void {
|
||||
this.service
|
||||
.downloadVoucherAttachment(a.id)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((blob: Blob | null) => {
|
||||
if (!blob) {
|
||||
this.errorMsg = 'Could not download that file.';
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = a.fileName ?? 'attachment';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
|
||||
deleteAttachment(a: any): void {
|
||||
const row = this.attachRow;
|
||||
this.service
|
||||
.deleteVoucherAttachment(a.id)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe(() => {
|
||||
if (row) this.openAttach(row);
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
const header = [
|
||||
'Section',
|
||||
'Voucher No',
|
||||
'Side',
|
||||
'Type',
|
||||
@@ -471,18 +241,11 @@ export class DayBookComponent implements OnInit {
|
||||
'Status',
|
||||
'Handled By',
|
||||
'Approved By',
|
||||
'Slip Check',
|
||||
'Slip Amount',
|
||||
'Remarks',
|
||||
'Checked By',
|
||||
'Resolution',
|
||||
'Resolution Notes',
|
||||
'Closed By',
|
||||
'Attachments',
|
||||
'Time'
|
||||
];
|
||||
const lines = this.filteredRows.map((r) =>
|
||||
[
|
||||
r.section,
|
||||
r.voucherNo ?? '',
|
||||
r.side,
|
||||
r.kind,
|
||||
@@ -493,14 +256,6 @@ export class DayBookComponent implements OnInit {
|
||||
r.status,
|
||||
r.handledBy ?? '',
|
||||
r.approvedBy ?? '',
|
||||
r.verifyStatus ?? 'NOT CHECKED',
|
||||
r.slipAmount != null ? String(r.slipAmount) : '',
|
||||
r.verifyRemarks ?? '',
|
||||
r.verifiedByName ?? '',
|
||||
r.resolutionStatus ? this.resolutionLabel(r.resolutionStatus) : '',
|
||||
r.resolutionNotes ?? '',
|
||||
r.resolvedByName ?? '',
|
||||
String(r.attachmentCount ?? 0),
|
||||
this.timeOf(r.submittedAt)
|
||||
]
|
||||
// Quote every field and double any embedded quote: names and narrations routinely
|
||||
|
||||
Reference in New Issue
Block a user