a0bdd2b5b4
Replace all corruption variants with HTML entities across 226 view files: - 3-char UTF-8-as-Win1252 sequences (ae-corruption) - Standalone smart/curly quotes that break C# Razor expressions - Partially re-corrupted variants where the 3rd byte was normalised to ASCII tools/Fix-Encoding.ps1: re-runnable sweep; uses [char] code points so the script itself never contains a literal non-ASCII character; supports -DryRun .githooks/pre-commit: blocks commits containing the ae-corruption byte signature (xc3xa2xe2x82xac); git core.hooksPath = .githooks so the hook is repo-committed and active for all future work on this machine. Build clean; 225 unit tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
611 lines
32 KiB
Plaintext
611 lines
32 KiB
Plaintext
@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 alert-permanent 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-outline-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>
|
||
}
|