Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -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 &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>
}