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