Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Bills/Create.cshtml
T
2026-04-23 21:38:24 -04:00

611 lines
32 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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 &amp; 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 &amp; 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 &amp; Fill';
}
});
</script>
}