Initial commit
This commit is contained in:
@@ -0,0 +1,610 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.CreateBillDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "New Bill";
|
||||
ViewData["PageIcon"] = "bi-receipt-cutoff";
|
||||
ViewData["PageHelpTitle"] = "New Bill";
|
||||
ViewData["PageHelpContent"] = "Record a vendor invoice to track what you owe. Bills start as Draft (editable) and become Open once confirmed. Partial payments are supported — each payment reduces the balance. Link line items to expense accounts and optionally to specific jobs for cost tracking.";
|
||||
string? fromPoNumber = ViewBag.FromPoNumber as string;
|
||||
int? fromPoId = ViewBag.FromPoId as int?;
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
@if (!string.IsNullOrEmpty(fromPoNumber))
|
||||
{
|
||||
<p class="text-muted mb-0 small"><i class="bi bi-box-arrow-in-down text-success me-1"></i> Pre-filled from <strong>@fromPoNumber</strong> — review and save</p>
|
||||
}
|
||||
</div>
|
||||
@if (fromPoId.HasValue)
|
||||
{
|
||||
<a asp-controller="PurchaseOrders" asp-action="Details" asp-route-id="@fromPoId" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<form asp-action="Create" method="post" enctype="multipart/form-data" id="billForm">
|
||||
@Html.AntiForgeryToken()
|
||||
@if (Model.PurchaseOrderId.HasValue)
|
||||
{
|
||||
<input type="hidden" name="PurchaseOrderId" value="@Model.PurchaseOrderId" />
|
||||
}
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3"></div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Left column: Bill header -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header fw-semibold d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
Bill Details
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Bill Details"
|
||||
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation. Payment Terms auto-fill from the vendor record.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#scanReceiptModal">
|
||||
<i class="bi bi-camera me-1"></i>Upload and Process Receipt Image
|
||||
</button>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||
data-bs-title="Upload and Process Receipt Image"
|
||||
data-bs-content="Upload a photo or PDF of a vendor receipt or invoice. AI will automatically extract the vendor name, date, invoice number, and line items and pre-fill this form for you. The file will also be saved as an attachment on the bill.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-danger">*</span></label>
|
||||
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select" id="vendorSelect"
|
||||
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
|
||||
<option value="">— Select Vendor —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
</select>
|
||||
<span asp-validation-for="VendorId" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="APAccountId" class="form-label fw-medium">AP Account <span class="text-danger">*</span></label>
|
||||
<select asp-for="APAccountId" asp-items="ViewBag.APAccounts" class="form-select"></select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="BillDate" class="form-label fw-medium">Bill Date <span class="text-danger">*</span></label>
|
||||
<input asp-for="BillDate" type="date" class="form-control" />
|
||||
<span asp-validation-for="BillDate" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="DueDate" class="form-label fw-medium">Due Date</label>
|
||||
<input asp-for="DueDate" type="date" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="VendorInvoiceNumber" class="form-label fw-medium">Vendor Invoice #</label>
|
||||
<input asp-for="VendorInvoiceNumber" class="form-control" placeholder="Their invoice number" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Terms" class="form-label fw-medium">Payment Terms</label>
|
||||
<input asp-for="Terms" class="form-control" placeholder="e.g. Net 30" id="termsInput" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label asp-for="Memo" class="form-label fw-medium">Memo</label>
|
||||
<textarea asp-for="Memo" class="form-control" rows="2" placeholder="Optional notes"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="receiptFile" class="form-label fw-medium">Attach Receipt / Document</label>
|
||||
<input type="file" name="receiptFile" id="receiptFile" class="form-control"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line items -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="fw-semibold">Line Items</span>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Line Items"
|
||||
data-bs-content="Each line maps to an expense account (e.g. Supplies, Materials, Subcontractors). Optionally link a line to a Job to track costs against specific work orders. Qty × Unit Price = Amount. Use multiple lines to split one bill across different expense categories.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="addLineItem()">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add Line
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0" id="lineItemsTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="min-width:180px">Account</th>
|
||||
<th>Description</th>
|
||||
<th style="width:80px">Job</th>
|
||||
<th style="width:70px">Qty</th>
|
||||
<th style="width:110px">Unit Price</th>
|
||||
<th style="width:110px" class="text-end">Amount</th>
|
||||
<th style="width:40px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="lineItemsBody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Totals -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow-sm sticky-top" style="top:80px">
|
||||
<div class="card-header fw-semibold d-flex align-items-center gap-2">
|
||||
Summary
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||
data-bs-title="Bill Summary"
|
||||
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-muted">Subtotal</span>
|
||||
<span id="subtotalDisplay">$0.00</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label asp-for="TaxPercent" class="form-label text-muted small">Tax %</label>
|
||||
<input asp-for="TaxPercent" type="number" step="0.01" min="0" max="100"
|
||||
class="form-control form-control-sm" id="taxPercent" value="0" oninput="recalcTotals()" />
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-muted">Tax</span>
|
||||
<span id="taxDisplay">$0.00</span>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="d-flex justify-content-between fw-bold fs-5">
|
||||
<span>Total</span>
|
||||
<span id="totalDisplay">$0.00</span>
|
||||
</div>
|
||||
|
||||
<!-- Pay now toggle -->
|
||||
<hr />
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="payNowToggle" name="payNow" value="true" />
|
||||
<label class="form-check-label fw-medium" for="payNowToggle">I already paid this</label>
|
||||
</div>
|
||||
<div id="payNowFields" class="d-none">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-medium">Payment Date</label>
|
||||
<input type="date" name="paymentDate" class="form-control form-control-sm"
|
||||
value="@DateTime.Today.ToString("yyyy-MM-dd")" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-medium">Payment Method</label>
|
||||
<select name="paymentMethod" class="form-select form-select-sm">
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.PaymentMethods)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-medium">Bank / Cash Account <span class="text-danger">*</span></label>
|
||||
<select name="bankAccountId" class="form-select form-select-sm" id="payNowBankAccount">
|
||||
<option value="">— Select Account —</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.BankAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-medium">Check #</label>
|
||||
<input type="text" name="checkNumber" class="form-control form-control-sm" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-medium">Memo</label>
|
||||
<input type="text" name="paymentMemo" class="form-control form-control-sm" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 mt-2">
|
||||
<button type="submit" class="btn btn-primary" id="saveBillBtn">
|
||||
<i class="bi bi-check-lg me-1"></i>Save Bill
|
||||
</button>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Line item template (hidden) -->
|
||||
<template id="lineItemTemplate">
|
||||
<tr class="line-item-row">
|
||||
<td>
|
||||
<select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required>
|
||||
<option value="">— Account —</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" class="form-control form-control-sm" name="LineItems[INDEX].Description" placeholder="Description" /></td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" name="LineItems[INDEX].JobId">
|
||||
<option value="">—</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="number" step="0.001" min="0.001" value="1" class="form-control form-control-sm qty-input" name="LineItems[INDEX].Quantity" oninput="recalcRow(this)" /></td>
|
||||
<td><input type="number" step="0.01" min="0" value="0" class="form-control form-control-sm price-input" name="LineItems[INDEX].UnitPrice" oninput="recalcRow(this)" /></td>
|
||||
<td class="text-end align-middle"><span class="row-amount fw-medium">$0.00</span></td>
|
||||
<td class="align-middle">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeLineItem(this)" tabindex="-1">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Scan Receipt Modal -->
|
||||
<div class="modal fade" id="scanReceiptModal" tabindex="-1" aria-labelledby="scanReceiptModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="scanReceiptModalLabel"><i class="bi bi-camera me-2"></i>Upload and Process Receipt Image</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small">Upload a photo, image, or PDF of a vendor receipt or invoice. The AI will extract the vendor name, date, invoice number, and line items and pre-fill this form.</p>
|
||||
<div class="mb-3">
|
||||
<label for="scanReceiptFile" class="form-label fw-medium">Receipt / Invoice Document</label>
|
||||
<input type="file" id="scanReceiptFile" class="form-control" accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||
</div>
|
||||
<div id="scanReceiptStatus" class="text-muted small mt-2"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="scanReceiptUploadBtn">
|
||||
<i class="bi bi-camera me-1"></i>Scan & Fill
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
|
||||
<script>
|
||||
let lineCount = 0;
|
||||
|
||||
/**
|
||||
* opts: { accountId, description, qty, price }
|
||||
* Backwards-compatible: addLineItem(42) still works.
|
||||
*/
|
||||
function addLineItem(opts) {
|
||||
if (typeof opts === 'number') opts = { accountId: opts };
|
||||
opts = opts || {};
|
||||
|
||||
const template = document.getElementById('lineItemTemplate');
|
||||
const clone = template.content.cloneNode(true);
|
||||
const row = clone.querySelector('tr');
|
||||
|
||||
row.innerHTML = row.innerHTML.replaceAll('INDEX', lineCount);
|
||||
|
||||
if (opts.accountId) {
|
||||
const sel = row.querySelector('.account-select');
|
||||
if (sel) sel.value = String(opts.accountId);
|
||||
}
|
||||
if (opts.description != null) {
|
||||
const desc = row.querySelector('[name$=".Description"]');
|
||||
if (desc) desc.value = opts.description;
|
||||
}
|
||||
if (opts.qty != null) {
|
||||
const qtyIn = row.querySelector('.qty-input');
|
||||
if (qtyIn) qtyIn.value = opts.qty;
|
||||
}
|
||||
if (opts.price != null) {
|
||||
const priceIn = row.querySelector('.price-input');
|
||||
if (priceIn) priceIn.value = opts.price;
|
||||
}
|
||||
|
||||
document.getElementById('lineItemsBody').appendChild(clone);
|
||||
lineCount++;
|
||||
|
||||
// Recalc the row amount after appending
|
||||
const appended = document.getElementById('lineItemsBody').lastElementChild;
|
||||
if (appended) recalcRowEl(appended);
|
||||
}
|
||||
|
||||
function removeLineItem(btn) {
|
||||
btn.closest('tr').remove();
|
||||
recalcTotals();
|
||||
}
|
||||
|
||||
function recalcRow(input) {
|
||||
recalcRowEl(input.closest('tr'));
|
||||
}
|
||||
|
||||
function recalcRowEl(row) {
|
||||
const qty = parseFloat(row.querySelector('.qty-input').value) || 0;
|
||||
const price = parseFloat(row.querySelector('.price-input').value) || 0;
|
||||
row.querySelector('.row-amount').textContent = '$' + (qty * price).toFixed(2);
|
||||
recalcTotals();
|
||||
}
|
||||
|
||||
function recalcTotals() {
|
||||
let subtotal = 0;
|
||||
document.querySelectorAll('.row-amount').forEach(el => {
|
||||
subtotal += parseFloat(el.textContent.replace('$', '')) || 0;
|
||||
});
|
||||
const taxPct = parseFloat(document.getElementById('taxPercent').value) || 0;
|
||||
const tax = subtotal * (taxPct / 100);
|
||||
|
||||
document.getElementById('subtotalDisplay').textContent = '$' + subtotal.toFixed(2);
|
||||
document.getElementById('taxDisplay').textContent = '$' + tax.toFixed(2);
|
||||
document.getElementById('totalDisplay').textContent = '$' + (subtotal + tax).toFixed(2);
|
||||
}
|
||||
|
||||
// Pay now toggle
|
||||
document.getElementById('payNowToggle').addEventListener('change', function () {
|
||||
const fields = document.getElementById('payNowFields');
|
||||
const btn = document.getElementById('saveBillBtn');
|
||||
if (this.checked) {
|
||||
fields.classList.remove('d-none');
|
||||
btn.innerHTML = '<i class="bi bi-cash me-1"></i>Save & Mark Paid';
|
||||
btn.classList.replace('btn-primary', 'btn-success');
|
||||
} else {
|
||||
fields.classList.add('d-none');
|
||||
btn.innerHTML = '<i class="bi bi-check-lg me-1"></i>Save Bill';
|
||||
btn.classList.replace('btn-success', 'btn-primary');
|
||||
}
|
||||
});
|
||||
|
||||
// Load vendor defaults
|
||||
document.getElementById('vendorSelect').addEventListener('change', async function () {
|
||||
const vendorId = this.value;
|
||||
if (!vendorId) return;
|
||||
try {
|
||||
const resp = await fetch(`/Bills/GetVendorDefaults?vendorId=${vendorId}`);
|
||||
const data = await resp.json();
|
||||
if (data.terms) document.getElementById('termsInput').value = data.terms;
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Init with line items from model (pre-filled from PO or empty)
|
||||
@foreach (var li in Model.LineItems)
|
||||
{
|
||||
<text>addLineItem({ accountId: @(li.AccountId.HasValue ? li.AccountId.ToString() : "null"), description: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(li.Description ?? "")), qty: @li.Quantity, price: @li.UnitPrice });</text>
|
||||
}
|
||||
if (lineCount === 0) addLineItem();
|
||||
|
||||
// ── AI Auto-suggest Account on description blur ───────────────────────
|
||||
// Keyword shortcuts — handle common cases with zero API cost
|
||||
const _keywordMap = [
|
||||
{ words: ['electric','power','utility','gas','water','internet','phone','telecom'], hint: 'utilities' },
|
||||
{ words: ['powder','paint','coat','material','supply','supplies','chemical','resin'], hint: 'materials' },
|
||||
{ words: ['freight','shipping','delivery','postage','courier','ups','fedex','usps'], hint: 'freight' },
|
||||
{ words: ['tool','equipment','machine','compressor','gun','booth','oven'], hint: 'equipment' },
|
||||
{ words: ['office','paper','printer','ink','toner','staple'], hint: 'office' },
|
||||
{ words: ['labor','subcontract','contract','wage','service fee'], hint: 'subcontractors' },
|
||||
{ words: ['insurance','premium','policy'], hint: 'insurance' },
|
||||
{ words: ['rent','lease','mortgage'], hint: 'rent' },
|
||||
{ words: ['advertising','marketing','promo'], hint: 'advertising' },
|
||||
];
|
||||
|
||||
// Session cache: description (lowercased) → { accountId, accountName }
|
||||
const _suggestCache = new Map();
|
||||
|
||||
function _keywordGuess(description) {
|
||||
const lower = description.toLowerCase();
|
||||
for (const entry of _keywordMap) {
|
||||
if (entry.words.some(w => lower.includes(w))) return entry.hint;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _findAccountByHint(hint, accountSel) {
|
||||
for (const opt of accountSel.options) {
|
||||
if (!opt.value) continue;
|
||||
if (opt.text.toLowerCase().includes(hint)) return { id: opt.value, name: opt.text };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _applyAccountSuggestion(row, accountId, accountName) {
|
||||
const sel = row.querySelector('.account-select');
|
||||
if (!sel || sel.value) return; // already chosen by user
|
||||
sel.value = String(accountId);
|
||||
// Show a subtle inline hint
|
||||
let hint = row.querySelector('.ai-account-hint');
|
||||
if (!hint) {
|
||||
hint = document.createElement('div');
|
||||
hint.className = 'ai-account-hint text-muted small mt-1';
|
||||
sel.parentNode.appendChild(hint);
|
||||
}
|
||||
hint.innerHTML = `<i class="bi bi-stars text-info"></i> AI: ${accountName}`;
|
||||
}
|
||||
|
||||
async function _suggestAccountForRow(row) {
|
||||
const descInput = row.querySelector('[name$=".Description"]');
|
||||
const accountSel = row.querySelector('.account-select');
|
||||
if (!descInput || !accountSel) return;
|
||||
|
||||
const description = descInput.value.trim();
|
||||
if (description.length < 8) return; // too short to be meaningful
|
||||
if (accountSel.value) return; // user already picked one
|
||||
|
||||
// 1. Check in-session cache
|
||||
const cacheKey = description.toLowerCase();
|
||||
if (_suggestCache.has(cacheKey)) {
|
||||
const cached = _suggestCache.get(cacheKey);
|
||||
_applyAccountSuggestion(row, cached.accountId, cached.accountName);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Try keyword shortcut (free)
|
||||
const hint = _keywordGuess(description);
|
||||
if (hint) {
|
||||
const match = _findAccountByHint(hint, accountSel);
|
||||
if (match) {
|
||||
_suggestCache.set(cacheKey, { accountId: match.id, accountName: match.name });
|
||||
_applyAccountSuggestion(row, match.id, match.name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fall back to AI
|
||||
const vendorSel = document.getElementById('vendorSelect');
|
||||
const vendorText = vendorSel.options[vendorSel.selectedIndex]?.text ?? '';
|
||||
const amount = parseFloat(row.querySelector('.price-input')?.value) || 0;
|
||||
|
||||
// Show a subtle loading indicator on the account select
|
||||
let hint2 = row.querySelector('.ai-account-hint');
|
||||
if (!hint2) {
|
||||
hint2 = document.createElement('div');
|
||||
hint2.className = 'ai-account-hint text-muted small mt-1';
|
||||
accountSel.parentNode.appendChild(hint2);
|
||||
}
|
||||
hint2.innerHTML = '<span class="spinner-border spinner-border-sm" style="width:.75rem;height:.75rem"></span> Thinking…';
|
||||
|
||||
try {
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||
const resp = await fetch('/Bills/SuggestAccount', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': token ?? '' },
|
||||
body: JSON.stringify({ vendorName: vendorText, description, amount, availableAccounts: [] })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success && data.suggestedAccountId) {
|
||||
_suggestCache.set(cacheKey, { accountId: data.suggestedAccountId, accountName: data.suggestedAccountName });
|
||||
_applyAccountSuggestion(row, data.suggestedAccountId, data.suggestedAccountName);
|
||||
} else {
|
||||
hint2.remove();
|
||||
}
|
||||
} catch {
|
||||
hint2?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Event delegation — works for dynamically added rows
|
||||
document.getElementById('lineItemsBody').addEventListener('blur', function (e) {
|
||||
if (e.target.matches('[name$=".Description"]')) {
|
||||
_suggestAccountForRow(e.target.closest('tr'));
|
||||
}
|
||||
}, true); // capture phase so blur bubbles
|
||||
|
||||
// ── Scan Receipt ─────────────────────────────────────────────────────
|
||||
document.getElementById('scanReceiptUploadBtn').addEventListener('click', async function () {
|
||||
const fileInput = document.getElementById('scanReceiptFile');
|
||||
if (!fileInput.files.length) { alert('Please select a file.'); return; }
|
||||
|
||||
const btn = this;
|
||||
const statusEl = document.getElementById('scanReceiptStatus');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Scanning...';
|
||||
statusEl.textContent = '';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('receiptImage', fileInput.files[0]);
|
||||
|
||||
// Include antiforgery token
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||
if (token) formData.append('__RequestVerificationToken', token);
|
||||
|
||||
try {
|
||||
const resp = await fetch('/Bills/ScanReceipt', { method: 'POST', body: formData });
|
||||
const data = await resp.json();
|
||||
|
||||
if (!data.success) {
|
||||
statusEl.textContent = data.errorMessage || 'Scan failed.';
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-fill bill header — try to match vendor name to dropdown
|
||||
if (data.vendorName) {
|
||||
const vendorSel = document.getElementById('vendorSelect');
|
||||
if (vendorSel && !vendorSel.value) {
|
||||
const needle = data.vendorName.toLowerCase().trim();
|
||||
let bestOption = null;
|
||||
for (const opt of vendorSel.options) {
|
||||
if (!opt.value) continue;
|
||||
const hay = opt.text.toLowerCase().trim();
|
||||
// Exact match first, then starts-with, then contains
|
||||
if (hay === needle) { bestOption = opt; break; }
|
||||
if (!bestOption && (hay.startsWith(needle) || needle.startsWith(hay))) bestOption = opt;
|
||||
if (!bestOption && hay.includes(needle)) bestOption = opt;
|
||||
}
|
||||
if (bestOption) {
|
||||
vendorSel.value = bestOption.value;
|
||||
vendorSel.dispatchEvent(new Event('change'));
|
||||
} else {
|
||||
// No match — put the name in Memo so user knows what the AI saw
|
||||
const memo = document.querySelector('[name="Memo"]');
|
||||
if (memo && !memo.value) memo.value = data.vendorName;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.date) {
|
||||
const billDateIn = document.querySelector('[name="BillDate"]');
|
||||
if (billDateIn) billDateIn.value = data.date;
|
||||
}
|
||||
if (data.invoiceNumber) {
|
||||
const invNumIn = document.querySelector('[name="VendorInvoiceNumber"]');
|
||||
if (invNumIn && !invNumIn.value) invNumIn.value = data.invoiceNumber;
|
||||
}
|
||||
|
||||
// Clear existing line items and add scanned ones
|
||||
if (data.lineItems && data.lineItems.length > 0) {
|
||||
document.getElementById('lineItemsBody').innerHTML = '';
|
||||
lineCount = 0;
|
||||
for (const li of data.lineItems) {
|
||||
addLineItem({
|
||||
accountId: li.suggestedAccountId || 0,
|
||||
description: li.description || '',
|
||||
qty: 1,
|
||||
price: li.amount || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Copy scanned file into the receipt attachment input so it gets saved with the bill
|
||||
const scannedFile = fileInput.files[0];
|
||||
const receiptFileInput = document.getElementById('receiptFile');
|
||||
if (scannedFile && receiptFileInput) {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(scannedFile);
|
||||
receiptFileInput.files = dt.files;
|
||||
// Show the filename so the user knows it will be attached
|
||||
const hint = receiptFileInput.nextElementSibling;
|
||||
if (hint) hint.textContent = `\u2713 Will attach: ${scannedFile.name}`;
|
||||
}
|
||||
|
||||
// Close modal
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('scanReceiptModal'));
|
||||
if (modal) modal.hide();
|
||||
|
||||
statusEl.textContent = 'Scan complete — review and adjust as needed.';
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Error connecting to AI service.';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-camera me-1"></i>Scan & Fill';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.BillDto
|
||||
@using PowderCoating.Core.Enums
|
||||
|
||||
@{
|
||||
ViewData["Title"] = $"Bill {Model.BillNumber}";
|
||||
ViewData["PageIcon"] = "bi-receipt-cutoff";
|
||||
ViewData["PageHelpTitle"] = "Bill Status";
|
||||
ViewData["PageHelpContent"] = "Draft: editable, not yet confirmed. Open: awaiting payment. Partially Paid: some payments recorded. Paid: fully settled. Voided: cancelled — preserves history. Edit is only available in Draft status. Use Void instead of deleting to keep a complete audit trail.";
|
||||
|
||||
string StatusBadge(BillStatus s) => s switch
|
||||
{
|
||||
BillStatus.Draft => "secondary",
|
||||
BillStatus.Open => "primary",
|
||||
BillStatus.PartiallyPaid => "warning",
|
||||
BillStatus.Paid => "success",
|
||||
BillStatus.Voided => "danger",
|
||||
_ => "secondary"
|
||||
};
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
|
||||
<span class="badge bg-@StatusBadge(Model.Status) fs-6">@Model.Status</span>
|
||||
<span class="text-muted small">@Model.VendorName</span>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
@if (Model.Status == BillStatus.Draft || Model.Status == BillStatus.Open || Model.Status == BillStatus.PartiallyPaid)
|
||||
{
|
||||
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
}
|
||||
@if (Model.Status == BillStatus.Open || Model.Status == BillStatus.PartiallyPaid)
|
||||
{
|
||||
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#paymentModal">
|
||||
<i class="bi bi-cash me-1"></i>Record Payment
|
||||
</button>
|
||||
}
|
||||
@if (Model.Status != BillStatus.Voided && Model.Status != BillStatus.Paid)
|
||||
{
|
||||
<form asp-action="Void" asp-route-id="@Model.Id" method="post"
|
||||
onsubmit="return confirm('Void bill @Model.BillNumber? This cannot be undone.')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-danger">
|
||||
<i class="bi bi-x-circle me-1"></i>Void
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-dismissible fade show">
|
||||
<i class="bi bi-check-circle me-2"></i>@TempData["Success"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Error"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Left column -->
|
||||
<div class="col-lg-8">
|
||||
|
||||
<!-- Bill info -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6">
|
||||
<p class="text-muted small mb-1">Vendor</p>
|
||||
<p class="fw-medium mb-0">@Model.VendorName</p>
|
||||
@if (!string.IsNullOrEmpty(Model.VendorEmail))
|
||||
{ <p class="text-muted small mb-0">@Model.VendorEmail</p> }
|
||||
@if (!string.IsNullOrEmpty(Model.VendorPhone))
|
||||
{ <p class="text-muted small mb-0">@Model.VendorPhone</p> }
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<p class="text-muted small mb-1">Bill Date</p>
|
||||
<p class="mb-0">@Model.BillDate.ToString("MMM d, yyyy")</p>
|
||||
</div>
|
||||
@if (Model.DueDate.HasValue)
|
||||
{
|
||||
<div class="col-6">
|
||||
<p class="text-muted small mb-1">Due Date</p>
|
||||
<p class="mb-0 @(Model.Status != BillStatus.Paid && Model.DueDate < DateTime.Today ? "text-danger fw-medium" : "")">
|
||||
@Model.DueDate.Value.ToString("MMM d, yyyy")
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.VendorInvoiceNumber))
|
||||
{
|
||||
<div class="col-6">
|
||||
<p class="text-muted small mb-1">Vendor Ref #</p>
|
||||
<p class="mb-0">@Model.VendorInvoiceNumber</p>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.Terms))
|
||||
{
|
||||
<div class="col-6">
|
||||
<p class="text-muted small mb-1">Terms</p>
|
||||
<p class="mb-0">@Model.Terms</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(Model.Memo))
|
||||
{
|
||||
<div class="col-12">
|
||||
<p class="text-muted small mb-1">Memo</p>
|
||||
<p class="mb-0">@Model.Memo</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line items -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header fw-semibold">Line Items</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Description</th>
|
||||
<th>Job</th>
|
||||
<th class="text-center">Qty</th>
|
||||
<th class="text-end">Unit Price</th>
|
||||
<th class="text-end">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var li in Model.LineItems.OrderBy(l => l.DisplayOrder))
|
||||
{
|
||||
<tr>
|
||||
<td><span class="text-muted small">@li.AccountNumber</span> @li.AccountName</td>
|
||||
<td>@li.Description</td>
|
||||
<td>@li.JobNumber</td>
|
||||
<td class="text-center">@li.Quantity.ToString("G")</td>
|
||||
<td class="text-end">@li.UnitPrice.ToString("C")</td>
|
||||
<td class="text-end fw-medium">@li.Amount.ToString("C")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot class="table-light">
|
||||
<tr>
|
||||
<td colspan="5" class="text-end text-muted">Subtotal</td>
|
||||
<td class="text-end">@Model.SubTotal.ToString("C")</td>
|
||||
</tr>
|
||||
@if (Model.TaxPercent > 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="5" class="text-end text-muted">Tax (@Model.TaxPercent.ToString("G")%)</td>
|
||||
<td class="text-end">@Model.TaxAmount.ToString("C")</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="fw-bold">
|
||||
<td colspan="5" class="text-end">Total</td>
|
||||
<td class="text-end fs-5">@Model.Total.ToString("C")</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receipt attachment -->
|
||||
@if (!string.IsNullOrEmpty(Model.ReceiptFilePath))
|
||||
{
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header fw-semibold"><i class="bi bi-paperclip me-2"></i>Receipt / Document</div>
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<a asp-action="DownloadReceipt" asp-route-id="@Model.Id"
|
||||
class="btn btn-outline-secondary" target="_blank">
|
||||
<i class="bi bi-download me-1"></i>Download Attachment
|
||||
</a>
|
||||
@if (Model.Status == PowderCoating.Core.Enums.BillStatus.Draft)
|
||||
{
|
||||
<form asp-action="RemoveReceipt" asp-route-id="@Model.Id" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm"
|
||||
onclick="return confirm('Remove this receipt attachment?')">
|
||||
<i class="bi bi-trash me-1"></i>Remove
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Payment history -->
|
||||
@if (Model.Payments.Any())
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold d-flex align-items-center gap-2">
|
||||
Payment History
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Payment History"
|
||||
data-bs-content="All payments applied to this bill. Each records the date, method, bank account, and optional check number for reconciliation. Delete a payment to reverse it and restore the balance due. Check # is useful for matching against your bank statement.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Payment #</th>
|
||||
<th>Date</th>
|
||||
<th>Method</th>
|
||||
<th>Check #</th>
|
||||
<th>Bank Account</th>
|
||||
<th>Memo</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var pmt in Model.Payments.OrderByDescending(p => p.PaymentDate))
|
||||
{
|
||||
<tr>
|
||||
<td><span class="text-muted small">@pmt.PaymentNumber</span></td>
|
||||
<td>@pmt.PaymentDate.ToString("MMM d, yyyy")</td>
|
||||
<td>@pmt.PaymentMethod</td>
|
||||
<td>@pmt.CheckNumber</td>
|
||||
<td>@pmt.BankAccountName</td>
|
||||
<td><span class="text-muted small">@pmt.Memo</span></td>
|
||||
<td class="text-end text-success fw-medium">@pmt.Amount.ToString("C")</td>
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary me-1" title="Edit payment"
|
||||
onclick="openEditBillPaymentModal(@pmt.Id, @Model.Id, '@pmt.PaymentDate.ToString("yyyy-MM-dd")', @((int)pmt.PaymentMethod), @pmt.BankAccountId, '@(pmt.CheckNumber ?? "")', '@(pmt.Memo ?? "")')">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<form asp-action="DeletePayment" method="post" class="d-inline"
|
||||
onsubmit="return confirm('Delete this payment? The bill balance will be restored.')">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="paymentId" value="@pmt.Id" />
|
||||
<input type="hidden" name="billId" value="@Model.Id" />
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete payment">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Right column: Balance summary -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow-sm border-@(Model.Status == BillStatus.Paid ? "success" : Model.BalanceDue > 0 ? "danger" : "secondary")">
|
||||
<div class="card-header fw-semibold d-flex align-items-center gap-2">
|
||||
Balance Summary
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||
data-bs-title="Balance Summary"
|
||||
data-bs-content="Balance Due = Bill Total minus all payments recorded. Multiple partial payments are supported — each reduces the balance until fully paid. Deleting a payment reverses it and restores the balance due.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-muted">Bill Total</span>
|
||||
<span class="fw-medium">@Model.Total.ToString("C")</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-muted">Amount Paid</span>
|
||||
<span class="text-success fw-medium">@Model.AmountPaid.ToString("C")</span>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="d-flex justify-content-between fs-5 fw-bold">
|
||||
<span>Balance Due</span>
|
||||
<span class="@(Model.BalanceDue > 0 ? "text-danger" : "text-success")">
|
||||
@Model.BalanceDue.ToString("C")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (Model.Status == BillStatus.Open || Model.Status == BillStatus.PartiallyPaid)
|
||||
{
|
||||
<div class="d-grid mt-3">
|
||||
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#paymentModal">
|
||||
<i class="bi bi-cash me-1"></i>Record Payment
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-header fw-semibold d-flex align-items-center gap-2">
|
||||
AP Account
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||
data-bs-title="AP Account"
|
||||
data-bs-content="The Accounts Payable liability account this bill is posted to. In double-entry bookkeeping, creating a bill debits the expense accounts on the line items and credits this AP account, recording the liability owed to the vendor.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-0 text-muted small">@Model.APAccountName</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Record Payment Modal -->
|
||||
@if (Model.Status == BillStatus.Open || Model.Status == BillStatus.PartiallyPaid)
|
||||
{
|
||||
<div class="modal fade" id="paymentModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Record Payment — @Model.BillNumber</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form asp-action="RecordPayment" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="BillId" value="@Model.Id" />
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label fw-medium">Payment Date <span class="text-danger">*</span></label>
|
||||
<input type="date" name="PaymentDate" value="@DateTime.Today.ToString("yyyy-MM-dd")" class="form-control" required />
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label fw-medium">Amount <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" step="0.01" min="0.01" max="@Model.BalanceDue"
|
||||
name="Amount" value="@Model.BalanceDue" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-text">Balance due: @Model.BalanceDue.ToString("C")</div>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label fw-medium">Payment Method <span class="text-danger">*</span></label>
|
||||
<select name="PaymentMethod" class="form-select" required>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.PaymentMethods)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<label class="form-label fw-medium mb-0">Bank Account <span class="text-danger">*</span></label>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||
data-bs-title="Bank Account"
|
||||
data-bs-content="The bank or cash account this payment is drawn from. Used for bank reconciliation — helps match this payment to the corresponding debit on your bank statement.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select name="BankAccountId" class="form-select" required>
|
||||
<option value="">— Select Account —</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.BankAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label fw-medium">Check #</label>
|
||||
<input type="text" name="CheckNumber" class="form-control" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Memo</label>
|
||||
<input type="text" name="Memo" class="form-control" placeholder="Optional notes" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-cash me-1"></i>Record Payment
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Edit Payment Modal -->
|
||||
<div class="modal fade" id="editBillPaymentModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-pencil me-2"></i>Edit Payment</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form asp-action="EditPayment" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="PaymentId" id="editBillPaymentId" />
|
||||
<input type="hidden" name="BillId" value="@Model.Id" />
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label fw-medium">Payment Date <span class="text-danger">*</span></label>
|
||||
<input type="date" name="PaymentDate" id="editBillPaymentDate" class="form-control" required />
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label fw-medium">Method <span class="text-danger">*</span></label>
|
||||
<select name="PaymentMethod" id="editBillPaymentMethod" class="form-select" required>
|
||||
<option value="0">Cash</option>
|
||||
<option value="1">Check</option>
|
||||
<option value="2">Credit/Debit Card</option>
|
||||
<option value="3">Bank Transfer / ACH</option>
|
||||
<option value="4">Digital Payment</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Bank Account <span class="text-danger">*</span></label>
|
||||
<select name="BankAccountId" id="editBillBankAccountId" class="form-select" required>
|
||||
<option value="">— Select Account —</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.BankAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label fw-medium">Check #</label>
|
||||
<input type="text" name="CheckNumber" id="editBillCheckNumber" class="form-control" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Memo</label>
|
||||
<input type="text" name="Memo" id="editBillMemo" class="form-control" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle me-1"></i>Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
function openEditBillPaymentModal(paymentId, billId, paymentDate, paymentMethod, bankAccountId, checkNumber, memo) {
|
||||
document.getElementById('editBillPaymentId').value = paymentId;
|
||||
document.getElementById('editBillPaymentDate').value = paymentDate;
|
||||
document.getElementById('editBillPaymentMethod').value = paymentMethod;
|
||||
document.getElementById('editBillBankAccountId').value = String(bankAccountId);
|
||||
document.getElementById('editBillCheckNumber').value = checkNumber;
|
||||
document.getElementById('editBillMemo').value = memo;
|
||||
new bootstrap.Modal(document.getElementById('editBillPaymentModal')).show();
|
||||
}
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.EditBillDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Bill";
|
||||
ViewData["PageIcon"] = "bi-pencil-square";
|
||||
ViewData["PageHelpTitle"] = "Edit Bill";
|
||||
ViewData["PageHelpContent"] = "Bills can only be edited while in Draft status. Once marked Open, they are locked — Void the bill and recreate it if corrections are needed after confirmation.";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-start mb-4">
|
||||
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
|
||||
</div>
|
||||
|
||||
<form asp-action="Edit" asp-route-id="@Model.Id" method="post" enctype="multipart/form-data" id="billForm">
|
||||
@Html.AntiForgeryToken()
|
||||
<input asp-for="Id" type="hidden" />
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3"></div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header fw-semibold d-flex align-items-center gap-2">
|
||||
Bill Details
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Bill Details"
|
||||
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-danger">*</span></label>
|
||||
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select"
|
||||
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
|
||||
<option value="">— Select Vendor —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="APAccountId" class="form-label fw-medium">AP Account</label>
|
||||
<select asp-for="APAccountId" asp-items="ViewBag.APAccounts" class="form-select"></select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="BillDate" class="form-label fw-medium">Bill Date <span class="text-danger">*</span></label>
|
||||
<input asp-for="BillDate" type="date" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="DueDate" class="form-label fw-medium">Due Date</label>
|
||||
<input asp-for="DueDate" type="date" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="VendorInvoiceNumber" class="form-label fw-medium">Vendor Invoice #</label>
|
||||
<input asp-for="VendorInvoiceNumber" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Terms" class="form-label fw-medium">Payment Terms</label>
|
||||
<input asp-for="Terms" class="form-control" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label asp-for="Memo" class="form-label fw-medium">Memo</label>
|
||||
<textarea asp-for="Memo" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
@if (!string.IsNullOrEmpty(Model.ReceiptFilePath))
|
||||
{
|
||||
<label class="form-label fw-medium">Current Receipt</label>
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<a asp-action="DownloadReceipt" asp-route-id="@Model.Id"
|
||||
class="btn btn-sm btn-outline-secondary" target="_blank">
|
||||
<i class="bi bi-paperclip me-1"></i>View Attachment
|
||||
</a>
|
||||
<form asp-action="RemoveReceipt" asp-route-id="@Model.Id" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Remove this receipt attachment?')">
|
||||
<i class="bi bi-trash me-1"></i>Remove
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<label for="receiptFile" class="form-label text-muted small">Replace with a new file:</label>
|
||||
}
|
||||
else
|
||||
{
|
||||
<label for="receiptFile" class="form-label fw-medium">Attach Receipt / Document</label>
|
||||
}
|
||||
<input type="file" name="receiptFile" id="receiptFile" class="form-control"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="fw-semibold">Line Items</span>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Line Items"
|
||||
data-bs-content="Each line maps to an expense account (e.g. Supplies, Materials, Subcontractors). Optionally link a line to a Job to track costs against specific work orders. Qty × Unit Price = Amount. Use multiple lines to split one bill across different expense categories.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="addLineItem()">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add Line
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0" id="lineItemsTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="min-width:180px">Account</th>
|
||||
<th>Description</th>
|
||||
<th style="width:80px">Job</th>
|
||||
<th style="width:70px">Qty</th>
|
||||
<th style="width:110px">Unit Price</th>
|
||||
<th style="width:110px" class="text-end">Amount</th>
|
||||
<th style="width:40px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="lineItemsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow-sm sticky-top" style="top:80px">
|
||||
<div class="card-header fw-semibold d-flex align-items-center gap-2">
|
||||
Summary
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||
data-bs-title="Bill Summary"
|
||||
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-muted">Subtotal</span>
|
||||
<span id="subtotalDisplay">$0.00</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label asp-for="TaxPercent" class="form-label text-muted small">Tax %</label>
|
||||
<input asp-for="TaxPercent" type="number" step="0.01" min="0" max="100"
|
||||
class="form-control form-control-sm" id="taxPercent" oninput="recalcTotals()" />
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-muted">Tax</span>
|
||||
<span id="taxDisplay">$0.00</span>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="d-flex justify-content-between fw-bold fs-5">
|
||||
<span>Total</span>
|
||||
<span id="totalDisplay">$0.00</span>
|
||||
</div>
|
||||
<div class="d-grid gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Save Changes</button>
|
||||
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template id="lineItemTemplate">
|
||||
<tr class="line-item-row">
|
||||
<td>
|
||||
<select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required>
|
||||
<option value="">— Account —</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" class="form-control form-control-sm" name="LineItems[INDEX].Description" placeholder="Description" /></td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" name="LineItems[INDEX].JobId">
|
||||
<option value="">—</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="number" step="0.001" min="0.001" value="1" class="form-control form-control-sm qty-input" name="LineItems[INDEX].Quantity" oninput="recalcRow(this)" /></td>
|
||||
<td><input type="number" step="0.01" min="0" value="0" class="form-control form-control-sm price-input" name="LineItems[INDEX].UnitPrice" oninput="recalcRow(this)" /></td>
|
||||
<td class="text-end align-middle"><span class="row-amount fw-medium">$0.00</span></td>
|
||||
<td class="align-middle">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeLineItem(this)" tabindex="-1"><i class="bi bi-x"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
@section Scripts {
|
||||
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
|
||||
<script>
|
||||
let lineCount = 0;
|
||||
|
||||
function addLineItem(accountId, description, qty, price) {
|
||||
const template = document.getElementById('lineItemTemplate');
|
||||
const clone = template.content.cloneNode(true);
|
||||
const row = clone.querySelector('tr');
|
||||
row.innerHTML = row.innerHTML.replaceAll('INDEX', lineCount);
|
||||
if (accountId) row.querySelector('.account-select').value = accountId;
|
||||
if (description) row.querySelector('[name*="Description"]').value = description;
|
||||
if (qty) row.querySelector('.qty-input').value = qty;
|
||||
if (price) { row.querySelector('.price-input').value = price; }
|
||||
document.getElementById('lineItemsBody').appendChild(clone);
|
||||
lineCount++;
|
||||
recalcTotals();
|
||||
}
|
||||
|
||||
function removeLineItem(btn) { btn.closest('tr').remove(); recalcTotals(); }
|
||||
|
||||
function recalcRow(input) {
|
||||
const row = input.closest('tr');
|
||||
const qty = parseFloat(row.querySelector('.qty-input').value) || 0;
|
||||
const price = parseFloat(row.querySelector('.price-input').value) || 0;
|
||||
row.querySelector('.row-amount').textContent = '$' + (qty * price).toFixed(2);
|
||||
recalcTotals();
|
||||
}
|
||||
|
||||
function recalcTotals() {
|
||||
let subtotal = 0;
|
||||
document.querySelectorAll('.row-amount').forEach(el => {
|
||||
subtotal += parseFloat(el.textContent.replace('$', '')) || 0;
|
||||
});
|
||||
const taxPct = parseFloat(document.getElementById('taxPercent').value) || 0;
|
||||
const tax = subtotal * (taxPct / 100);
|
||||
document.getElementById('subtotalDisplay').textContent = '$' + subtotal.toFixed(2);
|
||||
document.getElementById('taxDisplay').textContent = '$' + tax.toFixed(2);
|
||||
document.getElementById('totalDisplay').textContent = '$' + (subtotal + tax).toFixed(2);
|
||||
}
|
||||
|
||||
// Pre-populate existing line items
|
||||
@foreach (var li in Model.LineItems)
|
||||
{
|
||||
<text>addLineItem(@(li.AccountId.HasValue ? li.AccountId.ToString() : "null"), @Json.Serialize(li.Description), @li.Quantity, @li.UnitPrice);</text>
|
||||
}
|
||||
if (lineCount === 0) addLineItem();
|
||||
recalcTotals();
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
@model List<PowderCoating.Application.DTOs.Accounting.BillExpenseListDto>
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Bills / Expenses";
|
||||
ViewData["PageIcon"] = "bi-receipt-cutoff";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-end mb-4">
|
||||
<div class="btn-group">
|
||||
<a asp-controller="Bills" asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-lg me-1"></i>New Bill
|
||||
</a>
|
||||
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown">
|
||||
<span class="visually-hidden">Toggle dropdown</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a asp-controller="Bills" asp-action="Create" class="dropdown-item">
|
||||
<i class="bi bi-file-text me-2"></i>New Bill <span class="text-muted small">(pay later)</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a asp-controller="Expenses" asp-action="Create" class="dropdown-item">
|
||||
<i class="bi bi-receipt me-2"></i>New Expense <span class="text-muted small">(already paid)</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-dismissible fade show">
|
||||
<i class="bi bi-check-circle me-2"></i>@TempData["Success"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Error"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if ((decimal)ViewBag.TotalOwed > 0)
|
||||
{
|
||||
<div class="alert alert-warning d-flex align-items-center gap-2 mb-4">
|
||||
<i class="bi bi-exclamation-circle fs-5"></i>
|
||||
<span>Outstanding bills: <strong>@(((decimal)ViewBag.TotalOwed).ToString("C"))</strong></span>
|
||||
<a asp-action="Index" asp-route-status="Unpaid" class="btn btn-sm btn-warning ms-auto">
|
||||
<i class="bi bi-funnel me-1"></i>Show unpaid
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<input type="search" name="search" value="@ViewBag.Search" class="form-control"
|
||||
placeholder="Search by #, vendor, memo, amount…" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="type" class="form-select">
|
||||
<option value="">Bills & Expenses</option>
|
||||
<option value="Bill" selected="@(ViewBag.TypeFilter == "Bill")">Bills only</option>
|
||||
<option value="Expense" selected="@(ViewBag.TypeFilter == "Expense")">Expenses only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="status" class="form-select">
|
||||
<option value="">All statuses</option>
|
||||
<option value="Unpaid" selected="@(ViewBag.StatusFilter == "Unpaid")">Unpaid</option>
|
||||
<option value="Overdue" selected="@(ViewBag.StatusFilter == "Overdue")">Overdue</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
<i class="bi bi-search me-1"></i>Filter
|
||||
</button>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary ms-1">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.Any())
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:90px">Type</th>
|
||||
<th>Number</th>
|
||||
<th>Vendor</th>
|
||||
<th>Memo / Account</th>
|
||||
<th>Date</th>
|
||||
<th>Due Date</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th class="text-end">Balance Due</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var entry in Model)
|
||||
{
|
||||
<tr class="@(entry.IsOverdue ? "table-warning" : "")">
|
||||
<td>
|
||||
@if (entry.EntryType == "Bill")
|
||||
{
|
||||
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">
|
||||
<i class="bi bi-file-text me-1"></i>Bill
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">
|
||||
<i class="bi bi-receipt me-1"></i>Expense
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (entry.EntryType == "Bill")
|
||||
{
|
||||
<a asp-controller="Bills" asp-action="Details" asp-route-id="@entry.Id"
|
||||
class="fw-medium text-decoration-none">@entry.Number</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-controller="Expenses" asp-action="Details" asp-route-id="@entry.Id"
|
||||
class="fw-medium text-decoration-none">@entry.Number</a>
|
||||
}
|
||||
</td>
|
||||
<td>@entry.VendorName</td>
|
||||
<td class="text-muted small">
|
||||
@(entry.EntryType == "Bill" ? entry.Memo : entry.AccountName)
|
||||
@if (entry.HasReceipt)
|
||||
{
|
||||
<i class="bi bi-paperclip ms-1" title="Has receipt"></i>
|
||||
}
|
||||
</td>
|
||||
<td>@entry.Date.ToString("MMM d, yyyy")</td>
|
||||
<td>
|
||||
@if (entry.DueDate.HasValue)
|
||||
{
|
||||
<span class="@(entry.IsOverdue ? "text-danger fw-medium" : "")">
|
||||
@entry.DueDate.Value.ToString("MMM d, yyyy")
|
||||
@if (entry.IsOverdue) { <i class="bi bi-exclamation-circle ms-1"></i> }
|
||||
</span>
|
||||
}
|
||||
else if (entry.EntryType == "Expense")
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td><span class="badge bg-@entry.StatusColor">@entry.StatusLabel</span></td>
|
||||
<td class="text-end">@entry.Total.ToString("C")</td>
|
||||
<td class="text-end fw-medium @(entry.BalanceDue > 0 ? "text-danger" : "text-muted")">
|
||||
@(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "—")
|
||||
</td>
|
||||
<td>
|
||||
@if (entry.EntryType == "Bill")
|
||||
{
|
||||
<a asp-controller="Bills" asp-action="Details" asp-route-id="@entry.Id"
|
||||
class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i></a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-controller="Expenses" asp-action="Details" asp-route-id="@entry.Id"
|
||||
class="btn btn-sm btn-outline-secondary"><i class="bi bi-eye"></i></a>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
|
||||
No entries found.
|
||||
<div class="mt-2">
|
||||
<a asp-controller="Bills" asp-action="Create" class="btn btn-primary btn-sm me-2">
|
||||
<i class="bi bi-plus-lg me-1"></i>New Bill
|
||||
</a>
|
||||
<a asp-controller="Expenses" asp-action="Create" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-plus-lg me-1"></i>New Expense
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if ((int)ViewBag.TotalPages > 1)
|
||||
{
|
||||
<nav class="mt-3">
|
||||
<ul class="pagination justify-content-center">
|
||||
<li class="page-item @((int)ViewBag.Page <= 1 ? "disabled" : "")">
|
||||
<a class="page-link" asp-action="Index"
|
||||
asp-route-type="@ViewBag.TypeFilter"
|
||||
asp-route-status="@ViewBag.StatusFilter"
|
||||
asp-route-search="@ViewBag.Search"
|
||||
asp-route-page="@((int)ViewBag.Page - 1)"
|
||||
asp-route-pageSize="@ViewBag.PageSize">‹ Prev</a>
|
||||
</li>
|
||||
@for (var p = 1; p <= (int)ViewBag.TotalPages; p++)
|
||||
{
|
||||
<li class="page-item @(p == (int)ViewBag.Page ? "active" : "")">
|
||||
<a class="page-link" asp-action="Index"
|
||||
asp-route-type="@ViewBag.TypeFilter"
|
||||
asp-route-status="@ViewBag.StatusFilter"
|
||||
asp-route-search="@ViewBag.Search"
|
||||
asp-route-page="@p"
|
||||
asp-route-pageSize="@ViewBag.PageSize">@p</a>
|
||||
</li>
|
||||
}
|
||||
<li class="page-item @((int)ViewBag.Page >= (int)ViewBag.TotalPages ? "disabled" : "")">
|
||||
<a class="page-link" asp-action="Index"
|
||||
asp-route-type="@ViewBag.TypeFilter"
|
||||
asp-route-status="@ViewBag.StatusFilter"
|
||||
asp-route-search="@ViewBag.Search"
|
||||
asp-route-page="@((int)ViewBag.Page + 1)"
|
||||
asp-route-pageSize="@ViewBag.PageSize">Next ›</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-center text-muted small">
|
||||
Showing @(((int)ViewBag.Page - 1) * (int)ViewBag.PageSize + 1)–@(Math.Min((int)ViewBag.Page * (int)ViewBag.PageSize, (int)ViewBag.TotalCount))
|
||||
of @ViewBag.TotalCount entries
|
||||
</p>
|
||||
</nav>
|
||||
}
|
||||
Reference in New Issue
Block a user