Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Invoices/Edit.cshtml
T
spouliot 4ec55e7290 Restore all zeroed views + add bulk gift certificate creation
The HTML entity sweep script had a bug where it wrote empty files for any
view that contained no target Unicode characters, zeroing out 215 view files.
All views restored from the pre-sweep commit (cefdf3e).

Bulk gift certificate feature:
- BulkCreateGiftCertificateDto with Quantity (1-500), Amount, Reason, Expiry, Notes
- GenerateBulkGiftCertificatePdfAsync on IPdfService / PdfService: one Letter page
  per cert, reusing the same purple/gold branded ComposeGiftCertificateContent helper
- GiftCertificatesController: BulkCreate GET/POST, BulkResult GET, BulkDownloadPdf POST
- Views: BulkCreate.cshtml (form with live total preview), BulkResult.cshtml (table +
  Download All PDF button that POSTs cert IDs to avoid URL length limits)
- gift-certificate-bulk.js: live preview + spinner/disable on submit
- Index.cshtml: Bulk Create button added alongside New Certificate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:09:22 -04:00

379 lines
21 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@model PowderCoating.Application.DTOs.Invoice.UpdateInvoiceDto
@{
ViewData["Title"] = "Edit Invoice";
ViewData["PageIcon"] = "bi-pencil-square";
var invoiceNumber = ViewBag.InvoiceNumber as string;
var invoiceId = (int)(ViewBag.InvoiceId ?? 0);
var jobNumber = ViewBag.JobNumber as string;
var customerName = ViewBag.CustomerName as string;
var canResend = ViewBag.CanResend == true;
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<p class="text-muted mb-0 small">
@invoiceNumber
@if (!string.IsNullOrEmpty(jobNumber)) { <span>&mdash; Job #@jobNumber</span> }
@if (!string.IsNullOrEmpty(customerName)) { <span>&mdash; @customerName</span> }
</p>
</div>
<a asp-action="Details" asp-route-id="@invoiceId" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Invoice
</a>
</div>
<form asp-action="Edit" asp-route-id="@invoiceId" method="post" id="invoiceForm">
@Html.AntiForgeryToken()
<div class="row g-4">
<!-- LEFT: Main form -->
<div class="col-lg-8">
<!-- Invoice Details -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header border-0 py-3">
<div class="d-flex align-items-center gap-2">
<h6 class="mb-0"><i class="bi bi-info-circle me-2"></i>Invoice Details</h6>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Invoice Details"
data-bs-content="Invoice Date is the date of issue and the reference for payment terms. Due Date drives overdue status and A/R aging. Payment Terms prints on the invoice — changing it here only affects this invoice. Draft, Sent, and Overdue invoices can be edited; Paid and Partially Paid invoices are locked.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label asp-for="InvoiceDate" class="form-label fw-semibold">Invoice Date <span class="text-danger">*</span></label>
<input asp-for="InvoiceDate" type="date" class="form-control"
value="@Model.InvoiceDate.ToString("yyyy-MM-dd")" />
<span asp-validation-for="InvoiceDate" class="text-danger small"></span>
</div>
<div class="col-md-4">
<label asp-for="DueDate" class="form-label fw-semibold">Due Date</label>
<input asp-for="DueDate" type="date" class="form-control"
value="@(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("yyyy-MM-dd") : "")" />
<span asp-validation-for="DueDate" class="text-danger small"></span>
</div>
<div class="col-md-4">
<label asp-for="CustomerPO" class="form-label fw-semibold">Customer PO #</label>
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-12">
<label asp-for="Terms" class="form-label fw-semibold">Payment Terms</label>
<select asp-for="Terms" asp-items="ViewBag.PaymentTermsOptions" class="form-select"></select>
</div>
</div>
</div>
</div>
<!-- Line Items -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header border-0 py-3 d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<h6 class="mb-0"><i class="bi bi-list-ul me-2"></i>Line Items</h6>
<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 row is a billable line on the invoice. Qty × Unit Price = Total per line; you can also override Total directly. Color is optional and appears under the description when printed. Add manual lines for charges not in the original job (e.g., rush fee, pickup charge).">
<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-circle me-1"></i>Add Line
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-borderless align-middle mb-0" id="lineItemsTable">
<thead class="table-light">
<tr>
<th style="min-width:220px;">Description</th>
<th style="width:70px;" class="text-center">Qty</th>
<th style="width:120px;" class="text-end">Unit Price</th>
<th style="width:120px;" class="text-end">Total</th>
<th style="width:80px;" class="text-center">Color</th>
<th style="width:40px;"></th>
</tr>
</thead>
<tbody id="lineItemsBody">
@for (int i = 0; i < Model.InvoiceItems.Count; i++)
{
var item = Model.InvoiceItems[i];
<tr class="line-item-row" data-index="@i">
<td>
<input type="hidden" name="InvoiceItems[@i].SourceJobItemId" value="@item.SourceJobItemId" />
<input type="hidden" name="InvoiceItems[@i].DisplayOrder" class="display-order-input" value="@(i + 1)" />
<input type="hidden" name="InvoiceItems[@i].RevenueAccountId" value="@item.RevenueAccountId" />
<input type="text" name="InvoiceItems[@i].Description"
class="form-control form-control-sm"
value="@item.Description" placeholder="Description" required />
<input type="text" name="InvoiceItems[@i].Notes"
class="form-control form-control-sm mt-1"
value="@item.Notes" placeholder="Notes (optional)" />
</td>
<td class="text-center">
<input type="number" name="InvoiceItems[@i].Quantity"
class="form-control form-control-sm text-center qty-input"
value="@item.Quantity" min="0.01" step="0.01"
onchange="recalcRow(this)" oninput="recalcRow(this)" />
</td>
<td class="text-end">
<input type="number" name="InvoiceItems[@i].UnitPrice"
class="form-control form-control-sm text-end unit-price-input"
value="@item.UnitPrice.ToString("F2")" min="0" step="0.01"
onchange="recalcRow(this)" oninput="recalcRow(this)" />
</td>
<td class="text-end">
<input type="number" name="InvoiceItems[@i].TotalPrice"
class="form-control form-control-sm text-end total-price-input"
value="@item.TotalPrice.ToString("F2")" min="0" step="0.01"
oninput="recalcTotals()" />
</td>
<td class="text-center">
<input type="text" name="InvoiceItems[@i].ColorName"
class="form-control form-control-sm"
value="@item.ColorName" placeholder="Color" />
</td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeLineItem(this)" title="Remove">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<div id="noItemsMessage" class="text-center py-3 text-muted @(Model.InvoiceItems.Any() ? "d-none" : "")">
<i class="bi bi-receipt me-2"></i>No line items yet. Click "Add Line" to get started.
</div>
</div>
</div>
<!-- Notes -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header border-0 py-3">
<div class="d-flex align-items-center gap-2">
<h6 class="mb-0"><i class="bi bi-chat-text me-2"></i>Notes</h6>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Notes"
data-bs-content="Customer Notes appear on the printed and emailed invoice — use these for payment instructions, thank-you messages, or job-specific reminders. Internal Notes are only visible to staff in the app and are never sent to the customer.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<div class="mb-3">
<label asp-for="Notes" class="form-label fw-semibold">Customer Notes</label>
<textarea asp-for="Notes" class="form-control" rows="3"
placeholder="Notes visible to the customer on the invoice..."></textarea>
<div class="form-text">These notes appear on the printed/emailed invoice.</div>
</div>
<div class="mb-0">
<label asp-for="InternalNotes" class="form-label fw-semibold">Internal Notes</label>
<textarea asp-for="InternalNotes" class="form-control" rows="2"
placeholder="Internal notes (not visible to customer)..."></textarea>
</div>
</div>
</div>
</div>
<!-- RIGHT: Totals & Actions -->
<div class="col-lg-4">
<!-- Totals -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header border-0 py-3">
<div class="d-flex align-items-center gap-2">
<h6 class="mb-0"><i class="bi bi-calculator me-2"></i>Totals</h6>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Totals"
data-bs-content="Subtotal = sum of all line item totals. Discount is a flat dollar amount deducted before tax. Tax % is applied to (Subtotal − Discount). Both default from the company settings but can be overridden for this invoice.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Subtotal</span>
<span id="displaySubtotal" class="fw-semibold">$0.00</span>
</div>
<div class="mb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label mb-0 text-muted">Discount ($)</label>
</div>
<input asp-for="DiscountAmount" type="number" class="form-control form-control-sm text-end"
min="0" step="0.01" oninput="recalcTotals()" />
</div>
<div class="mb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label mb-0 text-muted">Tax (%)</label>
</div>
<input asp-for="TaxPercent" type="number" class="form-control form-control-sm text-end"
min="0" max="100" step="0.01" oninput="recalcTotals()" />
</div>
<div class="d-flex justify-content-between mb-1">
<span class="text-muted small">Tax Amount</span>
<span id="displayTax" class="small">$0.00</span>
</div>
<hr />
<div class="d-flex justify-content-between">
<span class="fw-bold fs-5">Total</span>
<span id="displayTotal" class="fw-bold fs-5 text-primary">$0.00</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="card border-0 shadow-sm">
<div class="card-body d-grid gap-2">
@if (canResend)
{
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" name="resendToCustomer" value="true" id="resendCheck" />
<label class="form-check-label small" for="resendCheck">
<i class="bi bi-send me-1"></i>Re-send updated invoice to customer
</label>
</div>
}
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-2"></i>Save Changes
</button>
<a asp-action="Details" asp-route-id="@invoiceId" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>Cancel
</a>
</div>
<div class="card-footer border-0 pt-0">
<div class="alert alert-info alert-permanent mb-0 small py-2">
<i class="bi bi-info-circle me-1"></i>
<strong>Draft, Sent,</strong> and <strong>Overdue</strong> invoices can be edited.
Paid invoices are locked.
</div>
</div>
</div>
</div>
</div>
</form>
@section Scripts {
<script>
let itemCount = @Model.InvoiceItems.Count;
function addLineItem() {
const idx = itemCount++;
const tbody = document.getElementById('lineItemsBody');
const row = document.createElement('tr');
row.className = 'line-item-row';
row.dataset.index = idx;
row.innerHTML = `
<td>
<input type="hidden" name="InvoiceItems[${idx}].SourceJobItemId" value="" />
<input type="hidden" name="InvoiceItems[${idx}].DisplayOrder" class="display-order-input" value="${idx + 1}" />
<input type="hidden" name="InvoiceItems[${idx}].RevenueAccountId" value="" />
<input type="text" name="InvoiceItems[${idx}].Description"
class="form-control form-control-sm" placeholder="Description" required />
<input type="text" name="InvoiceItems[${idx}].Notes"
class="form-control form-control-sm mt-1" placeholder="Notes (optional)" />
</td>
<td class="text-center">
<input type="number" name="InvoiceItems[${idx}].Quantity"
class="form-control form-control-sm text-center qty-input"
value="1" min="0.01" step="0.01"
onchange="recalcRow(this)" oninput="recalcRow(this)" />
</td>
<td class="text-end">
<input type="number" name="InvoiceItems[${idx}].UnitPrice"
class="form-control form-control-sm text-end unit-price-input"
value="0.00" min="0" step="0.01"
onchange="recalcRow(this)" oninput="recalcRow(this)" />
</td>
<td class="text-end">
<input type="number" name="InvoiceItems[${idx}].TotalPrice"
class="form-control form-control-sm text-end total-price-input"
value="0.00" min="0" step="0.01"
oninput="recalcTotals()" />
</td>
<td class="text-center">
<input type="text" name="InvoiceItems[${idx}].ColorName"
class="form-control form-control-sm" placeholder="Color" />
</td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeLineItem(this)" title="Remove">
<i class="bi bi-trash"></i>
</button>
</td>`;
tbody.appendChild(row);
document.getElementById('noItemsMessage').classList.add('d-none');
reindexRows();
recalcTotals();
row.querySelector('input[name$="].Description"]').focus();
}
function removeLineItem(btn) {
const row = btn.closest('tr');
row.remove();
reindexRows();
recalcTotals();
const tbody = document.getElementById('lineItemsBody');
if (tbody.querySelectorAll('tr.line-item-row').length === 0)
document.getElementById('noItemsMessage').classList.remove('d-none');
}
function reindexRows() {
const rows = document.querySelectorAll('#lineItemsBody tr.line-item-row');
rows.forEach((row, i) => {
row.dataset.index = i;
row.querySelectorAll('input, select, textarea').forEach(input => {
if (input.name) {
input.name = input.name.replace(/InvoiceItems\[\d+\]/, `InvoiceItems[${i}]`);
}
});
const orderInput = row.querySelector('.display-order-input');
if (orderInput) orderInput.value = i + 1;
});
itemCount = rows.length;
}
function recalcRow(input) {
const row = input.closest('tr');
const qty = parseFloat(row.querySelector('.qty-input')?.value) || 0;
const unit = parseFloat(row.querySelector('.unit-price-input')?.value) || 0;
const totalInput = row.querySelector('.total-price-input');
if (totalInput) totalInput.value = (qty * unit).toFixed(2);
recalcTotals();
}
function recalcTotals() {
let subtotal = 0;
document.querySelectorAll('#lineItemsBody .total-price-input').forEach(input => {
subtotal += parseFloat(input.value) || 0;
});
const discount = parseFloat(document.getElementById('DiscountAmount')?.value) || 0;
const taxPct = parseFloat(document.getElementById('TaxPercent')?.value) || 0;
const taxableAmount = subtotal - discount;
const tax = Math.round(taxableAmount * taxPct) / 100;
const total = taxableAmount + tax;
document.getElementById('displaySubtotal').textContent = formatCurrency(subtotal);
document.getElementById('displayTax').textContent = formatCurrency(tax);
document.getElementById('displayTotal').textContent = formatCurrency(total);
}
function formatCurrency(value) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value);
}
recalcTotals();
</script>
}