feat: implement cashier dashboard, dividend dashboards, share accounts views, and checker tasks
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
<!--
|
||||
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">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · DAY BOOK</div>
|
||||
<h1 class="pg-title">Day Book</h1>
|
||||
<p class="pg-sub">
|
||||
Every voucher raised on the selected date — cash deposits, withdrawals, disbursements, collections and bank
|
||||
transfers, with credit and debit totals.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn" (click)="shiftDate(-1)" title="Previous day">
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<label class="pg-field">
|
||||
<span>Date</span>
|
||||
<input type="date" [value]="date" (change)="date = $any($event.target).value; load()" />
|
||||
</label>
|
||||
<button type="button" class="pg-btn" (click)="shiftDate(1)" title="Next day">
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
<button type="button" class="pg-btn" (click)="goToday()">Today</button>
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
||||
<mat-icon>refresh</mat-icon> Reload
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="db-filters">
|
||||
<label class="pg-field">
|
||||
<span>Side</span>
|
||||
<select [(ngModel)]="side" (change)="load()">
|
||||
<option value="">All</option>
|
||||
<option value="CR">CR · Credit (money in)</option>
|
||||
<option value="DR">DR · Debit (money out)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="pg-field">
|
||||
<span>Status</span>
|
||||
<select [(ngModel)]="status" (change)="load()">
|
||||
<option value="">All</option>
|
||||
<option value="APPROVED">Approved</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="REJECTED">Rejected</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="pg-field db-filters__search">
|
||||
<span>Search</span>
|
||||
<input type="text" [(ngModel)]="search" placeholder="Voucher no, account, party, payment type, narration…" />
|
||||
</label>
|
||||
<button type="button" class="pg-btn" (click)="exportCsv()" [disabled]="filteredRows.length === 0">
|
||||
<mat-icon>download</mat-icon> CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@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>
|
||||
}
|
||||
|
||||
@if (!loading && loaded && rows.length === 0) {
|
||||
<div class="pg-empty">
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
<p>No vouchers were raised on {{ date }}.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading && filteredRows.length > 0) {
|
||||
<!-- Totals bar. Reflects what is on screen, so a search or side filter still reconciles. -->
|
||||
<div class="closing-summary">
|
||||
<div class="closing-summary__item closing-summary__item--in">
|
||||
<span class="closing-summary__label">Credit · CR (money in)</span>
|
||||
<span class="closing-summary__value">₹{{ shownCredit | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="closing-summary__sep">−</div>
|
||||
<div class="closing-summary__item closing-summary__item--out">
|
||||
<span class="closing-summary__label">Debit · DR (money out)</span>
|
||||
<span class="closing-summary__value">₹{{ shownDebit | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="closing-summary__sep">=</div>
|
||||
<div
|
||||
class="closing-summary__item"
|
||||
[class.closing-summary__item--net-pos]="shownCredit - shownDebit >= 0"
|
||||
[class.closing-summary__item--net-neg]="shownCredit - shownDebit < 0"
|
||||
>
|
||||
<span class="closing-summary__label">Net</span>
|
||||
<span class="closing-summary__value">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="db-note">
|
||||
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>
|
||||
</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>
|
||||
}
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
}
|
||||
|
||||
@if (!loading && loaded && rows.length > 0 && filteredRows.length === 0) {
|
||||
<div class="pg-empty">No voucher matches “{{ search }}”.</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/* Layout and page chrome come from cashier-shared.scss; only what is specific to the day book
|
||||
lives here. */
|
||||
|
||||
.db-filters {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
|
||||
/* The search box takes the slack so the two selects keep their natural width. */
|
||||
&__search {
|
||||
flex: 1 1 260px;
|
||||
}
|
||||
}
|
||||
|
||||
.db-note {
|
||||
margin: 8px 0 12px;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Dr/Cr marker — the one thing the eye should catch when scanning a day's vouchers. */
|
||||
.db-side {
|
||||
display: inline-block;
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
|
||||
&--cr {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&--dr {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
/* A hairline on the leading edge, tinted by side: readable without colouring the whole row,
|
||||
which would fight the table's own hover state. */
|
||||
.db-row--cr td:first-child {
|
||||
box-shadow: inset 3px 0 0 #22c55e;
|
||||
}
|
||||
|
||||
.db-row--dr td:first-child {
|
||||
box-shadow: inset 3px 0 0 #ef4444;
|
||||
}
|
||||
|
||||
.db-muted {
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.db-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
|
||||
&--ok {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&--rejected {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
/* Not defined in cashier-shared.scss, so the day book carries its own. */
|
||||
.pg-alert {
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
|
||||
&--error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
/* Exception summary, above the table: the day's flagged vouchers listed in full so nobody has to
|
||||
hunt for a badge. */
|
||||
.db-exceptions {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 16px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-left: 4px solid #dc2626;
|
||||
border-radius: 8px;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Pushed to the right so the count reads first. */
|
||||
.pg-btn {
|
||||
margin-left: auto;
|
||||
height: 30px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 0;
|
||||
font-size: 12px;
|
||||
border-top: 1px dotted #fecaca;
|
||||
}
|
||||
|
||||
&__amt {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
&__diff {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&__why {
|
||||
color: #0d1b2a;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
color: #92400e;
|
||||
|
||||
&:hover {
|
||||
background: #fef3c7;
|
||||
}
|
||||
}
|
||||
|
||||
.db-icon-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Attachment count rides on the paperclip so a voucher with a scanned slip reads as different
|
||||
at a glance, without a separate column. */
|
||||
&__count {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
padding: 0 3px;
|
||||
border-radius: 999px;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
line-height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline panel, opened under the row it belongs to. */
|
||||
.db-panel-row td {
|
||||
background: #f8fafc !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.db-panel {
|
||||
padding: 14px 16px;
|
||||
border-left: 3px solid #2563eb;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__remarks {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
&__warn {
|
||||
margin: 10px 0 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* The two amounts being compared, side by side. */
|
||||
.db-cmp {
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
&__label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
&__value {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&--good {
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
|
||||
.db-cmp__value {
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
&--bad {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
|
||||
.db-cmp__value {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* 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 { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
|
||||
interface DayBookRow {
|
||||
voucherNo: string | null;
|
||||
side: 'CR' | 'DR';
|
||||
kind: string;
|
||||
accountNo: string | null;
|
||||
accountType: string | null;
|
||||
party: string | null;
|
||||
amount: number | null;
|
||||
paymentTypeName: string | null;
|
||||
description: string | null;
|
||||
status: string;
|
||||
handledBy: string | null;
|
||||
approvedBy: string | null;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Day book — every voucher raised on one date in a single list: cash deposits, withdrawals,
|
||||
* disbursements, collections and bank transfers, with the day's credit and debit totals.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-day-book',
|
||||
templateUrl: './day-book.component.html',
|
||||
styleUrls: [
|
||||
'../cashier-shared.scss',
|
||||
'./day-book.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule
|
||||
]
|
||||
})
|
||||
export class DayBookComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
|
||||
date = '';
|
||||
side: '' | 'CR' | 'DR' = '';
|
||||
status = '';
|
||||
search = '';
|
||||
|
||||
rows: DayBookRow[] = [];
|
||||
creditTotal = 0;
|
||||
debitTotal = 0;
|
||||
netTotal = 0;
|
||||
loading = false;
|
||||
loaded = false;
|
||||
errorMsg = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.date = this.today();
|
||||
this.load();
|
||||
}
|
||||
|
||||
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
|
||||
private today(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
load(): void {
|
||||
if (!this.date) return;
|
||||
this.loading = true;
|
||||
this.errorMsg = '';
|
||||
this.service
|
||||
.dayBook(this.date, this.side || undefined, this.status || undefined)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day book.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
this.rows = res?.rows ?? [];
|
||||
this.creditTotal = Number(res?.creditTotal ?? 0);
|
||||
this.debitTotal = Number(res?.debitTotal ?? 0);
|
||||
this.netTotal = Number(res?.netTotal ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
/** Shifts the selected date by whole days, so yesterday is one click rather than a date picker. */
|
||||
shiftDate(days: number): void {
|
||||
const [
|
||||
y,
|
||||
m,
|
||||
d
|
||||
] = this.date.split('-').map(Number);
|
||||
const dt = new Date(y, m - 1, d + days);
|
||||
this.date = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
|
||||
this.load();
|
||||
}
|
||||
|
||||
goToday(): void {
|
||||
this.date = this.today();
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 q = this.search.trim().toLowerCase();
|
||||
if (!q) return base;
|
||||
return base.filter((r) =>
|
||||
[
|
||||
r.voucherNo,
|
||||
r.accountNo,
|
||||
r.party,
|
||||
r.kind,
|
||||
r.paymentTypeName,
|
||||
r.handledBy,
|
||||
r.description
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
/** Totals for what is on screen, so a search or side filter still reconciles. */
|
||||
get shownCredit(): number {
|
||||
return this.filteredRows
|
||||
.filter((r) => r.side === 'CR' && this.countsAsMoney(r))
|
||||
.reduce((s, r) => s + Number(r.amount ?? 0), 0);
|
||||
}
|
||||
|
||||
get shownDebit(): number {
|
||||
return this.filteredRows
|
||||
.filter((r) => r.side === 'DR' && this.countsAsMoney(r))
|
||||
.reduce((s, r) => s + Number(r.amount ?? 0), 0);
|
||||
}
|
||||
|
||||
/** Only approved money is real money — a pending or rejected voucher must not move the totals. */
|
||||
private countsAsMoney(r: DayBookRow): boolean {
|
||||
const s = (r.status ?? '').toUpperCase();
|
||||
return s === 'APPROVED' || s === 'COMPLETED';
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
switch ((status ?? '').toUpperCase()) {
|
||||
case 'APPROVED':
|
||||
case 'COMPLETED':
|
||||
return 'db-badge db-badge--ok';
|
||||
case 'PENDING':
|
||||
return 'db-badge db-badge--pending';
|
||||
case 'REJECTED':
|
||||
return 'db-badge db-badge--rejected';
|
||||
default:
|
||||
return 'db-badge';
|
||||
}
|
||||
}
|
||||
|
||||
/** Time of day only — the date is already the page's subject. */
|
||||
timeOf(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const t = iso.split('T')[1];
|
||||
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 = [
|
||||
'Voucher No',
|
||||
'Side',
|
||||
'Type',
|
||||
'Account',
|
||||
'Party',
|
||||
'Amount',
|
||||
'Payment Type',
|
||||
'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.voucherNo ?? '',
|
||||
r.side,
|
||||
r.kind,
|
||||
r.accountNo ?? '',
|
||||
r.party ?? '',
|
||||
String(r.amount ?? 0),
|
||||
r.paymentTypeName ?? '',
|
||||
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
|
||||
// contain commas, which would otherwise split into extra columns.
|
||||
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||
.join(',')
|
||||
);
|
||||
const csv = [
|
||||
header.join(','),
|
||||
...lines
|
||||
].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `day-book-${this.date}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user