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>
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.CreateExpenseDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "New Expense";
|
||||
ViewData["PageIcon"] = "bi-receipt";
|
||||
ViewData["PageHelpTitle"] = "New Expense";
|
||||
ViewData["PageHelpContent"] = "Use this for purchases paid immediately — credit card swipes, cash payments, debit transactions. For vendor invoices paid later, use Bills instead. Select the expense account (what was bought) and the payment account (where the money came from).";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-start mb-4">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form asp-action="Create" method="post" enctype="multipart/form-data">
|
||||
@Html.AntiForgeryToken()
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger alert-permanent mb-3"></div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6">
|
||||
<label asp-for="Date" class="form-label fw-medium">Date <span class="text-danger">*</span></label>
|
||||
<input asp-for="Date" type="date" class="form-control" />
|
||||
<span asp-validation-for="Date" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label asp-for="Amount" class="form-label fw-medium">Amount <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input asp-for="Amount" type="number" step="0.01" min="0.01" class="form-control" />
|
||||
</div>
|
||||
<span asp-validation-for="Amount" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<label asp-for="ExpenseAccountId" class="form-label fw-medium mb-0">Expense Account <span class="text-danger">*</span></label>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Expense Account"
|
||||
data-bs-content="The expense category this purchase belongs to — e.g. Supplies, Materials, Utilities, Fuel. This account is debited when the expense is saved. Choose the most specific account that fits to keep your reports accurate.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<select asp-for="ExpenseAccountId" asp-items="ViewBag.ExpenseAccounts" class="form-select" id="expenseAccountSelect">
|
||||
<option value="">— Select Account —</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary text-nowrap" id="expAiSuggestBtn" title="AI-suggest expense account">
|
||||
<i class="bi bi-stars me-1"></i>AI Suggest
|
||||
</button>
|
||||
</div>
|
||||
<span asp-validation-for="ExpenseAccountId" class="text-danger small"></span>
|
||||
<div id="expAiSuggestionBadge" class="mt-2 d-none">
|
||||
<span class="badge bg-info text-dark me-2" id="expAiSuggestionText"></span>
|
||||
<button type="button" class="btn btn-xs btn-success btn-sm py-0 px-2" id="expAiSuggestionUseBtn">Use This</button>
|
||||
<button type="button" class="btn btn-xs btn-outline-secondary btn-sm py-0 px-2 ms-1" onclick="document.getElementById('expAiSuggestionBadge').classList.add('d-none')">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6">
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<label asp-for="PaymentAccountId" class="form-label fw-medium mb-0">Paid From <span class="text-danger">*</span></label>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Paid From"
|
||||
data-bs-content="The bank or cash account the money came out of — e.g. Business Checking, Petty Cash, Company Credit Card. This account is credited when the expense is saved. Used for bank reconciliation.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="PaymentAccountId" asp-items="ViewBag.PaymentAccounts" class="form-select">
|
||||
<option value="">— Select Account —</option>
|
||||
</select>
|
||||
<span asp-validation-for="PaymentAccountId" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label asp-for="PaymentMethod" class="form-label fw-medium">Payment Method <span class="text-danger">*</span></label>
|
||||
<select asp-for="PaymentMethod" asp-items="ViewBag.PaymentMethods" class="form-select"></select>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6">
|
||||
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-muted small">(optional)</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="">— None —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<label asp-for="JobId" class="form-label fw-medium mb-0">Job <span class="text-muted small">(optional)</span></label>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Linked Job"
|
||||
data-bs-content="Attach this expense to a specific job to track its true cost. Job-linked expenses roll up in job profitability reports, helping you see whether a job was profitable after all direct costs.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="JobId" asp-items="ViewBag.Jobs" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
</select>
|
||||
</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="What was this for?"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<label for="receiptFile" class="form-label fw-medium mb-0">Receipt <span class="text-muted small">(optional)</span></label>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Receipt"
|
||||
data-bs-content="Attach a photo or PDF of the receipt for audit purposes and expense reimbursement. Supports JPG, PNG, and PDF up to 10 MB. The image is stored securely and viewable from the expense detail page.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<input type="file" name="receiptFile" id="receiptFile" class="form-control"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
||||
<div class="form-text">Image or PDF, up to 10 MB.</div>
|
||||
<div id="receiptPreview" class="mt-2 d-none">
|
||||
<img id="previewImg" src="" alt="Receipt preview" class="img-thumbnail" style="max-height:200px;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg me-1"></i>Save Expense
|
||||
</button>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
|
||||
<script>
|
||||
document.getElementById('receiptFile').addEventListener('change', function () {
|
||||
const file = this.files[0];
|
||||
const preview = document.getElementById('receiptPreview');
|
||||
const img = document.getElementById('previewImg');
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
img.src = URL.createObjectURL(file);
|
||||
preview.classList.remove('d-none');
|
||||
} else {
|
||||
preview.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
// ── AI Suggest Account ────────────────────────────────────────────────
|
||||
let _expAiSuggestedAccountId = null;
|
||||
|
||||
document.getElementById('expAiSuggestBtn').addEventListener('click', async function () {
|
||||
const vendorSel = document.getElementById('VendorId');
|
||||
const vendorText = vendorSel ? (vendorSel.options[vendorSel.selectedIndex]?.text ?? '') : '';
|
||||
const memoEl = document.querySelector('[name="Memo"]');
|
||||
const description = memoEl ? memoEl.value : '';
|
||||
const amountEl = document.querySelector('[name="Amount"]');
|
||||
const amount = amountEl ? parseFloat(amountEl.value) || 0 : 0;
|
||||
|
||||
const btn = this;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Thinking...';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/Expenses/SuggestAccount', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vendorName: vendorText, description, amount, availableAccounts: [] })
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.success && data.suggestedAccountId) {
|
||||
_expAiSuggestedAccountId = data.suggestedAccountId;
|
||||
document.getElementById('expAiSuggestionText').textContent =
|
||||
'\u2728 ' + (data.suggestedAccountName || 'Suggested') + (data.reasoning ? ' \u2014 ' + data.reasoning : '');
|
||||
document.getElementById('expAiSuggestionBadge').classList.remove('d-none');
|
||||
} else {
|
||||
alert(data.errorMessage || 'Could not suggest an account.');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error contacting AI service.');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-stars me-1"></i>AI Suggest';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('expAiSuggestionUseBtn').addEventListener('click', function () {
|
||||
if (!_expAiSuggestedAccountId) return;
|
||||
const sel = document.getElementById('expenseAccountSelect');
|
||||
if (sel) sel.value = String(_expAiSuggestedAccountId);
|
||||
document.getElementById('expAiSuggestionBadge').classList.add('d-none');
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.ExpenseDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = $"Expense {Model.ExpenseNumber}";
|
||||
ViewData["PageIcon"] = "bi-receipt";
|
||||
ViewData["PageHelpTitle"] = "Expense";
|
||||
ViewData["PageHelpContent"] = "A direct purchase paid at the time of transaction. Expense Account shows what was bought; Paid From shows which bank/cash account was debited. Edit to correct any details. Delete permanently removes the record — there is no Void for expenses.";
|
||||
}
|
||||
|
||||
<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="text-muted small">@Model.Date.ToString("MMMM d, yyyy")</span>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
<form asp-action="Delete" asp-route-id="@Model.Id" method="post"
|
||||
onsubmit="return confirm('Delete expense @Model.ExpenseNumber?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-danger">
|
||||
<i class="bi bi-trash me-1"></i>Delete
|
||||
</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>
|
||||
}
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold d-flex align-items-center gap-2">
|
||||
Expense Details
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Expense Details"
|
||||
data-bs-content="Expense Account: the category debited (what was purchased). Paid From: the bank or cash account that was credited. Payment Method: how it was paid. If a Job is linked, this expense contributes to that job's cost tracking.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4 text-muted">Date</dt>
|
||||
<dd class="col-sm-8">@Model.Date.ToString("MMMM d, yyyy")</dd>
|
||||
|
||||
<dt class="col-sm-4 text-muted">Expense Account</dt>
|
||||
<dd class="col-sm-8">
|
||||
<code>@Model.ExpenseAccountNumber</code> @Model.ExpenseAccountName
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4 text-muted">Paid From</dt>
|
||||
<dd class="col-sm-8">@Model.PaymentAccountName</dd>
|
||||
|
||||
<dt class="col-sm-4 text-muted">Payment Method</dt>
|
||||
<dd class="col-sm-8">@Model.PaymentMethod</dd>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.VendorName))
|
||||
{
|
||||
<dt class="col-sm-4 text-muted">Vendor</dt>
|
||||
<dd class="col-sm-8">@Model.VendorName</dd>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.JobNumber))
|
||||
{
|
||||
<dt class="col-sm-4 text-muted">Job</dt>
|
||||
<dd class="col-sm-8">@Model.JobNumber</dd>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.Memo))
|
||||
{
|
||||
<dt class="col-sm-4 text-muted">Memo</dt>
|
||||
<dd class="col-sm-8">@Model.Memo</dd>
|
||||
}
|
||||
|
||||
<dt class="col-sm-4 text-muted">Recorded</dt>
|
||||
<dd class="col-sm-8 text-muted small">@Model.CreatedAt.ToString("MMM d, yyyy h:mm tt")</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-5">
|
||||
<div class="card shadow-sm border-primary mb-3">
|
||||
<div class="card-body text-center py-4">
|
||||
<p class="text-muted mb-1 small">Amount</p>
|
||||
<p class="display-5 fw-bold text-primary mb-0">@Model.Amount.ToString("C")</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receipt -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span><i class="bi bi-receipt me-1"></i>Receipt</span>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||
data-bs-title="Receipt"
|
||||
data-bs-content="Attached receipt image or PDF for this expense. Click to view full size. Use the trash icon to remove it. To replace the receipt, use Edit and upload a new file.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(Model.ReceiptFilePath))
|
||||
{
|
||||
<form asp-action="DeleteReceipt" asp-route-id="@Model.Id" method="post"
|
||||
onsubmit="return confirm('Remove receipt?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (!string.IsNullOrEmpty(Model.ReceiptFilePath))
|
||||
{
|
||||
var ext = System.IO.Path.GetExtension(Model.ReceiptFilePath).ToLowerInvariant();
|
||||
if (ext == ".pdf")
|
||||
{
|
||||
<div class="text-center py-3">
|
||||
<i class="bi bi-file-earmark-pdf text-danger" style="font-size:3rem;"></i>
|
||||
<p class="small text-muted mt-2 mb-3">PDF receipt attached</p>
|
||||
<a asp-action="ViewReceipt" asp-route-id="@Model.Id"
|
||||
class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-download me-1"></i>Download PDF
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="#" data-bs-toggle="modal" data-bs-target="#receiptModal" style="cursor:zoom-in;">
|
||||
<img src="@Url.Action("ViewReceipt", new { id = Model.Id })"
|
||||
alt="Receipt" class="img-fluid rounded" style="max-height:300px;object-fit:contain;" />
|
||||
</a>
|
||||
<div class="text-center mt-2">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="modal" data-bs-target="#receiptModal">
|
||||
<i class="bi bi-arrows-fullscreen me-1"></i>View Full Size
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-3 text-muted">
|
||||
<i class="bi bi-image fs-2 d-block mb-2"></i>
|
||||
<p class="small mb-2">No receipt attached</p>
|
||||
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-upload me-1"></i>Upload Receipt
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.ReceiptFilePath) &&
|
||||
System.IO.Path.GetExtension(Model.ReceiptFilePath).ToLowerInvariant() != ".pdf")
|
||||
{
|
||||
<div class="modal fade" id="receiptModal" tabindex="-1" aria-labelledby="receiptModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="receiptModalLabel">
|
||||
<i class="bi bi-receipt me-2"></i>Receipt — @Model.ExpenseNumber
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center p-2">
|
||||
<img src="@Url.Action("ViewReceipt", new { id = Model.Id })"
|
||||
alt="Receipt" class="img-fluid" style="max-height:80vh;object-fit:contain;" />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a asp-action="ViewReceipt" asp-route-id="@Model.Id" target="_blank"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>Open in New Tab
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.EditExpenseDto
|
||||
@* Note: ReceiptFilePath is carried via hidden field to detect existing receipt *@
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Expense";
|
||||
ViewData["PageIcon"] = "bi-pencil-square";
|
||||
ViewData["PageHelpTitle"] = "Edit Expense";
|
||||
ViewData["PageHelpContent"] = "All fields are editable. Uploading a new receipt replaces the existing one. To remove a receipt without replacing it, use the Delete Receipt button on the Details page.";
|
||||
}
|
||||
|
||||
<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>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form asp-action="Edit" asp-route-id="@Model.Id" method="post" enctype="multipart/form-data">
|
||||
@Html.AntiForgeryToken()
|
||||
<input asp-for="Id" type="hidden" />
|
||||
<input asp-for="ReceiptFilePath" type="hidden" />
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger alert-permanent mb-3"></div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6">
|
||||
<label asp-for="Date" class="form-label fw-medium">Date <span class="text-danger">*</span></label>
|
||||
<input asp-for="Date" type="date" class="form-control" />
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label asp-for="Amount" class="form-label fw-medium">Amount <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input asp-for="Amount" type="number" step="0.01" min="0.01" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<label asp-for="ExpenseAccountId" class="form-label fw-medium mb-0">Expense Account <span class="text-danger">*</span></label>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Expense Account"
|
||||
data-bs-content="The expense category this purchase belongs to — e.g. Supplies, Materials, Utilities, Fuel. This account is debited when the expense is saved. Choose the most specific account that fits to keep your reports accurate.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="ExpenseAccountId" asp-items="ViewBag.ExpenseAccounts" class="form-select">
|
||||
<option value="">— Select Account —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<label asp-for="PaymentAccountId" class="form-label fw-medium mb-0">Paid From <span class="text-danger">*</span></label>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Paid From"
|
||||
data-bs-content="The bank or cash account the money came out of — e.g. Business Checking, Petty Cash, Company Credit Card. This account is credited when the expense is saved. Used for bank reconciliation.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="PaymentAccountId" asp-items="ViewBag.PaymentAccounts" class="form-select">
|
||||
<option value="">— Select Account —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label asp-for="PaymentMethod" class="form-label fw-medium">Payment Method <span class="text-danger">*</span></label>
|
||||
<select asp-for="PaymentMethod" asp-items="ViewBag.PaymentMethods" class="form-select"></select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label asp-for="VendorId" class="form-label fw-medium">Vendor</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="">— None —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<label asp-for="JobId" class="form-label fw-medium mb-0">Job</label>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Linked Job"
|
||||
data-bs-content="Attach this expense to a specific job to track its true cost. Job-linked expenses roll up in job profitability reports, helping you see whether a job was profitable after all direct costs.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="JobId" asp-items="ViewBag.Jobs" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
</select>
|
||||
</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">
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<label class="form-label fw-medium mb-0">Receipt</label>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Receipt"
|
||||
data-bs-content="Uploading a new file here replaces the existing receipt. To remove a receipt without replacing it, use the Delete Receipt button on the Details page. Supports JPG, PNG, and PDF up to 10 MB.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(Model.ReceiptFilePath))
|
||||
{
|
||||
<div class="d-flex align-items-center gap-3 mb-2 p-2 border rounded bg-light">
|
||||
<i class="bi bi-paperclip text-muted fs-5"></i>
|
||||
<span class="small text-muted flex-grow-1">Receipt attached</span>
|
||||
<a asp-action="ViewReceipt" asp-route-id="@Model.Id"
|
||||
class="btn btn-sm btn-outline-secondary" target="_blank">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
</div>
|
||||
<div class="form-text mb-2">Upload a new file below to replace the existing receipt.</div>
|
||||
}
|
||||
<input type="file" name="receiptFile" id="receiptFile" class="form-control"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
||||
<div class="form-text">Image or PDF, up to 10 MB.</div>
|
||||
<div id="receiptPreview" class="mt-2 d-none">
|
||||
<img id="previewImg" src="" alt="Receipt preview" class="img-thumbnail" style="max-height:200px;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex 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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
|
||||
<script>
|
||||
document.getElementById('receiptFile').addEventListener('change', function () {
|
||||
const file = this.files[0];
|
||||
const preview = document.getElementById('receiptPreview');
|
||||
const img = document.getElementById('previewImg');
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
img.src = URL.createObjectURL(file);
|
||||
preview.classList.remove('d-none');
|
||||
} else {
|
||||
preview.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
@model List<PowderCoating.Application.DTOs.Accounting.ExpenseListDto>
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Expenses";
|
||||
ViewData["PageIcon"] = "bi-receipt";
|
||||
ViewData["PageHelpTitle"] = "Expenses";
|
||||
ViewData["PageHelpContent"] = "Expenses are direct purchases paid immediately (credit card, cash, debit). Use Bills instead when you receive a vendor invoice now but pay later. Each expense posts to an expense account and reduces a payment account. Optionally link to a Vendor and Job for detailed cost tracking.";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-end mb-4">
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-lg me-1"></i>New Expense
|
||||
</a>
|
||||
</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 ((decimal)ViewBag.TotalAmount > 0)
|
||||
{
|
||||
<div class="alert alert-info alert-permanent d-flex align-items-center gap-2 mb-4">
|
||||
<i class="bi bi-info-circle fs-5"></i>
|
||||
<span>Total shown: <strong>@(((decimal)ViewBag.TotalAmount).ToString("C"))</strong></span>
|
||||
</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-3">
|
||||
<input type="search" name="search" value="@ViewBag.Search" class="form-control"
|
||||
placeholder="Search memo or vendor…" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="accountId" class="form-select">
|
||||
<option value="">All Expense Accounts</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AccountFilter)
|
||||
{
|
||||
<option value="@item.Value" selected="@(ViewBag.AccountId?.ToString() == item.Value)">@item.Text</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="date" name="from" value="@ViewBag.From" class="form-control" title="From date" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="date" name="to" value="@ViewBag.To" class="form-control" title="To date" />
|
||||
</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>Expense #</th>
|
||||
<th>Date</th>
|
||||
<th>Vendor</th>
|
||||
<th>Account</th>
|
||||
<th>Paid From</th>
|
||||
<th>Method</th>
|
||||
<th>Job</th>
|
||||
<th>Memo</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var exp in Model)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<a asp-action="Details" asp-route-id="@exp.Id" class="fw-medium text-decoration-none">
|
||||
@exp.ExpenseNumber
|
||||
</a>
|
||||
</td>
|
||||
<td>@exp.Date.ToString("MMM d, yyyy")</td>
|
||||
<td>@exp.VendorName</td>
|
||||
<td>
|
||||
<span class="text-muted small">@exp.ExpenseAccountNumber</span> @exp.ExpenseAccountName
|
||||
</td>
|
||||
<td><span class="text-muted small">@exp.PaymentAccountName</span></td>
|
||||
<td>@exp.PaymentMethod</td>
|
||||
<td>@exp.JobNumber</td>
|
||||
<td><span class="text-muted small">@exp.Memo</span></td>
|
||||
<td class="text-end fw-medium">@exp.Amount.ToString("C")</td>
|
||||
<td>
|
||||
<a asp-action="Details" asp-route-id="@exp.Id" class="btn btn-sm btn-outline-primary me-1">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a asp-action="Edit" asp-route-id="@exp.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-receipt-cutoff display-3 text-muted"></i>
|
||||
<p class="mt-3 text-muted">No expenses found. Record your first expense to get started.</p>
|
||||
<a asp-action="Create" class="btn btn-primary mt-2"><i class="bi bi-plus-lg me-1"></i>New Expense</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user