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>
}
@@ -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 &amp; 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>
}