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,154 @@
@{
ViewData["Title"] = "Set Your Password";
Layout = "/Views/Shared/_AuthLayout.cshtml";
}
@section Styles {
<style>
body { background: #f1f5f9; }
.pw-card {
max-width: 480px;
margin: 4rem auto;
background: white;
border: 1px solid #e2e8f0;
border-radius: 1rem;
padding: 2.5rem;
box-shadow: 0 4px 24px rgba(0,0,0,0.07);
}
.pw-card .logo-row {
text-align: center;
margin-bottom: 1.5rem;
}
.pw-card .logo-row img {
height: 48px;
}
.pw-card h2 {
font-size: 1.35rem;
font-weight: 700;
color: #1e293b;
text-align: center;
margin-bottom: 0.5rem;
}
.pw-card .subtitle {
font-size: 0.9rem;
color: #64748b;
text-align: center;
margin-bottom: 1.75rem;
}
.password-wrapper {
position: relative;
}
.password-wrapper .toggle-pw {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 0;
font-size: 1rem;
}
.password-wrapper .toggle-pw:hover { color: #475569; }
.password-wrapper input { padding-right: 2.75rem; }
</style>
}
<div class="pw-card">
<div class="logo-row">
<img src="/images/pcl-logo.png" alt="Powder Coating Logix" />
</div>
<h2>Set Your Password</h2>
<p class="subtitle">
Enter the temporary password from your welcome email, then choose a permanent one.
</p>
@if (!ViewData.ModelState.IsValid && ViewData.ModelState.ErrorCount > 0)
{
<div class="alert alert-danger alert-permanent">
<div asp-validation-summary="All" class="mb-0"></div>
</div>
}
<form method="post" id="changePwForm">
@Html.AntiForgeryToken()
<div class="mb-3">
<label for="currentPassword" class="form-label fw-medium">
Temporary Password <span class="text-danger">*</span>
</label>
<div class="password-wrapper">
<input type="password" id="currentPassword" name="currentPassword"
class="form-control" autocomplete="current-password" required />
<button type="button" class="toggle-pw" onclick="togglePw('currentPassword','cpIcon')" tabindex="-1">
<i id="cpIcon" class="bi bi-eye"></i>
</button>
</div>
</div>
<div class="mb-3">
<label for="newPassword" class="form-label fw-medium">
New Password <span class="text-danger">*</span>
</label>
<div class="password-wrapper">
<input type="password" id="newPassword" name="newPassword"
class="form-control" autocomplete="new-password" required />
<button type="button" class="toggle-pw" onclick="togglePw('newPassword','npIcon')" tabindex="-1">
<i id="npIcon" class="bi bi-eye"></i>
</button>
</div>
</div>
<div class="mb-4">
<label for="confirmPassword" class="form-label fw-medium">
Confirm New Password <span class="text-danger">*</span>
</label>
<div class="password-wrapper">
<input type="password" id="confirmPassword" name="confirmPassword"
class="form-control" autocomplete="new-password" required />
<button type="button" class="toggle-pw" onclick="togglePw('confirmPassword','cpwIcon')" tabindex="-1">
<i id="cpwIcon" class="bi bi-eye"></i>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-100 py-2 fw-semibold" id="submitBtn">
<i class="bi bi-check-circle me-2" id="submitIcon"></i>
<span id="submitText">Set Password &amp; Continue</span>
</button>
</form>
</div>
@section Scripts {
<script>
function togglePw(inputId, iconId) {
var input = document.getElementById(inputId);
var icon = document.getElementById(iconId);
if (input.type === 'password') {
input.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
input.type = 'password';
icon.className = 'bi bi-eye';
}
}
document.getElementById('changePwForm').addEventListener('submit', function () {
var btn = document.getElementById('submitBtn');
var icon = document.getElementById('submitIcon');
var text = document.getElementById('submitText');
btn.disabled = true;
icon.className = 'spinner-border spinner-border-sm me-2';
text.textContent = 'Saving\u2026';
});
</script>
}
@@ -0,0 +1,122 @@
@{
ViewData["Title"] = "Download Your Data";
ViewData["PageIcon"] = "bi-download";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<div class="container py-5" style="max-width:680px">
<p class="text-muted mb-4">
Export a copy of your company's data. Select which data types to include, then choose a format.
Your download will be generated immediately.
</p>
@if (TempData["Error"] != null)
{
<div class="alert alert-permanent alert-danger d-flex gap-2 mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>@TempData["Error"]</div>
</div>
}
<div class="card shadow-sm">
<div class="card-body p-4">
<form method="post" asp-action="Export" id="exportForm">
@Html.AntiForgeryToken()
<h6 class="fw-semibold text-uppercase text-muted small mb-3" style="letter-spacing:.05em">
Data to Include
</h6>
<div class="row g-2 mb-4">
@foreach (var item in new[]
{
("Customers", "people", "Customers"),
("Jobs", "tools", "Jobs"),
("Quotes", "file-earmark-text", "Quotes"),
("Invoices", "receipt", "Invoices"),
("Inventory", "boxes", "Inventory Items"),
("Equipment", "gear", "Equipment"),
("Vendors", "shop", "Vendors"),
("ShopWorkers", "person-badge", "Shop Workers"),
("Users", "person-lock", "Users / Logins"),
})
{
<div class="col-6">
<div class="form-check">
<input class="form-check-input sheet-check" type="checkbox"
name="sheets" value="@item.Item1"
id="sheet_@item.Item1" checked />
<label class="form-check-label" for="sheet_@item.Item1">
<i class="bi bi-@item.Item2 me-1 text-secondary"></i>@item.Item3
</label>
</div>
</div>
}
</div>
<div class="d-flex gap-2 mb-1">
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" id="selectAll">Select all</button>
<span class="text-muted small">·</span>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" id="selectNone">Deselect all</button>
</div>
<hr class="my-4" />
<h6 class="fw-semibold text-uppercase text-muted small mb-3" style="letter-spacing:.05em">
Format
</h6>
<div class="d-flex gap-4 mb-4">
<div class="form-check">
<input class="form-check-input" type="radio" name="format" value="xlsx" id="fmt_xlsx" checked />
<label class="form-check-label" for="fmt_xlsx">
<i class="bi bi-file-earmark-spreadsheet me-1 text-success"></i>
Excel (.xlsx) — all sheets in one file
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="format" value="csv" id="fmt_csv" />
<label class="form-check-label" for="fmt_csv">
<i class="bi bi-file-zip me-1 text-warning"></i>
CSV (.zip) — one file per sheet
</label>
</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg" id="exportBtn">
<i class="bi bi-download me-1"></i>Generate Export
</button>
</div>
</form>
</div>
</div>
<p class="text-muted small text-center mt-3">
<i class="bi bi-shield-lock me-1"></i>
Your export is generated on-demand and delivered directly to your browser. Nothing is stored.
</p>
</div>
@section Scripts {
<script>
document.getElementById('selectAll').addEventListener('click', function () {
document.querySelectorAll('.sheet-check').forEach(cb => cb.checked = true);
});
document.getElementById('selectNone').addEventListener('click', function () {
document.querySelectorAll('.sheet-check').forEach(cb => cb.checked = false);
});
document.getElementById('exportForm').addEventListener('submit', function () {
var btn = document.getElementById('exportBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Generating…';
// Re-enable after 10s in case browser blocks the download dialog
setTimeout(function () {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-download me-1"></i>Generate Export';
}, 10000);
});
</script>
}
@@ -0,0 +1,197 @@
@{
ViewData["Title"] = "Accounting Export";
ViewData["PageIcon"] = "bi-box-arrow-up";
}
<div class="row justify-content-center">
<div class="col-lg-7">
<form asp-action="Export" method="post" id="exportForm">
@Html.AntiForgeryToken()
<!-- Date Range -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-calendar-range me-2 text-primary"></i>Date Range</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Start Date <span class="text-danger">*</span></label>
<input type="date" name="startDate" class="form-control" value="@ViewBag.DefaultStart" required>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">End Date <span class="text-danger">*</span></label>
<input type="date" name="endDate" class="form-control" value="@ViewBag.DefaultEnd" required>
</div>
</div>
<div class="mt-3 d-flex flex-wrap gap-2" id="quickRanges">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setRange('this-month')">This Month</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setRange('last-month')">Last Month</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setRange('this-quarter')">This Quarter</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setRange('last-quarter')">Last Quarter</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setRange('this-year')">This Year</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setRange('last-year')">Last Year</button>
</div>
</div>
</div>
<!-- Format -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-file-earmark-zip me-2 text-primary"></i>Export Format</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="format-card d-block border rounded p-3 cursor-pointer @("quickbooks" == "quickbooks" ? "" : "")" id="card-quickbooks">
<input type="radio" name="format" value="quickbooks" class="d-none format-radio" id="fmt-quickbooks">
<div class="d-flex align-items-start gap-3">
<div class="mt-1 text-success" style="font-size:1.6rem;"><i class="bi bi-building"></i></div>
<div>
<div class="fw-semibold">QuickBooks Desktop</div>
<div class="text-muted small">IIF files — import directly via File &gt; Utilities &gt; Import</div>
<div class="mt-2">
<span class="badge bg-light text-dark border me-1">customers.iif</span>
<span class="badge bg-light text-dark border me-1">invoices_payments.iif</span>
<span class="badge bg-light text-dark border">expenses_bills.iif</span>
</div>
</div>
</div>
</label>
</div>
<div class="col-md-6">
<label class="format-card d-block border rounded p-3 cursor-pointer" id="card-csv">
<input type="radio" name="format" value="csv" class="d-none format-radio" id="fmt-csv" checked>
<div class="d-flex align-items-start gap-3">
<div class="mt-1 text-primary" style="font-size:1.6rem;"><i class="bi bi-filetype-csv"></i></div>
<div>
<div class="fw-semibold">CSV (Universal)</div>
<div class="text-muted small">Works with QuickBooks Online, Xero, Wave, Excel, and more</div>
<div class="mt-2">
<span class="badge bg-light text-dark border me-1">invoices.csv</span>
<span class="badge bg-light text-dark border me-1">payments.csv</span>
<span class="badge bg-light text-dark border">+5 more</span>
</div>
</div>
</div>
</label>
</div>
</div>
</div>
</div>
<!-- What's Included -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-list-check me-2 text-primary"></i>What's Included</h5>
</div>
<div class="card-body">
<div class="row g-2">
<div class="col-sm-6">
<div class="d-flex align-items-center gap-2 text-muted small">
<i class="bi bi-check-circle-fill text-success"></i> Customer list
</div>
</div>
<div class="col-sm-6">
<div class="d-flex align-items-center gap-2 text-muted small">
<i class="bi bi-check-circle-fill text-success"></i> Invoices &amp; line items
</div>
</div>
<div class="col-sm-6">
<div class="d-flex align-items-center gap-2 text-muted small">
<i class="bi bi-check-circle-fill text-success"></i> Payments received
</div>
</div>
<div class="col-sm-6">
<div class="d-flex align-items-center gap-2 text-muted small">
<i class="bi bi-check-circle-fill text-success"></i> Direct expenses
</div>
</div>
<div class="col-sm-6">
<div class="d-flex align-items-center gap-2 text-muted small">
<i class="bi bi-check-circle-fill text-success"></i> Vendor bills (AP)
</div>
</div>
<div class="col-sm-6">
<div class="d-flex align-items-center gap-2 text-muted small">
<i class="bi bi-check-circle-fill text-success"></i> Bill payments
</div>
</div>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary px-4" id="exportBtn">
<i class="bi bi-download me-2"></i>Download Export Package
</button>
<a asp-controller="Reports" asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
@section Scripts {
<script>
// Format card selection
document.querySelectorAll('.format-radio').forEach(function(radio) {
radio.addEventListener('change', updateFormatCards);
});
function updateFormatCards() {
document.querySelectorAll('.format-card').forEach(function(card) {
const radio = card.querySelector('.format-radio');
card.classList.toggle('border-primary', radio.checked);
card.classList.toggle('bg-primary-subtle', radio.checked);
});
}
// Init — mark CSV as selected by default
document.getElementById('fmt-csv').checked = true;
updateFormatCards();
// Prevent double-submit
document.getElementById('exportForm').addEventListener('submit', function() {
const btn = document.getElementById('exportBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Generating…';
setTimeout(function() { btn.disabled = false; btn.innerHTML = '<i class="bi bi-download me-2"></i>Download Export Package'; }, 8000);
});
// Quick date range shortcuts
function setRange(range) {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth(); // 0-indexed
let start, end;
if (range === 'this-month') {
start = new Date(y, m, 1);
end = new Date(y, m + 1, 0);
} else if (range === 'last-month') {
start = new Date(y, m - 1, 1);
end = new Date(y, m, 0);
} else if (range === 'this-quarter') {
const q = Math.floor(m / 3);
start = new Date(y, q * 3, 1);
end = new Date(y, q * 3 + 3, 0);
} else if (range === 'last-quarter') {
const q = Math.floor(m / 3) - 1;
const qy = q < 0 ? y - 1 : y;
const qq = q < 0 ? 3 : q;
start = new Date(qy, qq * 3, 1);
end = new Date(qy, qq * 3 + 3, 0);
} else if (range === 'this-year') {
start = new Date(y, 0, 1);
end = new Date(y, 11, 31);
} else if (range === 'last-year') {
start = new Date(y - 1, 0, 1);
end = new Date(y - 1, 11, 31);
}
const fmt = d => d.toISOString().slice(0, 10);
document.querySelector('[name="startDate"]').value = fmt(start);
document.querySelector('[name="endDate"]').value = fmt(end);
}
</script>
}
@@ -0,0 +1,183 @@
@model PowderCoating.Application.DTOs.Accounting.CreateAccountDto
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "New Account";
ViewData["PageIcon"] = "bi-journal-plus";
ViewData["PageHelpTitle"] = "New Account";
ViewData["PageHelpContent"] = "Add a custom account to the Chart of Accounts. Select a Sub-Type first — it auto-sets the Account Type. Use conventional numbering: 1000s = Assets, 2000s = Liabilities, 3000s = Equity, 4000s = Revenue, 5000s = Cost of Goods, 6000s+ = Expenses.";
bool isInline = ViewBag.Inline == true;
}
@if (!isInline)
{
<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>
}
@if (!isInline)
{
@:<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">
@Html.AntiForgeryToken()
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3"></div>
<div class="row g-3">
<div class="col-sm-4">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="AccountNumber" class="form-label fw-medium mb-0">Account Number <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="Account Number"
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 10001999 Assets, 20002999 Liabilities, 30003999 Equity, 40004999 Revenue, 50005999 Cost of Goods, 60009999 Expenses. Must be unique. Sub-accounts can use decimals (e.g. 6100.1).">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="AccountNumber" class="form-control" placeholder="e.g. 6100" />
<span asp-validation-for="AccountNumber" class="text-danger small"></span>
</div>
<div class="col-sm-8">
<label asp-for="Name" class="form-label fw-medium">Account Name <span class="text-danger">*</span></label>
<input asp-for="Name" class="form-control" placeholder="e.g. Utilities" />
<span asp-validation-for="Name" class="text-danger small"></span>
</div>
<div class="col-sm-6">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="AccountType" class="form-label fw-medium mb-0">Account Type <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="Account Type"
data-bs-content="Asset: things you own. Liability: things you owe. Equity: owner's investment and retained earnings. Revenue: income from sales. Cost of Goods Sold: direct production costs (powder, materials). Expense: operating overhead (rent, utilities). Tip: choosing a Sub-Type auto-sets this field.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<select asp-for="AccountType" asp-items="ViewBag.AccountTypes" class="form-select" id="accountTypeSelect">
<option value="">— Select Type —</option>
</select>
<span asp-validation-for="AccountType" class="text-danger small"></span>
</div>
<div class="col-sm-6">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="AccountSubType" class="form-label fw-medium mb-0">Sub-Type <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="Account Sub-Type"
data-bs-content="A finer classification that determines where the account appears in pickers. For example, only Checking and Savings sub-types appear in the 'Paid From' selector on expenses. Only Accounts Receivable appears in invoice AR pickers. Choosing a sub-type also auto-sets the Account Type above.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<select asp-for="AccountSubType" asp-items="ViewBag.AccountSubTypes" class="form-select" id="accountSubTypeSelect">
<option value="">— Select Sub-Type —</option>
</select>
<span asp-validation-for="AccountSubType" class="text-danger small"></span>
<div class="form-text text-primary" id="typeAutoSetHint" style="display:none">
<i class="bi bi-magic me-1"></i>Account type auto-set based on sub-type.
</div>
</div>
<div class="col-12">
<label asp-for="Description" class="form-label fw-medium">Description</label>
<textarea asp-for="Description" class="form-control" rows="2" placeholder="Optional notes about this account"></textarea>
</div>
<div class="col-12">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="ParentAccountId" class="form-label fw-medium mb-0">Parent Account <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="Parent Account"
data-bs-content="Nest this account under a parent to create a hierarchy — e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<select asp-for="ParentAccountId" asp-items="ViewBag.ParentAccounts" class="form-select">
<option value="">— None (top-level account) —</option>
</select>
</div>
<div class="col-12">
<hr class="my-1" />
<p class="text-muted small mb-2">
<i class="bi bi-info-circle me-1"></i>
Set an opening balance if this account had an existing balance before you started tracking in this system.
</p>
</div>
<div class="col-sm-6">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="OpeningBalance" class="form-label fw-medium mb-0">Opening Balance</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Opening Balance"
data-bs-content="The account's existing balance before you started using this system. Use when migrating from another system so historical balances are correct. Leave at $0 for new accounts with no prior history. Set the As of Date to pin when this balance was accurate.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="OpeningBalance" class="form-control" type="number" step="0.01" min="0" placeholder="0.00" />
</div>
<span asp-validation-for="OpeningBalance" class="text-danger small"></span>
</div>
<div class="col-sm-6">
<label asp-for="OpeningBalanceDate" class="form-label fw-medium">As of Date</label>
<input asp-for="OpeningBalanceDate" class="form-control" type="date" />
<div class="form-text">Leave blank to apply before all transactions.</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input asp-for="IsActive" class="form-check-input" type="checkbox" />
<label asp-for="IsActive" class="form-check-label">Active</label>
</div>
</div>
</div>
@if (!isInline)
{
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Create Account
</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
}
</form>
@if (!isInline)
{
@:</div></div></div></div>
}
<script>
(function () {
// SubType enum values → AccountType enum values (mirrors server-side mapping)
const subTypeToAccountType = {
1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, // Assets
10: 2, 11: 2, 12: 2, 13: 2, // Liabilities
20: 3, 21: 3, // Equity
30: 4, 31: 4, 32: 4, // Revenue
40: 5, // COGS
50: 6, 51: 6, 52: 6, 53: 6, 54: 6, 55: 6, 56: 6,
57: 6, 58: 6, 59: 6, 60: 6, 61: 6, 62: 6, 63: 6, 99: 6 // Expenses
};
const subSel = document.getElementById('accountSubTypeSelect');
const typeSel = document.getElementById('accountTypeSelect');
const hint = document.getElementById('typeAutoSetHint');
if (!subSel || !typeSel) return;
subSel.addEventListener('change', function () {
const mapped = subTypeToAccountType[parseInt(this.value)];
if (mapped) {
typeSel.value = mapped;
if (hint) hint.style.display = '';
} else {
if (hint) hint.style.display = 'none';
}
});
})();
</script>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}
@@ -0,0 +1,165 @@
@model PowderCoating.Application.DTOs.Accounting.EditAccountDto
@{
ViewData["Title"] = "Edit Account";
ViewData["PageIcon"] = "bi-pencil-square";
ViewData["PageHelpTitle"] = "Edit Account";
ViewData["PageHelpContent"] = "You can change number, name, type, sub-type, parent, and opening balance. Changing the account type or sub-type on an account that already has transactions is allowed but use caution — it changes how balances are reported going forward. Inactive accounts are hidden from pickers but preserved in history.";
}
<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="Edit" method="post">
@Html.AntiForgeryToken()
<input asp-for="Id" type="hidden" />
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3"></div>
<div class="row g-3">
<div class="col-sm-4">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="AccountNumber" class="form-label fw-medium mb-0">Account Number <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="Account Number"
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 10001999 Assets, 20002999 Liabilities, 30003999 Equity, 40004999 Revenue, 50005999 Cost of Goods, 60009999 Expenses. Must be unique.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="AccountNumber" class="form-control" />
<span asp-validation-for="AccountNumber" class="text-danger small"></span>
</div>
<div class="col-sm-8">
<label asp-for="Name" class="form-label fw-medium">Account Name <span class="text-danger">*</span></label>
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger small"></span>
</div>
<div class="col-sm-6">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="AccountType" class="form-label fw-medium mb-0">Account Type <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="Account Type"
data-bs-content="Asset: things you own. Liability: things you owe. Equity: owner's investment and retained earnings. Revenue: income from sales. Cost of Goods Sold: direct production costs. Expense: operating overhead. Changing this on an account with existing transactions affects how it appears in financial reports.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<select asp-for="AccountType" asp-items="ViewBag.AccountTypes" class="form-select" id="accountTypeSelect"></select>
<span asp-validation-for="AccountType" class="text-danger small"></span>
</div>
<div class="col-sm-6">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="AccountSubType" class="form-label fw-medium mb-0">Sub-Type <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="Account Sub-Type"
data-bs-content="A finer classification that determines where the account appears in pickers. For example, only Checking and Savings sub-types appear in the 'Paid From' selector on expenses. Choosing a sub-type also auto-sets the Account Type.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<select asp-for="AccountSubType" asp-items="ViewBag.AccountSubTypes" class="form-select" id="accountSubTypeSelect"></select>
<span asp-validation-for="AccountSubType" class="text-danger small"></span>
<div class="form-text text-primary" id="typeAutoSetHint" style="display:none">
<i class="bi bi-magic me-1"></i>Account type auto-set based on sub-type.
</div>
</div>
<div class="col-12">
<label asp-for="Description" class="form-label fw-medium">Description</label>
<textarea asp-for="Description" class="form-control" rows="2"></textarea>
</div>
<div class="col-12">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="ParentAccountId" class="form-label fw-medium mb-0">Parent Account</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Parent Account"
data-bs-content="Nest this account under a parent to create a hierarchy — e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<select asp-for="ParentAccountId" asp-items="ViewBag.ParentAccounts" class="form-select">
<option value="">— None —</option>
</select>
</div>
<div class="col-12">
<hr class="my-1" />
<p class="text-muted small mb-2">
<i class="bi bi-info-circle me-1"></i>
Opening balance represents the account's value before transactions were recorded in this system.
</p>
</div>
<div class="col-sm-6">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="OpeningBalance" class="form-label fw-medium mb-0">Opening Balance</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Opening Balance"
data-bs-content="The account's balance before transactions were recorded in this system. Changing this retroactively adjusts the running balance across all historical ledger entries. Set the As of Date to indicate when this balance was accurate.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="OpeningBalance" class="form-control" type="number" step="0.01" min="0" placeholder="0.00" />
</div>
<span asp-validation-for="OpeningBalance" class="text-danger small"></span>
</div>
<div class="col-sm-6">
<label asp-for="OpeningBalanceDate" class="form-label fw-medium">As of Date</label>
<input asp-for="OpeningBalanceDate" class="form-control" type="date" />
<div class="form-text">Leave blank to apply before all transactions.</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input asp-for="IsActive" class="form-check-input" type="checkbox" />
<label asp-for="IsActive" class="form-check-label">Active</label>
</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="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<script>
// Auto-set AccountType when SubType is changed
const subTypeToAccountType = {
1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, // Asset
10: 2, 11: 2, 12: 2, 13: 2, // Liability
20: 3, 21: 3, // Equity
30: 4, 31: 4, 32: 4, // Revenue
40: 5, // CostOfGoods
50: 6, 51: 6, 52: 6, 53: 6, 54: 6, 55: 6, 56: 6,
57: 6, 58: 6, 59: 6, 60: 6, 61: 6, 62: 6, 63: 6, 99: 6 // Expense
};
document.getElementById('accountSubTypeSelect').addEventListener('change', function () {
const mapped = subTypeToAccountType[parseInt(this.value)];
if (mapped) {
document.getElementById('accountTypeSelect').value = mapped;
document.getElementById('typeAutoSetHint').style.display = '';
} else {
document.getElementById('typeAutoSetHint').style.display = 'none';
}
});
</script>
}
@@ -0,0 +1,235 @@
@model List<IGrouping<PowderCoating.Core.Enums.AccountType, PowderCoating.Application.DTOs.Accounting.AccountListDto>>
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Chart of Accounts";
ViewData["PageIcon"] = "bi-journal-bookmark";
ViewData["PageHelpTitle"] = "Chart of Accounts";
ViewData["PageHelpContent"] = "The master list of financial accounts grouped by type: Asset (what you own), Liability (what you owe), Equity (owner&apos;s stake), Revenue (income), Cost of Goods Sold (direct costs), Expense (overhead). Click any row to view its ledger. System accounts (sys badge) cannot be deleted. Recalculate Balances recomputes every account&apos;s balance from transaction history.";
string TypeIcon(AccountType t) => t switch
{
AccountType.Asset => "bi-safe",
AccountType.Liability => "bi-credit-card",
AccountType.Equity => "bi-bar-chart-line",
AccountType.Revenue => "bi-graph-up-arrow",
AccountType.CostOfGoods => "bi-box-seam",
AccountType.Expense => "bi-receipt-cutoff",
_ => "bi-journal"
};
string TypeColor(AccountType t) => t switch
{
AccountType.Asset => "success",
AccountType.Liability => "danger",
AccountType.Equity => "primary",
AccountType.Revenue => "info",
AccountType.CostOfGoods => "warning",
AccountType.Expense => "secondary",
_ => "secondary"
};
string TypeLabel(AccountType t) => t switch
{
AccountType.CostOfGoods => "Cost of Goods Sold",
_ => t.ToString()
};
}
<div class="d-flex justify-content-end mb-4">
<div class="d-flex gap-2">
<form asp-action="FixOpeningBalanceSigns" method="post"
onsubmit="return confirm('Fix opening balance signs for Revenue, Liability, and Equity accounts imported from QuickBooks? This corrects negative balances caused by QB\'s sign convention.')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-warning"
title="Fixes negative opening balances on Revenue/Liability/Equity accounts imported from QuickBooks IIF">
<i class="bi bi-sign-stop me-1"></i>Fix QB Import Signs
</button>
</form>
<form id="recalcBalancesForm" asp-action="RecalculateBalances" method="post">
@Html.AntiForgeryToken()
<button type="button" id="btnRecalcBalances" class="btn btn-outline-secondary"
title="Recompute CurrentBalance for all accounts from ledger transactions">
<i class="bi bi-arrow-repeat me-1"></i>Recalculate Balances
</button>
</form>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>New Account
</a>
</div>
</div>
@* Bootstrap toast — confirmation before recalculating balances *@
<div class="toast-container position-fixed top-50 start-50 translate-middle p-3" style="z-index:1100">
<div id="recalcConfirmToast" class="toast align-items-center border-0 bg-dark text-white" role="alert" aria-atomic="true" data-bs-autohide="false">
<div class="toast-body d-flex flex-column gap-2 py-3 px-3">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-arrow-repeat fs-5 text-warning"></i>
<span class="fw-semibold">Recalculate all account balances?</span>
</div>
<p class="mb-1 small text-white-50">This will recompute every account's balance from transaction history. The page will reload when done.</p>
<div class="d-flex gap-2">
<button id="btnRecalcConfirm" type="button" class="btn btn-warning btn-sm fw-semibold">
<i class="bi bi-check-lg me-1"></i>Yes, recalculate
</button>
<button type="button" class="btn btn-outline-light btn-sm" data-bs-dismiss="toast">Cancel</button>
</div>
</div>
</div>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<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" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (!Model.Any())
{
<div class="card shadow-sm border-0">
<div class="card-body text-center py-5">
<i class="bi bi-journal-bookmark display-4 text-muted mb-3 d-block"></i>
<h5 class="mb-2">No accounts set up yet</h5>
<p class="text-muted mb-4">Get started quickly by loading a standard chart of accounts<br>tailored for a powder coating business.</p>
<form asp-action="SeedDefaultAccounts" method="post">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-primary">
<i class="bi bi-magic me-1"></i>Create Default Accounts
</button>
<a asp-action="Create" class="btn btn-outline-secondary ms-2">
<i class="bi bi-plus-lg me-1"></i>Add Manually
</a>
</form>
</div>
</div>
}
@foreach (var group in Model)
{
var color = TypeColor(group.Key);
var icon = TypeIcon(group.Key);
var label = TypeLabel(group.Key);
<div class="card shadow-sm mb-4">
<div class="card-header bg-@color bg-opacity-10 border-@color border-opacity-25">
<div class="d-flex align-items-center gap-2">
<i class="bi @icon text-@color fs-5"></i>
<h6 class="mb-0 fw-semibold text-@color">@label</h6>
<span class="badge bg-@color ms-auto">@group.Count() accounts</span>
</div>
</div>
<div class="card-body p-0">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th style="width:110px">Number</th>
<th>Name</th>
<th>Sub-Type</th>
<th>Parent</th>
<th style="width:80px">Status</th>
<th style="width:120px" class="text-end">Balance</th>
<th style="width:150px" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var acct in group.OrderBy(a => a.AccountNumber))
{
<tr class="@(acct.IsActive ? "" : "text-muted opacity-75") table-row-link"
data-href="@Url.Action("Ledger", new { id = acct.Id })"
style="cursor:pointer">
<td>
<code class="text-@color">@acct.AccountNumber</code>
</td>
<td>
<span class="fw-medium">@acct.Name</span>
@if (acct.IsSystem)
{
<span class="badge bg-secondary ms-1" title="System account — cannot be deleted">sys</span>
}
</td>
<td><span class="text-muted small">@acct.AccountSubType</span></td>
<td>
@if (!string.IsNullOrEmpty(acct.ParentAccountName))
{
<span class="text-muted small">@acct.ParentAccountName</span>
}
</td>
<td>
@if (acct.IsActive)
{
<span class="badge bg-success-subtle text-success">Active</span>
}
else
{
<span class="badge bg-secondary-subtle text-secondary">Inactive</span>
}
</td>
<td class="text-end text-nowrap fw-medium @(acct.CurrentBalance >= 0 ? "text-success" : "text-danger")">
@acct.CurrentBalance.ToString("C")
</td>
<td class="text-end text-nowrap">
<a asp-action="Ledger" asp-route-id="@acct.Id" class="btn btn-sm btn-outline-secondary me-1" title="View Ledger">
<i class="bi bi-journal-text"></i>
</a>
<a asp-action="Edit" asp-route-id="@acct.Id" class="btn btn-sm btn-outline-primary me-1" title="Edit">
<i class="bi bi-pencil"></i>
</a>
@if (!acct.IsSystem)
{
<form asp-action="Delete" asp-route-id="@acct.Id" method="post" class="d-inline"
onsubmit="return confirm('Delete account @acct.AccountNumber @acct.Name?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</button>
</form>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@if (!Model.Any())
{
<div class="text-center py-5">
<i class="bi bi-journal-x display-3 text-muted"></i>
<p class="mt-3 text-muted">No accounts found. Use the Seed Data page to generate default accounts.</p>
</div>
}
@section Scripts {
<script>
document.querySelectorAll('tr.table-row-link').forEach(row => {
row.addEventListener('click', e => {
if (e.target.closest('a, button, form')) return;
window.location.href = row.dataset.href;
});
});
// Recalculate Balances — show confirmation toast instead of native confirm()
const recalcToast = new bootstrap.Toast(document.getElementById('recalcConfirmToast'));
document.getElementById('btnRecalcBalances').addEventListener('click', () => {
recalcToast.show();
});
document.getElementById('btnRecalcConfirm').addEventListener('click', () => {
recalcToast.hide();
document.getElementById('recalcBalancesForm').submit();
});
</script>
}
@@ -0,0 +1,283 @@
@model PowderCoating.Application.DTOs.Accounting.AccountLedgerDto
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = $"Ledger — {Model.AccountNumber} {Model.Name}";
ViewData["PageIcon"] = "bi-journal-text";
ViewData["PageHelpTitle"] = "Account Ledger";
ViewData["PageHelpContent"] = "A chronological list of every transaction posted to this account. Click any Reference to open the source record. Debit increases asset and expense accounts; credit increases liability, equity, and revenue accounts. Use the date range or quick buttons (This Month, YTD, etc.) to narrow the view.";
string typeColor = Model.AccountType switch
{
AccountType.Asset => "success",
AccountType.Liability => "danger",
AccountType.Equity => "primary",
AccountType.Revenue => "info",
AccountType.CostOfGoods => "warning",
AccountType.Expense => "secondary",
_ => "secondary"
};
string typeIcon = Model.AccountType switch
{
AccountType.Asset => "bi-safe",
AccountType.Liability => "bi-credit-card",
AccountType.Equity => "bi-bar-chart-line",
AccountType.Revenue => "bi-graph-up-arrow",
AccountType.CostOfGoods => "bi-box-seam",
AccountType.Expense => "bi-receipt-cutoff",
_ => "bi-journal"
};
string typeLabel = Model.AccountType == AccountType.CostOfGoods ? "Cost of Goods Sold" : Model.AccountType.ToString();
// Derive from AccountSubType (more reliable than AccountType which users can misconfigure)
bool normalDebitBalance =
Model.AccountSubType == AccountSubType.Checking ||
Model.AccountSubType == AccountSubType.Savings ||
Model.AccountSubType == AccountSubType.AccountsReceivable ||
Model.AccountSubType == AccountSubType.Inventory ||
Model.AccountSubType == AccountSubType.FixedAsset ||
Model.AccountSubType == AccountSubType.OtherCurrentAsset ||
Model.AccountSubType == AccountSubType.OtherAsset ||
Model.AccountSubType == AccountSubType.CostOfGoodsSold ||
(int)Model.AccountSubType >= 50; // all Expense subtypes
string balanceLabel = normalDebitBalance ? "Debit Balance" : "Credit Balance";
// Quick-range helpers for the filter bar
var today = DateTime.Today;
var thisMonthFrom = new DateTime(today.Year, today.Month, 1).ToString("yyyy-MM-dd");
var thisMonthTo = today.ToString("yyyy-MM-dd");
var lastMonthFrom = new DateTime(today.Year, today.Month, 1).AddMonths(-1).ToString("yyyy-MM-dd");
var lastMonthTo = new DateTime(today.Year, today.Month, 1).AddDays(-1).ToString("yyyy-MM-dd");
var qtdFrom = new DateTime(today.Year, ((today.Month - 1) / 3) * 3 + 1, 1).ToString("yyyy-MM-dd");
var ytdFrom = new DateTime(today.Year, 1, 1).ToString("yyyy-MM-dd");
}
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a asp-action="Index">Chart of Accounts</a></li>
<li class="breadcrumb-item active">@Model.AccountNumber @Model.Name</li>
</ol>
</nav>
<!-- Account header -->
<div class="d-flex align-items-center gap-3 mb-4">
<div class="rounded-3 p-3 bg-@typeColor bg-opacity-10 text-@typeColor">
<i class="bi @typeIcon fs-3"></i>
</div>
<div>
<p class="text-muted mb-0">
<span class="badge bg-@typeColor bg-opacity-75 me-1">@typeLabel</span>
<span class="text-muted small">@Model.AccountSubType · @balanceLabel</span>
</p>
</div>
<div class="ms-auto">
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil me-1"></i>Edit Account
</a>
</div>
</div>
<!-- Date range filter -->
<div class="card shadow-sm mb-4">
<div class="card-body py-3">
<form method="get" class="row g-2 align-items-end">
<input type="hidden" name="id" value="@Model.Id" />
<div class="col-auto">
<label class="form-label form-label-sm mb-1">From</label>
<input type="date" name="from" class="form-control form-control-sm" value="@Model.From.ToString("yyyy-MM-dd")" />
</div>
<div class="col-auto">
<label class="form-label form-label-sm mb-1">To</label>
<input type="date" name="to" class="form-control form-control-sm" value="@Model.To.ToString("yyyy-MM-dd")" />
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-funnel me-1"></i>Filter
</button>
</div>
<div class="col-auto ms-2">
<div class="btn-group btn-group-sm" role="group">
<a href="@Url.Action("Ledger", new { id = Model.Id, from = thisMonthFrom, to = thisMonthTo })"
class="btn btn-outline-secondary">This Month</a>
<a href="@Url.Action("Ledger", new { id = Model.Id, from = lastMonthFrom, to = lastMonthTo })"
class="btn btn-outline-secondary">Last Month</a>
<a href="@Url.Action("Ledger", new { id = Model.Id, from = qtdFrom, to = thisMonthTo })"
class="btn btn-outline-secondary">QTD</a>
<a href="@Url.Action("Ledger", new { id = Model.Id, from = ytdFrom, to = thisMonthTo })"
class="btn btn-outline-secondary">YTD</a>
</div>
</div>
</form>
</div>
</div>
<!-- Summary cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100 text-center">
<div class="card-body py-3">
<div class="h5 text-muted mb-1">@Model.OpeningBalance.ToString("C")</div>
<div class="text-muted small">Opening Balance</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100 text-center">
<div class="card-body py-3">
@{
var netChange = normalDebitBalance
? Model.PeriodDebits - Model.PeriodCredits
: Model.PeriodCredits - Model.PeriodDebits;
var netColor = netChange >= 0 ? "success" : "danger";
}
<div class="h5 text-@netColor mb-1">@netChange.ToString("C")</div>
<div class="text-muted small">Period Net Change</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100 text-center">
<div class="card-body py-3">
<div class="h5 text-success mb-1">@Model.PeriodDebits.ToString("C")</div>
<div class="text-muted small">Total Debits</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100 text-center border-@typeColor border-opacity-50">
<div class="card-body py-3">
<div class="h5 fw-bold text-@typeColor mb-1">@Model.ClosingBalance.ToString("C")</div>
<div class="text-muted small">Closing @balanceLabel</div>
</div>
</div>
</div>
</div>
<!-- Ledger table -->
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<span class="fw-semibold">
<i class="bi bi-journal-text me-1"></i>
Transactions
<span class="text-muted fw-normal small ms-1">
@Model.From.ToString("MMM d") @Model.To.ToString("MMM d, yyyy")
</span>
</span>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Transaction Ledger"
data-bs-content="Each row is one side of a double-entry posting. Reference links back to the source record (invoice, bill, expense, or payment). Debit and Credit show which side of the entry hit this account. Running Balance updates after each line. The Opening Balance row shows the balance brought forward from before the selected date range.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<span class="badge bg-secondary">@Model.Entries.Count entries</span>
</div>
@if (!Model.Entries.Any())
{
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-journal-x fs-1 d-block mb-2"></i>
<p class="mb-0">No transactions found for this account in the selected period.</p>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width:105px">Date</th>
<th style="width:160px">Reference</th>
<th style="width:140px">Source</th>
<th>Description</th>
<th class="text-end" style="width:110px">Debit</th>
<th class="text-end" style="width:110px">Credit</th>
<th class="text-end" style="width:120px">Balance</th>
</tr>
</thead>
<tbody>
<!-- Opening balance row -->
<tr class="table-light">
<td class="text-muted small">@Model.From.ToString("MM/dd/yyyy")</td>
<td><span class="fw-medium text-muted">—</span></td>
<td><span class="badge bg-dark-subtle text-dark">Opening Balance</span></td>
<td class="text-muted small">Balance brought forward as of @Model.From.ToString("MMM d, yyyy")</td>
<td></td>
<td></td>
<td class="text-end">
<span class="fw-semibold @(Model.OpeningBalance < 0 ? "text-danger" : "")">
@Model.OpeningBalance.ToString("C")
</span>
</td>
</tr>
@foreach (var entry in Model.Entries)
{
<tr>
<td class="text-muted small">@entry.Date.ToString("MM/dd/yyyy")</td>
<td>
@if (entry.LinkController != null && entry.LinkId.HasValue)
{
<a asp-controller="@entry.LinkController" asp-action="Details"
asp-route-id="@entry.LinkId" class="text-decoration-none fw-medium">
@entry.Reference
</a>
}
else
{
<span class="fw-medium">@entry.Reference</span>
}
</td>
<td>
@{
string sourceBadge = entry.Source switch
{
"Invoice" => "bg-info-subtle text-info",
"Invoice Payment" => "bg-success-subtle text-success",
"Customer Payment" => "bg-success-subtle text-success",
"Bill" => "bg-warning-subtle text-warning",
"Bill Payment" => "bg-danger-subtle text-danger",
"Expense" => "bg-secondary-subtle text-secondary",
"Sales Tax" => "bg-primary-subtle text-primary",
_ => "bg-secondary-subtle text-secondary"
};
}
<span class="badge @sourceBadge">@entry.Source</span>
</td>
<td class="text-muted small">@entry.Description</td>
<td class="text-end">
@if (entry.Debit > 0)
{
<span class="text-success fw-medium">@entry.Debit.ToString("C")</span>
}
</td>
<td class="text-end">
@if (entry.Credit > 0)
{
<span class="text-danger fw-medium">@entry.Credit.ToString("C")</span>
}
</td>
<td class="text-end">
<span class="fw-semibold @(entry.RunningBalance < 0 ? "text-danger" : "")">
@entry.RunningBalance.ToString("C")
</span>
</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td colspan="4" class="text-muted small">Period Totals</td>
<td class="text-end text-success">@Model.PeriodDebits.ToString("C")</td>
<td class="text-end text-danger">@Model.PeriodCredits.ToString("C")</td>
<td class="text-end">@Model.ClosingBalance.ToString("C")</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -0,0 +1,200 @@
@model AiUsageReportViewModel
@{
ViewData["Title"] = "AI Usage Report";
ViewData["PageIcon"] = "bi-robot";
string FeatureIcon(string? f) => f switch {
"PhotoQuote" => "bi-camera",
"HelpChat" => "bi-chat-dots",
"ReceiptScan" => "bi-receipt",
"AccountSuggest" => "bi-tags",
"ArFollowUp" => "bi-envelope",
"FinancialSummary" => "bi-bar-chart",
"CashFlowForecast" => "bi-graph-up",
"AnomalyDetection" => "bi-exclamation-triangle",
_ => "bi-cpu"
};
}
<div class="container-fluid mt-3">
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h4 class="mb-0"><i class="bi bi-robot me-2 text-primary"></i>AI Usage Report</h4>
<p class="text-muted small mb-0">Anthropic API call volume and photo uploads per tenant. Last 30 days unless noted.</p>
</div>
</div>
<!-- Summary Cards -->
<div class="row g-3 mb-4">
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-center gap-3">
<div class="rounded-circle bg-primary bg-opacity-10 d-flex align-items-center justify-content-center" style="width:48px;height:48px;flex-shrink:0">
<i class="bi bi-cpu text-primary fs-4"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.TotalCallsLast30Days.ToString("N0")</div>
<div class="text-muted small">AI Calls — Last 30 Days</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-center gap-3">
<div class="rounded-circle bg-success bg-opacity-10 d-flex align-items-center justify-content-center" style="width:48px;height:48px;flex-shrink:0">
<i class="bi bi-activity text-success fs-4"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.TotalCallsToday.ToString("N0")</div>
<div class="text-muted small">AI Calls Today</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-center gap-3">
<div class="rounded-circle bg-info bg-opacity-10 d-flex align-items-center justify-content-center" style="width:48px;height:48px;flex-shrink:0">
<i class="bi bi-camera text-info fs-4"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.TotalPhotosUploaded.ToString("N0")</div>
<div class="text-muted small">Total AI Photos Uploaded</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-center gap-3">
<div class="rounded-circle bg-warning bg-opacity-10 d-flex align-items-center justify-content-center" style="width:48px;height:48px;flex-shrink:0">
<i class="bi bi-building text-warning fs-4"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.CompaniesActiveToday</div>
<div class="text-muted small">Companies Active Today</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tier Legend -->
<div class="d-flex flex-wrap gap-2 mb-3 align-items-center">
<span class="text-muted small fw-semibold me-1">Usage Tier (last 30 days):</span>
<span class="badge bg-secondary">Inactive — 0 calls</span>
<span class="badge bg-success">Light — 110</span>
<span class="badge bg-primary">Regular — 1150</span>
<span class="badge bg-warning text-dark">Heavy — 51200</span>
<span class="badge bg-danger">Power User — 200+</span>
</div>
<!-- Main Table -->
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center justify-content-between">
<span class="fw-semibold">Per-Company Breakdown</span>
<span class="text-muted small">@Model.Rows.Count companies total</span>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle" id="aiUsageTable">
<thead class="table-light">
<tr>
<th>Company</th>
<th>Plan</th>
<th class="text-center" title="Calls today">Today</th>
<th class="text-center" title="Calls in the last 7 days">7 Days</th>
<th class="text-center" title="Calls in the last 30 days">30 Days</th>
<th class="text-center" title="All-time call count">All Time</th>
<th class="text-center" title="Total AI analysis photos uploaded">Photos</th>
<th>Top Feature (30d)</th>
<th class="text-center">Tier</th>
</tr>
</thead>
<tbody>
@foreach (var row in Model.Rows)
{
<tr>
<td>
<a asp-controller="Companies" asp-action="Details" asp-route-id="@row.CompanyId"
class="fw-semibold text-decoration-none">
@row.CompanyName
</a>
@if (!row.IsActive)
{
<span class="badge bg-secondary ms-1">Inactive</span>
}
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">
@row.Plan
</span>
</td>
<td class="text-center @(row.Today > 0 ? "fw-semibold" : "text-muted")">
@(row.Today > 0 ? row.Today.ToString("N0") : "—")
</td>
<td class="text-center @(row.Last7Days > 0 ? "fw-semibold" : "text-muted")">
@(row.Last7Days > 0 ? row.Last7Days.ToString("N0") : "—")
</td>
<td class="text-center @(row.Last30Days > 0 ? "fw-semibold" : "text-muted")">
@(row.Last30Days > 0 ? row.Last30Days.ToString("N0") : "—")
</td>
<td class="text-center @(row.AllTime > 0 ? "" : "text-muted")">
@(row.AllTime > 0 ? row.AllTime.ToString("N0") : "—")
</td>
<td class="text-center @(row.PhotoCount > 0 ? "" : "text-muted")">
@if (row.PhotoCount > 0)
{
<span><i class="bi bi-camera me-1"></i>@row.PhotoCount.ToString("N0")</span>
}
else
{
<span>—</span>
}
</td>
<td>
@if (row.TopFeature != null)
{
<span title="@string.Join(", ", row.FeatureBreakdown.OrderByDescending(kv => kv.Value).Select(kv => $"{row.FeatureDisplayName(kv.Key)}: {kv.Value}"))">
<i class="bi @FeatureIcon(row.TopFeature) me-1 text-muted"></i>
@row.FeatureDisplayName(row.TopFeature)
@if (row.FeatureBreakdown.Count > 1)
{
<span class="badge bg-light text-muted border ms-1" style="font-size:.7rem">
+@(row.FeatureBreakdown.Count - 1) more
</span>
}
</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="text-center">
<span class="badge @row.TierBadgeClass">@row.UsageTier</span>
</td>
</tr>
}
@if (!Model.Rows.Any())
{
<tr>
<td colspan="9" class="text-center text-muted py-5">
<i class="bi bi-robot fs-1 d-block mb-2 opacity-25"></i>
No AI usage logged yet. Usage data will appear here once tenants start using AI features.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,25 @@
@using PowderCoating.Core.Entities
@model Announcement
@{
ViewData["Title"] = "New Announcement";
}
<div class="container-fluid py-3" style="max-width:700px">
<div class="d-flex align-items-center gap-3 mb-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-left me-1"></i>Back</a>
<h4 class="mb-0"><i class="bi bi-megaphone me-2 text-primary"></i>New Announcement</h4>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body">
<form method="post" asp-action="Create">
@Html.AntiForgeryToken()
@await Html.PartialAsync("_AnnouncementForm", Model)
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Create</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
@@ -0,0 +1,25 @@
@using PowderCoating.Core.Entities
@model Announcement
@{
ViewData["Title"] = "Edit Announcement";
}
<div class="container-fluid py-3" style="max-width:700px">
<div class="d-flex align-items-center gap-3 mb-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-left me-1"></i>Back</a>
<h4 class="mb-0"><i class="bi bi-megaphone me-2 text-primary"></i>Edit Announcement</h4>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body">
<form method="post" asp-action="Edit" asp-route-id="@Model.Id">
@Html.AntiForgeryToken()
@await Html.PartialAsync("_AnnouncementForm", Model)
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Save</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
@@ -0,0 +1,164 @@
@using PowderCoating.Core.Entities
@model List<Announcement>
@{
ViewData["Title"] = "Announcements";
string TypeBadge(string type) => type switch {
"success" => "bg-success",
"warning" => "bg-warning",
"danger" => "bg-danger",
_ => "bg-info"
};
}
@section Styles {
<style>
[data-bs-theme="dark"] a.text-dark { color: var(--bs-body-color) !important; }
</style>
}
<div class="container-fluid py-3">
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0"><i class="bi bi-megaphone me-2 text-primary"></i>Announcements</h4>
<a asp-action="Create" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>New Announcement
</a>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-dismissible mb-3" role="alert">
@TempData["Success"]<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 small">
<thead class="table-light">
<tr>
<th>Title</th>
<th>Type</th>
<th>Target</th>
<th>Starts</th>
<th>Expires</th>
<th>Active</th>
<th>Dismissible</th>
<th></th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr><td colspan="8" class="text-center text-muted py-5">No announcements yet.</td></tr>
}
@foreach (var a in Model)
{
var now = DateTime.UtcNow;
var isLive = a.IsActive && a.StartsAt <= now && (a.ExpiresAt == null || a.ExpiresAt > now);
<tr class="@(!a.IsActive ? "opacity-50" : "")" style="cursor:pointer"
onclick="window.location='@Url.Action("Edit", new { id = a.Id })'">
<td>
<div class="fw-medium">@a.Title</div>
<small class="text-muted">@a.Message.Substring(0, Math.Min(60, a.Message.Length))@(a.Message.Length > 60 ? "…" : "")</small>
</td>
<td><span class="badge @TypeBadge(a.Type)">@a.Type</span></td>
<td>
@if (a.Target == "All") { <span class="badge bg-secondary">All</span> }
else if (a.Target == "Plan") { <span class="badge bg-primary">Plan @a.TargetPlan</span> }
else { <span class="badge bg-warning">Co. #@a.TargetCompanyId</span> }
</td>
<td>@a.StartsAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yy HH:mm")</td>
<td>@(a.ExpiresAt.HasValue ? a.ExpiresAt.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yy HH:mm") : "Never")</td>
<td>
@if (isLive)
{ <span class="badge bg-success">Live</span> }
else if (!a.IsActive)
{ <span class="badge bg-secondary">Disabled</span> }
else if (a.ExpiresAt.HasValue && a.ExpiresAt < now)
{ <span class="badge bg-secondary">Expired</span> }
else
{ <span class="badge bg-warning">Scheduled</span> }
</td>
<td>
@(a.IsDismissible
? Html.Raw("<i class=\"bi bi-check-circle-fill text-success\"></i>")
: Html.Raw("<i class=\"bi bi-dash-circle text-muted\"></i>"))
</td>
<td class="text-end" onclick="event.stopPropagation()">
<a asp-action="Edit" asp-route-id="@a.Id"
class="btn btn-sm btn-outline-primary py-0 px-2 me-1">
<i class="bi bi-pencil"></i>
</a>
<form method="post" asp-action="ResetDismissals" asp-route-id="@a.Id" class="d-inline"
onsubmit="return confirm('Reset all dismissals? The announcement will reappear for all users.')">
@Html.AntiForgeryToken()
<button class="btn btn-sm btn-outline-warning py-0 px-2" title="Reset dismissals">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</form>
<form method="post" asp-action="Delete" asp-route-id="@a.Id" class="d-inline"
onsubmit="return confirm('Delete this announcement?')">
@Html.AntiForgeryToken()
<button class="btn btn-sm btn-outline-danger py-0 px-2">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile card view — shown on screens < 992px -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@if (!Model.Any())
{
<p class="text-center text-muted py-4">No announcements yet.</p>
}
@foreach (var a in Model)
{
var now = DateTime.UtcNow;
var isLive = a.IsActive && a.StartsAt <= now && (a.ExpiresAt == null || a.ExpiresAt > now);
string statusBadge, statusText;
if (isLive) { statusBadge = "bg-success"; statusText = "Live"; }
else if (!a.IsActive) { statusBadge = "bg-secondary"; statusText = "Disabled"; }
else if (a.ExpiresAt.HasValue && a.ExpiresAt < now) { statusBadge = "bg-secondary"; statusText = "Expired"; }
else { statusBadge = "bg-warning"; statusText = "Scheduled"; }
<a href="@Url.Action("Edit", new { id = a.Id })" class="mobile-data-card text-decoration-none">
<div class="mobile-card-header">
<div class="mobile-card-icon bg-primary"><i class="bi bi-megaphone"></i></div>
<div class="mobile-card-title">
<h6>@a.Title</h6>
<small>@a.StartsAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yy HH:mm")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value"><span class="badge @statusBadge">@statusText</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Target</span>
<span class="mobile-card-value">
@if (a.Target == "All") { <span class="badge bg-secondary">All</span> }
else if (a.Target == "Plan") { <span class="badge bg-primary">Plan @a.TargetPlan</span> }
else { <span class="badge bg-warning">Co. #@a.TargetCompanyId</span> }
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Expires</span>
<span class="mobile-card-value">@(a.ExpiresAt.HasValue ? a.ExpiresAt.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yy") : "Never")</span>
</div>
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-primary">Edit →</span>
</div>
</a>
}
</div>
</div>
</div>
</div>
@@ -0,0 +1,122 @@
@using PowderCoating.Core.Entities
@model Announcement
@{
var planConfigs = (dynamic)ViewBag.PlanConfigs;
var companies = (dynamic)ViewBag.Companies;
}
<div class="row g-3">
<div class="col-12">
<label class="form-label fw-medium">Title <span class="text-danger">*</span></label>
<input asp-for="Title" class="form-control" placeholder="e.g. Scheduled maintenance tonight" required />
<span asp-validation-for="Title" class="text-danger small"></span>
</div>
<div class="col-12">
<label class="form-label fw-medium">Message <span class="text-danger">*</span></label>
<textarea asp-for="Message" class="form-control" rows="3"
placeholder="The platform will be offline for maintenance on Saturday from 24 AM ET." required></textarea>
<span asp-validation-for="Message" class="text-danger small"></span>
</div>
<div class="col-md-4">
<label class="form-label fw-medium">Type</label>
<select asp-for="Type" class="form-select">
<option value="info">Info (blue)</option>
<option value="success">Success (green)</option>
<option value="warning">Warning (yellow)</option>
<option value="danger">Danger (red)</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-medium">Target Audience</label>
<select asp-for="Target" class="form-select" id="targetSelect" onchange="toggleTarget()">
<option value="All">All companies</option>
<option value="Plan">Specific plan</option>
<option value="Company">Specific company</option>
</select>
</div>
<div class="col-md-4">
<div id="planTargetGroup" style="display:none">
<label class="form-label fw-medium">Plan</label>
<select asp-for="TargetPlan" class="form-select">
<option value="">— select —</option>
@foreach (var p in planConfigs)
{
<option value="@p.Plan">@p.DisplayName</option>
}
</select>
</div>
<div id="companyTargetGroup" style="display:none">
<label class="form-label fw-medium">Company</label>
<select asp-for="TargetCompanyId" class="form-select">
<option value="">— select —</option>
@foreach (var c in companies)
{
<option value="@c.Id">@c.CompanyName</option>
}
</select>
</div>
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Starts At</label>
<input asp-for="StartsAt" type="datetime-local" class="form-control"
value="@Model.StartsAt.Tz(ViewBag.CompanyTimeZone as string).ToString("yyyy-MM-ddTHH:mm")" />
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Expires At <span class="text-muted fw-normal">(optional)</span></label>
<input asp-for="ExpiresAt" type="datetime-local" class="form-control"
value="@(Model.ExpiresAt.HasValue ? Model.ExpiresAt.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("yyyy-MM-ddTHH:mm") : "")" />
<div class="form-text">Leave blank to never expire.</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch mt-2">
<input asp-for="IsActive" class="form-check-input" type="checkbox" />
<label asp-for="IsActive" class="form-check-label">Active (visible to users)</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch mt-2">
<input asp-for="IsDismissible" class="form-check-input" type="checkbox" />
<label asp-for="IsDismissible" class="form-check-label">Dismissible by users</label>
</div>
</div>
@* Live preview *@
<div class="col-12">
<label class="form-label fw-medium text-muted">Preview</label>
<div id="announcementPreview" class="alert mb-0" role="alert">
<strong id="previewTitle">@Model.Title</strong>
<span id="previewMessage"> — @Model.Message</span>
</div>
</div>
</div>
<script>
function toggleTarget() {
const v = document.getElementById('targetSelect').value;
document.getElementById('planTargetGroup').style.display = v === 'Plan' ? '' : 'none';
document.getElementById('companyTargetGroup').style.display = v === 'Company' ? '' : 'none';
}
toggleTarget();
// Live preview
const typeMap = { info: 'alert-info', success: 'alert-success', warning: 'alert-warning', danger: 'alert-danger' };
function updatePreview() {
const type = document.getElementById('Type').value;
const preview = document.getElementById('announcementPreview');
preview.className = 'alert mb-0 ' + (typeMap[type] || 'alert-info');
document.getElementById('previewTitle').textContent = document.getElementById('Title').value || 'Title';
document.getElementById('previewMessage').textContent = ' — ' + (document.getElementById('Message').value || 'Message');
}
document.getElementById('Type')?.addEventListener('change', updatePreview);
document.getElementById('Title')?.addEventListener('input', updatePreview);
document.getElementById('Message')?.addEventListener('input', updatePreview);
updatePreview();
</script>
@@ -0,0 +1,174 @@
@{
ViewData["Title"] = "Schedule";
ViewData["PageIcon"] = "bi-calendar3";
ViewData["PageHelpTitle"] = "Schedule";
ViewData["PageHelpContent"] = "Master schedule view showing jobs, appointments, and maintenance together. Jobs appear as all-day banners; appointments as timed blocks. Drag unscheduled jobs from the left panel onto any day to schedule them. Drag jobs between days to reschedule. Click any event to view details.";
var currentView = ViewBag.CurrentView ?? "month";
var currentDate = ViewBag.CurrentDate ?? DateTime.Today;
}
<link rel="stylesheet" href="~/css/appointment-calendar.css" asp-append-version="true" />
@Html.AntiForgeryToken()
<div class="d-flex justify-content-end align-items-center mb-4">
<div class="btn-group" role="group">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-list-ul"></i> List View
</a>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Appointment
</a>
</div>
</div>
<!-- Calendar Controls (full width) -->
<div class="card mb-3">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-4">
<div class="btn-group w-100" role="group">
<button type="button" class="btn btn-outline-secondary" id="btnPrevious">
<i class="bi bi-chevron-left"></i> Previous
</button>
<button type="button" class="btn btn-outline-primary" id="btnToday">
Today
</button>
<button type="button" class="btn btn-outline-secondary" id="btnNext">
Next <i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
<div class="col-md-4 text-center">
<h5 class="mb-0 mt-3 mt-md-0" id="currentDateDisplay">Loading...</h5>
</div>
<div class="col-md-4">
<div class="btn-group w-100 mt-3 mt-md-0" role="group">
<button type="button" class="btn btn-outline-primary" id="btnDayView" data-view="day">
<i class="bi bi-calendar-day"></i> Day
</button>
<button type="button" class="btn btn-outline-primary" id="btnWeekView" data-view="week">
<i class="bi bi-calendar-week"></i> Week
</button>
<button type="button" class="btn btn-outline-primary" id="btnMonthView" data-view="month">
<i class="bi bi-calendar-month"></i> Month
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar + Calendar flex layout -->
<div class="d-flex gap-3 align-items-start mb-3">
<!-- Unscheduled Jobs Sidebar -->
<div id="unscheduledSidebar" class="schedule-sidebar">
<div class="card h-100">
<div class="card-header schedule-sidebar-header">
<div class="d-flex justify-content-between align-items-center">
<span class="fw-semibold small" id="sidebarTitleText"><i class="bi bi-clock-history me-1"></i>Unscheduled</span>
<button type="button" class="btn btn-link btn-sm p-0 text-secondary" id="btnCollapseSidebar" title="Collapse panel">
<i class="bi bi-chevron-left" id="sidebarChevron"></i>
</button>
</div>
<div class="text-muted" style="font-size:0.7rem;" id="unscheduledCount"></div>
</div>
<div class="card-body p-2 schedule-sidebar-body" id="unscheduledJobsPanel">
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
</div>
</div>
</div>
</div>
<!-- Calendar Container -->
<div class="flex-grow-1 min-width-0">
<div class="card">
<div class="card-body p-0">
<div id="calendarContainer">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading calendar...</span>
</div>
<p class="text-muted mt-3">Loading schedule...</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Legend -->
<div class="card mt-0">
<div class="card-body">
<div class="row g-3">
<!-- Appointment Types -->
<div class="col-md-5">
<h6 class="mb-2"><i class="bi bi-calendar-event me-2"></i>Appointment Types</h6>
<div class="row g-1">
@{
var appointmentTypes = ViewBag.AppointmentTypesForLegend as List<PowderCoating.Core.Entities.AppointmentTypeLookup> ?? new List<PowderCoating.Core.Entities.AppointmentTypeLookup>();
foreach (var type in appointmentTypes)
{
<div class="col-6">
<span class="badge calendar-event-@type.ColorClass w-100 text-truncate">
@if (!string.IsNullOrEmpty(type.IconClass))
{
<i class="@type.IconClass me-1"></i>
}
@type.DisplayName
</span>
</div>
}
}
</div>
</div>
<!-- Job Status Colors -->
<div class="col-md-4">
<h6 class="mb-2"><i class="bi bi-briefcase me-2"></i>Job Status</h6>
<div class="row g-1">
<div class="col-6"><span class="badge w-100" style="background:#6c757d">Pending/Quoted</span></div>
<div class="col-6"><span class="badge w-100" style="background:#0dcaf0">Approved</span></div>
<div class="col-6"><span class="badge w-100" style="background:#0d6efd">In Prep</span></div>
<div class="col-6"><span class="badge w-100" style="background:#fd7e14">In Oven</span></div>
<div class="col-6"><span class="badge w-100" style="background:#198754">Ready/Done</span></div>
<div class="col-6"><span class="badge w-100" style="background:#dc3545">Overdue</span></div>
</div>
</div>
<!-- Maintenance Priority -->
<div class="col-md-3">
<h6 class="mb-2"><i class="bi bi-tools me-2"></i>Maintenance</h6>
<div class="row g-1">
<div class="col-12"><span class="badge calendar-event-red w-100">Critical</span></div>
<div class="col-12"><span class="badge calendar-event-orange w-100">High</span></div>
<div class="col-12"><span class="badge calendar-event-yellow w-100">Normal</span></div>
<div class="col-12"><span class="badge calendar-event-gray w-100">Low</span></div>
</div>
</div>
</div>
</div>
</div>
<!-- Job hover preview card -->
<div id="sjPreviewCard" style="display:none;position:fixed;z-index:1200;pointer-events:none;"></div>
<!-- Toast container -->
<div id="scheduleToastContainer" class="position-fixed bottom-0 end-0 p-3" style="z-index:1100"></div>
<!-- Include Quick Create Modal -->
<partial name="_QuickCreateModal" />
@section Scripts {
<script src="~/js/appointment-calendar.js" asp-append-version="true"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const initialView = '@currentView';
const initialDate = new Date('@currentDate.ToString("yyyy-MM-dd")');
appointmentCalendar.init(initialView, initialDate);
});
</script>
}
@@ -0,0 +1,266 @@
@model PowderCoating.Application.DTOs.Appointment.CreateAppointmentDto
@{
ViewData["Title"] = "New Appointment";
ViewData["PageIcon"] = "bi-calendar-plus";
ViewData["PageHelpTitle"] = "New Appointment";
ViewData["PageHelpContent"] = "Create an appointment to schedule a customer visit, drop-off, pick-up, or consultation. Select the Type first — the Linked Job field appears once a type is chosen. Reminder notifications fire before the scheduled start time.";
}
<div class="d-flex justify-content-end align-items-center mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to List
</a>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<form asp-action="Create" method="post">
@if (!ViewData.ModelState.IsValid)
{
<div class="alert alert-danger">
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle me-2"></i>Please correct the following errors:</h6>
<partial name="_ValidationSummary" />
</div>
}
<!-- Title -->
<div class="mb-3">
<label asp-for="Title" class="form-label">Title <span class="text-danger">*</span></label>
<input asp-for="Title" class="form-control" placeholder="e.g., John's Rims - Drop Off" />
<span asp-validation-for="Title" class="text-danger"></span>
</div>
<!-- Description -->
<div class="mb-3">
<label asp-for="Description" class="form-label">Description</label>
<textarea asp-for="Description" class="form-control" rows="3" placeholder="Additional details about the appointment..."></textarea>
<span asp-validation-for="Description" class="text-danger"></span>
</div>
<div class="row">
<!-- Customer -->
<div class="col-md-6 mb-3">
<label asp-for="CustomerId" class="form-label">Customer</label>
<select asp-for="CustomerId" class="form-select" asp-items="ViewBag.Customers">
<option value="">-- Select Customer (Optional) --</option>
</select>
<span asp-validation-for="CustomerId" class="text-danger"></span>
</div>
<!-- Appointment Type -->
<div class="col-md-6 mb-3">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="AppointmentTypeId" class="form-label mb-0">Type <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="Appointment Type"
data-bs-content="Drop-Off: customer brings items in. Pick-Up: customer collects completed work. Consultation/Quote: meeting to discuss pricing. Job Work: block time for a specific job. The Linked Job field appears after you select a type.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<select asp-for="AppointmentTypeId" class="form-select" asp-items="ViewBag.AppointmentTypes" id="appointmentType">
<option value="">-- Select Type --</option>
</select>
<span asp-validation-for="AppointmentTypeId" class="text-danger"></span>
</div>
</div>
<!-- Job (conditional) -->
<div class="mb-3" id="jobField" style="display: none;">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="JobId" class="form-label mb-0">Linked Job <span class="text-danger" id="jobRequired">*</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="Connect this appointment to an active job so it appears on the job timeline. Useful for Drop-Offs, Pick-Ups, and scheduled job work. Optional for consultations and internal meetings.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<select asp-for="JobId" class="form-select" asp-items="ViewBag.Jobs">
<option value="">-- Select Job (Optional) --</option>
</select>
<span asp-validation-for="JobId" class="text-danger"></span>
<small class="text-muted">Link this appointment to an existing job.</small>
</div>
<!-- All Day Checkbox -->
<div class="mb-3 form-check">
<input asp-for="IsAllDay" class="form-check-input" id="isAllDay" />
<label asp-for="IsAllDay" class="form-check-label">
All Day Event
</label>
</div>
<div class="row">
<!-- Start Date/Time -->
<div class="col-md-6 mb-3">
<label asp-for="ScheduledStartTime" class="form-label">Start <span class="text-danger">*</span></label>
<input asp-for="ScheduledStartTime" type="datetime-local" class="form-control" id="startTime" />
<span asp-validation-for="ScheduledStartTime" class="text-danger"></span>
</div>
<!-- End Date/Time -->
<div class="col-md-6 mb-3">
<label asp-for="ScheduledEndTime" class="form-label">End <span class="text-danger">*</span></label>
<input asp-for="ScheduledEndTime" type="datetime-local" class="form-control" id="endTime" />
<span asp-validation-for="ScheduledEndTime" class="text-danger"></span>
</div>
</div>
<div class="row">
<!-- Assigned Worker -->
<div class="col-md-6 mb-3">
<label asp-for="AssignedUserId" class="form-label">Assign To Worker</label>
<select asp-for="AssignedUserId" class="form-select" asp-items="ViewBag.Workers">
<option value="">-- No Assignment --</option>
</select>
<span asp-validation-for="AssignedUserId" class="text-danger"></span>
</div>
<!-- Location -->
<div class="col-md-6 mb-3">
<label asp-for="Location" class="form-label">Location</label>
<input asp-for="Location" class="form-control" placeholder="e.g., Main Office, Loading Dock" />
<span asp-validation-for="Location" class="text-danger"></span>
</div>
</div>
<!-- Internal Notes -->
<div class="mb-3">
<label asp-for="Notes" class="form-label">Internal Notes</label>
<textarea asp-for="Notes" class="form-control" rows="2" placeholder="Notes for staff (not visible to customer)..."></textarea>
<span asp-validation-for="Notes" class="text-danger"></span>
</div>
<!-- Reminder Settings -->
<div class="card mb-3">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<h6 class="mb-0"><i class="bi bi-bell me-2"></i>Reminder Settings</h6>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Reminder Settings"
data-bs-content="Enable a reminder to receive an in-app notification before the appointment. Set how many minutes in advance — e.g., 30 for a brief heads-up, 1440 for a full day before. Reminders are per-appointment and do not send external emails or SMS.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<div class="mb-3 form-check">
<input asp-for="IsReminderEnabled" class="form-check-input" id="reminderEnabled" checked />
<label asp-for="IsReminderEnabled" class="form-check-label">
Send reminder notification
</label>
</div>
<div class="mb-0" id="reminderTime">
<label asp-for="ReminderMinutesBefore" class="form-label">Remind me</label>
<div class="input-group">
<input asp-for="ReminderMinutesBefore" type="number" class="form-control" min="5" max="1440" value="30" />
<span class="input-group-text">minutes before</span>
</div>
<span asp-validation-for="ReminderMinutesBefore" class="text-danger"></span>
</div>
</div>
</div>
<!-- Actions -->
<div class="d-flex justify-content-between">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Create Appointment
</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
<!-- Sidebar - Help -->
<div class="col-lg-4">
<div class="card">
<div class="card-header bg-info text-white">
<h6 class="mb-0"><i class="bi bi-info-circle me-2"></i>Tips</h6>
</div>
<div class="card-body">
<h6>Appointment Types:</h6>
<ul class="small mb-3">
<li><strong>Customer Drop-Off:</strong> Customer bringing items to the shop</li>
<li><strong>Customer Pick-Up:</strong> Customer collecting completed items</li>
<li><strong>Consultation/Quote:</strong> Meeting to discuss pricing and requirements</li>
<li><strong>Scheduled Job Work:</strong> Blocking time for working on a specific job</li>
</ul>
<h6>Reminders:</h6>
<p class="small mb-0">Set a reminder to receive a notification before the appointment starts. Useful for preparing materials or coordinating with staff.</p>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
// Show/hide job field based on appointment type
document.getElementById('appointmentType').addEventListener('change', function() {
const jobField = document.getElementById('jobField');
const jobRequired = document.getElementById('jobRequired');
// You can add logic here to check if selected type requires job link
// For now, we'll show it for all types but mark required only for JOB_WORK
if (this.value) {
jobField.style.display = 'block';
} else {
jobField.style.display = 'none';
}
});
// Toggle reminder time visibility
document.getElementById('reminderEnabled').addEventListener('change', function() {
const reminderTime = document.getElementById('reminderTime');
reminderTime.style.display = this.checked ? 'block' : 'none';
});
// Set default start time to tomorrow at 9 AM if empty
const startTimeInput = document.getElementById('startTime');
const endTimeInput = document.getElementById('endTime');
if (!startTimeInput.value) {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0);
startTimeInput.value = tomorrow.toISOString().slice(0, 16);
const endTime = new Date(tomorrow);
endTime.setHours(10, 0, 0, 0);
endTimeInput.value = endTime.toISOString().slice(0, 16);
}
// Auto-update end time when start time changes
startTimeInput.addEventListener('change', function() {
const isAllDay = document.getElementById('isAllDay').checked;
const newEndTime = new Date(this.value);
if (isAllDay) {
// For all-day events, set end date to same as start date
endTimeInput.value = this.value;
} else {
// For timed events, set end time to 1 hour after start time
newEndTime.setHours(newEndTime.getHours() + 1);
endTimeInput.value = newEndTime.toISOString().slice(0, 16);
}
});
// Hide time inputs when "All Day" is checked
document.getElementById('isAllDay').addEventListener('change', function() {
const timeInputs = document.querySelectorAll('#startTime, #endTime');
timeInputs.forEach(input => {
if (this.checked) {
input.type = 'date';
} else {
input.type = 'datetime-local';
}
});
});
</script>
}
@@ -0,0 +1,323 @@
@model PowderCoating.Application.DTOs.Appointment.AppointmentDto
@{
ViewData["Title"] = $"Appointment {Model.AppointmentNumber}";
ViewData["PageIcon"] = "bi-calendar-event";
ViewData["PageHelpTitle"] = "Appointment Details";
ViewData["PageHelpContent"] = "View all details for this appointment. Edit to update status or record actual times. Deleting permanently removes the record — consider setting status to Cancelled instead to preserve history.";
}
<div class="d-flex justify-content-end gap-2 mb-4">
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i> Edit
</a>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to List
</a>
</div><div class="row g-4">
<!-- Left Column - Customer & Schedule Info -->
<div class="col-lg-6">
<!-- Customer Information -->
<div class="card mb-3">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="bi bi-person-circle me-2"></i>Customer Information</h5>
</div>
<div class="card-body">
@if (Model.CustomerId.HasValue && !string.IsNullOrEmpty(Model.CustomerName))
{
<div class="row mb-3">
<div class="col-sm-4 text-muted">Customer:</div>
<div class="col-sm-8">
<strong>@Model.CustomerName</strong>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.CustomerPhone))
{
<div class="row mb-3">
<div class="col-sm-4 text-muted">Phone:</div>
<div class="col-sm-8">
<a href="tel:@Model.CustomerPhone">@Model.CustomerPhone</a>
</div>
</div>
}
@if (!string.IsNullOrEmpty(Model.CustomerEmail))
{
<div class="row">
<div class="col-sm-4 text-muted">Email:</div>
<div class="col-sm-8">
<a href="mailto:@Model.CustomerEmail">@Model.CustomerEmail</a>
</div>
</div>
}
}
else
{
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle me-2"></i>
<strong>Internal Appointment</strong><br />
<small>This appointment is not associated with a customer.</small>
</div>
}
</div>
</div>
<!-- Schedule Information -->
<div class="card mb-3">
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="bi bi-calendar-event me-2"></i>Schedule</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-sm-4 text-muted">Date:</div>
<div class="col-sm-8">
<strong>@Model.ScheduledStartTime.ToString("dddd, MMMM dd, yyyy")</strong>
</div>
</div>
@if (Model.IsAllDay)
{
<div class="row mb-3">
<div class="col-sm-4 text-muted">Time:</div>
<div class="col-sm-8">
<span class="badge bg-secondary">All Day</span>
</div>
</div>
}
else
{
<div class="row mb-3">
<div class="col-sm-4 text-muted">Start Time:</div>
<div class="col-sm-8">@Model.ScheduledStartTime.ToString("h:mm tt")</div>
</div>
<div class="row mb-3">
<div class="col-sm-4 text-muted">End Time:</div>
<div class="col-sm-8">@Model.ScheduledEndTime.ToString("h:mm tt")</div>
</div>
<div class="row">
<div class="col-sm-4 text-muted">Duration:</div>
<div class="col-sm-8">
@{
var duration = Model.ScheduledEndTime - Model.ScheduledStartTime;
var hours = (int)duration.TotalHours;
var minutes = duration.Minutes;
}
@if (hours > 0)
{
<span>@hours hour@(hours != 1 ? "s" : "")</span>
}
@if (minutes > 0)
{
<span>@minutes minute@(minutes != 1 ? "s" : "")</span>
}
</div>
</div>
}
@if (!string.IsNullOrEmpty(Model.Location))
{
<hr />
<div class="row">
<div class="col-sm-4 text-muted">Location:</div>
<div class="col-sm-8">
<i class="bi bi-geo-alt"></i> @Model.Location
</div>
</div>
}
</div>
</div>
<!-- Actual Times (if recorded) -->
@if (Model.ActualStartTime.HasValue || Model.ActualEndTime.HasValue)
{
<div class="card">
<div class="card-header bg-info text-white">
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Actual Times</h5>
</div>
<div class="card-body">
@if (Model.ActualStartTime.HasValue)
{
<div class="row mb-3">
<div class="col-sm-4 text-muted">Arrived:</div>
<div class="col-sm-8">@Model.ActualStartTime.Value.ToString("h:mm tt")</div>
</div>
}
@if (Model.ActualEndTime.HasValue)
{
<div class="row">
<div class="col-sm-4 text-muted">Completed:</div>
<div class="col-sm-8">@Model.ActualEndTime.Value.ToString("h:mm tt")</div>
</div>
}
</div>
</div>
}
</div>
<!-- Right Column - Appointment Details -->
<div class="col-lg-6">
<!-- Appointment Information -->
<div class="card mb-3">
<div class="card-header bg-secondary text-white">
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Appointment Details</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-sm-4 text-muted">Type:</div>
<div class="col-sm-8">
<span class="badge bg-@Model.TypeColorClass">
@if (!string.IsNullOrEmpty(Model.TypeIconClass))
{
<i class="@Model.TypeIconClass me-1"></i>
}
@Model.TypeDisplayName
</span>
</div>
</div>
<div class="row mb-3">
<div class="col-sm-4 text-muted">Status:</div>
<div class="col-sm-8">
<span class="badge bg-@Model.StatusColorClass">
@if (!string.IsNullOrEmpty(Model.StatusIconClass))
{
<i class="@Model.StatusIconClass me-1"></i>
}
@Model.StatusDisplayName
</span>
</div>
</div>
@if (Model.JobId.HasValue)
{
<div class="row mb-3">
<div class="col-sm-4 text-muted">Linked Job:</div>
<div class="col-sm-8">
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@Model.JobId">
@Model.JobNumber
</a>
</div>
</div>
}
@if (!string.IsNullOrEmpty(Model.AssignedWorkerName))
{
<div class="row">
<div class="col-sm-4 text-muted">Assigned To:</div>
<div class="col-sm-8">
<span class="badge bg-info">
<i class="bi bi-person"></i> @Model.AssignedWorkerName
</span>
</div>
</div>
}
</div>
</div>
<!-- Description & Notes -->
@if (!string.IsNullOrEmpty(Model.Description) || !string.IsNullOrEmpty(Model.Notes))
{
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-file-text me-2"></i>Description & Notes</h5>
</div>
<div class="card-body">
@if (!string.IsNullOrEmpty(Model.Description))
{
<div class="mb-3">
<strong>Description:</strong>
<p class="mb-0 mt-1">@Model.Description</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.Notes))
{
<div>
<strong>Internal Notes:</strong>
<p class="mb-0 mt-1 text-muted">@Model.Notes</p>
</div>
}
</div>
</div>
}
<!-- Reminder Settings -->
@if (Model.IsReminderEnabled)
{
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-bell me-2"></i>Reminder</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-sm-4 text-muted">Reminder:</div>
<div class="col-sm-8">
<i class="bi bi-check-circle text-success"></i>
@Model.ReminderMinutesBefore minutes before
</div>
</div>
</div>
</div>
}
<!-- Audit Information -->
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>History</h5>
</div>
<div class="card-body">
<div class="row mb-2">
<div class="col-sm-4 text-muted">Created:</div>
<div class="col-sm-8">
@Model.CreatedAt.ToString("MMM dd, yyyy h:mm tt")
@if (!string.IsNullOrEmpty(Model.CreatedBy))
{
<br /><small class="text-muted">by @Model.CreatedBy</small>
}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="card mt-4">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-primary">
<i class="bi bi-pencil"></i> Edit Appointment
</a>
<a asp-action="Calendar" class="btn btn-outline-secondary">
<i class="bi bi-calendar3"></i> View Calendar
</a>
</div>
<div>
<button type="button" class="btn btn-outline-danger" onclick="deleteAppointment(@Model.Id, '@Model.AppointmentNumber')">
<i class="bi bi-trash"></i> Delete
</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
function deleteAppointment(id, appointmentNumber) {
if (confirm(`Are you sure you want to delete appointment ${appointmentNumber}?\n\nThis action cannot be undone.`)) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '@Url.Action("Delete")';
const idInput = document.createElement('input');
idInput.type = 'hidden';
idInput.name = 'id';
idInput.value = id;
form.appendChild(idInput);
const tokenInput = document.createElement('input');
tokenInput.type = 'hidden';
tokenInput.name = '__RequestVerificationToken';
tokenInput.value = '@Html.AntiForgeryToken()'.match(/value="([^"]+)"/)[1];
form.appendChild(tokenInput);
document.body.appendChild(form);
form.submit();
}
}
</script>
}
@@ -0,0 +1,273 @@
@model PowderCoating.Application.DTOs.Appointment.UpdateAppointmentDto
@{
ViewData["Title"] = "Edit Appointment";
ViewData["PageIcon"] = "bi-pencil";
ViewData["PageHelpTitle"] = "Edit Appointment";
ViewData["PageHelpContent"] = "Update appointment details, change status, record actual arrival/completion times, or adjust the reminder. Use Actual Times to track punctuality vs scheduled time.";
}
<div class="d-flex justify-content-end align-items-center mb-4">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Details
</a>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<form asp-action="Edit" method="post">
<input type="hidden" asp-for="Id" />
<partial name="_ValidationSummary" />
<!-- Title -->
<div class="mb-3">
<label asp-for="Title" class="form-label">Title <span class="text-danger">*</span></label>
<input asp-for="Title" class="form-control" />
<span asp-validation-for="Title" class="text-danger"></span>
</div>
<!-- Description -->
<div class="mb-3">
<label asp-for="Description" class="form-label">Description</label>
<textarea asp-for="Description" class="form-control" rows="3"></textarea>
<span asp-validation-for="Description" class="text-danger"></span>
</div>
<div class="row">
<!-- Customer -->
<div class="col-md-6 mb-3">
<label asp-for="CustomerId" class="form-label">Customer</label>
<select asp-for="CustomerId" class="form-select" asp-items="ViewBag.Customers">
<option value="">-- Select Customer (Optional) --</option>
</select>
<span asp-validation-for="CustomerId" class="text-danger"></span>
</div>
<!-- Appointment Type -->
<div class="col-md-6 mb-3">
<label asp-for="AppointmentTypeId" class="form-label">Type <span class="text-danger">*</span></label>
<select asp-for="AppointmentTypeId" class="form-select" asp-items="ViewBag.AppointmentTypes" id="appointmentType">
<option value="">-- Select Type --</option>
</select>
<span asp-validation-for="AppointmentTypeId" class="text-danger"></span>
</div>
</div>
<div class="row">
<!-- Status -->
<div class="col-md-6 mb-3">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="AppointmentStatusId" class="form-label mb-0">Status <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="Appointment Status"
data-bs-content="Scheduled → Confirmed (customer acknowledged) → In Progress (currently happening) → Completed. Use Cancelled for cancellations, No Show if the customer didn't arrive, and Rescheduled when moved to a new time.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<select asp-for="AppointmentStatusId" class="form-select" asp-items="ViewBag.AppointmentStatuses">
<option value="">-- Select Status --</option>
</select>
<span asp-validation-for="AppointmentStatusId" class="text-danger"></span>
</div>
<!-- Job (conditional) -->
<div class="col-md-6 mb-3">
<label asp-for="JobId" class="form-label">Linked Job</label>
<select asp-for="JobId" class="form-select" asp-items="ViewBag.Jobs">
<option value="">-- Select Job (Optional) --</option>
</select>
<span asp-validation-for="JobId" class="text-danger"></span>
</div>
</div>
<!-- All Day Checkbox -->
<div class="mb-3 form-check">
<input asp-for="IsAllDay" class="form-check-input" id="isAllDay" />
<label asp-for="IsAllDay" class="form-check-label">
All Day Event
</label>
</div>
<div class="row">
<!-- Start Date/Time -->
<div class="col-md-6 mb-3">
<label asp-for="ScheduledStartTime" class="form-label">Start <span class="text-danger">*</span></label>
<input asp-for="ScheduledStartTime" type="datetime-local" class="form-control" id="startTime" />
<span asp-validation-for="ScheduledStartTime" class="text-danger"></span>
</div>
<!-- End Date/Time -->
<div class="col-md-6 mb-3">
<label asp-for="ScheduledEndTime" class="form-label">End <span class="text-danger">*</span></label>
<input asp-for="ScheduledEndTime" type="datetime-local" class="form-control" id="endTime" />
<span asp-validation-for="ScheduledEndTime" class="text-danger"></span>
</div>
</div>
<!-- Actual Times -->
<div class="card mb-3 bg-light">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<h6 class="mb-0"><i class="bi bi-clock-history me-2"></i>Actual Times</h6>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Actual Times"
data-bs-content="Record when the customer actually arrived and when the appointment finished. These are optional and separate from the scheduled times — useful for tracking punctuality and measuring how accurately appointments are estimated.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3 mb-md-0">
<label asp-for="ActualStartTime" class="form-label">Actual Start</label>
<input asp-for="ActualStartTime" type="datetime-local" class="form-control" />
<span asp-validation-for="ActualStartTime" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="ActualEndTime" class="form-label">Actual End</label>
<input asp-for="ActualEndTime" type="datetime-local" class="form-control" />
<span asp-validation-for="ActualEndTime" class="text-danger"></span>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Assigned Worker -->
<div class="col-md-6 mb-3">
<label asp-for="AssignedUserId" class="form-label">Assign To Worker</label>
<select asp-for="AssignedUserId" class="form-select" asp-items="ViewBag.Workers">
<option value="">-- No Assignment --</option>
</select>
<span asp-validation-for="AssignedUserId" class="text-danger"></span>
</div>
<!-- Location -->
<div class="col-md-6 mb-3">
<label asp-for="Location" class="form-label">Location</label>
<input asp-for="Location" class="form-control" />
<span asp-validation-for="Location" class="text-danger"></span>
</div>
</div>
<!-- Internal Notes -->
<div class="mb-3">
<label asp-for="Notes" class="form-label">Internal Notes</label>
<textarea asp-for="Notes" class="form-control" rows="2"></textarea>
<span asp-validation-for="Notes" class="text-danger"></span>
</div>
<!-- Reminder Settings -->
<div class="card mb-3">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<h6 class="mb-0"><i class="bi bi-bell me-2"></i>Reminder Settings</h6>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Reminder Settings"
data-bs-content="Enable a reminder to receive an in-app notification before the appointment. Set how many minutes in advance — e.g., 30 for a brief heads-up, 1440 for a full day before. Reminders are per-appointment and do not send external emails or SMS.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<div class="mb-3 form-check">
<input asp-for="IsReminderEnabled" class="form-check-input" id="reminderEnabled" />
<label asp-for="IsReminderEnabled" class="form-check-label">
Send reminder notification
</label>
</div>
<div class="mb-0" id="reminderTime" style="display: @(Model.IsReminderEnabled ? "block" : "none")">
<label asp-for="ReminderMinutesBefore" class="form-label">Remind me</label>
<div class="input-group">
<input asp-for="ReminderMinutesBefore" type="number" class="form-control" min="5" max="1440" />
<span class="input-group-text">minutes before</span>
</div>
<span asp-validation-for="ReminderMinutesBefore" class="text-danger"></span>
</div>
</div>
</div>
<!-- Actions -->
<div class="d-flex justify-content-between">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></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>
<!-- Sidebar - Status Guide -->
<div class="col-lg-4">
<div class="card">
<div class="card-header bg-info text-white">
<h6 class="mb-0"><i class="bi bi-info-circle me-2"></i>Status Guide</h6>
</div>
<div class="card-body">
<h6>Status Meanings:</h6>
<ul class="small mb-3">
<li><strong>Scheduled:</strong> Initial appointment booking</li>
<li><strong>Confirmed:</strong> Customer has confirmed attendance</li>
<li><strong>In Progress:</strong> Appointment is currently happening</li>
<li><strong>Completed:</strong> Appointment finished successfully</li>
<li><strong>Cancelled:</strong> Appointment was cancelled</li>
<li><strong>No Show:</strong> Customer didn't arrive</li>
<li><strong>Rescheduled:</strong> Moved to a different time</li>
</ul>
<h6>Actual Times:</h6>
<p class="small mb-0">Record when the customer actually arrived and when the appointment was completed. Useful for tracking punctuality and duration accuracy.</p>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
// Toggle reminder time visibility
document.getElementById('reminderEnabled').addEventListener('change', function() {
const reminderTime = document.getElementById('reminderTime');
reminderTime.style.display = this.checked ? 'block' : 'none';
});
// Hide time inputs when "All Day" is checked
const isAllDayCheckbox = document.getElementById('isAllDay');
const startTimeInput = document.getElementById('startTime');
const endTimeInput = document.getElementById('endTime');
function toggleTimeInputs() {
if (isAllDayCheckbox.checked) {
startTimeInput.type = 'date';
endTimeInput.type = 'date';
} else {
startTimeInput.type = 'datetime-local';
endTimeInput.type = 'datetime-local';
}
}
// Initialize on load
toggleTimeInputs();
isAllDayCheckbox.addEventListener('change', toggleTimeInputs);
// Auto-update end time when start time changes
startTimeInput.addEventListener('change', function() {
if (isAllDayCheckbox.checked) {
// For all-day events, set end date to same as start date
endTimeInput.value = this.value;
} else {
// For timed events, set end time to 1 hour after start time
const newEndTime = new Date(this.value);
newEndTime.setHours(newEndTime.getHours() + 1);
endTimeInput.value = newEndTime.toISOString().slice(0, 16);
}
});
</script>
}
@@ -0,0 +1,306 @@
@model PagedResult<PowderCoating.Application.DTOs.Appointment.AppointmentListDto>
@{
ViewData["Title"] = "Appointments";
ViewData["PageIcon"] = "bi-calendar-event";
ViewData["PageHelpTitle"] = "Appointments";
ViewData["PageHelpContent"] = "Schedule and track customer visits, drop-offs, pick-ups, consultations, and internal meetings. Appointments can be linked to customers and jobs. Statuses: Scheduled → Confirmed → In Progress → Completed. Use the Calendar view for a visual day/week/month overview.";
}
<!-- Stats Cards - Desktop -->
<div class="stats-cards-desktop">
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1" style="font-size: 0.875rem;">Total Appointments</p>
<h3 class="mb-0 fw-bold">@Model.TotalCount</h3>
</div>
<div class="rounded-circle p-3" style="background: #dbeafe;">
<i class="bi bi-calendar-event text-primary" style="font-size: 1.5rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1" style="font-size: 0.875rem;">Today</p>
<h3 class="mb-0 fw-bold">@Model.Items.Count(a => a.ScheduledStartTime.Date == DateTime.Today)</h3>
</div>
<div class="rounded-circle p-3" style="background: #fef3c7;">
<i class="bi bi-clock text-warning" style="font-size: 1.5rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1" style="font-size: 0.875rem;">This Week</p>
<h3 class="mb-0 fw-bold">@Model.Items.Count(a => a.ScheduledStartTime >= DateTime.Today && a.ScheduledStartTime < DateTime.Today.AddDays(7))</h3>
</div>
<div class="rounded-circle p-3" style="background: #d1fae5;">
<i class="bi bi-calendar-week text-success" style="font-size: 1.5rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1" style="font-size: 0.875rem;">Confirmed</p>
<h3 class="mb-0 fw-bold">@Model.Items.Count(a => a.StatusDisplayName == "Confirmed")</h3>
</div>
<div class="rounded-circle p-3" style="background: #e0e7ff;">
<i class="bi bi-check-circle text-info" style="font-size: 1.5rem;"></i>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Compact Stats - Mobile -->
<div class="mobile-stats-compact">
<div class="card">
<div class="stats-grid">
<div class="stat-item">
<div class="stat-icon"><i class="bi bi-calendar-event text-primary"></i></div>
<div class="stat-value">@Model.TotalCount</div>
<div class="stat-label">Total</div>
</div>
<div class="stat-item">
<div class="stat-icon"><i class="bi bi-clock text-warning"></i></div>
<div class="stat-value">@Model.Items.Count(a => a.ScheduledStartTime.Date == DateTime.Today)</div>
<div class="stat-label">Today</div>
</div>
<div class="stat-item">
<div class="stat-icon"><i class="bi bi-calendar-week text-success"></i></div>
<div class="stat-value">@Model.Items.Count(a => a.ScheduledStartTime >= DateTime.Today && a.ScheduledStartTime < DateTime.Today.AddDays(7))</div>
<div class="stat-label">This Week</div>
</div>
<div class="stat-item">
<div class="stat-icon"><i class="bi bi-check-circle text-info"></i></div>
<div class="stat-value">@Model.Items.Count(a => a.StatusDisplayName == "Confirmed")</div>
<div class="stat-label">Confirmed</div>
</div>
</div>
</div>
</div>
<!-- Search and Actions Bar -->
<div class="card mb-3">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<form method="get" asp-action="Index">
<div class="input-group">
<input type="text" class="form-control" name="searchTerm" value="@ViewBag.SearchTerm" placeholder="Search appointments...">
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
<input type="hidden" name="pageSize" value="@Model.PageSize" />
<button class="btn btn-outline-secondary" type="submit">
<i class="bi bi-search"></i>
</button>
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm))
{
<a href="@Url.Action("Index")" class="btn btn-outline-secondary">
<i class="bi bi-x"></i>
</a>
}
</div>
</form>
</div>
<div class="col-md-3">
<form method="get" asp-action="Index" id="typeFilterForm">
<input type="hidden" name="searchTerm" value="@ViewBag.SearchTerm" />
<input type="hidden" name="statusFilter" value="@ViewBag.StatusFilter" />
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
<input type="hidden" name="pageSize" value="@Model.PageSize" />
<select class="form-select" name="typeFilter" onchange="document.getElementById('typeFilterForm').submit()">
<option value="">All Types</option>
@foreach (var item in (SelectList)ViewBag.TypeFilterList)
{
<option value="@item.Value" selected="@(item.Value == ViewBag.TypeFilter?.ToString())">@item.Text</option>
}
</select>
</form>
</div>
<div class="col-md-3">
<form method="get" asp-action="Index" id="statusFilterForm">
<input type="hidden" name="searchTerm" value="@ViewBag.SearchTerm" />
<input type="hidden" name="typeFilter" value="@ViewBag.TypeFilter" />
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
<input type="hidden" name="pageSize" value="@Model.PageSize" />
<select class="form-select" name="statusFilter" onchange="document.getElementById('statusFilterForm').submit()">
<option value="">All Statuses</option>
@foreach (var item in (SelectList)ViewBag.StatusFilterList)
{
<option value="@item.Value" selected="@(item.Value == ViewBag.StatusFilter?.ToString())">@item.Text</option>
}
</select>
</form>
</div>
<div class="col-md-2 text-end">
<a asp-action="Calendar" class="btn btn-outline-primary w-100 mb-2">
<i class="bi bi-calendar3"></i> View Calendar
</a>
<a asp-action="Create" class="btn btn-primary w-100">
<i class="bi bi-plus-circle"></i> New Appointment
</a>
</div>
</div>
</div>
</div>
<!-- Appointments Table -->
<div class="card">
<div class="card-body">
@if (Model.Items.Any())
{
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th sortable-column="AppointmentNumber" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Number</th>
<th sortable-column="Title" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Title</th>
<th sortable-column="Customer" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Customer</th>
<th sortable-column="Type" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Type</th>
<th sortable-column="Status" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Status</th>
<th sortable-column="ScheduledStartTime" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Scheduled</th>
<th>Worker</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var appointment in Model.Items)
{
<tr>
<td>
<a asp-action="Details" asp-route-id="@appointment.Id" class="text-decoration-none">
@appointment.AppointmentNumber
</a>
</td>
<td>
<strong>@appointment.Title</strong>
@if (appointment.IsAllDay)
{
<span class="badge bg-secondary ms-1">All Day</span>
}
</td>
<td>@(appointment.CustomerName ?? "<em class=\"text-muted\">Internal</em>")</td>
<td>
<span class="badge bg-@appointment.TypeColorClass">
@appointment.TypeDisplayName
</span>
</td>
<td>
<span class="badge bg-@appointment.StatusColorClass">
@appointment.StatusDisplayName
</span>
</td>
<td>
<div>@appointment.ScheduledStartTime.ToString("MMM dd, yyyy")</div>
@if (!appointment.IsAllDay)
{
<small class="text-muted">@appointment.ScheduledStartTime.ToString("h:mm tt") - @appointment.ScheduledEndTime.ToString("h:mm tt")</small>
}
</td>
<td>
@if (!string.IsNullOrEmpty(appointment.AssignedWorkerName))
{
<span class="badge bg-info">
<i class="bi bi-person"></i> @appointment.AssignedWorkerName
</span>
}
else
{
<span class="text-muted">Unassigned</span>
}
</td>
<td>
<div class="btn-group" role="group">
<a asp-action="Details" asp-route-id="@appointment.Id" class="btn btn-sm btn-outline-primary" title="View Details">
<i class="bi bi-eye"></i>
</a>
<a asp-action="Edit" asp-route-id="@appointment.Id" class="btn btn-sm btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteAppointment(@appointment.Id, '@appointment.AppointmentNumber')" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Pagination -->
<partial name="_Pagination" model="Model" />
}
else
{
<div class="text-center py-5">
<i class="bi bi-calendar-x text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-3">No appointments found.</p>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create First Appointment
</a>
</div>
}
</div>
</div>
@section Scripts {
<script>
function deleteAppointment(id, appointmentNumber) {
if (confirm(`Are you sure you want to delete appointment ${appointmentNumber}?`)) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '@Url.Action("Delete")';
const idInput = document.createElement('input');
idInput.type = 'hidden';
idInput.name = 'id';
idInput.value = id;
form.appendChild(idInput);
const tokenInput = document.createElement('input');
tokenInput.type = 'hidden';
tokenInput.name = '__RequestVerificationToken';
tokenInput.value = document.querySelector('input[name="__RequestVerificationToken"]')?.value || '';
form.appendChild(tokenInput);
document.body.appendChild(form);
form.submit();
}
}
function changePageSize(pageSize) {
const url = new URL(window.location);
url.searchParams.set('pageSize', pageSize);
url.searchParams.set('pageNumber', '1');
window.location.href = url.toString();
}
</script>
}
@@ -0,0 +1,188 @@
<!-- Quick Create Appointment Modal -->
<div class="modal fade" id="quickCreateModal" tabindex="-1" aria-labelledby="quickCreateModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="quickCreateModalLabel">
<i class="bi bi-calendar-plus me-2"></i>Quick Create Appointment
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="quickCreateForm">
<div class="modal-body">
<div class="alert alert-danger d-none" id="quickCreateError"></div>
<!-- Title -->
<div class="mb-3">
<label for="quickTitle" class="form-label">Title <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="quickTitle" name="Title" required placeholder="e.g., Customer Drop-Off">
</div>
<!-- Customer -->
<div class="mb-3">
<label for="quickCustomer" class="form-label">Customer</label>
<select class="form-select" id="quickCustomer" name="CustomerId">
<option value="">-- Select Customer (Optional) --</option>
@foreach (var customer in (SelectList)ViewBag.Customers)
{
<option value="@customer.Value">@customer.Text</option>
}
</select>
</div>
<!-- Appointment Type -->
<div class="mb-3">
<label for="quickType" class="form-label">Type <span class="text-danger">*</span></label>
<select class="form-select" id="quickType" name="AppointmentTypeId" required>
<option value="">-- Select Type --</option>
@foreach (var type in (SelectList)ViewBag.AppointmentTypes)
{
<option value="@type.Value">@type.Text</option>
}
</select>
</div>
<!-- All Day -->
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="quickAllDay" name="IsAllDay">
<label class="form-check-label" for="quickAllDay">
All Day Event
</label>
</div>
<div class="row">
<!-- Start Date/Time -->
<div class="col-md-6 mb-3">
<label for="quickStart" class="form-label">Start <span class="text-danger">*</span></label>
<input type="datetime-local" class="form-control" id="quickStart" name="ScheduledStartTime" required>
</div>
<!-- End Date/Time -->
<div class="col-md-6 mb-3">
<label for="quickEnd" class="form-label">End <span class="text-danger">*</span></label>
<input type="datetime-local" class="form-control" id="quickEnd" name="ScheduledEndTime" required>
</div>
</div>
<p class="text-muted small mb-0">
<i class="bi bi-info-circle"></i> For more options, use the <a asp-action="Create">full create form</a>.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary" id="quickCreateSubmit">
<i class="bi bi-check-circle"></i> Create Appointment
</button>
</div>
</form>
</div>
</div>
</div>
<script>
// Quick Create Modal Logic
document.getElementById('quickCreateForm').addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = document.getElementById('quickCreateSubmit');
const errorDiv = document.getElementById('quickCreateError');
errorDiv.classList.add('d-none');
// Disable submit button
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
try {
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
// Convert checkbox to boolean
data.IsAllDay = formData.get('IsAllDay') === 'on';
const response = await fetch('@Url.Action("QuickCreate")', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('quickCreateModal'));
modal.hide();
// Show success toast
showToast('success', result.message || 'Appointment created successfully');
// Reload calendar events
if (typeof loadCalendarEvents === 'function') {
loadCalendarEvents();
}
// Reset form
this.reset();
} else {
errorDiv.textContent = result.message || 'An error occurred';
errorDiv.classList.remove('d-none');
}
} catch (error) {
console.error('Error:', error);
errorDiv.textContent = 'An unexpected error occurred';
errorDiv.classList.remove('d-none');
} finally {
// Re-enable submit button
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-check-circle"></i> Create Appointment';
}
});
// Toggle datetime/date input based on All Day checkbox
document.getElementById('quickAllDay').addEventListener('change', function() {
const startInput = document.getElementById('quickStart');
const endInput = document.getElementById('quickEnd');
if (this.checked) {
startInput.type = 'date';
endInput.type = 'date';
} else {
startInput.type = 'datetime-local';
endInput.type = 'datetime-local';
}
});
// Auto-update end time when start time changes
document.getElementById('quickStart').addEventListener('change', function() {
const endInput = document.getElementById('quickEnd');
const isAllDay = document.getElementById('quickAllDay').checked;
// Always update end time based on start time
if (isAllDay) {
endInput.value = this.value;
} else {
const newEndTime = new Date(this.value);
newEndTime.setHours(newEndTime.getHours() + 1);
endInput.value = newEndTime.toISOString().slice(0, 16);
}
});
// Helper function to show toast notifications
function showToast(type, message) {
// Simple toast implementation - can be enhanced with Bootstrap Toast component
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type} alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3`;
alertDiv.style.zIndex = '9999';
alertDiv.innerHTML = `
<i class="bi bi-${type === 'success' ? 'check-circle' : 'exclamation-triangle'} me-2"></i>${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alertDiv);
setTimeout(() => {
alertDiv.remove();
}, 5000);
}
</script>
@@ -0,0 +1,124 @@
@using PowderCoating.Core.Entities
@using System.Text.Json
@model AuditLog
@{
ViewData["Title"] = "Audit Entry";
JsonElement ParseJson(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return default;
try { return JsonDocument.Parse(json).RootElement; }
catch { return default; }
}
var oldData = ParseJson(Model.OldValues);
var newData = ParseJson(Model.NewValues);
}
<div class="container-fluid py-3" style="max-width:900px">
<div class="d-flex align-items-center gap-3 mb-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<h4 class="mb-0"><i class="bi bi-shield-check me-2 text-primary"></i>Audit Entry #@Model.Id</h4>
</div>
<div class="row g-3">
<div class="col-md-5">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0 py-3">
<h6 class="mb-0 fw-semibold">Event Details</h6>
</div>
<div class="card-body">
<dl class="row small mb-0">
<dt class="col-5 text-muted">Timestamp</dt>
<dd class="col-7">@Model.Timestamp.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM dd, yyyy HH:mm:ss")</dd>
<dt class="col-5 text-muted">Action</dt>
<dd class="col-7">
<span class="badge @(Model.Action switch {
"Created" => "bg-success", "Updated" => "bg-primary",
"Deleted" => "bg-danger", "Restored" => "bg-warning text-dark",
_ => "bg-secondary" })">@Model.Action</span>
</dd>
<dt class="col-5 text-muted">Entity Type</dt>
<dd class="col-7">@Model.EntityType</dd>
<dt class="col-5 text-muted">Entity ID</dt>
<dd class="col-7">@(Model.EntityId ?? "—")</dd>
<dt class="col-5 text-muted">Description</dt>
<dd class="col-7">@(Model.EntityDescription ?? "—")</dd>
<dt class="col-5 text-muted">User</dt>
<dd class="col-7">@Model.UserName</dd>
<dt class="col-5 text-muted">Company</dt>
<dd class="col-7">@(Model.CompanyName ?? (Model.CompanyId?.ToString() ?? "—"))</dd>
<dt class="col-5 text-muted">IP Address</dt>
<dd class="col-7">@(Model.IpAddress ?? "—")</dd>
</dl>
</div>
</div>
</div>
<div class="col-md-7">
@if (Model.OldValues != null || Model.NewValues != null)
{
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h6 class="mb-0 fw-semibold">Change Diff</h6>
</div>
<div class="card-body p-0">
<table class="table table-sm small mb-0">
<thead class="table-light">
<tr>
<th>Field</th>
<th class="text-danger">Old Value</th>
<th class="text-success">New Value</th>
</tr>
</thead>
<tbody>
@{
// Union all property names from both old and new
var keys = new HashSet<string>();
if (oldData.ValueKind == JsonValueKind.Object)
foreach (var p in oldData.EnumerateObject()) keys.Add(p.Name);
if (newData.ValueKind == JsonValueKind.Object)
foreach (var p in newData.EnumerateObject()) keys.Add(p.Name);
foreach (var key in keys.OrderBy(k => k))
{
var oldVal = oldData.ValueKind == JsonValueKind.Object && oldData.TryGetProperty(key, out var ov) ? ov.ToString() : null;
var newVal = newData.ValueKind == JsonValueKind.Object && newData.TryGetProperty(key, out var nv) ? nv.ToString() : null;
<tr>
<td class="fw-medium">@key</td>
<td class="text-danger font-monospace">@(oldVal ?? "—")</td>
<td class="text-success font-monospace">@(newVal ?? "—")</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
}
else
{
@if (!string.IsNullOrWhiteSpace(Model.NewValues))
{
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h6 class="mb-0 fw-semibold">Notes / Detail</h6>
</div>
<div class="card-body">
<pre class="mb-0 small">@Model.NewValues</pre>
</div>
</div>
}
}
</div>
</div>
</div>
@@ -0,0 +1,224 @@
@using PowderCoating.Core.Entities
@model List<AuditLog>
@section Styles {
<style>
[data-bs-theme="dark"] .card-header.bg-white { background-color: var(--bs-card-cap-bg) !important; }
</style>
}
@{
ViewData["Title"] = "Audit Log";
int page = ViewBag.Page;
int totalPages = ViewBag.TotalPages;
int totalCount = ViewBag.TotalCount;
int pageSize = ViewBag.PageSize;
string PageLink(int p) => Url.Action("Index", new {
search = ViewBag.Search, entityType = ViewBag.EntityType,
action = ViewBag.Action, companyId = ViewBag.CompanyId,
from = ViewBag.From, to = ViewBag.To,
page = p, pageSize
})!;
string BadgeClass(string action) => action switch {
"Created" => "bg-success",
"Updated" => "bg-primary",
"Deleted" => "bg-danger",
"Restored" => "bg-warning text-dark",
"ManualChange" => "bg-info",
"Login" => "bg-success",
"Login2FABypassed" => "bg-success",
"FailedLogin" => "bg-warning text-dark",
"LoginDenied" => "bg-warning text-dark",
"AccountLockedOut" => "bg-danger",
_ => "bg-secondary"
};
}
<div class="container-fluid py-3">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-shield-check me-2 text-primary"></i>Audit Log</h4>
<small class="text-muted">@totalCount.ToString("N0") entries</small>
</div>
</div>
@* Filters *@
<div class="card border-0 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 name="search" value="@ViewBag.Search" class="form-control form-control-sm"
placeholder="User, entity name, ID…" />
</div>
<div class="col-md-2">
<select name="entityType" class="form-select form-select-sm">
<option value="">All entity types</option>
@foreach (var t in (List<string>)ViewBag.EntityTypes)
{
<option value="@t" selected="@(ViewBag.EntityType == t)">@t</option>
}
</select>
</div>
<div class="col-md-2">
<select name="action" class="form-select form-select-sm">
<option value="">All actions</option>
@foreach (var a in new[] { "Created", "Updated", "Deleted", "Restored", "ManualChange", "Login", "FailedLogin", "LoginDenied", "AccountLockedOut", "Login2FABypassed" })
{
<option value="@a" selected="@(ViewBag.Action == a)">@a</option>
}
</select>
</div>
<div class="col-md-2">
<select name="companyId" class="form-select form-select-sm">
<option value="">All companies</option>
@foreach (var c in (dynamic)ViewBag.Companies)
{
<option value="@c.Id" selected="@(ViewBag.CompanyId?.ToString() == c.Id.ToString())">@c.CompanyName</option>
}
</select>
</div>
<div class="col-md-1">
<input type="date" name="from" value="@ViewBag.From" class="form-control form-control-sm" title="From date" />
</div>
<div class="col-md-1">
<input type="date" name="to" value="@ViewBag.To" class="form-control form-control-sm" title="To date" />
</div>
<div class="col-md-1">
<button class="btn btn-sm btn-primary w-100">Filter</button>
</div>
<div class="col-auto">
<a asp-action="Index" class="btn btn-sm btn-outline-secondary">Clear</a>
</div>
<input type="hidden" name="pageSize" value="@pageSize" />
</form>
</div>
</div>
@* Table *@
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 small">
<thead class="table-light">
<tr>
<th style="width:160px">Timestamp</th>
<th style="width:90px">Action</th>
<th>Entity</th>
<th>Description</th>
<th>User</th>
<th>Company</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr><td colspan="7" class="text-center text-muted py-5">No audit entries found.</td></tr>
}
@foreach (var log in Model)
{
<tr style="cursor:pointer" onclick="window.location='@Url.Action("Details", new { id = log.Id })'">
<td class="text-muted">@log.Timestamp.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yy HH:mm:ss")</td>
<td><span class="badge @BadgeClass(log.Action)">@log.Action</span></td>
<td>@log.EntityType <span class="text-muted">@log.EntityId</span></td>
<td>@log.EntityDescription</td>
<td>@log.UserName</td>
<td>@log.CompanyName</td>
<td onclick="event.stopPropagation()">
@if (log.OldValues != null || log.NewValues != null)
{
<a asp-action="Details" asp-route-id="@log.Id"
class="btn btn-xs btn-outline-secondary py-0 px-1">
<i class="bi bi-eye"></i>
</a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile card view — shown on screens < 992px (table-responsive hidden by mobile-cards.css) -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@if (!Model.Any())
{
<div class="text-center text-muted py-5">No audit entries found.</div>
}
@foreach (var log in Model)
{
<a href="@Url.Action("Details", new { id = log.Id })" class="mobile-data-card text-decoration-none">
<div class="mobile-card-header">
<div class="mobile-card-icon bg-primary"><i class="bi bi-shield-check"></i></div>
<div class="mobile-card-title">
<h6><span class="badge @BadgeClass(log.Action)">@log.Action</span></h6>
<small class="text-muted">@log.Timestamp.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yy HH:mm")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Entity</span>
<span class="mobile-card-value">@log.EntityType @log.EntityId</span>
</div>
@if (!string.IsNullOrEmpty(log.EntityDescription))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Description</span>
<span class="mobile-card-value">@log.EntityDescription</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">User</span>
<span class="mobile-card-value">@log.UserName</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Company</span>
<span class="mobile-card-value">@log.CompanyName</span>
</div>
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-primary">View →</span>
</div>
</a>
}
</div>
</div>
@* Pagination *@
@if (totalPages > 1)
{
<div class="card-footer d-flex align-items-center justify-content-between py-2">
<small class="text-muted">
Showing @((page - 1) * pageSize + 1)@Math.Min(page * pageSize, totalCount) of @totalCount.ToString("N0")
</small>
<nav>
<ul class="pagination pagination-sm mb-0">
<li class="page-item @(page == 1 ? "disabled" : "")">
<a class="page-link" href="@PageLink(page - 1)"><i class="bi bi-chevron-left"></i></a>
</li>
@for (int p = Math.Max(1, page - 2); p <= Math.Min(totalPages, page + 2); p++)
{
<li class="page-item @(p == page ? "active" : "")">
<a class="page-link" href="@PageLink(p)">@p</a>
</li>
}
<li class="page-item @(page == totalPages ? "disabled" : "")">
<a class="page-link" href="@PageLink(page + 1)"><i class="bi bi-chevron-right"></i></a>
</li>
</ul>
</nav>
<div>
<select class="form-select form-select-sm" style="width:auto"
onchange="window.location='@PageLink(1)'.replace('pageSize=@pageSize','pageSize='+this.value)">
@foreach (var ps in new[] { 25, 50, 100 })
{
<option value="@ps" selected="@(pageSize == ps)">@ps / page</option>
}
</select>
</div>
</div>
}
</div>
</div>
@@ -0,0 +1,183 @@
@model IEnumerable<PowderCoating.Core.Entities.BannedIp>
@{
ViewData["Title"] = "Banned IPs";
ViewData["PageIcon"] = "bi-slash-circle";
var now = DateTime.UtcNow;
var active = Model.Where(b => b.IsActive && (b.ExpiresAt == null || b.ExpiresAt > now)).ToList();
var inactive = Model.Where(b => !b.IsActive || (b.ExpiresAt.HasValue && b.ExpiresAt <= now)).ToList();
}
<div class="container-fluid py-4">
@* Add new ban form *@
<div class="card shadow-sm mb-4">
<div class="card-header bg-danger text-white">
<h5 class="mb-0"><i class="bi bi-plus-circle"></i> Add IP Ban</h5>
</div>
<div class="card-body">
<form asp-action="Add" method="post">
@Html.AntiForgeryToken()
<div class="row g-3 align-items-end">
<div class="col-md-3">
<label class="form-label">IP Address <span class="text-danger">*</span></label>
<div class="input-group">
<input type="text" class="form-control font-monospace" name="ipAddress"
id="ipAddressInput" placeholder="e.g. 203.0.113.42" required />
<button type="button" class="btn btn-outline-secondary" id="fillMyIp" title="Fill with your current IP">
<i class="bi bi-geo-alt"></i> My IP
</button>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Reason</label>
<input type="text" class="form-control" name="reason"
placeholder="e.g. Competitor snooping, scraping, abuse" maxlength="500" />
</div>
<div class="col-md-3">
<label class="form-label">Expires (leave blank = permanent)</label>
<input type="datetime-local" class="form-control" name="expiresAt" />
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-danger w-100">
<i class="bi bi-slash-circle"></i> Ban IP
</button>
</div>
</div>
</form>
</div>
</div>
@* Active bans *@
<div class="card shadow-sm mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-slash-circle-fill text-danger"></i> Active Bans <span class="badge bg-danger ms-1">@active.Count</span></h5>
</div>
<div class="card-body p-0">
@if (active.Any())
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>IP Address</th>
<th>Reason</th>
<th>Banned</th>
<th>Expires</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var ban in active)
{
<tr>
<td><code>@ban.IpAddress</code></td>
<td>@(ban.Reason ?? "<em class=\"text-muted\">No reason given</em>")</td>
<td><small class="text-muted">@ban.BannedAt.ToString("MMM dd, yyyy HH:mm")</small></td>
<td>
@if (ban.ExpiresAt.HasValue)
{
<span class="badge bg-warning text-dark">@ban.ExpiresAt.Value.ToString("MMM dd, yyyy HH:mm")</span>
}
else
{
<span class="badge bg-secondary">Permanent</span>
}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<form asp-action="Lift" asp-route-id="@ban.Id" method="post" class="d-inline"
onsubmit="return confirm('Lift the ban on @ban.IpAddress?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-success" title="Lift ban">
<i class="bi bi-check-circle"></i> Lift
</button>
</form>
<form asp-action="Delete" asp-route-id="@ban.Id" method="post" class="d-inline"
onsubmit="return confirm('Delete ban record for @ban.IpAddress?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-danger" title="Delete record">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="text-center py-4 text-muted">
<i class="bi bi-check-circle display-4"></i>
<p class="mt-2">No active IP bans.</p>
</div>
}
</div>
</div>
@* Lifted / expired bans *@
@if (inactive.Any())
{
<div class="card shadow-sm">
<div class="card-header">
<h6 class="mb-0 text-muted"><i class="bi bi-clock-history"></i> Lifted / Expired Bans</h6>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>IP Address</th>
<th>Reason</th>
<th>Banned</th>
<th>Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var ban in inactive)
{
<tr class="text-muted">
<td><code>@ban.IpAddress</code></td>
<td><small>@(ban.Reason ?? "—")</small></td>
<td><small>@ban.BannedAt.ToString("MMM dd, yyyy")</small></td>
<td>
@if (!ban.IsActive)
{
<span class="badge bg-success">Lifted</span>
}
else
{
<span class="badge bg-secondary">Expired</span>
}
</td>
<td class="text-end">
<form asp-action="Delete" asp-route-id="@ban.Id" method="post" class="d-inline"
onsubmit="return confirm('Delete ban record for @ban.IpAddress?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete record">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
</div>
@section Scripts {
<script>
document.getElementById('fillMyIp').addEventListener('click', function () {
fetch('@Url.Action("MyIp", "BannedIps")')
.then(r => r.json())
.then(d => { document.getElementById('ipAddressInput').value = d.ip; });
});
</script>
}
@@ -0,0 +1,39 @@
@{
ViewData["Title"] = "Subscription Expired";
Layout = "_Layout";
var isCompanyAdmin = User.FindFirst("CompanyRole")?.Value == "CompanyAdmin"
|| User.IsInRole("SuperAdmin");
}
<div class="text-center py-5">
<div class="mb-4">
<i class="bi bi-exclamation-triangle-fill text-danger" style="font-size: 4rem;"></i>
</div>
<h1 class="h3 mb-2">Subscription Expired</h1>
@if (isCompanyAdmin)
{
<p class="text-muted mb-4">
Your subscription has expired and the grace period has ended.<br />
Please renew your plan to regain access to the application.
</p>
<a asp-controller="Billing" asp-action="Index" class="btn btn-primary btn-lg me-2">
<i class="bi bi-credit-card me-1"></i>Renew Subscription
</a>
<a asp-controller="AccountDataExport" asp-action="Index" class="btn btn-outline-secondary btn-lg me-2">
<i class="bi bi-download me-1"></i>Download Your Data
</a>
}
else
{
<p class="text-muted mb-4">
Your company's subscription has expired and the grace period has ended.<br />
Please contact your company administrator to renew the subscription.
</p>
}
@if (User.Identity?.IsAuthenticated == true)
{
<a asp-controller="Account" asp-action="Logout" class="btn btn-outline-secondary">
<i class="bi bi-box-arrow-right me-1"></i>Sign Out
</a>
}
</div>
@@ -0,0 +1,21 @@
@{
ViewData["Title"] = "Account Inactive";
Layout = "_Layout";
}
<div class="text-center py-5">
<div class="mb-4">
<i class="bi bi-slash-circle-fill text-secondary" style="font-size: 4rem;"></i>
</div>
<h1 class="h3 mb-2">Account Inactive</h1>
<p class="text-muted mb-4">
Your company account has been deactivated.<br />
Please contact support to reactivate your account.
</p>
<a href="mailto:support@powdercoating.com" class="btn btn-primary me-2">
<i class="bi bi-envelope me-1"></i>Contact Support
</a>
<a asp-area="Identity" asp-page="/Account/Logout" class="btn btn-outline-secondary">
<i class="bi bi-box-arrow-right me-1"></i>Sign Out
</a>
</div>
@@ -0,0 +1,411 @@
@using PowderCoating.Application.DTOs.Subscription
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Billing & Subscription";
ViewData["PageIcon"] = "bi-credit-card";
var status = (SubscriptionStatusDto)ViewBag.StatusDto;
var limits = (PlanLimitsDto)ViewBag.LimitsDto;
var planConfigs = ((IEnumerable<PowderCoating.Core.Entities.SubscriptionPlanConfig>)ViewBag.PlanConfigs)
.OrderBy(c => c.SortOrder).ToList();
var company = (PowderCoating.Core.Entities.Company)ViewBag.Company;
var isExpiringSoon = status.DaysRemaining.HasValue && status.DaysRemaining.Value > 0 && status.DaysRemaining.Value <= 14;
// DB-driven plan badge color (by SortOrder position)
var planBadgeColors = planConfigs
.Select((c, i) => (c.Plan, i))
.ToDictionary(x => x.Plan, x => x.i switch {
0 => "bg-secondary",
1 => "bg-primary",
2 => "bg-info",
_ => "bg-success"
});
string PlanBadge(int plan) => planBadgeColors.TryGetValue(plan, out var c) ? c : "bg-secondary";
// Current plan display name from configs
var currentPlanConfig = planConfigs.FirstOrDefault(c => c.Plan == status.Plan);
var currentPlanName = currentPlanConfig?.DisplayName ?? status.Plan.ToString();
// Usage meter helper
string UsageBarColor(int current, int max) {
if (max == -1) return "bg-success";
var pct = max > 0 ? (int)Math.Round((double)current / max * 100) : 0;
return pct >= 90 ? "bg-danger" : pct >= 70 ? "bg-warning" : "bg-success";
}
int UsagePercent(int current, int max) {
if (max == -1) return 100;
return max > 0 ? (int)Math.Round((double)current / max * 100) : 0;
}
}
<div class="mb-4"></div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<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" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (isExpiringSoon)
{
<div class="alert alert-warning d-flex align-items-center justify-content-between mb-4" role="alert">
<div>
<i class="bi bi-clock-history me-2"></i>
<strong>Your subscription expires in @status.DaysRemaining day@(status.DaysRemaining == 1 ? "" : "s").</strong>
Renew now to ensure uninterrupted access.
</div>
<form method="post" asp-action="Checkout" class="ms-3 flex-shrink-0 checkout-form">
@Html.AntiForgeryToken()
<input type="hidden" name="plan" value="@status.Plan" />
<input type="hidden" name="isAnnual" value="@company.IsAnnualBilling.ToString().ToLower()" class="is-annual-field" />
<button type="submit" class="btn btn-warning btn-sm fw-semibold">
<i class="bi bi-arrow-repeat me-1"></i>Renew @currentPlanName Now
</button>
</form>
</div>
}
else if (status.IsGracePeriod)
{
<div class="alert alert-warning d-flex align-items-center justify-content-between mb-4" role="alert">
<div>
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>Your subscription has expired.</strong>
You are in a grace period until @status.EndDate?.AddDays(14).ToString("MMM d, yyyy").
Renew now to avoid losing access.
</div>
<form method="post" asp-action="Checkout" class="ms-3 flex-shrink-0 checkout-form">
@Html.AntiForgeryToken()
<input type="hidden" name="plan" value="@status.Plan" />
<input type="hidden" name="isAnnual" value="@company.IsAnnualBilling.ToString().ToLower()" class="is-annual-field" />
<button type="submit" class="btn btn-warning btn-sm fw-semibold">
<i class="bi bi-arrow-repeat me-1"></i>Renew @currentPlanName Now
</button>
</form>
</div>
}
else if (status.IsExpired)
{
<div class="alert alert-danger d-flex align-items-center justify-content-between mb-4" role="alert">
<div>
<i class="bi bi-x-octagon-fill me-2"></i>
<strong>Your subscription has expired and access is restricted.</strong>
Reactivate your plan to restore full access.
</div>
<form method="post" asp-action="Checkout" class="ms-3 flex-shrink-0 checkout-form">
@Html.AntiForgeryToken()
<input type="hidden" name="plan" value="@status.Plan" />
<input type="hidden" name="isAnnual" value="@company.IsAnnualBilling.ToString().ToLower()" class="is-annual-field" />
<button type="submit" class="btn btn-danger btn-sm fw-semibold">
<i class="bi bi-arrow-repeat me-1"></i>Reactivate @currentPlanName
</button>
</form>
</div>
}
<!-- Current Plan Card -->
<div class="row g-4 mb-4">
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body">
<h5 class="card-title text-muted small mb-3">CURRENT PLAN</h5>
<div class="d-flex align-items-center mb-2">
<span class="badge fs-6 me-2 @PlanBadge(status.Plan)">@currentPlanName</span>
<span class="badge @(status.Status switch {
SubscriptionStatus.Active => "bg-success-subtle text-success",
SubscriptionStatus.GracePeriod => "bg-warning-subtle text-warning",
SubscriptionStatus.Expired => "bg-danger-subtle text-danger",
SubscriptionStatus.Canceled => "bg-secondary-subtle text-secondary",
_ => "bg-secondary-subtle text-secondary"
})">@status.Status</span>
</div>
@if (status.EndDate.HasValue)
{
<p class="small text-muted mb-1">
<i class="bi bi-calendar me-1"></i>
@if (status.IsExpired)
{
<span class="text-danger">Expired @status.EndDate.Value.ToString("MMM d, yyyy")</span>
}
else if (status.IsGracePeriod)
{
<span class="text-warning">Grace period — expired @status.EndDate.Value.ToString("MMM d, yyyy")</span>
}
else
{
<text>Renews @status.EndDate.Value.ToString("MMM d, yyyy")</text>
}
</p>
}
else
{
<p class="small text-muted mb-1"><i class="bi bi-infinity me-1"></i>No expiry date</p>
}
@if (!string.IsNullOrEmpty(company.StripeCustomerId))
{
<div class="d-flex gap-2 mt-2 flex-wrap">
<form method="post" asp-action="ManageBilling">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-box-arrow-up-right me-1"></i>Manage Billing Portal
</button>
</form>
@if (!string.IsNullOrEmpty(company.StripeSubscriptionId))
{
<form method="post" asp-action="SyncWithStripe">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-repeat me-1"></i>Sync with Stripe
</button>
</form>
}
</div>
}
</div>
</div>
</div>
<!-- Usage Meters -->
<div class="col-md-8">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body">
<h5 class="card-title text-muted small mb-3">USAGE</h5>
<div class="row g-3">
<!-- Users -->
<div class="col-md-4">
<div class="d-flex align-items-center mb-1">
<i class="bi bi-people me-2 text-muted"></i>
<span class="fw-semibold">Users</span>
</div>
<div class="d-flex justify-content-between small text-muted mb-1">
<span>@limits.CurrentUsers used</span>
<span>@(limits.MaxUsers == -1 ? "Unlimited" : limits.MaxUsers.ToString()) max</span>
</div>
<div class="progress" style="height: 6px;">
<div class="progress-bar @UsageBarColor(limits.CurrentUsers, limits.MaxUsers)"
role="progressbar" style="width: @UsagePercent(limits.CurrentUsers, limits.MaxUsers)%"></div>
</div>
</div>
<!-- Active Jobs -->
<div class="col-md-4">
<div class="d-flex align-items-center mb-1">
<i class="bi bi-briefcase me-2 text-muted"></i>
<span class="fw-semibold">Active Jobs</span>
</div>
<div class="d-flex justify-content-between small text-muted mb-1">
<span>@limits.CurrentJobs used</span>
<span>@(limits.MaxActiveJobs == -1 ? "Unlimited" : limits.MaxActiveJobs.ToString()) max</span>
</div>
<div class="progress" style="height: 6px;">
<div class="progress-bar @UsageBarColor(limits.CurrentJobs, limits.MaxActiveJobs)"
role="progressbar" style="width: @UsagePercent(limits.CurrentJobs, limits.MaxActiveJobs)%"></div>
</div>
</div>
<!-- Customers -->
<div class="col-md-4">
<div class="d-flex align-items-center mb-1">
<i class="bi bi-person-rolodex me-2 text-muted"></i>
<span class="fw-semibold">Customers</span>
</div>
<div class="d-flex justify-content-between small text-muted mb-1">
<span>@limits.CurrentCustomers used</span>
<span>@(limits.MaxCustomers == -1 ? "Unlimited" : limits.MaxCustomers.ToString()) max</span>
</div>
<div class="progress" style="height: 6px;">
<div class="progress-bar @UsageBarColor(limits.CurrentCustomers, limits.MaxCustomers)"
role="progressbar" style="width: @UsagePercent(limits.CurrentCustomers, limits.MaxCustomers)%"></div>
</div>
</div>
<!-- Quotes -->
<div class="col-md-4">
<div class="d-flex align-items-center mb-1">
<i class="bi bi-file-earmark-text me-2 text-muted"></i>
<span class="fw-semibold">Quotes</span>
</div>
<div class="d-flex justify-content-between small text-muted mb-1">
<span>@limits.CurrentQuotes used</span>
<span>@(limits.MaxQuotes == -1 ? "Unlimited" : limits.MaxQuotes.ToString()) max</span>
</div>
<div class="progress" style="height: 6px;">
<div class="progress-bar @UsageBarColor(limits.CurrentQuotes, limits.MaxQuotes)"
role="progressbar" style="width: @UsagePercent(limits.CurrentQuotes, limits.MaxQuotes)%"></div>
</div>
</div>
<!-- Catalog Items -->
<div class="col-md-4">
<div class="d-flex align-items-center mb-1">
<i class="bi bi-grid me-2 text-muted"></i>
<span class="fw-semibold">Catalog Items</span>
</div>
<div class="d-flex justify-content-between small text-muted mb-1">
<span>@limits.CurrentCatalogItems used</span>
<span>@(limits.MaxCatalogItems == -1 ? "Unlimited" : limits.MaxCatalogItems.ToString()) max</span>
</div>
<div class="progress" style="height: 6px;">
<div class="progress-bar @UsageBarColor(limits.CurrentCatalogItems, limits.MaxCatalogItems)"
role="progressbar" style="width: @UsagePercent(limits.CurrentCatalogItems, limits.MaxCatalogItems)%"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Refund & Cancellation Notice -->
<div class="alert alert-permanent alert-info d-flex gap-2 mb-4" role="alert">
<i class="bi bi-info-circle-fill flex-shrink-0 mt-1"></i>
<div class="small">
<strong>Cancellation &amp; Refund Policy:</strong>
You may cancel your subscription at any time from this page or by contacting
<a href="mailto:support@powdercoatinglogix.com">support@powdercoatinglogix.com</a>.
Cancellation takes effect at the end of your current billing period — you retain full access until then.
All fees are <strong>non-refundable</strong>; unused time is not credited back.
See our <a asp-controller="Home" asp-action="TermsOfService" asp-fragment="section-5" target="_blank">full billing terms</a> for details.
</div>
</div>
<!-- Plan Options -->
<div class="d-flex align-items-center justify-content-between mb-3">
<h5 class="mb-0">Available Plans</h5>
<div class="billing-period-toggle d-inline-flex align-items-center bg-light border rounded-pill p-1 gap-1">
<button type="button" id="btnMonthly" class="btn btn-sm rounded-pill px-3 @(!company.IsAnnualBilling ? "btn-dark" : "btn-link text-muted")" onclick="setBillingPeriod(false)">Monthly</button>
<button type="button" id="btnAnnual" class="btn btn-sm rounded-pill px-3 @(company.IsAnnualBilling ? "btn-dark" : "btn-link text-muted")" onclick="setBillingPeriod(true)">
Annual <span class="badge bg-success ms-1" style="font-size:0.65rem;">2 months free</span>
</button>
</div>
</div>
<div class="row g-4 mb-4">
@foreach (var plan in planConfigs)
{
var isCurrent = plan.Plan == status.Plan;
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm @(isCurrent ? "border border-2 border-primary" : "")">
@if (isCurrent)
{
<div class="card-header text-white text-center small fw-semibold py-1 @(status.IsGracePeriod ? "bg-warning" : status.IsExpired ? "bg-danger" : "bg-primary")">
@(status.IsGracePeriod ? "GRACE PERIOD" : status.IsExpired ? "EXPIRED" : "CURRENT PLAN")
</div>
}
<div class="card-body d-flex flex-column">
<h5 class="card-title">@plan.DisplayName</h5>
@if (!string.IsNullOrEmpty(plan.Description))
{
<p class="text-muted small mb-3">@plan.Description</p>
}
<div class="mb-3">
<span class="price-display fs-3 fw-bold">
<span class="price-monthly">$@plan.MonthlyPrice.ToString("0.##")</span>
@if (plan.AnnualPrice > 0)
{
<span class="price-annual" style="display:none;">$@((plan.AnnualPrice / 12m).ToString("0.##"))</span>
}
else
{
<span class="price-annual" style="display:none;">$@plan.MonthlyPrice.ToString("0.##")</span>
}
</span>
<span class="text-muted">/mo</span>
@if (plan.AnnualPrice > 0)
{
<br />
<small class="price-annual-note text-muted" style="display:none;">$@plan.AnnualPrice.ToString("0.##") billed annually</small>
<small class="price-monthly-note text-muted">or $@plan.AnnualPrice.ToString("0.##")/yr (save 2 months)</small>
}
</div>
<ul class="list-unstyled mb-4 flex-grow-1">
<li class="mb-1"><i class="bi bi-check-circle text-success me-2"></i>
@(plan.MaxUsers == -1 ? "Unlimited" : plan.MaxUsers.ToString()) users
</li>
<li class="mb-1"><i class="bi bi-check-circle text-success me-2"></i>
@(plan.MaxActiveJobs == -1 ? "Unlimited" : plan.MaxActiveJobs.ToString()) active jobs
</li>
<li class="mb-1"><i class="bi bi-check-circle text-success me-2"></i>
@(plan.MaxCustomers == -1 ? "Unlimited" : plan.MaxCustomers.ToString()) customers
</li>
<li class="mb-1"><i class="bi bi-check-circle text-success me-2"></i>
@(plan.MaxQuotes == -1 ? "Unlimited" : plan.MaxQuotes.ToString()) quotes
</li>
<li class="mb-1"><i class="bi bi-check-circle text-success me-2"></i>
@(plan.MaxCatalogItems == -1 ? "Unlimited" : plan.MaxCatalogItems.ToString()) catalog items
</li>
<li class="mb-1">
<i class="bi bi-camera @(plan.MaxJobPhotos == 0 ? "text-danger" : "text-success") me-2"></i>
@(plan.MaxJobPhotos == -1 ? "Unlimited" : plan.MaxJobPhotos == 0 ? "No" : plan.MaxJobPhotos.ToString()) photos per job
</li>
<li class="mb-1">
<i class="bi bi-image @(plan.MaxQuotePhotos == 0 ? "text-danger" : "text-success") me-2"></i>
@(plan.MaxQuotePhotos == -1 ? "Unlimited" : plan.MaxQuotePhotos == 0 ? "No" : plan.MaxQuotePhotos.ToString()) photos per quote
</li>
<li class="mb-1">
<i class="bi bi-robot @(plan.MaxAiPhotoQuotesPerMonth == 0 ? "text-danger" : "text-success") me-2"></i>
@(plan.MaxAiPhotoQuotesPerMonth == -1 ? "Unlimited" : plan.MaxAiPhotoQuotesPerMonth == 0 ? "No" : plan.MaxAiPhotoQuotesPerMonth.ToString()) AI photo quotes/mo
</li>
<li class="mb-1">
<i class="bi bi-credit-card @(plan.AllowOnlinePayments ? "text-success" : "text-danger") me-2"></i>
@(plan.AllowOnlinePayments ? "Online payments" : "No online payments")
</li>
</ul>
@if (!isCurrent)
{
<form method="post" asp-action="Checkout" class="checkout-form">
@Html.AntiForgeryToken()
<input type="hidden" name="plan" value="@((int)plan.Plan)" />
<input type="hidden" name="isAnnual" value="@company.IsAnnualBilling.ToString().ToLower()" class="is-annual-field" />
<button type="submit" class="btn btn-primary w-100">
@(plan.Plan > status.Plan ? "Upgrade" : "Downgrade") to @plan.DisplayName
</button>
</form>
}
else if (status.IsGracePeriod || status.IsExpired || isExpiringSoon)
{
<form method="post" asp-action="Checkout" class="checkout-form">
@Html.AntiForgeryToken()
<input type="hidden" name="plan" value="@((int)plan.Plan)" />
<input type="hidden" name="isAnnual" value="@company.IsAnnualBilling.ToString().ToLower()" class="is-annual-field" />
<button type="submit" class="btn @(status.IsExpired ? "btn-danger" : "btn-warning") w-100 fw-semibold">
<i class="bi bi-arrow-repeat me-1"></i>@(status.IsExpired ? "Reactivate" : "Renew") @plan.DisplayName
</button>
</form>
}
else
{
<button class="btn btn-outline-secondary w-100" disabled>Current Plan</button>
}
</div>
</div>
</div>
}
</div>
@section Scripts {
<script>
function setBillingPeriod(isAnnual) {
// Toggle button styles
document.getElementById('btnMonthly').className = 'btn btn-sm rounded-pill px-3 ' + (isAnnual ? 'btn-link text-muted' : 'btn-dark');
document.getElementById('btnAnnual').className = 'btn btn-sm rounded-pill px-3 ' + (isAnnual ? 'btn-dark' : 'btn-link text-muted');
// Swap price display
document.querySelectorAll('.price-monthly').forEach(el => el.style.display = isAnnual ? 'none' : '');
document.querySelectorAll('.price-annual').forEach(el => el.style.display = isAnnual ? '' : 'none');
document.querySelectorAll('.price-annual-note').forEach(el => el.style.display = isAnnual ? '' : 'none');
document.querySelectorAll('.price-monthly-note').forEach(el => el.style.display = isAnnual ? 'none' : '');
// Update all checkout form hidden fields
document.querySelectorAll('.is-annual-field').forEach(el => el.value = isAnnual ? 'true' : 'false');
}
// Apply saved preference on load
@if (company.IsAnnualBilling)
{
<text>setBillingPeriod(true);</text>
}
</script>
}
@@ -0,0 +1,17 @@
@{
ViewData["Title"] = "Subscription Updated";
}
<div class="text-center py-5">
<div class="mb-4">
<i class="bi bi-check-circle-fill text-success" style="font-size: 4rem;"></i>
</div>
<h1 class="h3 mb-2">Plan Updated Successfully!</h1>
<p class="text-muted mb-4">Your subscription has been activated. Enjoy your new plan features.</p>
<a asp-action="Index" class="btn btn-primary me-2">
<i class="bi bi-credit-card me-1"></i>View Billing
</a>
<a asp-controller="Home" asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-house me-1"></i>Go to Dashboard
</a>
</div>
@@ -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>
}
@@ -0,0 +1,217 @@
@model PowderCoating.Application.DTOs.BugReport.EditBugReportDto
@using PowderCoating.Core.Enums
@using PowderCoating.Application.DTOs.BugReport
@{
ViewData["Title"] = "Edit Bug Report";
ViewData["PageIcon"] = "bi-bug";
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<p class="text-muted mb-0">
Submitted by <strong>@Model.SubmittedByUserName</strong> on @Model.CreatedAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yyyy h:mm tt")
@if (!string.IsNullOrWhiteSpace(Model.CompanyName))
{
<span class="ms-2"><i class="bi bi-building"></i> <strong>@Model.CompanyName</strong></span>
}
</p>
</div>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Bug Reports
</a>
</div>
@if (TempData["ErrorMessage"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle"></i> @TempData["ErrorMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-pencil-square"></i> Report Details</h5>
</div>
<div class="card-body">
<form asp-action="Edit" asp-route-id="@Model.Id" method="post">
<input type="hidden" asp-for="Id" />
<input type="hidden" asp-for="SubmittedByUserName" />
<input type="hidden" asp-for="CompanyName" />
<input type="hidden" asp-for="CreatedAt" />
<input type="hidden" asp-for="CompanyId" />
<div asp-validation-summary="ModelOnly" class="alert alert-danger" role="alert"></div>
<div class="mb-3">
<label asp-for="Title" class="form-label fw-semibold">
Title <span class="text-danger">*</span>
</label>
<input asp-for="Title" class="form-control" maxlength="200" />
<span asp-validation-for="Title" class="text-danger small"></span>
</div>
<div class="mb-3">
<label asp-for="Description" class="form-label fw-semibold">
Description <span class="text-danger">*</span>
</label>
<textarea asp-for="Description" class="form-control" rows="6" maxlength="4000"></textarea>
<span asp-validation-for="Description" class="text-danger small"></span>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label asp-for="Priority" class="form-label fw-semibold">Priority</label>
<select asp-for="Priority" class="form-select">
@foreach (var p in Enum.GetValues<BugReportPriority>())
{
<option value="@((int)p)">@p</option>
}
</select>
<span asp-validation-for="Priority" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Status" class="form-label fw-semibold">Status</label>
<select asp-for="Status" class="form-select">
<option value="@((int)BugReportStatus.New)">New</option>
<option value="@((int)BugReportStatus.InProgress)">In Progress</option>
<option value="@((int)BugReportStatus.Completed)">Completed</option>
<option value="@((int)BugReportStatus.Cancelled)">Cancelled</option>
</select>
<span asp-validation-for="Status" class="text-danger small"></span>
</div>
</div>
<div class="mb-4">
<label asp-for="ResolutionNotes" class="form-label fw-semibold">Resolution Notes</label>
<textarea asp-for="ResolutionNotes" class="form-control" rows="4" maxlength="4000"
placeholder="Describe the resolution or any notes about this report..."></textarea>
<span asp-validation-for="ResolutionNotes" class="text-danger small"></span>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Save Changes
</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
@if (Model.Attachments.Count > 0)
{
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-paperclip"></i> Attachments (@Model.Attachments.Count)</h5>
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-3">
@foreach (var att in Model.Attachments)
{
var attUrl = Url.Action("Attachment", "BugReport", new { id = att.Id });
var isImage = att.ContentType.StartsWith("image/");
var isVideo = att.ContentType.StartsWith("video/");
var sizeMb = (att.FileSizeBytes / 1024.0 / 1024.0).ToString("F1");
if (isImage)
{
<div class="attachment-thumb" title="@att.FileName (@sizeMb MB)"
data-type="image" data-src="@attUrl" data-name="@att.FileName"
style="cursor:pointer;">
<img src="@attUrl" alt="@att.FileName"
style="width:120px;height:90px;object-fit:cover;border-radius:6px;border:2px solid #dee2e6;" />
<div class="small text-muted text-truncate mt-1" style="max-width:120px;">@att.FileName</div>
</div>
}
else if (isVideo)
{
<div class="attachment-thumb" title="@att.FileName (@sizeMb MB)"
data-type="video" data-src="@attUrl" data-name="@att.FileName"
data-content-type="@att.ContentType"
style="cursor:pointer;">
<div style="width:120px;height:90px;border-radius:6px;border:2px solid #dee2e6;background:#1a1a2e;display:flex;align-items:center;justify-content:center;">
<i class="bi bi-play-circle-fill text-white" style="font-size:2.5rem;"></i>
</div>
<div class="small text-muted text-truncate mt-1" style="max-width:120px;">@att.FileName</div>
</div>
}
else
{
<div class="d-flex align-items-center gap-2 border rounded p-2" style="min-width:200px;">
<i class="bi bi-file-earmark fs-4 text-secondary"></i>
<div>
<div class="small fw-semibold text-truncate" style="max-width:150px;">@att.FileName</div>
<div class="small text-muted">@sizeMb MB</div>
</div>
<a href="@attUrl" class="btn btn-sm btn-outline-secondary ms-auto" target="_blank">
<i class="bi bi-download"></i>
</a>
</div>
}
}
</div>
</div>
</div>
<!-- Lightbox modal -->
<div class="modal fade" id="attachmentModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-dark">
<div class="modal-header border-secondary py-2">
<h6 class="modal-title text-white mb-0" id="attachmentModalLabel"></h6>
<div class="d-flex gap-2 ms-3">
<a id="attachmentDownloadLink" href="#" class="btn btn-sm btn-outline-light" target="_blank">
<i class="bi bi-download"></i> Download
</a>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
</div>
<div class="modal-body p-2 text-center" id="attachmentModalBody">
</div>
</div>
</div>
</div>
}
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
@if (Model.Attachments.Any(a => a.ContentType.StartsWith("image/") || a.ContentType.StartsWith("video/")))
{
<script>
document.querySelectorAll('.attachment-thumb').forEach(el => {
el.addEventListener('click', function () {
const src = this.dataset.src;
const name = this.dataset.name;
const type = this.dataset.type;
const contentType = this.dataset.contentType || '';
document.getElementById('attachmentModalLabel').textContent = name;
document.getElementById('attachmentDownloadLink').href = src;
const body = document.getElementById('attachmentModalBody');
if (type === 'image') {
body.innerHTML = `<img src="${src}" alt="${name}" style="max-width:100%;max-height:80vh;border-radius:4px;" />`;
} else {
body.innerHTML = `<video controls autoplay style="max-width:100%;max-height:80vh;border-radius:4px;">
<source src="${src}" type="${contentType}">
Your browser does not support video playback.
</video>`;
}
new bootstrap.Modal(document.getElementById('attachmentModal')).show();
});
});
// Stop video playback when modal closes
document.getElementById('attachmentModal')?.addEventListener('hidden.bs.modal', function () {
document.getElementById('attachmentModalBody').innerHTML = '';
});
</script>
}
}
@@ -0,0 +1,364 @@
@model List<PowderCoating.Application.DTOs.BugReport.BugReportDto>
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Bug Reports";
ViewData["PageIcon"] = "bi-bug";
var sortCol = ViewBag.SortColumn as string ?? "CreatedAt";
var sortDir = ViewBag.SortDirection as string ?? "desc";
int totalCount = ViewBag.TotalCount ?? 0;
int pageNumber = ViewBag.PageNumber ?? 1;
int pageSize = ViewBag.PageSize ?? 25;
int totalPages = ViewBag.TotalPages ?? 1;
string NextDir(string col) => sortCol == col && sortDir == "asc" ? "desc" : "asc";
string SortIcon(string col) => sortCol == col
? (sortDir == "asc" ? "bi-sort-up" : "bi-sort-down")
: "bi-arrow-down-up text-muted";
}
@section Styles {
<style>
[data-bs-theme="dark"] .table-light th,
[data-bs-theme="dark"] .table-light td {
background-color: var(--bs-secondary-bg);
color: var(--bs-body-color);
}
[data-bs-theme="dark"] .card {
border-color: var(--bs-border-color) !important;
}
[data-bs-theme="dark"] .sort-link {
color: var(--bs-body-color) !important;
}
[data-bs-theme="dark"] .border-top {
border-color: var(--bs-border-color) !important;
}
[data-bs-theme="dark"] .pagination .page-link {
background-color: var(--bs-body-bg);
border-color: var(--bs-border-color);
color: var(--bs-body-color);
}
[data-bs-theme="dark"] .pagination .page-item.active .page-link {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: #fff;
}
</style>
}
<div class="container-fluid">
<div class="mb-4"></div>
@if (TempData["SuccessMessage"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle"></i> @TempData["SuccessMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (TempData["ErrorMessage"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle"></i> @TempData["ErrorMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<!-- Filters -->
<div class="card mb-3">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small mb-1">Search</label>
<input type="text" name="searchTerm" value="@ViewBag.SearchTerm" class="form-control form-control-sm"
placeholder="Title, description, or submitter..." />
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Status</label>
<select name="statusFilter" class="form-select form-select-sm">
<option value="">All Statuses</option>
@foreach (var s in Enum.GetValues<BugReportStatus>())
{
<option value="@s" selected="@(ViewBag.StatusFilter == s.ToString())">@s.ToString()</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Priority</label>
<select name="priorityFilter" class="form-select form-select-sm">
<option value="">All Priorities</option>
@foreach (var p in Enum.GetValues<BugReportPriority>())
{
<option value="@p" selected="@(ViewBag.PriorityFilter == p.ToString())">@p.ToString()</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Per page</label>
<select name="pageSize" class="form-select form-select-sm">
@foreach (var n in new[] { 10, 25, 50, 100 })
{
<option value="@n" selected="@(pageSize == n)">@n</option>
}
</select>
</div>
<input type="hidden" name="sortColumn" value="@sortCol" />
<input type="hidden" name="sortDirection" value="@sortDir" />
<div class="col-md-2">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-search"></i> Filter
</button>
</div>
</form>
</div>
</div>
<!-- Table -->
<div class="card">
<div class="card-body p-0">
@if (!Model.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-check-circle display-4 d-block mb-2"></i>
<p>No bug reports found.</p>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>
<a asp-action="Index"
asp-route-searchTerm="@ViewBag.SearchTerm"
asp-route-statusFilter="@ViewBag.StatusFilter"
asp-route-priorityFilter="@ViewBag.PriorityFilter"
asp-route-pageSize="@pageSize"
asp-route-sortColumn="Title"
asp-route-sortDirection="@NextDir("Title")"
class="text-decoration-none sort-link">
Title <i class="bi @SortIcon("Title")"></i>
</a>
</th>
<th>
<a asp-action="Index"
asp-route-searchTerm="@ViewBag.SearchTerm"
asp-route-statusFilter="@ViewBag.StatusFilter"
asp-route-priorityFilter="@ViewBag.PriorityFilter"
asp-route-pageSize="@pageSize"
asp-route-sortColumn="Priority"
asp-route-sortDirection="@NextDir("Priority")"
class="text-decoration-none sort-link">
Priority <i class="bi @SortIcon("Priority")"></i>
</a>
</th>
<th>
<a asp-action="Index"
asp-route-searchTerm="@ViewBag.SearchTerm"
asp-route-statusFilter="@ViewBag.StatusFilter"
asp-route-priorityFilter="@ViewBag.PriorityFilter"
asp-route-pageSize="@pageSize"
asp-route-sortColumn="Status"
asp-route-sortDirection="@NextDir("Status")"
class="text-decoration-none sort-link">
Status <i class="bi @SortIcon("Status")"></i>
</a>
</th>
<th>
<a asp-action="Index"
asp-route-searchTerm="@ViewBag.SearchTerm"
asp-route-statusFilter="@ViewBag.StatusFilter"
asp-route-priorityFilter="@ViewBag.PriorityFilter"
asp-route-pageSize="@pageSize"
asp-route-sortColumn="Submitted"
asp-route-sortDirection="@NextDir("Submitted")"
class="text-decoration-none sort-link">
Submitted By <i class="bi @SortIcon("Submitted")"></i>
</a>
</th>
<th>Company</th>
<th>
<a asp-action="Index"
asp-route-searchTerm="@ViewBag.SearchTerm"
asp-route-statusFilter="@ViewBag.StatusFilter"
asp-route-priorityFilter="@ViewBag.PriorityFilter"
asp-route-pageSize="@pageSize"
asp-route-sortColumn="CreatedAt"
asp-route-sortDirection="@NextDir("CreatedAt")"
class="text-decoration-none sort-link">
Submitted <i class="bi @SortIcon("CreatedAt")"></i>
</a>
</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var report in Model)
{
<tr style="cursor:pointer" onclick="window.location='@Url.Action("Edit", new { id = report.Id })'">
<td>
<div class="fw-semibold">@report.Title</div>
<div class="text-muted small text-truncate" style="max-width:320px;" title="@report.Description">
@report.Description
</div>
</td>
<td>
@{
var priClass = report.Priority switch
{
BugReportPriority.Critical => "bg-danger",
BugReportPriority.High => "bg-warning text-dark",
BugReportPriority.Normal => "bg-primary",
_ => "bg-secondary"
};
}
<span class="badge @priClass">@report.Priority</span>
</td>
<td>
@{
var statusClass = report.Status switch
{
BugReportStatus.New => "bg-info text-dark",
BugReportStatus.InProgress => "bg-warning text-dark",
BugReportStatus.Completed => "bg-success",
BugReportStatus.Cancelled => "bg-secondary",
_ => "bg-secondary"
};
var statusLabel = report.Status switch
{
BugReportStatus.InProgress => "In Progress",
_ => report.Status.ToString()
};
}
<span class="badge @statusClass">@statusLabel</span>
</td>
<td class="small">@report.SubmittedByUserName</td>
<td class="small text-muted">@report.CompanyId</td>
<td class="small text-muted text-nowrap">@report.CreatedAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yyyy h:mm tt")</td>
<td onclick="event.stopPropagation()">
<a asp-action="Edit" asp-route-id="@report.Id" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i> Edit
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile card view — shown on screens < 992px -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var report in Model)
{
var mPriClass = report.Priority switch
{
BugReportPriority.Critical => "bg-danger",
BugReportPriority.High => "bg-warning text-dark",
BugReportPriority.Normal => "bg-primary",
_ => "bg-secondary"
};
var mStatusClass = report.Status switch
{
BugReportStatus.New => "bg-info text-dark",
BugReportStatus.InProgress => "bg-warning text-dark",
BugReportStatus.Completed => "bg-success",
BugReportStatus.Cancelled => "bg-secondary",
_ => "bg-secondary"
};
var mStatusLabel = report.Status switch
{
BugReportStatus.InProgress => "In Progress",
_ => report.Status.ToString()
};
<a href="@Url.Action("Edit", new { id = report.Id })" class="mobile-data-card text-decoration-none">
<div class="mobile-card-header">
<div class="mobile-card-icon bg-danger"><i class="bi bi-bug"></i></div>
<div class="mobile-card-title">
<h6>@report.Title</h6>
<small>@report.CreatedAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yyyy h:mm tt")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value"><span class="badge @mStatusClass">@mStatusLabel</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Priority</span>
<span class="mobile-card-value"><span class="badge @mPriClass">@report.Priority</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Reporter</span>
<span class="mobile-card-value">@report.SubmittedByUserName</span>
</div>
@if (report.CompanyId > 0)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Company ID</span>
<span class="mobile-card-value">@report.CompanyId</span>
</div>
}
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-primary">Edit →</span>
</div>
</a>
}
</div>
</div>
<!-- Pagination -->
@if (totalPages > 1)
{
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top">
<small class="text-muted">
Showing @((pageNumber - 1) * pageSize + 1)@(Math.Min(pageNumber * pageSize, totalCount)) of @totalCount
</small>
<nav>
<ul class="pagination pagination-sm mb-0">
<li class="page-item @(pageNumber <= 1 ? "disabled" : "")">
<a class="page-link" asp-action="Index"
asp-route-searchTerm="@ViewBag.SearchTerm"
asp-route-statusFilter="@ViewBag.StatusFilter"
asp-route-priorityFilter="@ViewBag.PriorityFilter"
asp-route-sortColumn="@sortCol"
asp-route-sortDirection="@sortDir"
asp-route-pageSize="@pageSize"
asp-route-pageNumber="@(pageNumber - 1)">
<i class="bi bi-chevron-left"></i>
</a>
</li>
@for (int i = Math.Max(1, pageNumber - 2); i <= Math.Min(totalPages, pageNumber + 2); i++)
{
<li class="page-item @(i == pageNumber ? "active" : "")">
<a class="page-link" asp-action="Index"
asp-route-searchTerm="@ViewBag.SearchTerm"
asp-route-statusFilter="@ViewBag.StatusFilter"
asp-route-priorityFilter="@ViewBag.PriorityFilter"
asp-route-sortColumn="@sortCol"
asp-route-sortDirection="@sortDir"
asp-route-pageSize="@pageSize"
asp-route-pageNumber="@i">@i</a>
</li>
}
<li class="page-item @(pageNumber >= totalPages ? "disabled" : "")">
<a class="page-link" asp-action="Index"
asp-route-searchTerm="@ViewBag.SearchTerm"
asp-route-statusFilter="@ViewBag.StatusFilter"
asp-route-priorityFilter="@ViewBag.PriorityFilter"
asp-route-sortColumn="@sortCol"
asp-route-sortDirection="@sortDir"
asp-route-pageSize="@pageSize"
asp-route-pageNumber="@(pageNumber + 1)">
<i class="bi bi-chevron-right"></i>
</a>
</li>
</ul>
</nav>
</div>
}
}
</div>
</div>
</div>
@@ -0,0 +1,115 @@
@model PowderCoating.Application.DTOs.BugReport.CreateBugReportDto
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Report a Bug";
ViewData["PageIcon"] = "bi-bug";
}
<div class="container-fluid">
<div class="d-flex justify-content-end align-items-center mb-4">
<a asp-controller="Tools" asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Tools
</a>
</div>
@if (TempData["SuccessMessage"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle"></i> @TempData["SuccessMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (TempData["ErrorMessage"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle"></i> @TempData["ErrorMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-pencil-square"></i> Bug Report Details</h5>
</div>
<div class="card-body">
<form asp-action="Submit" method="post" enctype="multipart/form-data">
<div asp-validation-summary="ModelOnly" class="alert alert-danger" role="alert"></div>
<div class="mb-3">
<label asp-for="Title" class="form-label fw-semibold">
Title <span class="text-danger">*</span>
</label>
<input asp-for="Title" class="form-control" placeholder="Brief summary of the issue" maxlength="200" />
<span asp-validation-for="Title" class="text-danger small"></span>
<div class="form-text">Provide a short, descriptive title (e.g., "Invoice PDF fails to generate").</div>
</div>
<div class="mb-3">
<label asp-for="Description" class="form-label fw-semibold">
Description <span class="text-danger">*</span>
</label>
<textarea asp-for="Description" class="form-control" rows="7"
placeholder="Describe what happened, what you expected to happen, and any steps to reproduce the issue..." maxlength="4000"></textarea>
<span asp-validation-for="Description" class="text-danger small"></span>
<div class="form-text">Include steps to reproduce, what you expected, and what actually happened.</div>
</div>
<div class="mb-4">
<label asp-for="Priority" class="form-label fw-semibold">Priority</label>
<select asp-for="Priority" class="form-select">
<option value="@((int)BugReportPriority.Low)">Low Minor inconvenience, workaround exists</option>
<option value="@((int)BugReportPriority.Normal)" selected>Normal Affects workflow but not critical</option>
<option value="@((int)BugReportPriority.High)">High Significantly impacts operations</option>
<option value="@((int)BugReportPriority.Critical)">Critical System unusable or data loss risk</option>
</select>
<span asp-validation-for="Priority" class="text-danger small"></span>
</div>
<div class="mb-4">
<label for="attachments" class="form-label fw-semibold">
<i class="bi bi-paperclip"></i> Attachments <span class="text-muted fw-normal">(optional)</span>
</label>
<input type="file" id="attachments" name="attachments" class="form-control"
multiple accept=".jpg,.jpeg,.png,.gif,.webp,.mp4,.mov,.avi,.mkv,.webm"
onchange="updateFileList(this)" />
<div class="form-text">Photos or videos up to 100 MB each. Accepted: JPG, PNG, GIF, WEBP, MP4, MOV, AVI, MKV, WEBM.</div>
<ul id="fileList" class="list-unstyled mt-2 small text-muted"></ul>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-send"></i> Submit Report
</button>
<a asp-controller="Tools" asp-action="Index" class="btn btn-outline-secondary">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<script>
function updateFileList(input) {
const list = document.getElementById('fileList');
list.innerHTML = '';
const maxBytes = 100 * 1024 * 1024;
Array.from(input.files).forEach(f => {
const li = document.createElement('li');
const sizeMb = (f.size / 1024 / 1024).toFixed(1);
if (f.size > maxBytes) {
li.innerHTML = `<i class="bi bi-exclamation-triangle text-danger"></i> ${f.name} (${sizeMb} MB) — exceeds 100 MB limit`;
} else {
li.innerHTML = `<i class="bi bi-file-earmark text-secondary"></i> ${f.name} (${sizeMb} MB)`;
}
list.appendChild(li);
});
}
</script>
}
@@ -0,0 +1,78 @@
@model PowderCoating.Application.DTOs.Catalog.CreateCategoryDto
@{
ViewData["Title"] = "Add Category";
}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="bi bi-plus-circle me-2"></i>Add Category
</h4>
</div>
<div class="card-body">
<form asp-action="Create" method="post">
<partial name="_ValidationSummary" />
<div class="mb-3">
<label asp-for="Name" class="form-label required"></label>
<input asp-for="Name" class="form-control" placeholder="e.g., Wheels, Engine Parts" autofocus />
<span asp-validation-for="Name" class="text-danger small"></span>
</div>
<div class="mb-3">
<label asp-for="Description" class="form-label"></label>
<textarea asp-for="Description" class="form-control" rows="3" placeholder="Optional description of this category..."></textarea>
<span asp-validation-for="Description" class="text-danger small"></span>
</div>
<div class="mb-3">
<div class="d-flex align-items-center gap-1">
<label asp-for="ParentCategoryId" class="form-label mb-0"></label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Parent Category"
data-bs-content="Leave as '(None)' to create a top-level category. Choose a parent to nest this under it — e.g., place 'Aluminum Wheels' under a parent 'Wheels' category. This creates a browsable hierarchy in the catalog and quote wizard.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<select asp-for="ParentCategoryId" class="form-select" asp-items="ViewBag.ParentCategories">
</select>
<span asp-validation-for="ParentCategoryId" class="text-danger small"></span>
</div>
<div class="mb-3">
<div class="d-flex align-items-center gap-1">
<label asp-for="DisplayOrder" class="form-label mb-0"></label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Display Order"
data-bs-content="Controls where this category appears in lists. Categories with lower numbers (e.g., 10) appear before those with higher numbers (e.g., 20). Use multiples of 10 so you can easily insert new categories in between later.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="DisplayOrder" class="form-control" />
<span asp-validation-for="DisplayOrder" class="text-danger small"></span>
<small class="form-text text-muted">Lower numbers appear first in lists.</small>
</div>
<div class="d-flex justify-content-between mt-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>Create Category
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
@@ -0,0 +1,113 @@
@model PowderCoating.Application.DTOs.Catalog.CategoryDto
@{
ViewData["Title"] = "Delete Category";
var canDelete = ViewBag.CanDelete ?? false;
var hasItems = ViewBag.HasItems ?? false;
var hasSubCategories = ViewBag.HasSubCategories ?? false;
}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">
<i class="bi bi-exclamation-triangle me-2"></i>Delete Category
</h4>
</div>
<div class="card-body">
@if (!canDelete)
{
<div class="alert alert-danger">
<i class="bi bi-x-circle-fill me-2"></i>
<strong>Cannot Delete:</strong> This category cannot be deleted because it contains @(hasItems ? "items" : "") @(hasItems && hasSubCategories ? "and" : "") @(hasSubCategories ? "subcategories" : "").
</div>
<p>To delete this category, you must first:</p>
<ul>
@if (hasItems)
{
<li>Move or delete all <strong>@Model.ItemCount items</strong> in this category</li>
}
@if (hasSubCategories)
{
<li>Move or delete all <strong>@Model.SubCategoryCount subcategories</strong></li>
}
</ul>
<div class="d-flex justify-content-between mt-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Categories
</a>
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-primary">
<i class="bi bi-eye me-1"></i>View Category
</a>
</div>
}
else
{
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>Warning:</strong> You are about to delete this category. This action cannot be undone.
</div>
<h5 class="mb-3">Are you sure you want to delete this category?</h5>
<!-- Category Details -->
<div class="card mb-4">
<div class="card-body">
<div class="row mb-2">
<div class="col-md-4"><strong>Name:</strong></div>
<div class="col-md-8">@Model.Name</div>
</div>
@if (!string.IsNullOrWhiteSpace(Model.Description))
{
<div class="row mb-2">
<div class="col-md-4"><strong>Description:</strong></div>
<div class="col-md-8">@Model.Description</div>
</div>
}
<div class="row mb-2">
<div class="col-md-4"><strong>Parent Category:</strong></div>
<div class="col-md-8">@(Model.ParentCategoryName ?? "Root Category")</div>
</div>
<div class="row mb-2">
<div class="col-md-4"><strong>Items:</strong></div>
<div class="col-md-8">@Model.ItemCount</div>
</div>
<div class="row mb-2">
<div class="col-md-4"><strong>Subcategories:</strong></div>
<div class="col-md-8">@Model.SubCategoryCount</div>
</div>
<div class="row mb-2">
<div class="col-md-4"><strong>Status:</strong></div>
<div class="col-md-8">
@if (Model.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-secondary">Inactive</span>
}
</div>
</div>
</div>
</div>
<form asp-action="Delete" method="post">
<div class="d-flex justify-content-between">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Cancel
</a>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1"></i>Delete Category
</button>
</div>
</form>
}
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,181 @@
@model PowderCoating.Application.DTOs.Catalog.CategoryDto
@{
ViewData["Title"] = Model.Name;
var items = ViewBag.Items as List<PowderCoating.Core.Entities.CatalogItem> ?? new List<PowderCoating.Core.Entities.CatalogItem>();
}
<div class="container">
<div class="row">
<!-- Left Column: Category Details -->
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="bi bi-folder me-2"></i>@Model.Name
</h4>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<p class="mb-1"><strong>Name:</strong></p>
<p>@Model.Name</p>
</div>
<div class="col-md-6">
<p class="mb-1"><strong>Status:</strong></p>
<p>
@if (Model.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-secondary">Inactive</span>
}
</p>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(Model.Description))
{
<div class="mb-3">
<p class="mb-1"><strong>Description:</strong></p>
<p>@Model.Description</p>
</div>
}
<div class="row mb-3">
<div class="col-md-6">
<p class="mb-1"><strong>Parent Category:</strong></p>
<p>@(Model.ParentCategoryName ?? "Root Category")</p>
</div>
<div class="col-md-6">
<p class="mb-1"><strong>Display Order:</strong></p>
<p>@Model.DisplayOrder</p>
</div>
</div>
<div class="row">
<div class="col-md-6">
<p class="mb-1"><strong>Items in Category:</strong></p>
<p class="h4 text-primary">@Model.ItemCount</p>
</div>
<div class="col-md-6">
<p class="mb-1"><strong>Subcategories:</strong></p>
<p class="h4 text-info">@Model.SubCategoryCount</p>
</div>
</div>
</div>
</div>
<!-- Items in Category -->
@if (items.Any())
{
<div class="card mt-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Items in this Category</h5>
<a asp-controller="CatalogItems" asp-action="Create" asp-route-categoryId="@Model.Id" class="btn btn-sm btn-primary">
<i class="bi bi-plus-circle me-1"></i>Add Item
</a>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th>Name</th>
<th>SKU</th>
<th class="text-end">Price</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in items)
{
<tr>
<td>
<a asp-controller="CatalogItems" asp-action="Details" asp-route-id="@item.Id">
@item.Name
</a>
</td>
<td>@(item.SKU ?? "-")</td>
<td class="text-end">@item.DefaultPrice.ToString("C")</td>
<td class="text-end">
<a asp-controller="CatalogItems" asp-action="Edit" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
else
{
<div class="card mt-4">
<div class="card-body text-center py-4">
<i class="bi bi-inbox" style="font-size: 3rem; color: #ccc;"></i>
<p class="text-muted mt-2">No items in this category yet.</p>
<a asp-controller="CatalogItems" asp-action="Create" asp-route-categoryId="@Model.Id" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>Add First Item
</a>
</div>
</div>
}
</div>
<!-- Right Column: Actions -->
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Actions</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-primary">
<i class="bi bi-pencil me-1"></i>Edit Category
</a>
<a asp-action="Delete" asp-route-id="@Model.Id" class="btn btn-outline-danger">
<i class="bi bi-trash me-1"></i>Delete Category
</a>
<hr />
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Categories
</a>
<a asp-controller="CatalogItems" asp-action="Create" asp-route-categoryId="@Model.Id" class="btn btn-outline-primary">
<i class="bi bi-plus-circle me-1"></i>Add Item
</a>
</div>
</div>
</div>
<!-- Stats Card -->
<div class="card mt-3">
<div class="card-header">
<h6 class="mb-0">Category Statistics</h6>
</div>
<div class="card-body">
<ul class="list-unstyled mb-0">
<li class="mb-2">
<i class="bi bi-box-seam text-primary me-2"></i>
<strong>@Model.ItemCount</strong> items
</li>
<li class="mb-2">
<i class="bi bi-folder text-info me-2"></i>
<strong>@Model.SubCategoryCount</strong> subcategories
</li>
@if (!string.IsNullOrWhiteSpace(Model.ParentCategoryName))
{
<li class="mb-2">
<i class="bi bi-diagram-3 text-secondary me-2"></i>
Parent: <strong>@Model.ParentCategoryName</strong>
</li>
}
</ul>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,96 @@
@model PowderCoating.Application.DTOs.Catalog.UpdateCategoryDto
@{
ViewData["Title"] = "Edit Category";
}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="bi bi-pencil me-2"></i>Edit Category
</h4>
</div>
<div class="card-body">
<form asp-action="Edit" method="post">
<input type="hidden" asp-for="Id" />
<partial name="_ValidationSummary" />
<div class="mb-3">
<label asp-for="Name" class="form-label required"></label>
<input asp-for="Name" class="form-control" autofocus />
<span asp-validation-for="Name" class="text-danger small"></span>
</div>
<div class="mb-3">
<label asp-for="Description" class="form-label"></label>
<textarea asp-for="Description" class="form-control" rows="3"></textarea>
<span asp-validation-for="Description" class="text-danger small"></span>
</div>
<div class="mb-3">
<div class="d-flex align-items-center gap-1">
<label asp-for="ParentCategoryId" class="form-label mb-0"></label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Parent Category"
data-bs-content="Leave as '(None)' to keep this as a top-level category. Choose a parent to nest it — e.g., 'Aluminum Wheels' under 'Wheels'. The system prevents circular references (you cannot make a category its own ancestor).">
<i class="bi bi-question-circle"></i>
</a>
</div>
<select asp-for="ParentCategoryId" class="form-select" asp-items="ViewBag.ParentCategories">
</select>
<span asp-validation-for="ParentCategoryId" class="text-danger small"></span>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex align-items-center gap-1">
<label asp-for="DisplayOrder" class="form-label mb-0"></label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Display Order"
data-bs-content="Controls where this category appears in lists. Categories with lower numbers appear before those with higher numbers. Use multiples of 10 so you can easily insert new categories in between later.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="DisplayOrder" class="form-control" />
<span asp-validation-for="DisplayOrder" class="text-danger small"></span>
<small class="form-text text-muted">Lower numbers appear first.</small>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center gap-1">
<label class="form-label mb-0">Status</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Category Status"
data-bs-content="Inactive categories are hidden from the quote/job item picker but remain in the database so existing quotes and jobs still display correctly. Use this instead of deleting when a category is no longer in active use.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="form-check form-switch mt-2">
<input asp-for="IsActive" class="form-check-input" role="switch" />
<label asp-for="IsActive" class="form-check-label"></label>
</div>
</div>
</div>
<div class="d-flex justify-content-between mt-4">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
@@ -0,0 +1,213 @@
@model List<PowderCoating.Application.DTOs.Catalog.CategoryDto>
@{
ViewData["Title"] = "Catalog Categories";
ViewData["PageIcon"] = "bi-folder";
ViewData["PageHelpTitle"] = "Catalog Categories";
ViewData["PageHelpContent"] = "Categories organize your catalog items into a tree so staff can browse them quickly in the quoting wizard. You can nest categories (e.g., Wheels Aluminum Wheels). A category must exist before you can assign items to it. Inactive categories are hidden from the picker but preserved for historical records.";
var rootCategories = ViewBag.RootCategories as List<PowderCoating.Application.DTOs.Catalog.CategoryDto>;
var allCategories = ViewBag.AllCategories as List<PowderCoating.Application.DTOs.Catalog.CategoryDto>;
}
@section Styles {
<link rel="stylesheet" href="~/css/catalog.css" />
}
<div class="container-fluid">
<!-- Header -->
<div class="d-flex justify-content-end gap-2 mb-4">
<a asp-controller="CatalogItems" asp-action="Index" class="btn btn-outline-primary">
<i class="bi bi-box-seam me-1"></i>View Items
</a>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>Add Category
</a>
</div>
@if (Model.Any())
{
<div class="row">
<!-- Left: Category Tree -->
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Category Hierarchy</h5>
</div>
<div class="card-body">
<div class="list-group list-group-flush">
@foreach (var rootCategory in rootCategories)
{
@await Html.PartialAsync("_CategoryTreeNode", rootCategory)
}
</div>
</div>
</div>
</div>
<!-- Right: Category List -->
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">All Categories</h5>
</div>
<div class="card-body p-0">
<!-- Desktop Table View -->
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Name</th>
<th>Parent</th>
<th class="text-center">Items</th>
<th class="text-center">Subcategories</th>
<th class="text-center">Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var category in allCategories.OrderBy(c => c.Name))
{
<tr style="cursor:pointer" onclick="window.location='@Url.Action("Details", new { id = category.Id })'">
<td>
<a asp-action="Details" asp-route-id="@category.Id" class="text-decoration-none">
<i class="bi bi-folder me-1"></i>@category.Name
</a>
</td>
<td>
@if (!string.IsNullOrWhiteSpace(category.ParentCategoryName))
{
<span class="badge bg-secondary">@category.ParentCategoryName</span>
}
else
{
<span class="catalog-text-muted">Root</span>
}
</td>
<td class="text-center">
<span class="badge bg-primary">@category.ItemCount</span>
</td>
<td class="text-center">
<span class="badge bg-info">@category.SubCategoryCount</span>
</td>
<td class="text-center">
@if (category.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-secondary">Inactive</span>
}
</td>
<td class="text-end" onclick="event.stopPropagation()">
<div class="btn-group btn-group-sm">
<a asp-action="Details" asp-route-id="@category.Id" class="btn btn-outline-primary" title="View Details">
<i class="bi bi-eye"></i>
</a>
<a asp-action="Edit" asp-route-id="@category.Id" class="btn btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a asp-action="Delete" asp-route-id="@category.Id" class="btn btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</a>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var category in allCategories.OrderBy(c => c.Name))
{
<a asp-action="Details" asp-route-id="@category.Id" class="mobile-data-card text-decoration-none">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #30cfd0 0%, #330867 100%);">
<i class="bi bi-folder"></i>
</div>
<div class="mobile-card-title">
<h6>@category.Name</h6>
@if (!string.IsNullOrWhiteSpace(category.ParentCategoryName))
{
<small><span class="badge bg-secondary">@category.ParentCategoryName</span></small>
}
else
{
<small class="text-muted">Root Category</small>
}
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Items</span>
<span class="mobile-card-value"><span class="badge bg-primary">@category.ItemCount</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Subcategories</span>
<span class="mobile-card-value"><span class="badge bg-info">@category.SubCategoryCount</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@if (category.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-secondary">Inactive</span>
}
</span>
</div>
</div>
<div class="mobile-card-footer">
<a href="@Url.Action("Details", new { id = category.Id })" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation();">
<i class="bi bi-eye me-1"></i>View
</a>
<a href="@Url.Action("Edit", new { id = category.Id })" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation();">
<i class="bi bi-pencil me-1"></i>Edit
</a>
</div>
</a>
}
</div>
</div>
</div>
</div>
</div>
</div>
}
else
{
<div class="card">
<div class="card-body text-center py-5">
<i class="bi bi-folder-x catalog-empty-icon" style="font-size: 4rem;"></i>
<p class="catalog-text-muted mt-3">No categories yet. Click "Add Category" to create your first category.</p>
<a asp-action="Create" class="btn btn-primary mt-2">
<i class="bi bi-plus-circle me-1"></i>Add First Category
</a>
</div>
</div>
}
</div>
@section Scripts {
<script>
// Toggle subcategories
document.querySelectorAll('.category-toggle').forEach(toggle => {
toggle.addEventListener('click', function (e) {
e.preventDefault();
const icon = this.querySelector('i');
const target = this.closest('.list-group-item').nextElementSibling;
if (target && target.classList.contains('subcategories')) {
target.style.display = target.style.display === 'none' ? 'block' : 'none';
icon.classList.toggle('bi-chevron-right');
icon.classList.toggle('bi-chevron-down');
}
});
});
</script>
}
@@ -0,0 +1,38 @@
@model PowderCoating.Application.DTOs.Catalog.CategoryDto
@{
var hasSubCategories = Model.SubCategoryCount > 0;
var allCategories = ViewBag.AllCategories as List<PowderCoating.Application.DTOs.Catalog.CategoryDto>;
var subCategories = allCategories?.Where(c => c.ParentCategoryId == Model.Id).OrderBy(c => c.DisplayOrder).ThenBy(c => c.Name).ToList() ?? new List<PowderCoating.Application.DTOs.Catalog.CategoryDto>();
}
<div class="list-group-item p-2">
<div class="d-flex align-items-center">
@if (hasSubCategories)
{
<a href="#" class="category-toggle text-decoration-none me-2">
<i class="bi bi-chevron-right"></i>
</a>
}
else
{
<span class="me-3"></span>
}
<a asp-action="Details" asp-route-id="@Model.Id" class="flex-grow-1 text-decoration-none">
<i class="bi bi-folder me-1"></i>@Model.Name
@if (Model.ItemCount > 0)
{
<span class="badge bg-primary ms-2">@Model.ItemCount</span>
}
</a>
</div>
</div>
@if (hasSubCategories)
{
<div class="subcategories ms-4" style="display: none;">
@foreach (var subCategory in subCategories)
{
@await Html.PartialAsync("_CategoryTreeNode", subCategory)
}
</div>
}
@@ -0,0 +1,353 @@
@model PowderCoating.Application.DTOs.Catalog.CreateCatalogItemDto
@{
ViewData["Title"] = "Add Catalog Item";
var allowAccounting = Context.Items["AllowAccounting"] as bool? ?? false;
}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="bi bi-plus-circle me-2"></i>Add Catalog Item
</h4>
</div>
<div class="card-body">
<form asp-action="Create" method="post">
<partial name="_ValidationSummary" />
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
<strong>Catalog item prices are fixed.</strong> The price you enter here is exactly what gets charged when this item is added to a quote or job — no markup, no prep service charges, and no complexity adjustments are added on top. Make sure your price already includes labor, materials, and margin.
</div>
</div>
<!-- Basic Information Section -->
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0">Basic Information</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Basic Information"
data-bs-content="Give the item a descriptive name (e.g., '18 inch Aluminum Wheel') so staff can find it quickly in the picker. The category groups it in the catalog tree. The description appears on quotes and invoices so customers know exactly what they're being charged for.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="mb-3">
<div class="d-flex align-items-center gap-1">
<label asp-for="Name" class="form-label required mb-0"></label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Item Name"
data-bs-content="Use a specific, recognizable name. The name appears on quotes, invoices, and in the picker dropdown. Good names include material and size where relevant — e.g., 'Steel Bracket (6in)', '18in Alloy Wheel'.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="Name" class="form-control" placeholder="e.g., 18 inch Aluminum Wheel" />
<span asp-validation-for="Name" class="text-danger small"></span>
</div>
<div class="mb-3">
<label asp-for="CategoryId" class="form-label required"></label>
<div class="input-group">
<select asp-for="CategoryId" id="categorySelect" class="form-select" asp-items="ViewBag.Categories">
<option value="">-- Select Category --</option>
</select>
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addCategoryModal">
<i class="bi bi-plus-circle me-1"></i>New
</button>
</div>
<span asp-validation-for="CategoryId" class="text-danger small"></span>
<small class="form-text text-muted">Select an existing category or click "New" to create one.</small>
</div>
<div class="mb-3">
<label asp-for="Description" class="form-label"></label>
<textarea asp-for="Description" class="form-control" rows="3" placeholder="Detailed description of the item..."></textarea>
<span asp-validation-for="Description" class="text-danger small"></span>
</div>
<!-- Pricing Section -->
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3 mt-4">
<h5 class="mb-0">Pricing</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Pricing"
data-bs-content="The Default Price is the exact amount charged when this item is added to a quote — no markup, prep services, or complexity adjustments are applied on top. Set the all-in price you want to bill. Approximate Area is optional — if set, it helps estimate powder needed for reporting purposes.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<div class="d-flex align-items-center gap-1">
<label asp-for="DefaultPrice" class="form-label required mb-0"></label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Default Price"
data-bs-content="This is the final price charged to the customer — no markup, prep services, or complexity charges are added on top. Enter the all-in amount you want to bill for this item. Staff can still override it on individual quotes if needed.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="DefaultPrice" class="form-control" placeholder="0.00" />
</div>
<span asp-validation-for="DefaultPrice" class="text-danger small"></span>
</div>
<div class="col-md-6 mb-3">
<div class="d-flex align-items-center gap-1">
<label for="ApproximateArea" class="form-label mb-0">Approximate @ViewBag.AreaUnit</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Approximate Surface Area"
data-bs-content="Optional. If you know the typical surface area for this item type (e.g., 4.5 sqft for an 18in wheel), enter it here. The quoting wizard will use it to auto-calculate powder needed and oven cost. Staff can always override it per quote item.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="ApproximateArea" class="form-control" placeholder="0.00" step="0.01" />
<span asp-validation-for="ApproximateArea" class="text-danger small"></span>
<small class="form-text text-muted">Optional: Surface area for quoting purposes</small>
</div>
</div>
@if (allowAccounting)
{
<!-- Financial Accounts Section -->
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3 mt-4">
<h5 class="mb-0">Financial Accounts</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Financial Accounts"
data-bs-content="Links this item to your chart of accounts for accounting exports. Revenue Account is credited when invoiced; COGS Account is debited when materials are consumed. Leave both blank to use the company default accounts — most shops only need to set these for items with special accounting treatment.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<p class="text-muted small mb-3">Map this item to chart-of-account entries for proper revenue and cost tracking. Leave blank to use defaults.</p>
<div class="row">
<div class="col-md-6 mb-3">
<label asp-for="RevenueAccountId" class="form-label"></label>
<select asp-for="RevenueAccountId" class="form-select" asp-items="ViewBag.RevenueAccounts">
<option value="">(Default revenue account)</option>
</select>
<small class="form-text text-muted">Account credited when this item is invoiced.</small>
</div>
<div class="col-md-6 mb-3">
<label asp-for="CogsAccountId" class="form-label"></label>
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
<option value="">(Default COGS account)</option>
<option value="__new__">+ Add New Account…</option>
</select>
<small class="form-text text-muted">Account debited when materials are consumed.</small>
</div>
</div>
}
<!-- Merchandise Flag -->
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3 mt-4">
<h5 class="mb-0">Sale Options</h5>
</div>
<div class="form-check mb-3">
<input asp-for="IsMerchandise" class="form-check-input" type="checkbox" />
<label asp-for="IsMerchandise" class="form-check-label fw-semibold">
Available for direct sale (merchandise)
</label>
<div class="form-text">When checked, this item appears in the merchandise picker on invoices and can be sold without a job (e.g. branded apparel, retail cleaning products).</div>
</div>
<!-- Actions -->
<div class="d-flex justify-content-between mt-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>Create Item
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Add Category Modal -->
<div class="modal fade" id="addCategoryModal" tabindex="-1" aria-labelledby="addCategoryModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addCategoryModalLabel">
<i class="bi bi-folder-plus me-2"></i>Add New Category
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="newCategoryName" class="form-label">Category Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="newCategoryName" placeholder="e.g., Wheels, Engine Parts" required />
</div>
<div class="mb-3">
<label for="newCategoryParent" class="form-label">Parent Category</label>
<select class="form-select" id="newCategoryParent">
<option value="">(None - Root Category)</option>
</select>
<small class="form-text text-muted">Select a parent category to create a subcategory, or leave as "(None)" for a root category.</small>
</div>
<div class="mb-3">
<label for="newCategoryDescription" class="form-label">Description (Optional)</label>
<textarea class="form-control" id="newCategoryDescription" rows="2" placeholder="Brief description..."></textarea>
</div>
<div id="categoryModalError" class="alert alert-danger d-none"></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="saveCategoryBtn">
<i class="bi bi-check-circle me-1"></i>Create Category
</button>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
document.addEventListener('DOMContentLoaded', function () {
const modal = new bootstrap.Modal(document.getElementById('addCategoryModal'));
const saveBtn = document.getElementById('saveCategoryBtn');
const categorySelect = document.getElementById('categorySelect');
const parentCategorySelect = document.getElementById('newCategoryParent');
const errorDiv = document.getElementById('categoryModalError');
// Load categories into parent dropdown when modal opens
document.getElementById('addCategoryModal').addEventListener('show.bs.modal', async function () {
try {
// Get existing categories from the main dropdown
parentCategorySelect.innerHTML = '<option value="">(None - Root Category)</option>';
// Copy options from main category dropdown (excluding the placeholder)
const mainOptions = categorySelect.options;
for (let i = 0; i < mainOptions.length; i++) {
if (mainOptions[i].value) { // Skip empty placeholder option
const option = new Option(mainOptions[i].text, mainOptions[i].value);
parentCategorySelect.add(option);
}
}
} catch (error) {
console.error('Error loading parent categories:', error);
}
});
saveBtn.addEventListener('click', async function () {
const name = document.getElementById('newCategoryName').value.trim();
const description = document.getElementById('newCategoryDescription').value.trim();
const parentCategoryId = parentCategorySelect.value || null;
if (!name) {
showError('Category name is required.');
return;
}
// Disable button during save
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Creating...';
try {
const response = await fetch('@Url.Action("QuickCreate", "CatalogCategories")', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
},
body: JSON.stringify({
name: name,
description: description || null,
parentCategoryId: parentCategoryId
})
});
const result = await response.json();
if (result.success) {
// Reload category dropdowns to show new category in correct hierarchical position
await reloadCategoryDropdowns(result.id);
// Show success message
showToast('success', result.message);
// Reset and close modal
document.getElementById('newCategoryName').value = '';
document.getElementById('newCategoryDescription').value = '';
parentCategorySelect.value = '';
errorDiv.classList.add('d-none');
modal.hide();
} else {
showError(result.message);
}
} catch (error) {
showError('An error occurred while creating the category.');
console.error('Error:', error);
} finally {
// Re-enable button
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-check-circle me-1"></i>Create Category';
}
});
// Reset modal when closed
document.getElementById('addCategoryModal').addEventListener('hidden.bs.modal', function () {
document.getElementById('newCategoryName').value = '';
document.getElementById('newCategoryDescription').value = '';
parentCategorySelect.value = '';
errorDiv.classList.add('d-none');
});
function showError(message) {
errorDiv.textContent = message;
errorDiv.classList.remove('d-none');
}
function showToast(type, message) {
// Create a simple toast notification
const toast = document.createElement('div');
toast.className = `alert alert-${type} alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3`;
toast.style.zIndex = '9999';
toast.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
async function reloadCategoryDropdowns(selectedCategoryId) {
try {
const response = await fetch('@Url.Action("GetCategoriesForDropdown", "CatalogCategories")');
const data = await response.json();
if (data.success) {
// Rebuild main category dropdown
categorySelect.innerHTML = '<option value="">-- Select Category --</option>';
data.categories.forEach(cat => {
const option = new Option(cat.displayText, cat.id, cat.id === selectedCategoryId, cat.id === selectedCategoryId);
categorySelect.add(option);
});
// Rebuild parent category dropdown in modal
parentCategorySelect.innerHTML = '<option value="">(None - Root Category)</option>';
data.categories.forEach(cat => {
const option = new Option(cat.displayText, cat.id);
parentCategorySelect.add(option);
});
}
} catch (error) {
console.error('Error reloading category dropdowns:', error);
}
}
});
</script>
}
@@ -0,0 +1,84 @@
@model PowderCoating.Application.DTOs.Catalog.CatalogItemDto
@{
ViewData["Title"] = "Delete Catalog Item";
}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">
<i class="bi bi-exclamation-triangle me-2"></i>Delete Catalog Item
</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>Warning:</strong> You are about to delete this catalog item. This action cannot be undone.
</div>
<h5 class="mb-3">Are you sure you want to delete this item?</h5>
<!-- Item Details -->
<div class="card mb-4">
<div class="card-body">
<div class="row mb-2">
<div class="col-md-4"><strong>Name:</strong></div>
<div class="col-md-8">@Model.Name</div>
</div>
@if (!string.IsNullOrWhiteSpace(Model.SKU))
{
<div class="row mb-2">
<div class="col-md-4"><strong>SKU:</strong></div>
<div class="col-md-8">@Model.SKU</div>
</div>
}
<div class="row mb-2">
<div class="col-md-4"><strong>Category:</strong></div>
<div class="col-md-8">
<span class="badge bg-secondary">@Model.FullCategoryPath</span>
</div>
</div>
<div class="row mb-2">
<div class="col-md-4"><strong>Default Price:</strong></div>
<div class="col-md-8">@Model.DefaultPrice.ToString("C")</div>
</div>
@if (!string.IsNullOrWhiteSpace(Model.Description))
{
<div class="row mb-2">
<div class="col-md-4"><strong>Description:</strong></div>
<div class="col-md-8">@Model.Description</div>
</div>
}
<div class="row mb-2">
<div class="col-md-4"><strong>Status:</strong></div>
<div class="col-md-8">
@if (Model.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-secondary">Inactive</span>
}
</div>
</div>
</div>
</div>
<form asp-action="Delete" method="post">
<div class="d-flex justify-content-between">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Cancel
</a>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1"></i>Delete Item
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,192 @@
@model PowderCoating.Application.DTOs.Catalog.CatalogItemDto
@{
ViewData["Title"] = Model.Name;
}
<div class="container">
<div class="row">
<!-- Left Column: Item Details -->
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="bi bi-box-seam me-2"></i>@Model.Name
</h4>
</div>
<div class="card-body">
<!-- Basic Information -->
<h5 class="border-bottom pb-2 mb-3">Basic Information</h5>
<div class="row mb-3">
<div class="col-md-6">
<p class="mb-1"><strong>Name:</strong></p>
<p>@Model.Name</p>
</div>
<div class="col-md-6">
<p class="mb-1"><strong>SKU:</strong></p>
<p>@(Model.SKU ?? "-")</p>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<p class="mb-1"><strong>Category:</strong></p>
<p>
<span class="badge bg-secondary">@Model.FullCategoryPath</span>
</p>
</div>
<div class="col-md-6">
<p class="mb-1"><strong>Status:</strong></p>
<p>
@if (Model.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-secondary">Inactive</span>
}
</p>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(Model.Description))
{
<div class="mb-3">
<p class="mb-1"><strong>Description:</strong></p>
<p>@Model.Description</p>
</div>
}
<!-- Pricing & Defaults -->
<h5 class="border-bottom pb-2 mb-3 mt-4">Pricing & Defaults</h5>
<div class="row mb-3">
<div class="col-md-6">
<p class="mb-1"><strong>Default Price:</strong></p>
<p class="h4 text-primary">@Model.DefaultPrice.ToString("C")</p>
</div>
<div class="col-md-6">
<p class="mb-1"><strong>Estimated Time:</strong></p>
<p>@(Model.DefaultEstimatedMinutes.HasValue ? $"{Model.DefaultEstimatedMinutes} minutes" : "-")</p>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<p class="mb-1"><strong>Processing Requirements:</strong></p>
<div>
@if (Model.DefaultRequiresSandblasting)
{
<span class="badge bg-warning text-dark me-1">
<i class="bi bi-exclamation-triangle me-1"></i>Sandblasting
</span>
}
@if (Model.DefaultRequiresMasking)
{
<span class="badge bg-info me-1">
<i class="bi bi-shield me-1"></i>Masking
</span>
}
@if (!Model.DefaultRequiresSandblasting && !Model.DefaultRequiresMasking)
{
<span class="text-muted">None specified</span>
}
</div>
</div>
<div class="col-md-6">
<p class="mb-1"><strong>Display Order:</strong></p>
<p>@Model.DisplayOrder</p>
</div>
</div>
@if (Model.RevenueAccountId.HasValue || Model.CogsAccountId.HasValue)
{
<h5 class="border-bottom pb-2 mb-3 mt-4">Financial Accounts</h5>
<div class="row mb-3">
<div class="col-md-6">
<p class="mb-1"><strong>Revenue Account:</strong></p>
<p>
@if (Model.RevenueAccountId.HasValue)
{
<span class="badge bg-info text-dark">@Model.RevenueAccountName</span>
}
else
{
<span class="text-muted small">Default</span>
}
</p>
</div>
<div class="col-md-6">
<p class="mb-1"><strong>COGS Account:</strong></p>
<p>
@if (Model.CogsAccountId.HasValue)
{
<span class="badge bg-warning text-dark">@Model.CogsAccountName</span>
}
else
{
<span class="text-muted small">Default</span>
}
</p>
</div>
</div>
}
</div>
</div>
</div>
<!-- Right Column: Actions -->
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Actions</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-primary">
<i class="bi bi-pencil me-1"></i>Edit Item
</a>
<a asp-action="Delete" asp-route-id="@Model.Id" class="btn btn-outline-danger">
<i class="bi bi-trash me-1"></i>Delete Item
</a>
<hr />
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Catalog
</a>
<a asp-action="Create" asp-route-categoryId="@Model.CategoryId" class="btn btn-outline-primary">
<i class="bi bi-plus-circle me-1"></i>Add Similar Item
</a>
</div>
</div>
</div>
<!-- Quick Stats Card -->
<div class="card mt-3">
<div class="card-header">
<h6 class="mb-0">Quick Info</h6>
</div>
<div class="card-body">
<ul class="list-unstyled mb-0">
<li class="mb-2">
<i class="bi bi-folder text-muted me-2"></i>
<strong>Category:</strong><br />
<small class="ms-4">@Model.CategoryName</small>
</li>
<li class="mb-2">
<i class="bi bi-cash text-muted me-2"></i>
<strong>Price:</strong><br />
<small class="ms-4">@Model.DefaultPrice.ToString("C")</small>
</li>
@if (Model.DefaultEstimatedMinutes.HasValue)
{
<li class="mb-2">
<i class="bi bi-clock text-muted me-2"></i>
<strong>Time:</strong><br />
<small class="ms-4">@Model.DefaultEstimatedMinutes min</small>
</li>
}
</ul>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,349 @@
@model PowderCoating.Application.DTOs.Catalog.UpdateCatalogItemDto
@{
ViewData["Title"] = "Edit Catalog Item";
var allowAccounting = Context.Items["AllowAccounting"] as bool? ?? false;
}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="bi bi-pencil me-2"></i>Edit Catalog Item
</h4>
</div>
<div class="card-body">
<form asp-action="Edit" method="post">
<input type="hidden" asp-for="Id" />
<partial name="_ValidationSummary" />
<!-- Basic Information Section -->
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0">Basic Information</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Basic Information"
data-bs-content="Give the item a descriptive name (e.g., '18 inch Aluminum Wheel') so staff can find it quickly in the picker. The category groups it in the catalog tree. The description appears on quotes and invoices so customers know exactly what they're being charged for.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="mb-3">
<div class="d-flex align-items-center gap-1">
<label asp-for="Name" class="form-label required mb-0"></label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Item Name"
data-bs-content="Use a specific, recognizable name. The name appears on quotes, invoices, and in the picker dropdown. Good names include material and size where relevant — e.g., 'Steel Bracket (6in)', '18in Alloy Wheel'.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger small"></span>
</div>
<div class="mb-3">
<label asp-for="CategoryId" class="form-label required"></label>
<div class="input-group">
<select asp-for="CategoryId" id="categorySelect" class="form-select" asp-items="ViewBag.Categories">
<option value="">-- Select Category --</option>
</select>
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addCategoryModal">
<i class="bi bi-plus-circle me-1"></i>New
</button>
</div>
<span asp-validation-for="CategoryId" class="text-danger small"></span>
<small class="form-text text-muted">Select an existing category or click "New" to create one.</small>
</div>
<div class="mb-3">
<label asp-for="Description" class="form-label"></label>
<textarea asp-for="Description" class="form-control" rows="3"></textarea>
<span asp-validation-for="Description" class="text-danger small"></span>
</div>
<!-- Pricing & Status Section -->
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3 mt-4">
<h5 class="mb-0">Pricing &amp; Status</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Pricing &amp; Status"
data-bs-content="The Default Price is the exact amount charged when this item is added to a quote — no markup, prep services, or complexity adjustments are applied on top. Staff can override it on individual quotes. Set Status to Inactive to hide the item from the picker without deleting it.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex align-items-center gap-1">
<label asp-for="DefaultPrice" class="form-label required mb-0"></label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Default Price"
data-bs-content="This is the final price charged to the customer — no markup, prep services, or complexity charges are added on top. Enter the all-in amount you want to bill for this item. Staff can still override it on individual quotes if needed.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="DefaultPrice" class="form-control" />
</div>
<span asp-validation-for="DefaultPrice" class="text-danger small"></span>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center gap-1">
<label for="ApproximateArea" class="form-label mb-0">Approximate @ViewBag.AreaUnit</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Approximate Surface Area"
data-bs-content="Optional. If you know the typical surface area for this item type (e.g., 4.5 sqft for an 18in wheel), enter it here. The quoting wizard will use it to auto-calculate powder needed and oven cost. Staff can always override it per quote item.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="ApproximateArea" class="form-control" placeholder="0.00" step="0.01" />
<span asp-validation-for="ApproximateArea" class="text-danger small"></span>
<small class="form-text text-muted">Optional: Surface area for quoting purposes</small>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Status</label>
<div class="form-check form-switch mt-2">
<input asp-for="IsActive" class="form-check-input" role="switch" />
<label asp-for="IsActive" class="form-check-label"></label>
</div>
</div>
</div>
@if (allowAccounting)
{
<!-- Financial Accounts Section -->
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3 mt-4">
<h5 class="mb-0">Financial Accounts</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Financial Accounts"
data-bs-content="Links this item to your chart of accounts for accounting exports. Revenue Account is credited when invoiced; COGS Account is debited when materials are consumed. Leave both blank to use the company default accounts — most shops only need to set these for items with special accounting treatment.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<p class="text-muted small mb-3">Map this item to chart-of-account entries for proper revenue and cost tracking. Leave blank to use defaults.</p>
<div class="row">
<div class="col-md-6 mb-3">
<label asp-for="RevenueAccountId" class="form-label"></label>
<select asp-for="RevenueAccountId" class="form-select" asp-items="ViewBag.RevenueAccounts">
<option value="">(Default revenue account)</option>
</select>
<small class="form-text text-muted">Account credited when this item is invoiced.</small>
</div>
<div class="col-md-6 mb-3">
<label asp-for="CogsAccountId" class="form-label"></label>
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
<option value="">(Default COGS account)</option>
<option value="__new__">+ Add New Account…</option>
</select>
<small class="form-text text-muted">Account debited when materials are consumed.</small>
</div>
</div>
}
<!-- Merchandise Flag -->
<div class="form-check mb-3 mt-3">
<input asp-for="IsMerchandise" class="form-check-input" type="checkbox" />
<label asp-for="IsMerchandise" class="form-check-label fw-semibold">
Available for direct sale (merchandise)
</label>
<div class="form-text">When checked, this item appears in the merchandise picker on invoices and can be sold without a job.</div>
</div>
<!-- Actions -->
<div class="d-flex justify-content-between mt-4">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Add Category Modal -->
<div class="modal fade" id="addCategoryModal" tabindex="-1" aria-labelledby="addCategoryModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addCategoryModalLabel">
<i class="bi bi-folder-plus me-2"></i>Add New Category
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="newCategoryName" class="form-label">Category Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="newCategoryName" placeholder="e.g., Wheels, Engine Parts" required />
</div>
<div class="mb-3">
<label for="newCategoryParent" class="form-label">Parent Category</label>
<select class="form-select" id="newCategoryParent">
<option value="">(None - Root Category)</option>
</select>
<small class="form-text text-muted">Select a parent category to create a subcategory, or leave as "(None)" for a root category.</small>
</div>
<div class="mb-3">
<label for="newCategoryDescription" class="form-label">Description (Optional)</label>
<textarea class="form-control" id="newCategoryDescription" rows="2" placeholder="Brief description..."></textarea>
</div>
<div id="categoryModalError" class="alert alert-danger d-none"></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="saveCategoryBtn">
<i class="bi bi-check-circle me-1"></i>Create Category
</button>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
document.addEventListener('DOMContentLoaded', function () {
const modal = new bootstrap.Modal(document.getElementById('addCategoryModal'));
const saveBtn = document.getElementById('saveCategoryBtn');
const categorySelect = document.getElementById('categorySelect');
const parentCategorySelect = document.getElementById('newCategoryParent');
const errorDiv = document.getElementById('categoryModalError');
// Load categories into parent dropdown when modal opens
document.getElementById('addCategoryModal').addEventListener('show.bs.modal', async function () {
try {
// Get existing categories from the main dropdown
parentCategorySelect.innerHTML = '<option value="">(None - Root Category)</option>';
// Copy options from main category dropdown (excluding the placeholder)
const mainOptions = categorySelect.options;
for (let i = 0; i < mainOptions.length; i++) {
if (mainOptions[i].value) { // Skip empty placeholder option
const option = new Option(mainOptions[i].text, mainOptions[i].value);
parentCategorySelect.add(option);
}
}
} catch (error) {
console.error('Error loading parent categories:', error);
}
});
saveBtn.addEventListener('click', async function () {
const name = document.getElementById('newCategoryName').value.trim();
const description = document.getElementById('newCategoryDescription').value.trim();
const parentCategoryId = parentCategorySelect.value || null;
if (!name) {
showError('Category name is required.');
return;
}
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Creating...';
try {
const response = await fetch('@Url.Action("QuickCreate", "CatalogCategories")', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
},
body: JSON.stringify({
name: name,
description: description || null,
parentCategoryId: parentCategoryId
})
});
const result = await response.json();
if (result.success) {
// Reload category dropdowns to show new category in correct hierarchical position
await reloadCategoryDropdowns(result.id);
// Show success message
showToast('success', result.message);
// Reset and close modal
document.getElementById('newCategoryName').value = '';
document.getElementById('newCategoryDescription').value = '';
parentCategorySelect.value = '';
errorDiv.classList.add('d-none');
modal.hide();
} else {
showError(result.message);
}
} catch (error) {
showError('An error occurred while creating the category.');
console.error('Error:', error);
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-check-circle me-1"></i>Create Category';
}
});
document.getElementById('addCategoryModal').addEventListener('hidden.bs.modal', function () {
document.getElementById('newCategoryName').value = '';
document.getElementById('newCategoryDescription').value = '';
parentCategorySelect.value = '';
errorDiv.classList.add('d-none');
});
function showError(message) {
errorDiv.textContent = message;
errorDiv.classList.remove('d-none');
}
function showToast(type, message) {
const toast = document.createElement('div');
toast.className = `alert alert-${type} alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3`;
toast.style.zIndex = '9999';
toast.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
async function reloadCategoryDropdowns(selectedCategoryId) {
try {
const response = await fetch('@Url.Action("GetCategoriesForDropdown", "CatalogCategories")');
const data = await response.json();
if (data.success) {
// Rebuild main category dropdown
categorySelect.innerHTML = '<option value="">-- Select Category --</option>';
data.categories.forEach(cat => {
const option = new Option(cat.displayText, cat.id, cat.id === selectedCategoryId, cat.id === selectedCategoryId);
categorySelect.add(option);
});
// Rebuild parent category dropdown in modal
parentCategorySelect.innerHTML = '<option value="">(None - Root Category)</option>';
data.categories.forEach(cat => {
const option = new Option(cat.displayText, cat.id);
parentCategorySelect.add(option);
});
}
} catch (error) {
console.error('Error reloading category dropdowns:', error);
}
}
});
</script>
}
@@ -0,0 +1,215 @@
@model List<PowderCoating.Web.Controllers.CategoryWithItems>
@{
ViewData["Title"] = "Product Catalog";
ViewData["PageIcon"] = "bi-book";
ViewData["PageHelpTitle"] = "Product Catalog";
ViewData["PageHelpContent"] = "The Product Catalog is a library of standard items (wheels, brackets, panels, etc.) that your shop regularly quotes and invoices. Each item has a fixed price — when a catalog item is added to a quote or job, that price is used exactly as entered. No markup, no prep services, and no complexity charges are added on top. Organize items into categories to keep the catalog easy to browse.";
var totalItemsCount = ViewBag.TotalItemsCount ?? 0;
var activeItemsCount = ViewBag.ActiveItemsCount ?? 0;
var averagePrice = ViewBag.AveragePrice ?? 0m;
var categoryCount = ViewBag.CategoryCount ?? 0;
var currentCategoryId = ViewBag.CurrentCategoryId;
var searchTerm = ViewBag.SearchTerm ?? "";
var hasFilters = ViewBag.HasFilters ?? false;
var filteredItemsCount = ViewBag.FilteredItemsCount ?? 0;
}
@section Styles {
<link rel="stylesheet" href="~/css/catalog.css" />
}
@section Scripts {
<script src="~/js/catalog.js"></script>
}
<div class="d-flex justify-content-end align-items-center mb-4">
<a asp-action="ExportCatalogPdf" class="btn btn-primary text-nowrap">
<i class="bi bi-file-pdf me-2"></i>
<span class="d-none d-sm-inline">Export Product Catalog to PDF</span>
<span class="d-inline d-sm-none">PDF</span>
</a>
</div>
<!-- Stats Cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card catalog-stats-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="catalog-text-muted mb-1" style="font-size: 0.875rem;">Total Items</p>
<h3 class="mb-0 fw-bold">@totalItemsCount</h3>
</div>
<div class="catalog-stats-icon blue">
<i class="bi bi-box-seam text-primary" style="font-size: 1.5rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card catalog-stats-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="catalog-text-muted mb-1" style="font-size: 0.875rem;">Active Items</p>
<h3 class="mb-0 fw-bold">@activeItemsCount</h3>
</div>
<div class="catalog-stats-icon green">
<i class="bi bi-check-circle text-success" style="font-size: 1.5rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card catalog-stats-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="catalog-text-muted mb-1" style="font-size: 0.875rem;">Average Price</p>
<h3 class="mb-0 fw-bold">@averagePrice.ToString("C")</h3>
</div>
<div class="catalog-stats-icon yellow">
<i class="bi bi-cash-stack text-warning" style="font-size: 1.5rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card catalog-stats-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="catalog-text-muted mb-1" style="font-size: 0.875rem;">Categories</p>
<h3 class="mb-0 fw-bold">@categoryCount</h3>
</div>
<div class="catalog-stats-icon pink">
<i class="bi bi-folder text-danger" style="font-size: 1.5rem;"></i>
</div>
</div>
</div>
</div>
</div>
</div>
@if (hasFilters)
{
<div class="alert alert-info alert-permanent d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-funnel me-2"></i>
Showing <strong>@filteredItemsCount</strong> item(s)
@if (!string.IsNullOrEmpty(searchTerm))
{
<span> matching "<strong>@searchTerm</strong>"</span>
}
@if (currentCategoryId != null)
{
var categoryName = (ViewBag.Categories as List<SelectListItem>)?.FirstOrDefault(c => c.Value == currentCategoryId.ToString())?.Text;
<span> in category "<strong>@categoryName</strong>"</span>
}
</div>
<a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x me-1"></i>Clear Filters
</a>
</div>
}
<!-- Catalog Items Card -->
<div class="card border-0 shadow-sm">
<div class="card-header catalog-card-header py-3">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3">
<div class="d-flex flex-column flex-sm-row gap-2 w-100 w-lg-auto">
<form asp-action="Index" method="get" class="d-flex flex-column flex-sm-row gap-2 flex-grow-1 flex-lg-grow-0">
<div class="input-group catalog-search-group">
<span class="input-group-text catalog-search-icon">
<i class="bi bi-search catalog-text-muted"></i>
</span>
<input type="text" name="searchTerm" class="form-control catalog-search-input"
placeholder="Search items..."
value="@searchTerm"
aria-label="Search catalog items">
</div>
<select name="categoryId" class="form-select catalog-category-select">
<option value="">All Categories</option>
@if (ViewBag.Categories != null)
{
foreach (var category in ViewBag.Categories as List<SelectListItem>)
{
<option value="@category.Value" selected="@(category.Value == currentCategoryId?.ToString())">
@category.Text
</option>
}
}
</select>
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i>
</button>
@if (hasFilters)
{
<a href="@Url.Action("Index")" class="btn btn-outline-secondary" title="Clear filters">
<i class="bi bi-x-lg"></i>
</a>
}
</form>
<div class="d-flex gap-2">
<a asp-controller="CatalogCategories" asp-action="Index" class="btn btn-outline-secondary text-nowrap">
<i class="bi bi-folder me-2"></i>
<span class="d-none d-sm-inline">Manage Categories</span>
<span class="d-inline d-sm-none">Categories</span>
</a>
<a asp-action="Create" class="btn btn-primary text-nowrap">
<i class="bi bi-plus-circle me-2"></i>
<span class="d-none d-sm-inline">Add Item</span>
<span class="d-inline d-sm-none">Add</span>
</a>
</div>
</div>
</div>
</div>
<div class="card-body p-0">
@if (!Model.Any())
{
<div class="text-center py-5">
<i class="bi bi-inbox catalog-empty-icon" style="font-size: 4rem;"></i>
<h5 class="mt-3 catalog-text-secondary">No catalog items found</h5>
@if (hasFilters)
{
<p class="catalog-text-muted mb-4">Try adjusting your filters</p>
<a href="@Url.Action("Index")" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-2"></i>Clear Filters
</a>
}
else
{
<p class="catalog-text-muted mb-4">Get started by creating your first catalog item</p>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Create Your First Item
</a>
}
</div>
}
else
{
<!-- Nested Category View -->
<div class="catalog-tree p-3">
@foreach (var categoryWithItems in Model)
{
<partial name="_CategoryNode" model="categoryWithItems" />
}
</div>
<script>
(function () {
var PREFIX = 'pcl_catalog_acc_';
document.querySelectorAll('.catalog-tree .collapse').forEach(function (el) {
var stored = localStorage.getItem(PREFIX + el.id);
if (stored === null) return;
if (stored === '1') { el.classList.add('show'); }
else { el.classList.remove('show'); }
});
}());
</script>
}
</div>
</div>
@@ -0,0 +1,90 @@
@model PowderCoating.Web.Controllers.CategoryWithItems
@{
var categoryId = Model.Category.Id;
var collapseId = $"category-{categoryId}";
var hasItems = Model.Items.Any();
var hasSubCategories = Model.SubCategories.Any();
}
<div class="category-node mb-2">
<!-- Category Header -->
<div class="category-header @(hasItems || hasSubCategories ? "" : "collapsed")"
data-bs-toggle="collapse"
data-bs-target="#@collapseId"
aria-expanded="@(hasItems || hasSubCategories ? "true" : "false")">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-folder-fill text-primary"></i>
<span class="fw-semibold">@Model.Category.Name</span>
<span class="badge bg-secondary">@Model.TotalItems items</span>
</div>
<div>
<i class="bi bi-chevron-down"></i>
</div>
</div>
</div>
<!-- Category Content (Items and Subcategories) -->
<div class="collapse @(hasItems || hasSubCategories ? "show" : "")" id="@collapseId">
<div class="category-content ms-3">
<!-- Items in this category -->
@if (hasItems)
{
<div class="items-list">
@foreach (var item in Model.Items)
{
<div class="item-row">
<div class="item-row-name">
<a asp-action="Details" asp-route-id="@item.Id" class="catalog-item-link">
<i class="bi bi-box me-2 catalog-text-muted"></i>@item.Name
</a>
</div>
<div class="item-row-meta">
<span class="catalog-price">@item.DefaultPrice.ToString("C")</span>
@if (item.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-secondary">Inactive</span>
}
<div class="btn-group btn-group-sm">
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-primary btn-sm" title="View Details">
<i class="bi bi-eye"></i>
</a>
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="Delete">
<i class="bi bi-trash"></i>
</a>
</div>
</div>
</div>
}
</div>
}
<!-- Subcategories -->
@if (hasSubCategories)
{
<div class="subcategory mt-2">
@foreach (var subCategory in Model.SubCategories)
{
<partial name="_CategoryNode" model="subCategory" />
}
</div>
}
<!-- Empty state for category -->
@if (!hasItems && !hasSubCategories)
{
<div class="no-items text-center py-3 catalog-text-muted">
<i class="bi bi-inbox"></i>
<p class="mb-0">No items in this category</p>
</div>
}
</div>
</div>
</div>
@@ -0,0 +1,311 @@
@model PowderCoating.Application.DTOs.Company.CreateCompanyDto
@{
ViewData["Title"] = "Create Company";
ViewData["PageIcon"] = "bi-building-add";
var planConfigs = ((IEnumerable<PowderCoating.Core.Entities.SubscriptionPlanConfig>)ViewBag.PlanConfigs)
.OrderBy(c => c.SortOrder).ToList();
}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-end mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to List
</a>
</div>
<div class="card shadow-sm">
<div class="card-body">
<form asp-action="Create" method="post">
<partial name="_ValidationSummary" />
<h5 class="card-title mb-3 pb-2 border-bottom">Company Information</h5>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label asp-for="CompanyName" class="form-label">Company Name *</label>
<input asp-for="CompanyName" class="form-control" placeholder="Enter company name" />
<span asp-validation-for="CompanyName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="CompanyCode" class="form-label">Company Code</label>
<input asp-for="CompanyCode" class="form-control" placeholder="e.g., ABC" maxlength="10" />
<span asp-validation-for="CompanyCode" class="text-danger"></span>
<small class="form-text text-muted">Optional short code for the company</small>
</div>
</div>
<h5 class="card-title mb-3 pb-2 border-bottom">Primary Contact</h5>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label asp-for="PrimaryContactName" class="form-label">Contact Name *</label>
<input asp-for="PrimaryContactName" class="form-control" placeholder="Full name" />
<span asp-validation-for="PrimaryContactName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="PrimaryContactEmail" class="form-label">Contact Email *</label>
<input asp-for="PrimaryContactEmail" class="form-control" type="email" placeholder="email@example.com" />
<span asp-validation-for="PrimaryContactEmail" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Phone" class="form-label">Phone</label>
<input asp-for="Phone" class="form-control" placeholder="(555) 123-4567" />
<span asp-validation-for="Phone" class="text-danger"></span>
</div>
</div>
<h5 class="card-title mb-3 pb-2 border-bottom">Address</h5>
<div class="row g-3 mb-4">
<div class="col-12">
<label asp-for="Address" class="form-label">Street Address</label>
<input asp-for="Address" class="form-control" placeholder="123 Main Street" />
<span asp-validation-for="Address" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="City" class="form-label">City</label>
<input asp-for="City" class="form-control" placeholder="City" />
<span asp-validation-for="City" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="State" class="form-label">State</label>
<input asp-for="State" class="form-control" placeholder="CA" maxlength="2" />
<span asp-validation-for="State" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="ZipCode" class="form-label">Zip Code</label>
<input asp-for="ZipCode" class="form-control" placeholder="90210" />
<span asp-validation-for="ZipCode" class="text-danger"></span>
</div>
</div>
<h5 class="card-title mb-3 pb-2 border-bottom">Subscription Details</h5>
<div class="row g-3 mb-4">
<div class="col-md-4">
<label asp-for="SubscriptionPlan" class="form-label">Plan</label>
<select asp-for="SubscriptionPlan" class="form-select">
@foreach (var plan in planConfigs)
{
<option value="@plan.Plan">@plan.DisplayName</option>
}
</select>
<span asp-validation-for="SubscriptionPlan" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="SubscriptionStartDate" class="form-label">Start Date</label>
<input asp-for="SubscriptionStartDate" class="form-control" type="date" />
<span asp-validation-for="SubscriptionStartDate" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="SubscriptionEndDate" class="form-label">End Date</label>
<input asp-for="SubscriptionEndDate" class="form-control" type="date" />
<span asp-validation-for="SubscriptionEndDate" class="text-danger"></span>
<small class="form-text text-muted">Leave blank for ongoing</small>
</div>
<div class="col-md-6">
<label asp-for="TimeZone" class="form-label">Time Zone</label>
<select asp-for="TimeZone" class="form-select">
<option value="America/New_York">Eastern Time (ET)</option>
<option value="America/Chicago">Central Time (CT)</option>
<option value="America/Denver">Mountain Time (MT)</option>
<option value="America/Phoenix">Arizona Time (MT - No DST)</option>
<option value="America/Los_Angeles">Pacific Time (PT)</option>
<option value="America/Anchorage">Alaska Time (AKT)</option>
<option value="Pacific/Honolulu">Hawaii Time (HT)</option>
</select>
<span asp-validation-for="TimeZone" class="text-danger"></span>
</div>
<div class="col-md-6">
<div class="form-check form-switch mt-4">
<input asp-for="IsActive" class="form-check-input" type="checkbox" />
<label asp-for="IsActive" class="form-check-label">Company Active</label>
</div>
</div>
</div>
<h5 class="card-title mb-3 pb-2 border-bottom">Initial Admin User</h5>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label asp-for="AdminFirstName" class="form-label">First Name *</label>
<input asp-for="AdminFirstName" class="form-control" placeholder="First name" />
<span asp-validation-for="AdminFirstName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="AdminLastName" class="form-label">Last Name *</label>
<input asp-for="AdminLastName" class="form-control" placeholder="Last name" />
<span asp-validation-for="AdminLastName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="AdminEmail" class="form-label">Admin Email *</label>
<input asp-for="AdminEmail" class="form-control" type="email" placeholder="admin@company.com" />
<span asp-validation-for="AdminEmail" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="AdminPassword" class="form-label">Admin Password *</label>
<div class="input-group">
<input asp-for="AdminPassword" id="adminPassword" class="form-control" type="password"
placeholder="Enter strong password"
pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@@$!%*?&#^()_+=\-\[\]{};':&quot;\\|,.<>\/`~]).{8,}$"
title="Password must be at least 8 characters and include uppercase, lowercase, number, and special character" />
<button class="btn btn-outline-secondary" type="button" id="togglePassword">
<i class="bi bi-eye" id="toggleIcon"></i>
</button>
</div>
<span asp-validation-for="AdminPassword" class="text-danger d-block"></span>
<!-- Password Strength Indicator -->
<div class="mt-2">
<div class="progress" style="height: 5px;">
<div id="passwordStrength" class="progress-bar" role="progressbar" style="width: 0%"></div>
</div>
<small id="passwordStrengthText" class="form-text"></small>
</div>
<!-- Password Requirements -->
<div class="mt-2">
<small class="form-text text-muted d-block mb-1"><strong>Password must contain:</strong></small>
<small class="form-text d-block" id="req-length">
<i class="bi bi-circle text-muted"></i> At least 8 characters
</small>
<small class="form-text d-block" id="req-uppercase">
<i class="bi bi-circle text-muted"></i> One uppercase letter (A-Z)
</small>
<small class="form-text d-block" id="req-lowercase">
<i class="bi bi-circle text-muted"></i> One lowercase letter (a-z)
</small>
<small class="form-text d-block" id="req-number">
<i class="bi bi-circle text-muted"></i> One number (0-9)
</small>
<small class="form-text d-block" id="req-special">
<i class="bi bi-circle text-muted"></i> One special character (!@@#$%^&amp;*)
</small>
</div>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>Create Company
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{
await Html.RenderPartialAsync("_ValidationScriptsPartial");
}
<script>
// Password visibility toggle
document.getElementById('togglePassword').addEventListener('click', function() {
const passwordInput = document.getElementById('adminPassword');
const toggleIcon = document.getElementById('toggleIcon');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
toggleIcon.classList.remove('bi-eye');
toggleIcon.classList.add('bi-eye-slash');
} else {
passwordInput.type = 'password';
toggleIcon.classList.remove('bi-eye-slash');
toggleIcon.classList.add('bi-eye');
}
});
// Password validation and strength
document.getElementById('adminPassword').addEventListener('input', function() {
const password = this.value;
let strength = 0;
const requirements = {
length: password.length >= 8,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
number: /\d/.test(password),
special: /[@@$!%*?&#^()_+=\-\[\]{};':\"\\|,.<>\/`~]/.test(password)
};
// Update requirement indicators
updateRequirement('req-length', requirements.length);
updateRequirement('req-uppercase', requirements.uppercase);
updateRequirement('req-lowercase', requirements.lowercase);
updateRequirement('req-number', requirements.number);
updateRequirement('req-special', requirements.special);
// Calculate strength
if (requirements.length) strength += 20;
if (requirements.uppercase) strength += 20;
if (requirements.lowercase) strength += 20;
if (requirements.number) strength += 20;
if (requirements.special) strength += 20;
// Update strength bar
const strengthBar = document.getElementById('passwordStrength');
const strengthText = document.getElementById('passwordStrengthText');
strengthBar.style.width = strength + '%';
strengthBar.className = 'progress-bar';
if (strength === 0) {
strengthBar.classList.add('bg-secondary');
strengthText.textContent = '';
strengthText.className = 'form-text';
} else if (strength < 60) {
strengthBar.classList.add('bg-danger');
strengthText.textContent = 'Weak password';
strengthText.className = 'form-text text-danger';
} else if (strength < 100) {
strengthBar.classList.add('bg-warning');
strengthText.textContent = 'Fair password';
strengthText.className = 'form-text text-warning';
} else {
strengthBar.classList.add('bg-success');
strengthText.textContent = 'Strong password';
strengthText.className = 'form-text text-success';
}
});
function updateRequirement(elementId, met) {
const element = document.getElementById(elementId);
const icon = element.querySelector('i');
if (met) {
icon.classList.remove('bi-circle', 'text-muted');
icon.classList.add('bi-check-circle-fill', 'text-success');
element.classList.remove('text-muted');
element.classList.add('text-success');
} else {
icon.classList.remove('bi-check-circle-fill', 'text-success');
icon.classList.add('bi-circle', 'text-muted');
element.classList.remove('text-success');
element.classList.add('text-muted');
}
}
// Form submission validation
document.querySelector('form').addEventListener('submit', function(e) {
const password = document.getElementById('adminPassword').value;
const requirements = {
length: password.length >= 8,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
number: /\d/.test(password),
special: /[@@$!%*?&#^()_+=\-\[\]{};':\"\\|,.<>\/`~]/.test(password)
};
const allMet = Object.values(requirements).every(req => req === true);
if (!allMet) {
e.preventDefault();
showError('Please ensure the admin password meets all requirements before submitting.', 'Validation Error');
document.getElementById('adminPassword').focus();
return false;
}
});
</script>
}
@@ -0,0 +1,145 @@
@model PowderCoating.Application.DTOs.Company.CreateCompanyAdminDto
@{
ViewData["Title"] = "Create Company Admin";
ViewData["PageIcon"] = "bi-person-badge";
}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="d-flex justify-content-end mb-4">
<a asp-action="Details" asp-route-id="@Model.CompanyId" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to Company
</a>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form asp-action="CreateCompanyAdmin" method="post">
<input type="hidden" asp-for="CompanyId" />
<input type="hidden" asp-for="CompanyName" />
<partial name="_ValidationSummary" />
<!-- Company Information -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-building me-2 text-primary"></i>Company
</h5>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
Creating admin user for: <strong>@Model.CompanyName</strong>
</div>
</div>
<!-- Personal Information -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-person me-2 text-primary"></i>Personal Information
</h5>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="FirstName" class="form-label"></label>
<input asp-for="FirstName" class="form-control" placeholder="Enter first name" />
<span asp-validation-for="FirstName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="LastName" class="form-label"></label>
<input asp-for="LastName" class="form-control" placeholder="Enter last name" />
<span asp-validation-for="LastName" class="text-danger"></span>
</div>
</div>
</div>
<!-- Contact Information -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-envelope me-2 text-primary"></i>Contact & Account
</h5>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="Email" class="form-label"></label>
<input asp-for="Email" type="email" class="form-control" placeholder="admin@example.com" />
<span asp-validation-for="Email" class="text-danger"></span>
<small class="text-muted">This will be the user's login email</small>
</div>
<div class="col-md-6">
<label asp-for="Phone" class="form-label"></label>
<input asp-for="Phone" type="tel" class="form-control" placeholder="(555) 123-4567" />
<span asp-validation-for="Phone" class="text-danger"></span>
</div>
</div>
</div>
<!-- Password -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-key me-2 text-primary"></i>Password
</h5>
<div class="row g-3">
<div class="col-md-12">
<label asp-for="Password" class="form-label"></label>
<input asp-for="Password" class="form-control" placeholder="Enter a strong password" />
<span asp-validation-for="Password" class="text-danger"></span>
<small class="text-muted d-block mt-1">
Password must be at least 8 characters with uppercase, lowercase, digit, and special character
</small>
</div>
</div>
</div>
<!-- Work Information (Optional) -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-briefcase me-2 text-primary"></i>Work Information (Optional)
</h5>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="Department" class="form-label"></label>
<input asp-for="Department" class="form-control" placeholder="e.g., Management" />
<span asp-validation-for="Department" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Position" class="form-label"></label>
<input asp-for="Position" class="form-control" placeholder="e.g., Company Administrator" />
<span asp-validation-for="Position" class="text-danger"></span>
</div>
</div>
</div>
<!-- Permissions Notice -->
<div class="alert alert-success mb-4">
<h6 class="alert-heading">
<i class="bi bi-shield-check me-2"></i>Administrator Permissions
</h6>
<p class="mb-0">
This user will be created with <strong>Company Administrator</strong> role and will have full permissions to:
</p>
<ul class="mb-0 mt-2">
<li>Manage all jobs, quotes, and customers</li>
<li>Manage inventory and equipment</li>
<li>Create and approve quotes</li>
<li>Manage company users and settings</li>
</ul>
</div>
<!-- Form Actions -->
<div class="d-flex gap-2 justify-content-end pt-3 border-top">
<a asp-action="Details" asp-route-id="@Model.CompanyId" class="btn btn-outline-secondary px-4">
Cancel
</a>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-person-plus me-2"></i>Create Admin User
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
@@ -0,0 +1,893 @@
@model PowderCoating.Application.DTOs.Company.CompanyDto
@{
ViewData["Title"] = Model.CompanyName;
ViewData["PageIcon"] = "bi-building";
var planConfigs = ((IEnumerable<PowderCoating.Core.Entities.SubscriptionPlanConfig>)ViewBag.PlanConfigs)
.OrderBy(c => c.SortOrder).ToList();
var planBadgeColors = planConfigs.Select((c, i) => (c.Plan, i))
.ToDictionary(x => x.Plan, x => x.i switch { 0 => "bg-secondary", 1 => "bg-primary", 2 => "bg-info", _ => "bg-success" });
var planNames = planConfigs.ToDictionary(c => c.Plan, c => c.DisplayName);
string PlanBadge(int plan) => planBadgeColors.TryGetValue(plan, out var c) ? c : "bg-secondary";
string PlanName(int plan) => planNames.TryGetValue(plan, out var n) ? n : plan.ToString();
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex align-items-center gap-2">
@if (!string.IsNullOrEmpty(Model.CompanyCode))
{
<span class="badge bg-secondary">@Model.CompanyCode</span>
}
@if (Model.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-danger">Inactive</span>
}
</div>
<div class="btn-group">
<a asp-action="CreateCompanyAdmin" asp-route-id="@Model.Id" class="btn btn-success">
<i class="bi bi-person-plus me-1"></i>Add Admin User
</a>
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-primary">
<i class="bi bi-pencil me-1"></i>Edit
</a>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to List
</a>
</div>
</div> @if (TempData["Warning"] != null)
{
<div class="alert alert-warning alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Warning"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
} <!-- Statistics Cards - Desktop -->
<div class="stats-cards-desktop">
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card shadow-sm">
<div class="card-body text-center">
<i class="bi bi-people text-primary" style="font-size: 2rem;"></i>
<h3 class="mt-2 mb-0">@Model.UserCount</h3>
<p class="text-muted small mb-0">Users</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card shadow-sm">
<div class="card-body text-center">
<i class="bi bi-person-badge text-success" style="font-size: 2rem;"></i>
<h3 class="mt-2 mb-0">@Model.CustomerCount</h3>
<p class="text-muted small mb-0">Customers</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card shadow-sm">
<div class="card-body text-center">
<i class="bi bi-briefcase text-info" style="font-size: 2rem;"></i>
<h3 class="mt-2 mb-0">@Model.JobCount</h3>
<p class="text-muted small mb-0">Jobs</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card shadow-sm">
<div class="card-body text-center">
<i class="bi bi-star text-warning" style="font-size: 2rem;"></i>
<h3 class="mt-2 mb-0">@PlanName(Model.SubscriptionPlan)</h3>
<p class="text-muted small mb-0">Plan</p>
</div>
</div>
</div>
</div>
</div>
<!-- Compact Stats - Mobile -->
<div class="mobile-stats-compact">
<div class="card">
<div class="stats-grid">
<div class="stat-item">
<div class="stat-icon"><i class="bi bi-people text-primary"></i></div>
<div class="stat-value">@Model.UserCount</div>
<div class="stat-label">Users</div>
</div>
<div class="stat-item">
<div class="stat-icon"><i class="bi bi-person-badge text-success"></i></div>
<div class="stat-value">@Model.CustomerCount</div>
<div class="stat-label">Customers</div>
</div>
<div class="stat-item">
<div class="stat-icon"><i class="bi bi-briefcase text-info"></i></div>
<div class="stat-value">@Model.JobCount</div>
<div class="stat-label">Jobs</div>
</div>
<div class="stat-item">
<div class="stat-icon"><i class="bi bi-star text-warning"></i></div>
<div class="stat-value">@PlanName(Model.SubscriptionPlan)</div>
<div class="stat-label">Plan</div>
</div>
</div>
</div>
</div>
<div class="row g-3">
<!-- Company Information -->
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="card-title mb-0">
<i class="bi bi-info-circle me-2"></i>Company Information
</h5>
</div>
<div class="card-body">
<table class="table table-sm table-borderless">
<tr>
<th style="width: 40%;">Company Name:</th>
<td>@Model.CompanyName</td>
</tr>
<tr>
<th>Company Code:</th>
<td>@(Model.CompanyCode ?? "N/A")</td>
</tr>
<tr>
<th>Status:</th>
<td>
@if (Model.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-danger">Inactive</span>
}
</td>
</tr>
<tr>
<th>Time Zone:</th>
<td>@(Model.TimeZone ?? "America/New_York")</td>
</tr>
</table>
</div>
</div>
</div>
<!-- Contact Information -->
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="card-title mb-0">
<i class="bi bi-person-lines-fill me-2"></i>Primary Contact
</h5>
</div>
<div class="card-body">
<table class="table table-sm table-borderless">
<tr>
<th style="width: 40%;">Contact Name:</th>
<td>@Model.PrimaryContactName</td>
</tr>
<tr>
<th>Email:</th>
<td><a href="mailto:@Model.PrimaryContactEmail">@Model.PrimaryContactEmail</a></td>
</tr>
<tr>
<th>Phone:</th>
<td>@(Model.Phone ?? "N/A")</td>
</tr>
</table>
</div>
</div>
</div>
<!-- Address -->
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="card-title mb-0">
<i class="bi bi-geo-alt me-2"></i>Address
</h5>
</div>
<div class="card-body">
@if (!string.IsNullOrEmpty(Model.Address))
{
<address>
@Model.Address<br />
@if (!string.IsNullOrEmpty(Model.City) || !string.IsNullOrEmpty(Model.State) || !string.IsNullOrEmpty(Model.ZipCode))
{
<text>@Model.City, @Model.State @Model.ZipCode</text>
}
</address>
}
else
{
<p class="text-muted mb-0">No address provided</p>
}
</div>
</div>
</div>
<!-- Subscription -->
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="card-title mb-0">
<i class="bi bi-credit-card me-2"></i>Subscription Details
</h5>
</div>
<div class="card-body">
<table class="table table-sm table-borderless">
<tr>
<th style="width: 40%;">Plan:</th>
<td>
<span class="badge @PlanBadge(Model.SubscriptionPlan)">@PlanName(Model.SubscriptionPlan)</span>
</td>
</tr>
<tr>
<th>Start Date:</th>
<td>@Model.SubscriptionStartDate.ToString("MMMM d, yyyy")</td>
</tr>
<tr>
<th>End Date:</th>
<td>
@if (Model.SubscriptionEndDate.HasValue)
{
@Model.SubscriptionEndDate.Value.ToString("MMMM d, yyyy")
}
else
{
<span class="text-muted">Ongoing</span>
}
</td>
</tr>
</table>
</div>
</div>
</div>
<!-- Audit Information -->
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="card-title mb-0">
<i class="bi bi-clock-history me-2"></i>Audit Information
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<table class="table table-sm table-borderless">
<tr>
<th style="width: 30%;">Created:</th>
<td>@Model.CreatedAt.ToString("MMMM d, yyyy h:mm tt")</td>
</tr>
@if (!string.IsNullOrEmpty(Model.CreatedBy))
{
<tr>
<th>Created By:</th>
<td>@Model.CreatedBy</td>
</tr>
}
</table>
</div>
<div class="col-md-6">
@if (Model.UpdatedAt.HasValue)
{
<table class="table table-sm table-borderless">
<tr>
<th style="width: 30%;">Updated:</th>
<td>@Model.UpdatedAt.Value.ToString("MMMM d, yyyy h:mm tt")</td>
</tr>
@if (!string.IsNullOrEmpty(Model.UpdatedBy))
{
<tr>
<th>Updated By:</th>
<td>@Model.UpdatedBy</td>
</tr>
}
</table>
}
</div>
</div>
</div>
</div>
</div>
<!-- Users List -->
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
<i class="bi bi-people me-2"></i>Users (@Model.Users.Count)
</h5>
<a asp-action="CreateCompanyAdmin" asp-route-id="@Model.Id" class="btn btn-sm btn-success">
<i class="bi bi-person-plus me-1"></i>Add Admin User
</a>
</div>
<div class="card-body p-0">
@if (Model.Users.Any())
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Department</th>
<th>Status</th>
<th>Last Login</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var user in Model.Users.OrderBy(u => u.LastName))
{
<tr class="user-row" style="cursor:pointer;"
data-user-id="@user.Id"
data-company-id="@Model.Id"
title="Click to view user details and login history">
<td>
<strong>@user.FullName</strong>
@if (user.CompanyRole == null)
{
<span class="badge bg-warning text-dark ms-1" title="Platform User">
<i class="bi bi-star-fill"></i> SuperAdmin
</span>
}
</td>
<td>
<a href="mailto:@user.Email">@user.Email</a>
</td>
<td>
@if (!string.IsNullOrEmpty(user.CompanyRole))
{
<span class="badge @(user.CompanyRole switch
{
"CompanyAdmin" => "bg-primary",
"Manager" => "bg-info",
"Worker" => "bg-secondary",
_ => "bg-light text-dark"
})">
@user.CompanyRole.Replace("Company", "")
</span>
}
else
{
<span class="text-muted">N/A</span>
}
</td>
<td>@(user.Department ?? "N/A")</td>
<td>
@if (user.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-danger">Inactive</span>
}
</td>
<td>
@if (user.LastLoginDate.HasValue)
{
<small class="text-muted">@user.LastLoginDate.Value.ToString("MMM d, yyyy")</small>
}
else
{
<small class="text-muted">Never</small>
}
</td>
<td class="text-end" onclick="event.stopPropagation()">
@if (user.CompanyRole != null)
{
<div class="btn-group btn-group-sm" role="group">
<a asp-controller="CompanyUsers"
asp-action="Edit"
asp-route-id="@user.Id"
asp-route-returnUrl="@Url.Action("Details", "Companies", new { id = Model.Id })"
class="btn btn-outline-primary"
title="Edit User">
<i class="bi bi-pencil"></i>
</a>
<button type="button"
class="btn btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#resetPasswordModal"
data-user-id="@user.Id"
data-user-name="@user.FullName"
title="Reset Password (set manually)">
<i class="bi bi-key"></i>
</button>
<form asp-controller="CompanyUsers"
asp-action="SendPasswordResetEmail"
asp-route-id="@user.Id"
method="post"
class="d-inline"
onsubmit="return confirm('Send a password reset link to @Html.Encode(user.Email)?')">
@Html.AntiForgeryToken()
<button type="submit"
class="btn btn-outline-info"
title="Email password reset link">
<i class="bi bi-envelope-arrow-up"></i>
</button>
</form>
<form asp-controller="CompanyUsers"
asp-action="ToggleActive"
asp-route-id="@user.Id"
method="post"
class="d-inline">
@Html.AntiForgeryToken()
<button type="submit"
class="btn btn-outline-@(user.IsActive ? "warning" : "success")"
title="@(user.IsActive ? "Deactivate" : "Activate")">
<i class="bi bi-@(user.IsActive ? "pause" : "play")"></i>
</button>
</form>
</div>
}
else
{
<span class="text-muted small">Platform User</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="text-center py-5">
<i class="bi bi-people" style="font-size: 3rem; color: #ccc;"></i>
<p class="text-muted mt-3 mb-3">No users found for this company.</p>
<a asp-action="CreateCompanyAdmin" asp-route-id="@Model.Id" class="btn btn-primary">
<i class="bi bi-person-plus me-1"></i>Create First Admin User
</a>
</div>
}
</div>
</div>
</div>
<!-- Actions -->
<div class="col-12">
<div class="card shadow-sm border-danger">
<div class="card-header bg-light">
<h5 class="card-title mb-0 text-danger">
<i class="bi bi-exclamation-triangle me-2"></i>Danger Zone
</h5>
</div>
<div class="card-body d-flex flex-column gap-3">
<div class="d-flex justify-content-between align-items-start border rounded p-3 bg-warning bg-opacity-10">
<div>
<h6 class="mb-1 text-warning-emphasis">
<i class="bi bi-fire me-1"></i>Reset All Company Data
</h6>
<p class="text-muted small mb-0">
Permanently deletes <strong>all business data</strong> — customers, vendors, accounts, invoices, bills, jobs, quotes, inventory, catalog items, and more.
The company record, user accounts, and system configuration are preserved.
Use this to wipe a migration and start fresh.
</p>
</div>
<button type="button" class="btn btn-warning ms-3 text-nowrap"
data-bs-toggle="modal" data-bs-target="#resetDataModal">
<i class="bi bi-fire me-1"></i>Reset Data
</button>
</div>
<div class="d-flex justify-content-between align-items-start border rounded p-3 bg-danger bg-opacity-10">
<div>
<h6 class="mb-1 text-danger">
<i class="bi bi-trash me-1"></i>Delete Company
</h6>
<p class="text-muted small mb-0">
Permanently deletes the company <strong>and everything in it</strong>, including all users.
There is no going back.
@if (Model.UserCount > 0)
{
<br /><strong class="text-danger">This company has @Model.UserCount user(s) — remove them first.</strong>
}
</p>
</div>
<button type="button" class="btn btn-danger ms-3 text-nowrap"
data-bs-toggle="modal" data-bs-target="#hardDeleteModal"
@(Model.UserCount > 0 ? "disabled" : "")>
<i class="bi bi-trash me-1"></i>Delete Company
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- User Details Modal -->
<div class="modal fade" tabindex="-1" id="userDetailOffcanvas" aria-labelledby="userDetailOffcanvasLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" style="max-width:520px;">
<div class="modal-content">
<div class="modal-header border-bottom">
<h5 class="modal-title" id="userDetailOffcanvasLabel">
<i class="bi bi-person-circle me-2"></i><span id="oc-name">User Details</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<!-- Loading spinner -->
<div id="oc-loading" class="d-flex justify-content-center align-items-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading…</span>
</div>
</div>
<!-- Content (hidden until loaded) -->
<div id="oc-content" style="display:none;">
<!-- Profile card -->
<div class="px-3 pt-3 pb-2">
<div class="d-flex align-items-center gap-3 mb-3">
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
style="width:52px;height:52px;">
<i class="bi bi-person-fill text-primary fs-4"></i>
</div>
<div>
<h6 class="mb-0 fw-semibold" id="oc-fullname"></h6>
<small class="text-muted" id="oc-email"></small>
</div>
<span id="oc-status-badge" class="badge ms-auto"></span>
</div>
<table class="table table-sm table-borderless mb-0 small">
<tr><th style="width:38%;" class="text-muted fw-normal">Role</th><td id="oc-role">—</td></tr>
<tr><th class="text-muted fw-normal">Department</th><td id="oc-dept">—</td></tr>
<tr><th class="text-muted fw-normal">Position</th><td id="oc-position">—</td></tr>
<tr><th class="text-muted fw-normal">Phone</th><td id="oc-phone">—</td></tr>
<tr><th class="text-muted fw-normal">Hired</th><td id="oc-hire">—</td></tr>
<tr><th class="text-muted fw-normal">Account created</th><td id="oc-created">—</td></tr>
<tr><th class="text-muted fw-normal">Last login</th><td id="oc-lastlogin">—</td></tr>
<tr><th class="text-muted fw-normal">Email confirmed</th><td id="oc-emailconf">—</td></tr>
</table>
</div>
<hr class="my-0" />
<!-- Login history -->
<div class="px-3 pt-3">
<h6 class="fw-semibold mb-2">
<i class="bi bi-clock-history me-1"></i>Login History
<span class="text-muted fw-normal small" id="oc-log-count"></span>
</h6>
</div>
<div id="oc-no-logs" class="px-3 pb-3 text-muted small" style="display:none;">
No login records found. (The audit log table may have been empty before the fix was applied.)
</div>
<div class="table-responsive" id="oc-log-table-wrap">
<table class="table table-sm table-hover mb-0 small">
<thead class="table-light sticky-top">
<tr>
<th>Time</th>
<th>Event</th>
<th>IP Address</th>
</tr>
</thead>
<tbody id="oc-log-body"></tbody>
</table>
</div>
</div><!-- /oc-content -->
<!-- Error state -->
<div id="oc-error" class="px-3 py-4 text-danger small" style="display:none;">
<i class="bi bi-exclamation-triangle me-1"></i><span id="oc-error-msg">Failed to load user data.</span>
</div>
</div>
</div><!-- /modal-content -->
</div><!-- /modal-dialog -->
</div>
<!-- Reset Data Modal -->
<div class="modal fade" id="resetDataModal" tabindex="-1" aria-labelledby="resetDataModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-warning">
<div class="modal-header bg-warning bg-opacity-10">
<h5 class="modal-title text-warning-emphasis" id="resetDataModalLabel">
<i class="bi bi-fire me-2"></i>Reset All Company Data
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning alert-permanent">
<strong>This will permanently delete:</strong>
<ul class="mb-0 mt-1">
<li>All customers, vendors, catalog items, and inventory</li>
<li>All chart of accounts entries</li>
<li>All invoices, bills, payments, and deposits</li>
<li>All jobs, quotes, and purchase orders</li>
<li>All equipment, shop workers, and maintenance records</li>
<li>All appointments, notifications, and related data</li>
</ul>
</div>
<p><strong>Preserved:</strong> Company record, user accounts, operating costs, preferences, and system lookup tables.</p>
<p class="mb-1">Type <strong>DELETE</strong> to confirm:</p>
<input type="text" id="resetDataConfirmInput" class="form-control" placeholder="Type DELETE here" autocomplete="off" />
</div>
<form asp-action="ResetData" asp-route-id="@Model.Id" method="post" id="resetDataForm">
@Html.AntiForgeryToken()
<input type="hidden" name="confirmation" id="resetDataConfirmHidden" />
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" id="btnResetDataConfirm" class="btn btn-warning" disabled>
<i class="bi bi-fire me-1"></i>Reset All Data
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Hard Delete Company Modal -->
<div class="modal fade" id="hardDeleteModal" tabindex="-1" aria-labelledby="hardDeleteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-danger">
<div class="modal-header bg-danger bg-opacity-10">
<h5 class="modal-title text-danger" id="hardDeleteModalLabel">
<i class="bi bi-trash me-2"></i>Permanently Delete Company
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger alert-permanent">
<strong>This will permanently delete the company and everything in it:</strong> all data, all users, all configuration. This cannot be undone.
</div>
<p class="mb-1">Type <strong>DELETE</strong> to confirm:</p>
<input type="text" id="hardDeleteConfirmInput" class="form-control" placeholder="Type DELETE here" autocomplete="off" />
</div>
<form asp-action="HardDelete" asp-route-id="@Model.Id" method="post" id="hardDeleteForm">
@Html.AntiForgeryToken()
<input type="hidden" name="confirmation" id="hardDeleteConfirmHidden" />
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" id="btnHardDeleteConfirm" class="btn btn-danger" disabled>
<i class="bi bi-trash me-1"></i>Permanently Delete Company
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Reset Password Modal -->
<div class="modal fade" id="resetPasswordModal" tabindex="-1" aria-labelledby="resetPasswordModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="resetPasswordModalLabel">
<i class="bi bi-key me-2"></i>Reset Password
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="resetPasswordForm" asp-controller="CompanyUsers" asp-action="ResetPassword" method="post">
@Html.AntiForgeryToken()
<input type="hidden" id="resetUserId" name="id" />
<div class="modal-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
You are about to reset the password for <strong id="resetUserName"></strong>.
</div>
<div class="mb-3">
<label for="newPassword" class="form-label">New Password</label>
<input type="password" class="form-control" id="newPassword" name="newPassword" required
minlength="8" placeholder="Enter new password">
<small class="text-muted">
Password must be at least 8 characters with uppercase, lowercase, digit, and special character
</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-key me-2"></i>Reset Password
</button>
</div>
</form>
</div>
</div>
</div>
@section Scripts {
<script>
// Reset Data Modal — enable submit only when user types "DELETE"
(function () {
var input = document.getElementById('resetDataConfirmInput');
var hidden = document.getElementById('resetDataConfirmHidden');
var btn = document.getElementById('btnResetDataConfirm');
if (input) {
input.addEventListener('input', function () {
var match = input.value.trim() === 'DELETE';
btn.disabled = !match;
hidden.value = input.value.trim();
});
document.getElementById('resetDataModal').addEventListener('hidden.bs.modal', function () {
input.value = '';
hidden.value = '';
btn.disabled = true;
});
}
})();
// Hard Delete Modal — enable submit only when user types "DELETE"
(function () {
var input = document.getElementById('hardDeleteConfirmInput');
var hidden = document.getElementById('hardDeleteConfirmHidden');
var btn = document.getElementById('btnHardDeleteConfirm');
if (input) {
input.addEventListener('input', function () {
var match = input.value.trim() === 'DELETE';
btn.disabled = !match;
hidden.value = input.value.trim();
});
document.getElementById('hardDeleteModal').addEventListener('hidden.bs.modal', function () {
input.value = '';
hidden.value = '';
btn.disabled = true;
});
}
})();
// Reset Password Modal
var resetPasswordModal = document.getElementById('resetPasswordModal');
if (resetPasswordModal) {
resetPasswordModal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
var userId = button.getAttribute('data-user-id');
var userName = button.getAttribute('data-user-name');
var modalUserIdInput = resetPasswordModal.querySelector('#resetUserId');
var modalUserName = resetPasswordModal.querySelector('#resetUserName');
modalUserIdInput.value = userId;
modalUserName.textContent = userName;
});
}
// ── User Details Modal ────────────────────────────────────────────────
(function () {
const offcanvasEl = document.getElementById('userDetailOffcanvas');
const oc = new bootstrap.Modal(offcanvasEl);
const loading = document.getElementById('oc-loading');
const content = document.getElementById('oc-content');
const errorDiv = document.getElementById('oc-error');
const errorMsg = document.getElementById('oc-error-msg');
const actionBadgeClass = {
'Login': 'bg-success',
'Login2FABypassed': 'bg-success',
'FailedLogin': 'bg-danger',
'LoginDenied': 'bg-warning text-dark',
'AccountLockedOut': 'bg-danger',
};
const actionLabel = {
'Login': 'Login',
'Login2FABypassed': 'Login (2FA bypassed)',
'FailedLogin': 'Failed login',
'LoginDenied': 'Login denied',
'AccountLockedOut': 'Account locked out',
'SelfServiceAccountDeletion': 'Account deleted',
};
/// Shows the loading spinner and hides all other states.
function showLoading() {
loading.classList.remove('d-none');
content.style.display = 'none';
errorDiv.style.display = 'none';
}
/// Populates the offcanvas with the fetched user data and login history.
function renderUser(data) {
const u = data.user;
document.getElementById('oc-name').textContent = u.fullName;
document.getElementById('oc-fullname').textContent = u.fullName;
document.getElementById('oc-email').textContent = u.email;
const badge = document.getElementById('oc-status-badge');
badge.textContent = u.isActive ? 'Active' : 'Inactive';
badge.className = 'badge ms-auto ' + (u.isActive ? 'bg-success' : 'bg-danger');
document.getElementById('oc-role').textContent = u.companyRole ? u.companyRole.replace('Company', '') : '—';
document.getElementById('oc-dept').textContent = u.department || '—';
document.getElementById('oc-position').textContent = u.position || '—';
document.getElementById('oc-phone').textContent = u.phone || '—';
document.getElementById('oc-hire').textContent = u.hireDate || '—';
document.getElementById('oc-created').textContent = u.createdAt || '—';
document.getElementById('oc-lastlogin').textContent = u.lastLoginDate || 'Never';
document.getElementById('oc-emailconf').innerHTML = u.emailConfirmed
? '<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>Yes</span>'
: '<span class="text-warning"><i class="bi bi-x-circle-fill me-1"></i>No</span>';
// Login history table
const tbody = document.getElementById('oc-log-body');
const noLogs = document.getElementById('oc-no-logs');
const logWrap = document.getElementById('oc-log-table-wrap');
const countEl = document.getElementById('oc-log-count');
const logs = data.loginHistory;
tbody.innerHTML = '';
if (!logs || logs.length === 0) {
noLogs.style.display = '';
logWrap.style.display = 'none';
countEl.textContent = '';
} else {
noLogs.style.display = 'none';
logWrap.style.display = '';
countEl.textContent = `(${logs.length})`;
logs.forEach(function (log) {
const badgeClass = actionBadgeClass[log.action] || 'bg-secondary';
const label = actionLabel[log.action] || log.action;
const noteHtml = log.note ? `<br><span class="text-muted">${escHtml(log.note)}</span>` : '';
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="text-nowrap">${escHtml(log.timestamp)}</td>
<td><span class="badge ${badgeClass}">${escHtml(label)}</span>${noteHtml}</td>
<td class="text-nowrap text-muted">${escHtml(log.ipAddress)}</td>`;
tbody.appendChild(tr);
});
}
loading.classList.add('d-none');
content.style.display = '';
}
function showError(msg) {
loading.classList.add('d-none');
content.style.display = 'none';
errorMsg.textContent = msg;
errorDiv.style.display = '';
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// Wire up every user row
document.querySelectorAll('.user-row').forEach(function (row) {
row.addEventListener('click', function () {
const userId = row.dataset.userId;
const companyId = row.dataset.companyId;
showLoading();
oc.show();
fetch(`/Companies/UserLoginHistory?companyId=${encodeURIComponent(companyId)}&userId=${encodeURIComponent(userId)}`)
.then(function (resp) {
if (!resp.ok) throw new Error('Server returned ' + resp.status);
return resp.json();
})
.then(renderUser)
.catch(function (err) {
showError('Failed to load user data. ' + err.message);
});
});
});
})();
</script>
}
@@ -0,0 +1,189 @@
@model PowderCoating.Application.DTOs.Company.UpdateCompanyDto
@{
ViewData["Title"] = "Edit Company";
ViewData["PageIcon"] = "bi-building-gear";
var planConfigs = ((IEnumerable<PowderCoating.Core.Entities.SubscriptionPlanConfig>)ViewBag.PlanConfigs)
.OrderBy(c => c.SortOrder).ToList();
}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-end mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to List
</a>
</div>
<div class="card shadow-sm">
<div class="card-body">
<form asp-action="Edit" method="post">
<input type="hidden" asp-for="Id" />
<partial name="_ValidationSummary" />
<h5 class="card-title mb-3 pb-2 border-bottom">Company Information</h5>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label asp-for="CompanyName" class="form-label">Company Name *</label>
<input asp-for="CompanyName" class="form-control" />
<span asp-validation-for="CompanyName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="CompanyCode" class="form-label">Company Code</label>
<input asp-for="CompanyCode" class="form-control" maxlength="10" />
<span asp-validation-for="CompanyCode" class="text-danger"></span>
</div>
</div>
<h5 class="card-title mb-3 pb-2 border-bottom">Primary Contact</h5>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label asp-for="PrimaryContactName" class="form-label">Contact Name *</label>
<input asp-for="PrimaryContactName" class="form-control" />
<span asp-validation-for="PrimaryContactName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="PrimaryContactEmail" class="form-label">Contact Email *</label>
<input asp-for="PrimaryContactEmail" class="form-control" type="email" />
<span asp-validation-for="PrimaryContactEmail" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Phone" class="form-label">Phone</label>
<input asp-for="Phone" class="form-control" />
<span asp-validation-for="Phone" class="text-danger"></span>
</div>
</div>
<h5 class="card-title mb-3 pb-2 border-bottom">Address</h5>
<div class="row g-3 mb-4">
<div class="col-12">
<label asp-for="Address" class="form-label">Street Address</label>
<input asp-for="Address" class="form-control" />
<span asp-validation-for="Address" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="City" class="form-label">City</label>
<input asp-for="City" class="form-control" />
<span asp-validation-for="City" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="State" class="form-label">State</label>
<input asp-for="State" class="form-control" maxlength="2" />
<span asp-validation-for="State" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="ZipCode" class="form-label">Zip Code</label>
<input asp-for="ZipCode" class="form-control" />
<span asp-validation-for="ZipCode" class="text-danger"></span>
</div>
</div>
<h5 class="card-title mb-3 pb-2 border-bottom">Subscription Details</h5>
<div class="row g-3 mb-4">
<div class="col-md-4">
<label asp-for="SubscriptionPlan" class="form-label">Plan</label>
<select asp-for="SubscriptionPlan" class="form-select">
@foreach (var plan in planConfigs)
{
<option value="@plan.Plan">@plan.DisplayName</option>
}
</select>
<span asp-validation-for="SubscriptionPlan" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="SubscriptionStartDate" class="form-label">Start Date</label>
<input asp-for="SubscriptionStartDate" class="form-control" type="date" />
<span asp-validation-for="SubscriptionStartDate" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="SubscriptionEndDate" class="form-label">End Date</label>
<input asp-for="SubscriptionEndDate" class="form-control" type="date" />
<span asp-validation-for="SubscriptionEndDate" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="TimeZone" class="form-label">Time Zone</label>
<select asp-for="TimeZone" class="form-select">
<option value="America/New_York">Eastern Time (ET)</option>
<option value="America/Chicago">Central Time (CT)</option>
<option value="America/Denver">Mountain Time (MT)</option>
<option value="America/Phoenix">Arizona Time (MT - No DST)</option>
<option value="America/Los_Angeles">Pacific Time (PT)</option>
<option value="America/Anchorage">Alaska Time (AKT)</option>
<option value="Pacific/Honolulu">Hawaii Time (HT)</option>
</select>
<span asp-validation-for="TimeZone" class="text-danger"></span>
</div>
<div class="col-md-6">
<div class="form-check form-switch mt-4">
<input asp-for="IsActive" class="form-check-input" type="checkbox" />
<label asp-for="IsActive" class="form-check-label">Company Active</label>
</div>
</div>
</div>
<h5 class="card-title mb-3 pb-2 border-bottom">Feature Overrides</h5>
<p class="text-muted small mb-3">Override plan-level feature access for this company. Leave blank (—) to inherit from the subscription plan.</p>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label fw-medium">Online Payments</label>
<select asp-for="OnlinePaymentsOverride" class="form-select">
<option value="">— Use plan default —</option>
<option value="true">Force Enable</option>
<option value="false">Force Disable</option>
</select>
<div class="form-text">Stripe Connect invoice payments. Still requires the company to connect their Stripe account.</div>
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Accounting Module</label>
<select asp-for="AccountingOverride" class="form-select">
<option value="">— Use plan default —</option>
<option value="true">Force Enable</option>
<option value="false">Force Disable</option>
</select>
<div class="form-text">Chart of Accounts, Bills, Expenses, and Accounting Export.</div>
</div>
</div>
<h5 class="card-title mb-3 pb-2 border-bottom">AI Features</h5>
<p class="text-muted small mb-3">Control which AI-powered features are available to this company and set monthly usage limits.</p>
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="form-check form-switch">
<input asp-for="AiPhotoQuotesEnabled" class="form-check-input" type="checkbox" />
<label asp-for="AiPhotoQuotesEnabled" class="form-check-label fw-medium">AI Photo Quotes</label>
</div>
<div class="form-text">Allow this company to use photo-based AI quoting.</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<input asp-for="AiInventoryAssistEnabled" class="form-check-input" type="checkbox" />
<label asp-for="AiInventoryAssistEnabled" class="form-check-label fw-medium">AI Inventory Assist</label>
</div>
<div class="form-text">Allow this company to use AI lookup on inventory items.</div>
</div>
<div class="col-md-4">
<label asp-for="MaxAiPhotoQuotesPerMonthOverride" class="form-label">Monthly AI Quote Limit Override</label>
<input asp-for="MaxAiPhotoQuotesPerMonthOverride" type="number" class="form-control" min="-1" placeholder="Leave blank to use plan default" />
<div class="form-text">-1 = unlimited. 0 = disabled. Blank = use subscription plan default.</div>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{
await Html.RenderPartialAsync("_ValidationScriptsPartial");
}
}
@@ -0,0 +1,477 @@
@model List<PowderCoating.Application.DTOs.Company.CompanyListDto>
@section Styles {
<style>
[data-bs-theme="dark"] a.text-dark { color: var(--bs-body-color) !important; }
[data-bs-theme="dark"] .bi-building[style*="color:#ccc"] { color: var(--bs-secondary-color) !important; }
</style>
}
@{
ViewData["Title"] = "Companies";
ViewData["PageIcon"] = "bi-building";
var planConfigs = ((IEnumerable<PowderCoating.Core.Entities.SubscriptionPlanConfig>)ViewBag.PlanConfigs)
.OrderBy(c => c.SortOrder).ToList();
var planBadgeColors = planConfigs.Select((c, i) => (c.Plan, i))
.ToDictionary(x => x.Plan, x => x.i switch { 0 => "bg-secondary", 1 => "bg-primary", 2 => "bg-info", _ => "bg-success" });
var planNames = planConfigs.ToDictionary(c => c.Plan, c => c.DisplayName);
string PlanBadge(int plan) => planBadgeColors.TryGetValue(plan, out var c) ? c : "bg-secondary";
string PlanName(int plan) => planNames.TryGetValue(plan, out var n) ? n : plan.ToString();
var searchTerm = (string?)ViewBag.SearchTerm;
var sortColumn = (string)(ViewBag.SortColumn ?? "CompanyName");
var sortDirection = (string)(ViewBag.SortDirection ?? "asc");
var pageNumber = (int)(ViewBag.PageNumber ?? 1);
var pageSize = (int)(ViewBag.PageSize ?? 25);
var totalPages = (int)(ViewBag.TotalPages ?? 1);
var totalCount = (int)(ViewBag.TotalCount ?? 0);
var impersonatingId = (int?)(ViewBag.ImpersonatingCompanyId);
string SortLink(string col)
{
var dir = (sortColumn == col && sortDirection == "asc") ? "desc" : "asc";
return Url.Action("Index", new { searchTerm, sortColumn = col, sortDirection = dir, pageNumber = 1, pageSize })!;
}
string SortIcon(string col)
{
if (sortColumn != col) return "bi-chevron-expand text-muted";
return sortDirection == "asc" ? "bi-chevron-up" : "bi-chevron-down";
}
}
<div class="container-fluid">
<div class="d-flex justify-content-end mb-4">
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>Create New Company
</a>
</div>
<!-- Search -->
<div class="card shadow-sm mb-3">
<div class="card-body py-2">
<form asp-action="Index" method="get" class="row g-2 align-items-end">
<input type="hidden" name="sortColumn" value="@sortColumn" />
<input type="hidden" name="sortDirection" value="@sortDirection" />
<input type="hidden" name="pageSize" value="@pageSize" />
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" name="searchTerm" class="form-control"
placeholder="Search by name, code, email, phone…"
value="@searchTerm" />
</div>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
@if (!string.IsNullOrWhiteSpace(searchTerm))
{
<a asp-action="Index" asp-route-sortColumn="@sortColumn"
asp-route-sortDirection="@sortDirection" asp-route-pageSize="@pageSize"
class="btn btn-outline-secondary ms-1">Clear</a>
}
</div>
</form>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
@if (Model != null && Model.Any())
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>
<a href="@SortLink("CompanyName")" class="text-decoration-none text-dark d-flex align-items-center gap-1">
Company Name <i class="bi @SortIcon("CompanyName")"></i>
</a>
</th>
<th>Code</th>
<th>Contact Email</th>
<th>Phone</th>
<th>
<a href="@SortLink("Plan")" class="text-decoration-none text-dark d-flex align-items-center gap-1">
Plan <i class="bi @SortIcon("Plan")"></i>
</a>
</th>
<th>Users</th>
<th>Setup Wizard</th>
<th>
<a href="@SortLink("Status")" class="text-decoration-none text-dark d-flex align-items-center gap-1">
Status <i class="bi @SortIcon("Status")"></i>
</a>
</th>
<th>
<a href="@SortLink("Created")" class="text-decoration-none text-dark d-flex align-items-center gap-1">
Created <i class="bi @SortIcon("Created")"></i>
</a>
</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var company in Model)
{
var isImpersonating = impersonatingId.HasValue && impersonatingId.Value == company.Id;
var detailsUrl = Url.Action("Details", new { id = company.Id });
<tr class="@(isImpersonating ? "table-warning" : "")" style="cursor:pointer"
onclick="window.location='@detailsUrl'">
<td>
<strong>@company.CompanyName</strong>
@if (isImpersonating)
{
<span class="badge bg-warning text-dark ms-1">
<i class="bi bi-eye-fill me-1"></i>Active
</span>
}
</td>
<td>
@if (!string.IsNullOrEmpty(company.CompanyCode))
{
<span class="badge bg-secondary">@company.CompanyCode</span>
}
</td>
<td>@company.PrimaryContactEmail</td>
<td>@company.Phone</td>
<td>
<span class="badge @PlanBadge(company.SubscriptionPlan)">@PlanName(company.SubscriptionPlan)</span>
</td>
<td>
<span class="badge bg-primary rounded-pill">@company.UserCount</span>
</td>
<td>
@if (company.WizardCompleted)
{
var tooltip = company.WizardCompletedByName != null
? $"Completed by {company.WizardCompletedByName}"
+ (company.WizardCompletedAt.HasValue
? $" on {company.WizardCompletedAt.Value.Tz(ViewBag.CompanyTimeZone as string):MMM d, yyyy}"
: "")
: "Completed";
<span class="badge bg-success" title="@tooltip" data-bs-toggle="tooltip">
<i class="bi bi-check-circle-fill me-1"></i>Done
</span>
}
else
{
<span class="badge bg-light text-muted border">
<i class="bi bi-hourglass me-1"></i>Pending
</span>
}
</td>
<td>
@if (company.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-danger">Inactive</span>
}
</td>
<td>
<small class="text-muted">@company.CreatedAt.ToString("MMM d, yyyy")</small>
</td>
<td class="text-end" onclick="event.stopPropagation()">
<div class="btn-group btn-group-sm" role="group">
<a asp-action="Details" asp-route-id="@company.Id"
class="btn btn-outline-primary" title="View Details">
<i class="bi bi-eye"></i>
</a>
<a asp-action="Edit" asp-route-id="@company.Id"
class="btn btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<form asp-action="ToggleActive" asp-route-id="@company.Id"
method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit"
class="btn @(company.IsActive ? "btn-outline-warning" : "btn-outline-success")"
title="@(company.IsActive ? "Deactivate" : "Activate")">
<i class="bi bi-@(company.IsActive ? "pause" : "play")"></i>
</button>
</form>
@if (isImpersonating)
{
<form asp-action="StopImpersonating" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-warning" title="Stop Impersonating">
<i class="bi bi-x-circle"></i>
</button>
</form>
}
else
{
<form asp-action="StartImpersonating" method="post" class="d-inline">
@Html.AntiForgeryToken()
<input type="hidden" name="companyId" value="@company.Id" />
<button type="submit" class="btn btn-outline-dark" title="Impersonate this company">
<i class="bi bi-person-fill-gear"></i>
</button>
</form>
}
<button type="button"
class="btn btn-outline-danger"
title="Delete Company"
onclick="event.stopPropagation(); openDeleteModal(@company.Id, '@Html.Raw(company.CompanyName.Replace("'", "\\'"))', @company.UserCount, @company.JobCount, @company.QuoteCount, @company.CustomerCount)">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile card view — shown on screens < 992px (table-responsive hidden by mobile-cards.css) -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var company in Model)
{
var isImpersonating = impersonatingId.HasValue && impersonatingId.Value == company.Id;
<a href="@Url.Action("Details", new { id = company.Id })" class="mobile-data-card text-decoration-none">
<div class="mobile-card-header">
<div class="mobile-card-icon bg-primary"><i class="bi bi-building"></i></div>
<div class="mobile-card-title">
<h6>@company.CompanyName</h6>
<small class="text-muted">@company.PrimaryContactEmail</small>
</div>
@if (company.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-danger">Inactive</span>
}
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Plan</span>
<span class="mobile-card-value"><span class="badge @PlanBadge(company.SubscriptionPlan)">@PlanName(company.SubscriptionPlan)</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Users</span>
<span class="mobile-card-value"><span class="badge bg-primary rounded-pill">@company.UserCount</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Created</span>
<span class="mobile-card-value">@company.CreatedAt.ToString("MMM d, yyyy")</span>
</div>
@if (!string.IsNullOrEmpty(company.CompanyCode))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Code</span>
<span class="mobile-card-value"><span class="badge bg-secondary">@company.CompanyCode</span></span>
</div>
}
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-primary">View →</span>
</div>
</a>
}
</div>
</div>
<!-- Pagination -->
@if (totalPages > 1)
{
<div class="card-footer d-flex justify-content-between align-items-center">
<div class="text-muted small">
Showing @((pageNumber - 1) * pageSize + 1)@(Math.Min(pageNumber * pageSize, totalCount)) of @totalCount companies
</div>
<div class="d-flex align-items-center gap-3">
<div>
<select class="form-select form-select-sm" onchange="changePageSize(this.value)">
@foreach (var size in new[] { 10, 25, 50, 100 })
{
<option value="@size" selected="@(pageSize == size)">@size per page</option>
}
</select>
</div>
<nav>
<ul class="pagination pagination-sm mb-0">
<li class="page-item @(pageNumber == 1 ? "disabled" : "")">
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = pageNumber - 1, pageSize })">
<i class="bi bi-chevron-left"></i>
</a>
</li>
@for (int p = Math.Max(1, pageNumber - 2); p <= Math.Min(totalPages, pageNumber + 2); p++)
{
<li class="page-item @(p == pageNumber ? "active" : "")">
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = p, pageSize })">@p</a>
</li>
}
<li class="page-item @(pageNumber == totalPages ? "disabled" : "")">
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = pageNumber + 1, pageSize })">
<i class="bi bi-chevron-right"></i>
</a>
</li>
</ul>
</nav>
</div>
</div>
}
}
else
{
<div class="text-center py-5">
<i class="bi bi-building text-secondary" style="font-size: 3rem;"></i>
<p class="text-muted mt-3">
@if (!string.IsNullOrWhiteSpace(searchTerm))
{
<text>No companies match "<strong>@searchTerm</strong>".</text>
}
else
{
<text>No companies found.</text>
}
</p>
@if (string.IsNullOrWhiteSpace(searchTerm))
{
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>Create Your First Company
</a>
}
</div>
}
</div>
</div>
</div>
<!-- Delete Company Modal -->
<div class="modal fade" id="deleteCompanyModal" tabindex="-1" aria-labelledby="deleteCompanyModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteCompanyModalLabel">
<i class="bi bi-exclamation-triangle-fill me-2"></i>Delete Company
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-4">
<p class="mb-3">
You are about to delete <strong id="modal-company-name"></strong>. This company has the following data:
</p>
<div class="row g-3 mb-4">
<div class="col-3">
<div class="card border text-center p-2">
<div class="fs-4 fw-bold text-primary" id="modal-user-count">0</div>
<small class="text-muted">Users</small>
</div>
</div>
<div class="col-3">
<div class="card border text-center p-2">
<div class="fs-4 fw-bold text-success" id="modal-job-count">0</div>
<small class="text-muted">Jobs</small>
</div>
</div>
<div class="col-3">
<div class="card border text-center p-2">
<div class="fs-4 fw-bold text-info" id="modal-quote-count">0</div>
<small class="text-muted">Quotes</small>
</div>
</div>
<div class="col-3">
<div class="card border text-center p-2">
<div class="fs-4 fw-bold text-warning" id="modal-customer-count">0</div>
<small class="text-muted">Customers</small>
</div>
</div>
</div>
<hr />
<!-- Soft Delete Section -->
<div class="mb-4">
<h6 class="fw-bold text-warning"><i class="bi bi-pause-circle me-2"></i>Soft Delete (Deactivate)</h6>
<p class="text-muted small mb-2">
The company and all users will be <strong>deactivated</strong> but data is preserved.
This is reversible and can be undone by an administrator.
</p>
<form id="softDeleteForm" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="id" id="soft-delete-id" />
<button type="submit" class="btn btn-warning">
<i class="bi bi-pause-circle me-1"></i>Deactivate Company
</button>
</form>
</div>
<hr />
<!-- Hard Delete Section -->
<div>
<h6 class="fw-bold text-danger"><i class="bi bi-fire me-2"></i>Hard Delete (Permanent)</h6>
<p class="text-muted small mb-2">
<strong class="text-danger">This cannot be undone.</strong>
All company data — users, jobs, quotes, customers, invoices, and everything else — will be
<strong>permanently and irreversibly deleted</strong> from the database.
</p>
<div class="alert alert-danger py-2 mb-3">
<i class="bi bi-exclamation-octagon-fill me-2"></i>
Type <strong>DELETE</strong> below to enable permanent deletion.
</div>
<div class="mb-3">
<input type="text"
id="hard-delete-confirmation"
class="form-control"
placeholder="Type DELETE to confirm"
autocomplete="off"
oninput="validateHardDelete(this.value)" />
</div>
<form id="hardDeleteForm" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="id" id="hard-delete-id" />
<input type="hidden" name="confirmation" value="DELETE" />
<button type="submit" id="hard-delete-btn" class="btn btn-danger" disabled>
<i class="bi bi-trash-fill me-1"></i>Permanently Delete Everything
</button>
</form>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
function changePageSize(size) {
const url = new URL(window.location.href);
url.searchParams.set('pageSize', size);
url.searchParams.set('pageNumber', '1');
window.location.href = url.toString();
}
function openDeleteModal(id, name, users, jobs, quotes, customers) {
document.getElementById('modal-company-name').textContent = name;
document.getElementById('modal-user-count').textContent = users;
document.getElementById('modal-job-count').textContent = jobs;
document.getElementById('modal-quote-count').textContent = quotes;
document.getElementById('modal-customer-count').textContent = customers;
document.getElementById('soft-delete-id').value = id;
document.getElementById('hard-delete-id').value = id;
document.getElementById('softDeleteForm').action = '/Companies/SoftDelete/' + id;
document.getElementById('hardDeleteForm').action = '/Companies/HardDelete/' + id;
// Reset hard delete input
document.getElementById('hard-delete-confirmation').value = '';
document.getElementById('hard-delete-btn').disabled = true;
new bootstrap.Modal(document.getElementById('deleteCompanyModal')).show();
}
function validateHardDelete(value) {
document.getElementById('hard-delete-btn').disabled = (value !== 'DELETE');
}
</script>
}
@@ -0,0 +1,377 @@
@using PowderCoating.Web.Controllers
@using PowderCoating.Application.DTOs.Health
@model List<CompanyHealthDto>
@{
ViewData["Title"] = "Company Health";
string RiskBadge(ChurnRisk r) => r switch {
ChurnRisk.Healthy => "bg-success",
ChurnRisk.AtRisk => "bg-warning text-dark",
ChurnRisk.Critical => "bg-danger",
ChurnRisk.NeverActivated => "bg-secondary",
_ => "bg-secondary"
};
string RiskLabel(ChurnRisk r) => r switch {
ChurnRisk.Healthy => "Healthy",
ChurnRisk.AtRisk => "At Risk",
ChurnRisk.Critical => "Critical",
ChurnRisk.NeverActivated => "Never Activated",
_ => r.ToString()
};
string RowClass(ChurnRisk r) => r switch {
ChurnRisk.Critical => "table-danger",
ChurnRisk.AtRisk => "table-warning",
ChurnRisk.NeverActivated => "opacity-75",
_ => ""
};
string ScoreColor(int s) => s >= 75 ? "text-success" : s >= 45 ? "text-warning" : "text-danger";
string ConfigBadgeClass(CompanyConfigHealth ch) => ch.IsHealthy ? "bg-success" :
ch.OverallSeverity == ConfigIssueSeverity.Critical ? "bg-danger" : "bg-warning text-dark";
string LoginLabel(int days) => days switch {
-1 => "Never",
0 => "Today",
1 => "Yesterday",
_ => $"{days}d ago"
};
string LoginClass(int days) => days is -1 or >= 90 ? "text-danger" : days >= 30 ? "text-warning" : "text-success";
}
@section Styles {
<style>
[data-bs-theme="dark"] .table-light th,
[data-bs-theme="dark"] .table-light td {
background-color: var(--bs-secondary-bg);
color: var(--bs-body-color);
}
[data-bs-theme="dark"] .table-danger td {
background-color: rgba(220,53,69,.15) !important;
}
[data-bs-theme="dark"] .table-warning td {
background-color: rgba(255,193,7,.1) !important;
}
[data-bs-theme="dark"] .card {
border-color: var(--bs-border-color) !important;
}
[data-bs-theme="dark"] .card-footer {
background-color: var(--bs-secondary-bg);
border-color: var(--bs-border-color);
}
</style>
}
<div class="container-fluid py-3">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-heart-pulse me-2 text-primary"></i>Company Health</h4>
<small class="text-muted">Churn risk signals across all tenants</small>
</div>
</div>
@* Summary stat cards *@
<div class="row g-3 mb-3">
<div class="col-6 col-lg-3">
<a href="@Url.Action("Index", new { risk = "", search = ViewBag.Search })"
class="card border-0 shadow-sm text-decoration-none @(string.IsNullOrEmpty(ViewBag.Risk as string) ? "border-bottom border-3 border-secondary" : "")">
<div class="card-body py-3">
<div class="d-flex align-items-center gap-3">
<div class="rounded-circle bg-secondary bg-opacity-10 p-2">
<i class="bi bi-building text-secondary fs-5"></i>
</div>
<div>
<div class="fs-4 fw-bold">@Model.Count</div>
<div class="small text-muted">All Companies</div>
</div>
</div>
</div>
</a>
</div>
<div class="col-6 col-lg-3">
<a href="@Url.Action("Index", new { risk = "Healthy", search = ViewBag.Search })"
class="card border-0 shadow-sm text-decoration-none @(ViewBag.Risk == "Healthy" ? "border-bottom border-3 border-success" : "")">
<div class="card-body py-3">
<div class="d-flex align-items-center gap-3">
<div class="rounded-circle bg-success bg-opacity-10 p-2">
<i class="bi bi-check-circle text-success fs-5"></i>
</div>
<div>
<div class="fs-4 fw-bold text-success">@ViewBag.HealthyCount</div>
<div class="small text-muted">Healthy</div>
</div>
</div>
</div>
</a>
</div>
<div class="col-6 col-lg-3">
<a href="@Url.Action("Index", new { risk = "AtRisk", search = ViewBag.Search })"
class="card border-0 shadow-sm text-decoration-none @(ViewBag.Risk == "AtRisk" ? "border-bottom border-3 border-warning" : "")">
<div class="card-body py-3">
<div class="d-flex align-items-center gap-3">
<div class="rounded-circle bg-warning bg-opacity-10 p-2">
<i class="bi bi-exclamation-triangle text-warning fs-5"></i>
</div>
<div>
<div class="fs-4 fw-bold text-warning">@ViewBag.AtRiskCount</div>
<div class="small text-muted">At Risk</div>
</div>
</div>
</div>
</a>
</div>
<div class="col-6 col-lg-3">
<a href="@Url.Action("Index", new { risk = "Critical", search = ViewBag.Search })"
class="card border-0 shadow-sm text-decoration-none @(ViewBag.Risk == "Critical" ? "border-bottom border-3 border-danger" : "")">
<div class="card-body py-3">
<div class="d-flex align-items-center gap-3">
<div class="rounded-circle bg-danger bg-opacity-10 p-2">
<i class="bi bi-x-circle text-danger fs-5"></i>
</div>
<div>
<div class="fs-4 fw-bold text-danger">@(ViewBag.CriticalCount + ViewBag.NeverActivatedCount)</div>
<div class="small text-muted">Critical / Dormant</div>
</div>
</div>
</div>
</a>
</div>
</div>
@* Config issues summary card *@
<div class="row g-3 mb-3">
<div class="col-12 col-lg-4">
<a href="@Url.Action("Index", new { configIssuesOnly = true, search = ViewBag.Search })"
class="card border-0 shadow-sm text-decoration-none @((bool)(ViewBag.ConfigIssuesOnly ?? false) ? "border-bottom border-3 border-danger" : "")">
<div class="card-body py-3">
<div class="d-flex align-items-center gap-3">
<div class="rounded-circle bg-danger bg-opacity-10 p-2">
<i class="bi bi-tools text-danger fs-5"></i>
</div>
<div>
<div class="fs-4 fw-bold @((int)(ViewBag.ConfigIssuesCount ?? 0) > 0 ? "text-danger" : "text-success")">
@ViewBag.ConfigIssuesCount
</div>
<div class="small text-muted">Config Issues</div>
</div>
@if ((int)(ViewBag.ConfigIssuesCount ?? 0) > 0)
{
<div class="ms-auto">
<span class="badge bg-danger rounded-pill">Action needed</span>
</div>
}
</div>
</div>
</a>
</div>
</div>
@* Search + filter bar *@
<div class="card border-0 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 name="search" value="@ViewBag.Search" class="form-control form-control-sm"
placeholder="Company name or email…" />
</div>
<div class="col-md-3">
<select name="risk" class="form-select form-select-sm">
<option value="">All risk levels</option>
<option value="Healthy" selected="@(ViewBag.Risk == "Healthy")">Healthy</option>
<option value="AtRisk" selected="@(ViewBag.Risk == "AtRisk")">At Risk</option>
<option value="Critical" selected="@(ViewBag.Risk == "Critical")">Critical</option>
<option value="NeverActivated" selected="@(ViewBag.Risk == "NeverActivated")">Never Activated</option>
</select>
</div>
<div class="col-md-3 d-flex align-items-center">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" name="configIssuesOnly" value="true"
id="configOnly" @((bool)(ViewBag.ConfigIssuesOnly ?? false) ? "checked" : "") />
<label class="form-check-label small" for="configOnly">Config issues only</label>
</div>
</div>
<div class="col-auto">
<button class="btn btn-sm btn-primary">Filter</button>
<a asp-action="Index" class="btn btn-sm btn-outline-secondary ms-1">Clear</a>
</div>
</form>
</div>
</div>
@* Table *@
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 small">
<thead class="table-light">
<tr>
<th>Company</th>
<th>Plan</th>
<th style="width:110px">Risk</th>
<th style="width:70px">Score</th>
<th>Last Login</th>
<th>Jobs 30d</th>
<th>Jobs 90d</th>
<th>Total</th>
<th>Engagement Signals</th>
<th>Config</th>
<th></th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr><td colspan="10" class="text-center text-muted py-5">No companies match the current filter.</td></tr>
}
@foreach (var h in Model)
{
var manageUrl = Url.Action("Manage", "SubscriptionManagement", new { id = h.Id });
<tr class="@RowClass(h.RiskLevel)" style="cursor:pointer" onclick="window.location='@manageUrl'">
<td>
<div class="fw-medium">
@h.CompanyName
@if (h.IsComped)
{
<span class="badge bg-success-subtle text-success ms-1 small">Comped</span>
}
@if (!h.IsActive)
{
<span class="badge bg-danger ms-1 small">Inactive</span>
}
</div>
<small class="text-muted">@h.PrimaryContactEmail</small>
</td>
<td class="text-muted">@h.PlanDisplayName</td>
<td>
<span class="badge @RiskBadge(h.RiskLevel)">@RiskLabel(h.RiskLevel)</span>
</td>
<td>
@if (h.RiskLevel == ChurnRisk.NeverActivated)
{
<span class="text-muted">—</span>
}
else
{
<span class="fw-semibold @ScoreColor(h.HealthScore)">@h.HealthScore</span>
}
</td>
<td class="@LoginClass(h.DaysSinceLastLogin)">
@LoginLabel(h.DaysSinceLastLogin)
</td>
<td>
@if (h.JobsLast30Days > 0)
{ <span class="fw-medium text-success">@h.JobsLast30Days</span> }
else
{ <span class="text-muted">0</span> }
</td>
<td>
@if (h.JobsLast90Days > 0)
{ <span class="fw-medium">@h.JobsLast90Days</span> }
else
{ <span class="text-muted">0</span> }
</td>
<td class="text-muted">@h.TotalJobs</td>
<td>
@if (h.RiskSignals.Any())
{
<div class="d-flex flex-wrap gap-1">
@foreach (var s in h.RiskSignals)
{
<span class="badge bg-secondary-subtle text-secondary fw-normal">@s</span>
}
</div>
}
else
{
<span class="text-success small"><i class="bi bi-check-circle me-1"></i>All clear</span>
}
</td>
<td onclick="event.stopPropagation()">
@if (h.ConfigHealth.IsHealthy)
{
<span class="badge bg-success"><i class="bi bi-check-lg me-1"></i>OK</span>
}
else
{
var configUrl = Url.Action("Details", "Companies", new { id = h.Id });
<a href="@configUrl" class="badge @ConfigBadgeClass(h.ConfigHealth) text-decoration-none"
title="@string.Join(" | ", h.ConfigHealth.Issues.Select(i => i.Title))">
<i class="bi bi-exclamation-triangle me-1"></i>
@h.ConfigHealth.Issues.Count issue@(h.ConfigHealth.Issues.Count == 1 ? "" : "s")
</a>
}
</td>
<td onclick="event.stopPropagation()">
<a href="@manageUrl" class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-pencil"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile card view — shown on screens < 992px -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var h in Model)
{
var manageUrl = Url.Action("Manage", "SubscriptionManagement", new { id = h.Id });
<a href="@manageUrl" class="mobile-data-card text-decoration-none">
<div class="mobile-card-header">
<div class="mobile-card-icon bg-primary"><i class="bi bi-building"></i></div>
<div class="mobile-card-title">
<h6>
@h.CompanyName
@if (!h.IsActive) { <span class="badge bg-danger ms-1" style="font-size:0.6rem">Inactive</span> }
</h6>
<small>Last login: @LoginLabel(h.DaysSinceLastLogin)</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Risk</span>
<span class="mobile-card-value"><span class="badge @RiskBadge(h.RiskLevel)">@RiskLabel(h.RiskLevel)</span></span>
</div>
@if (h.RiskLevel != ChurnRisk.NeverActivated)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Score</span>
<span class="mobile-card-value fw-semibold @ScoreColor(h.HealthScore)">@h.HealthScore</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Plan</span>
<span class="mobile-card-value">@h.PlanDisplayName</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Jobs (30d / 90d)</span>
<span class="mobile-card-value">@h.JobsLast30Days / @h.JobsLast90Days</span>
</div>
@if (h.RiskSignals.Any())
{
<div class="mobile-card-row">
<span class="mobile-card-label">Signals</span>
<span class="mobile-card-value">@string.Join(", ", h.RiskSignals)</span>
</div>
}
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-secondary">Manage →</span>
</div>
</a>
}
</div>
</div>
@if (!Model.Any(h => h.RiskLevel != ChurnRisk.Healthy) && Model.Any())
{
<div class="card-footer text-center py-3 text-success">
<i class="bi bi-check-circle-fill me-2"></i>All tenants are healthy — no churn signals detected.
</div>
}
</div>
</div>
@@ -0,0 +1,183 @@
@{
ViewData["Title"] = "Delete Account";
ViewData["PageIcon"] = "bi-trash3-fill";
var companyName = ViewBag.CompanyName as string ?? "your company";
var userCount = (int)(ViewBag.UserCount ?? 0);
var jobCount = (int)(ViewBag.JobCount ?? 0);
var quoteCount = (int)(ViewBag.QuoteCount ?? 0);
var customerCount = (int)(ViewBag.CustomerCount ?? 0);
var invoiceCount = (int)(ViewBag.InvoiceCount ?? 0);
}
<div class="container" style="max-width:720px;">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a asp-controller="CompanySettings" asp-action="Index">Company Settings</a>
</li>
<li class="breadcrumb-item active">Delete Account</li>
</ol>
</nav>
<!-- Error alert -->
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>@TempData["Error"]
</div>
}
<!-- Header -->
<div class="d-flex align-items-center gap-3 mb-4">
<div class="bg-danger bg-opacity-10 rounded-circle p-3">
<i class="bi bi-trash3-fill text-danger fs-3"></i>
</div>
<div>
<p class="text-muted mb-0">This action is permanent and cannot be undone.</p>
</div>
</div>
<!-- What happens card -->
<div class="card border-danger mb-4">
<div class="card-header bg-danger text-white fw-semibold">
<i class="bi bi-exclamation-triangle-fill me-2"></i>What happens when you delete your account
</div>
<div class="card-body">
<p class="mb-3">
Deleting your account will permanently deactivate access to
<strong>@companyName</strong> and mark the following data for removal:
</p>
<ul class="list-unstyled mb-3">
<li class="mb-2">
<i class="bi bi-person-x-fill text-danger me-2"></i>
<strong>@userCount user account@(userCount != 1 ? "s" : "")</strong> will be deactivated — no one will be able to log in.
</li>
<li class="mb-2">
<i class="bi bi-briefcase-fill text-danger me-2"></i>
<strong>@jobCount job@(jobCount != 1 ? "s" : "")</strong>,
<strong>@quoteCount quote@(quoteCount != 1 ? "s" : "")</strong>, and
<strong>@invoiceCount invoice@(invoiceCount != 1 ? "s" : "")</strong> will be marked deleted.
</li>
<li class="mb-2">
<i class="bi bi-people-fill text-danger me-2"></i>
<strong>@customerCount customer record@(customerCount != 1 ? "s" : "")</strong> will be marked deleted.
</li>
<li class="mb-2">
<i class="bi bi-cloud-slash-fill text-danger me-2"></i>
All inventory, equipment, maintenance, and other company data will be marked deleted.
</li>
</ul>
<div class="alert alert-warning alert-permanent mb-0">
<i class="bi bi-lock-fill me-2"></i>
<strong>You will lose access to your data immediately.</strong>
Once your account is deleted you will no longer be able to export, download,
or access any of your data. All records are subject to purge and
<strong>cannot be recovered</strong> after deletion.
If you need a data export, please do so <em>before</em> deleting your account.
</div>
</div>
</div>
<!-- Alternatives card -->
<div class="card mb-4">
<div class="card-header fw-semibold">
<i class="bi bi-lightbulb me-2 text-warning"></i>Consider these alternatives first
</div>
<div class="card-body">
<ul class="mb-0">
<li class="mb-1">
<strong>Pause instead of delete</strong> — Contact support to temporarily suspend your account.
</li>
<li class="mb-1">
<strong>Cancel your subscription</strong> — Stop future billing without deleting your data.
</li>
<li>
<strong>Export your data first</strong> — Go to
<a asp-controller="Reports" asp-action="Index">Reports</a>
to export jobs, customers, invoices, and more before proceeding.
</li>
</ul>
</div>
</div>
<!-- Confirmation form -->
<div class="card border-danger">
<div class="card-header bg-danger bg-opacity-10 fw-semibold text-danger">
<i class="bi bi-shield-exclamation me-2"></i>Confirm account deletion
</div>
<div class="card-body">
<form asp-action="DeleteAccount" asp-controller="CompanySettings" method="post" id="deleteAccountForm">
@Html.AntiForgeryToken()
<!-- Acknowledgement checkbox -->
<div class="mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="acknowledged" name="acknowledged" value="true" required />
<label class="form-check-label" for="acknowledged">
I understand that deleting my account is <strong>permanent</strong>.
I will no longer be able to export any data, and all records will be
purged. I accept that this action cannot be undone.
</label>
</div>
</div>
<!-- Type DELETE -->
<div class="mb-4">
<label for="confirmationWord" class="form-label fw-semibold">
To confirm, type <code class="text-danger fs-6">DELETE</code> in the box below:
</label>
<input type="text"
class="form-control form-control-lg"
id="confirmationWord"
name="confirmationWord"
placeholder="Type DELETE here"
autocomplete="off"
spellcheck="false" />
<div class="form-text text-muted">This field is case-sensitive. You must type it in all capitals.</div>
</div>
<!-- Action buttons -->
<div class="d-flex gap-2 flex-wrap">
<button type="submit" class="btn btn-danger px-4" id="deleteBtn" disabled>
<i class="bi bi-trash3 me-1"></i>Delete My Account Permanently
</button>
<a asp-controller="CompanySettings" asp-action="Index" class="btn btn-outline-secondary px-4">
Cancel — Keep My Account
</a>
</div>
</form>
</div>
</div>
</div>
@section Scripts {
<script>
(function () {
const ackCheckbox = document.getElementById('acknowledged');
const confirmInput = document.getElementById('confirmationWord');
const deleteBtn = document.getElementById('deleteBtn');
/// Re-evaluates whether the submit button should be enabled.
/// Both the checkbox AND the exact word "DELETE" must be present.
function updateButton() {
const wordOk = confirmInput.value.trim() === 'DELETE';
const ackOk = ackCheckbox.checked;
deleteBtn.disabled = !(wordOk && ackOk);
}
confirmInput.addEventListener('input', updateButton);
ackCheckbox.addEventListener('change', updateButton);
// Extra guard: prevent accidental double-submit after the form is submitted.
document.getElementById('deleteAccountForm').addEventListener('submit', function () {
deleteBtn.disabled = true;
deleteBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Deleting…';
});
})();
</script>
}
@@ -0,0 +1,206 @@
@model PowderCoating.Application.DTOs.Notification.NotificationTemplateDto
@{
ViewData["Title"] = $"Edit Template — {Model.DisplayName}";
ViewData["PageIcon"] = "bi-envelope-gear";
var placeholders = ViewBag.Placeholders as List<(string Placeholder, string Description)>
?? new List<(string, string)>();
var isEmail = Model.IsEmail;
}
@section Styles {
<style>
.placeholder-pill {
cursor: pointer;
transition: background-color 0.15s;
font-family: monospace;
font-size: 0.85rem;
}
.placeholder-pill:hover { background-color: #0d6efd !important; color: white !important; }
.copy-feedback { display: none; font-size: 0.75rem; color: #198754; }
</style>
}
<div class="container-fluid">
<div class="d-flex justify-content-start mb-4">
<a asp-controller="CompanySettings" asp-action="NotificationTemplates" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i>
</a>
</div>
<div class="row g-4">
<!-- LEFT: Edit form -->
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center gap-2">
@if (isEmail)
{
<span class="badge bg-primary"><i class="bi bi-envelope"></i> Email</span>
}
else
{
<span class="badge bg-success"><i class="bi bi-phone"></i> SMS</span>
}
<span class="fw-semibold">@Model.NotificationType</span>
</div>
<div class="card-body">
<form asp-action="EditTemplate" asp-route-id="@Model.Id" method="post" id="templateForm">
@Html.AntiForgeryToken()
<input type="hidden" asp-for="Id" />
@if (isEmail)
{
<div class="mb-3">
<label asp-for="Subject" class="form-label fw-semibold">Subject</label>
<input asp-for="Subject" class="form-control" placeholder="Email subject line" />
<span asp-validation-for="Subject" class="text-danger small"></span>
</div>
}
<div class="mb-3">
<label class="form-label fw-semibold">
Body
@if (!isEmail)
{
<span class="text-muted fw-normal small ms-2">
<span id="charCount">@Model.Body.Length</span> characters
(<span id="segCount">@((Model.Body.Length / 160) + 1)</span> SMS segment@(((Model.Body.Length / 160) + 1) != 1 ? "s" : ""))
</span>
}
</label>
@if (isEmail)
{
<!-- Raw HTML textarea for email — supports {{placeholders}} and full HTML -->
<textarea asp-for="Body" class="form-control font-monospace" rows="16"
placeholder="Enter HTML email body..."></textarea>
<div class="form-text text-muted mt-1">
<i class="bi bi-code-slash me-1"></i>HTML is supported. Use <code>{{placeholder}}</code> tokens anywhere in the body.
</div>
}
else
{
<!-- Plain textarea for SMS -->
<textarea asp-for="Body" class="form-control" rows="5"
id="smsBody" maxlength="1000"
placeholder="Enter your SMS message text..."></textarea>
<span asp-validation-for="Body" class="text-danger small"></span>
<div class="form-text text-muted">
<i class="bi bi-info-circle"></i>
Standard SMS segments are 160 characters. Messages over 160 chars are split into multiple segments.
Always include <code>Reply STOP to opt out</code> for CTIA compliance.
</div>
}
</div>
<div class="d-flex justify-content-between align-items-center pt-2">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-floppy"></i> Save Template
</button>
<a asp-controller="CompanySettings" asp-action="NotificationTemplates"
class="btn btn-outline-secondary">
Cancel
</a>
</div>
<form asp-action="ResetTemplate" asp-route-id="@Model.Id" method="post"
onsubmit="return confirm('Reset this template to the built-in default? Your customisations will be lost.');">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-arrow-counterclockwise"></i> Reset to Default
</button>
</form>
</div>
</form>
</div>
</div>
</div>
<!-- RIGHT: Placeholder reference -->
<div class="col-lg-4">
<div class="card shadow-sm">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-braces"></i> Available Placeholders</h6>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Click any placeholder to copy it to your clipboard, then paste it into the template body.
</p>
<div class="d-flex flex-column gap-2">
@foreach (var (placeholder, description) in placeholders)
{
<div>
<span class="badge bg-light text-dark border placeholder-pill px-2 py-1"
onclick="copyPlaceholder('@placeholder', this)"
title="@description — click to copy">
@placeholder
</span>
<span class="copy-feedback ms-1">Copied!</span>
<div class="text-muted" style="font-size: 0.78rem;">@description</div>
</div>
}
</div>
</div>
</div>
<div class="card shadow-sm mt-3">
<div class="card-body">
<h6 class="card-title"><i class="bi bi-lightbulb"></i> Tips</h6>
<ul class="small text-muted mb-0 ps-3">
<li>Placeholders are case-insensitive.</li>
<li>Unrecognised placeholders are left as-is.</li>
@if (isEmail)
{
<li>Edit raw HTML directly. A plain-text version is generated automatically for email clients that require it.</li>
<li>An unsubscribe link is always appended to comply with CAN-SPAM.</li>
}
else
{
<li>Keep messages under 160 characters to avoid splitting.</li>
<li>Always include opt-out instructions (e.g. "Reply STOP") to comply with CTIA guidelines.</li>
}
</ul>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@if (!isEmail)
{
<script>
const smsBody = document.getElementById('smsBody');
const charCount = document.getElementById('charCount');
const segCount = document.getElementById('segCount');
smsBody?.addEventListener('input', function () {
const len = this.value.length;
charCount.textContent = len;
const segs = Math.ceil(len / 160) || 1;
segCount.textContent = segs;
const suffixEl = segCount.nextSibling;
if (suffixEl) suffixEl.textContent = ` SMS segment${segs !== 1 ? 's' : ''}`;
});
</script>
}
<script>
function copyPlaceholder(text, el) {
navigator.clipboard.writeText(text).then(function () {
const feedback = el.nextElementSibling;
if (feedback) {
feedback.style.display = 'inline';
setTimeout(() => { feedback.style.display = 'none'; }, 1800);
}
}).catch(function () {
// Fallback for browsers that don't support clipboard API
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
});
}
</script>
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,75 @@
@model List<PowderCoating.Application.DTOs.Notification.NotificationTemplateDto>
@{
ViewData["Title"] = "Notification Templates";
ViewData["PageIcon"] = "bi-envelope-gear";
}
<div class="container-fluid">
@if (TempData["SuccessMessage"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle me-2"></i>@TempData["SuccessMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<div class="d-flex justify-content-end mb-4">
<a asp-controller="CompanySettings" asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Settings
</a>
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-3">Template</th>
<th>Channel</th>
<th>Last Modified</th>
<th class="text-end pe-3" style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var template in Model.Where(t => t.IsEmail || ViewBag.SmsEnabled == true))
{
<tr>
<td class="ps-3 fw-semibold">@template.DisplayName</td>
<td>
@if (template.IsEmail)
{
<span class="badge bg-primary">
<i class="bi bi-envelope"></i> Email
</span>
}
else
{
<span class="badge bg-success">
<i class="bi bi-phone"></i> SMS
</span>
}
</td>
<td class="text-muted small">
@(template.UpdatedAt?.ToString("MMM d, yyyy h:mm tt") ?? "Using defaults")
</td>
<td class="text-end pe-3">
<a asp-controller="CompanySettings"
asp-action="EditTemplate"
asp-route-id="@template.Id"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i> Edit
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<div class="mt-3 text-muted small">
<i class="bi bi-info-circle"></i>
Templates support <strong>&#123;&#123;placeholder&#125;&#125;</strong> tokens that are replaced
with live data when notifications are sent. Click <strong>Edit</strong> to customise any template.
</div>
</div>
@@ -0,0 +1,500 @@
@* Lookup Management Modals *@
<!-- Job Status Modal -->
<div class="modal fade" id="jobStatusModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-list-check me-2"></i><span id="jobStatusModalTitle">Add Job Status</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="jobStatusForm">
<input type="hidden" id="jobStatusId" value="">
<div class="mb-3">
<label for="jobStatusDisplayName" class="form-label">Display Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="jobStatusDisplayName" placeholder="e.g., Custom Status" required maxlength="100">
</div>
<div class="mb-3">
<label for="jobStatusCode" class="form-label">Status Code <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="jobStatusCode" placeholder="e.g., CUSTOM_STATUS" required pattern="[A-Z_]+" maxlength="50">
<small class="form-text text-muted">Uppercase letters and underscores only. This cannot be changed later.</small>
</div>
<div class="mb-3">
<label for="jobStatusColorClass" class="form-label">Badge Color <span class="text-danger">*</span></label>
<select class="form-select" id="jobStatusColorClass" required>
<option value="primary">Primary (Blue)</option>
<option value="secondary">Secondary (Gray)</option>
<option value="success">Success (Green)</option>
<option value="danger">Danger (Red)</option>
<option value="warning">Warning (Yellow)</option>
<option value="info">Info (Cyan)</option>
<option value="dark">Dark</option>
</select>
</div>
<div class="mb-3">
<label for="jobStatusCategory" class="form-label">Workflow Category</label>
<select class="form-select" id="jobStatusCategory">
<option value="">Select a category</option>
<option value="Pre-Production">Pre-Production</option>
<option value="Production">Production</option>
<option value="Post-Production">Post-Production</option>
<option value="Other">Other</option>
</select>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="jobStatusIsTerminal">
<label class="form-check-label" for="jobStatusIsTerminal">
Terminal Status
<small class="d-block text-muted">Job is completed/closed</small>
</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="jobStatusIsWIP">
<label class="form-check-label" for="jobStatusIsWIP">
Work In Progress
<small class="d-block text-muted">Job is actively being worked on</small>
</label>
</div>
</div>
</div>
<div class="mb-3">
<label for="jobStatusDescription" class="form-label">Description</label>
<textarea class="form-control" id="jobStatusDescription" rows="2" placeholder="Optional description..." maxlength="500"></textarea>
</div>
</form>
</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="saveJobStatusBtn">
<i class="bi bi-check-circle me-1"></i>Save
</button>
</div>
</div>
</div>
</div>
<!-- Job Priority Modal -->
<div class="modal fade" id="jobPriorityModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-arrow-up-circle me-2"></i><span id="jobPriorityModalTitle">Add Job Priority</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="jobPriorityForm">
<input type="hidden" id="jobPriorityId" value="">
<div class="mb-3">
<label for="jobPriorityDisplayName" class="form-label">Display Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="jobPriorityDisplayName" placeholder="e.g., Custom Priority" required maxlength="100">
</div>
<div class="mb-3">
<label for="jobPriorityCode" class="form-label">Priority Code <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="jobPriorityCode" placeholder="e.g., CUSTOM_PRIORITY" required pattern="[A-Z_]+" maxlength="50">
<small class="form-text text-muted">Uppercase letters and underscores only. This cannot be changed later.</small>
</div>
<div class="mb-3">
<label for="jobPriorityColorClass" class="form-label">Badge Color <span class="text-danger">*</span></label>
<select class="form-select" id="jobPriorityColorClass" required>
<option value="primary">Primary (Blue)</option>
<option value="secondary">Secondary (Gray)</option>
<option value="success">Success (Green)</option>
<option value="danger">Danger (Red)</option>
<option value="warning">Warning (Yellow)</option>
<option value="info">Info (Cyan)</option>
<option value="dark">Dark</option>
</select>
</div>
<div class="mb-3">
<label for="jobPriorityDescription" class="form-label">Description</label>
<textarea class="form-control" id="jobPriorityDescription" rows="2" placeholder="Optional description..." maxlength="500"></textarea>
</div>
</form>
</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="saveJobPriorityBtn">
<i class="bi bi-check-circle me-1"></i>Save
</button>
</div>
</div>
</div>
</div>
<!-- Quote Status Modal -->
<div class="modal fade" id="quoteStatusModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-file-text me-2"></i><span id="quoteStatusModalTitle">Add Quote Status</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="quoteStatusForm">
<input type="hidden" id="quoteStatusId" value="">
<div class="mb-3">
<label for="quoteStatusDisplayName" class="form-label">Display Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="quoteStatusDisplayName" placeholder="e.g., Custom Status" required maxlength="100">
</div>
<div class="mb-3">
<label for="quoteStatusCode" class="form-label">Status Code <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="quoteStatusCode" placeholder="e.g., CUSTOM_STATUS" required pattern="[A-Z_]+" maxlength="50">
<small class="form-text text-muted">Uppercase letters and underscores only. This cannot be changed later.</small>
</div>
<div class="mb-3">
<label for="quoteStatusColorClass" class="form-label">Badge Color <span class="text-danger">*</span></label>
<select class="form-select" id="quoteStatusColorClass" required>
<option value="primary">Primary (Blue)</option>
<option value="secondary">Secondary (Gray)</option>
<option value="success">Success (Green)</option>
<option value="danger">Danger (Red)</option>
<option value="warning">Warning (Yellow)</option>
<option value="info">Info (Cyan)</option>
<option value="dark">Dark</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Business Flags</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="quoteStatusIsApproved">
<label class="form-check-label" for="quoteStatusIsApproved">
<strong>Approved Status</strong>
<small class="d-block text-muted">Marks quotes that can be converted to jobs</small>
</label>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="quoteStatusIsConverted">
<label class="form-check-label" for="quoteStatusIsConverted">
<strong>Converted Status</strong>
<small class="d-block text-muted">Set automatically after converting to job</small>
</label>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="quoteStatusIsDraft">
<label class="form-check-label" for="quoteStatusIsDraft">
<strong>Draft Status</strong>
<small class="d-block text-muted">Quote is being prepared</small>
</label>
</div>
</div>
<div class="mb-3">
<label for="quoteStatusDescription" class="form-label">Description</label>
<textarea class="form-control" id="quoteStatusDescription" rows="2" placeholder="Optional description..." maxlength="500"></textarea>
</div>
</form>
</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="saveQuoteStatusBtn">
<i class="bi bi-check-circle me-1"></i>Save
</button>
</div>
</div>
</div>
</div>
<!-- Inventory Category Modal -->
<div class="modal fade" id="inventoryCategoryModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-box-seam me-2"></i><span id="inventoryCategoryModalTitle">Add Inventory Category</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="inventoryCategoryForm">
<input type="hidden" id="inventoryCategoryId" value="">
<div class="mb-3">
<label for="inventoryCategoryDisplayName" class="form-label">Display Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="inventoryCategoryDisplayName" placeholder="e.g., Custom Category" required maxlength="100">
</div>
<div class="mb-3">
<label for="inventoryCategoryCode" class="form-label">Category Code <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="inventoryCategoryCode" placeholder="e.g., CUSTOM_CATEGORY" required pattern="[A-Z_]+" maxlength="50">
<small class="form-text text-muted">Uppercase letters and underscores only. This cannot be changed later.</small>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="inventoryCategoryIsCoating">
<label class="form-check-label" for="inventoryCategoryIsCoating">
<strong>Is Coating</strong>
<small class="d-block text-muted">Items in this category are coatings that can be applied to parts</small>
</label>
</div>
</div>
<div class="mb-3">
<label for="inventoryCategoryDescription" class="form-label">Description</label>
<textarea class="form-control" id="inventoryCategoryDescription" rows="2" placeholder="Optional description of what this category is used for..." maxlength="500"></textarea>
</div>
</form>
</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="saveInventoryCategoryBtn">
<i class="bi bi-check-circle me-1"></i>Save
</button>
</div>
</div>
</div>
</div>
<!-- Appointment Type Modal -->
<div class="modal fade" id="appointmentTypeModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-calendar-event me-2"></i><span id="appointmentTypeModalTitle">Add Appointment Type</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="appointmentTypeForm">
<input type="hidden" id="appointmentTypeId" value="">
<div class="mb-3">
<label for="appointmentTypeDisplayName" class="form-label">Display Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="appointmentTypeDisplayName" placeholder="e.g., Equipment Delivery" required maxlength="100">
</div>
<div class="mb-3">
<label for="appointmentTypeCode" class="form-label">Type Code <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="appointmentTypeCode" placeholder="e.g., DELIVERY" required pattern="[A-Z_]+" maxlength="50">
<small class="form-text text-muted">Uppercase letters and underscores only. This cannot be changed later.</small>
</div>
<div class="mb-3">
<label for="appointmentTypeColorClass" class="form-label">Calendar Color <span class="text-danger">*</span></label>
<div class="d-flex gap-2 align-items-center mb-2">
<span class="badge fs-6 px-3 py-2" id="appointmentTypeColorPreview">
<i class="bi bi-calendar-event me-1"></i>Preview
</span>
<small class="text-muted">Live preview of calendar color</small>
</div>
<select class="form-select" id="appointmentTypeColorClass" required onchange="updateAppointmentTypeColorPreview()">
<option value="purple">🟣 Purple</option>
<option value="green">🟢 Green</option>
<option value="blue">🔵 Blue</option>
<option value="orange">🟠 Orange</option>
<option value="red">🔴 Red</option>
<option value="yellow">🟡 Yellow</option>
<option value="pink">🩷 Pink</option>
<option value="cyan">🔷 Cyan</option>
<option value="teal">🩵 Teal</option>
<option value="indigo">🟣 Indigo</option>
<option value="lime">🟢 Lime</option>
<option value="brown">🟤 Brown</option>
<option value="gray">⚫ Gray</option>
<option value="success">✅ Success (Green)</option>
<option value="danger">❌ Danger (Red)</option>
<option value="warning">⚠️ Warning (Yellow)</option>
<option value="info">️ Info (Blue)</option>
<option value="primary">🔷 Primary (Blue)</option>
<option value="secondary">⚫ Secondary (Gray)</option>
<option value="dark">⬛ Dark</option>
</select>
<small class="form-text text-muted">Choose a color that will appear on the calendar for this appointment type</small>
</div>
<div class="mb-3">
<label for="appointmentTypeIconClass" class="form-label">Icon Class (Optional)</label>
<input type="text" class="form-control" id="appointmentTypeIconClass" placeholder="e.g., bi-truck, bi-box-arrow-down" maxlength="50">
<small class="form-text text-muted">Bootstrap icon class for visual identification</small>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="appointmentTypeRequiresJob">
<label class="form-check-label" for="appointmentTypeRequiresJob">
Require Job Link
<small class="d-block text-muted">This appointment type must be linked to an existing job</small>
</label>
</div>
</div>
<div class="mb-3" id="appointmentTypeActiveField" style="display: none;">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="appointmentTypeIsActive" checked>
<label class="form-check-label" for="appointmentTypeIsActive">
Active
<small class="d-block text-muted">Inactive types won't appear in dropdown menus</small>
</label>
</div>
</div>
<div class="mb-3">
<label for="appointmentTypeDescription" class="form-label">Description</label>
<textarea class="form-control" id="appointmentTypeDescription" rows="2" placeholder="Optional description..." maxlength="500"></textarea>
</div>
</form>
</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="saveAppointmentTypeBtn">
<i class="bi bi-check-circle me-1"></i>Save
</button>
</div>
</div>
</div>
</div>
<!-- Prep Service Modal -->
<div class="modal fade" id="prepServiceModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-tools me-2"></i><span id="prepServiceModalTitle">Add Prep Service</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="prepServiceForm">
<input type="hidden" id="prepServiceId" value="">
<div class="mb-3">
<label for="prepServiceName" class="form-label">Service Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="prepServiceName" placeholder="e.g., Sandblasting, Chemical Stripping" required maxlength="100">
<small class="form-text text-muted">Enter a descriptive name for this preparation service</small>
</div>
<div class="mb-3">
<label for="prepServiceDescription" class="form-label">Description</label>
<textarea class="form-control" id="prepServiceDescription" rows="3" placeholder="Optional description of this service..." maxlength="500"></textarea>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="prepServiceRequiresBlastSetup">
<label class="form-check-label" for="prepServiceRequiresBlastSetup">
<strong>Requires Blast Setup Selection</strong>
<small class="d-block text-muted">When checked, the item wizard shows a blast rig dropdown for this service so the correct throughput rate is used</small>
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="prepServiceIsActive" checked>
<label class="form-check-label" for="prepServiceIsActive">
<strong>Active</strong>
<small class="d-block text-muted">Inactive services won't appear in dropdown menus</small>
</label>
</div>
</div>
</form>
</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="savePrepServiceBtn">
<i class="bi bi-check-circle me-1"></i>Save
</button>
</div>
</div>
</div>
</div>
<!-- Blast Setup Modal -->
<div class="modal fade" id="blastSetupModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-fan me-2"></i><span id="blastSetupModalTitle">Add Blast Setup</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="blastSetupForm">
<input type="hidden" id="blastSetupId" value="">
<div class="row g-3">
<div class="col-12">
<label for="blastSetupName" class="form-label">Setup Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="blastSetupName"
placeholder="e.g., Main Cabinet, Outdoor Blast Pot, Blast Room" required maxlength="100">
</div>
<div class="col-sm-6">
<label for="blastSetupModalType" class="form-label">Setup Type <span class="text-danger">*</span></label>
<select class="form-select" id="blastSetupModalType">
<option value="0">Siphon Cabinet</option>
<option value="1">Siphon Pot</option>
<option value="2" selected>Pressure Pot</option>
<option value="3">Wet Blasting</option>
</select>
</div>
<div class="col-sm-6">
<label for="blastSetupCfm" class="form-label">Compressor CFM <span class="text-danger">*</span></label>
<div class="input-group">
<input type="number" class="form-control blast-modal-input" id="blastSetupCfm"
min="0" max="9999" step="0.5" placeholder="e.g. 40">
<span class="input-group-text">CFM</span>
</div>
</div>
<div class="col-sm-6">
<label for="blastSetupNozzleSize" class="form-label">Nozzle Size</label>
<select class="form-select blast-modal-input" id="blastSetupNozzleSize">
<option value="2">#2 (1/8") — Very small / entry level</option>
<option value="3">#3 (3/16") — Small / hobby</option>
<option value="4">#4 (1/4") — Light duty</option>
<option value="5" selected>#5 (5/16") — Medium (most common)</option>
<option value="6">#6 (3/8") — Heavy duty</option>
<option value="7">#7 (7/16") — High volume</option>
<option value="8">#8 (1/2") — Industrial</option>
</select>
</div>
<div class="col-sm-6">
<label for="blastSetupSubstrate" class="form-label">Primary Substrate</label>
<select class="form-select blast-modal-input" id="blastSetupSubstrate">
<option value="1">Paint / light coating</option>
<option value="0" selected>Mixed (typical)</option>
<option value="2">Rust &amp; scale</option>
<option value="3">Existing powder coat</option>
</select>
</div>
<div class="col-sm-6">
<label for="blastSetupOverride" class="form-label">Rate Override <small class="text-muted">(optional)</small></label>
<div class="input-group">
<input type="number" class="form-control" id="blastSetupOverride"
min="0" max="99999" step="0.1" placeholder="Leave blank to use formula">
<span class="input-group-text">sqft/hr</span>
</div>
<small class="text-muted">Enter your actual measured rate to bypass the formula</small>
</div>
<div class="col-sm-6 d-flex align-items-end">
<div class="w-100 p-3 bg-light rounded text-center">
<div class="text-muted small">Derived Rate</div>
<div class="fw-bold fs-5" id="blastSetupDerivedRate">—</div>
<div class="text-muted small">sqft/hr</div>
</div>
</div>
<div class="col-12">
<div class="d-flex gap-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="blastSetupIsDefault">
<label class="form-check-label" for="blastSetupIsDefault">
<strong>Default</strong>
<small class="d-block text-muted">Pre-selected in AI Photo Quotes</small>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="blastSetupIsActive" checked>
<label class="form-check-label" for="blastSetupIsActive">
<strong>Active</strong>
</label>
</div>
</div>
</div>
</div>
</form>
</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="saveBlastSetupBtn">
<i class="bi bi-check-circle me-1"></i>Save
</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,250 @@
@model PowderCoating.Application.DTOs.User.CreateCompanyUserDto
@{
ViewData["Title"] = "Add New User";
ViewData["PageIcon"] = "bi-person-plus";
ViewData["PageHelpTitle"] = "Add New User";
ViewData["PageHelpContent"] = "Creates a new login account for a member of your company. The email address doubles as the login username. Set a temporary password — the user can change it from their Profile page after their first login. Assign a Role, then fine-tune individual permissions below.";
}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-end mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to List
</a>
</div>
<div class="card shadow-sm">
<div class="card-body">
<form asp-action="Create" method="post">
<partial name="_ValidationSummary" />
<div class="d-flex align-items-center gap-2 mb-3 pb-2 border-bottom">
<h5 class="card-title mb-0">Basic Information</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Basic Information"
data-bs-content="First Name, Last Name, and Email are required. The email is used as the login username — it must be unique across the system. Employee Number is an optional internal reference. The user can update their name and phone from their own Profile page after logging in.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label asp-for="FirstName" class="form-label">First Name *</label>
<input asp-for="FirstName" class="form-control" />
<span asp-validation-for="FirstName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="LastName" class="form-label">Last Name *</label>
<input asp-for="LastName" class="form-control" />
<span asp-validation-for="LastName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Email" class="form-label">Email *</label>
<input asp-for="Email" class="form-control" type="email" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Password" class="form-label">Password *</label>
<input asp-for="Password" class="form-control" type="password" />
<span asp-validation-for="Password" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="EmployeeNumber" class="form-label">Employee Number</label>
<input asp-for="EmployeeNumber" class="form-control" />
<span asp-validation-for="EmployeeNumber" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Phone" class="form-label">Phone</label>
<input asp-for="Phone" class="form-control" />
<span asp-validation-for="Phone" class="text-danger"></span>
</div>
</div>
<div class="d-flex align-items-center gap-2 mb-3 pb-2 border-bottom">
<h5 class="card-title mb-0">Role &amp; Department</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Role &amp; Department"
data-bs-content="Role controls default access level: Viewer = read-only, Worker = day-to-day tasks, Manager = broader access, Company Admin = full access to everything. Department and Position are informational and appear on the user's profile. Hire Date is used for record-keeping.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3 mb-4">
<div class="col-md-4">
<label asp-for="CompanyRole" class="form-label">Role *</label>
<select asp-for="CompanyRole" class="form-select">
<option value="Viewer">Viewer (Read-only)</option>
<option value="Worker">Worker</option>
<option value="Manager">Manager</option>
<option value="CompanyAdmin">Company Admin</option>
</select>
<span asp-validation-for="CompanyRole" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="Department" class="form-label">Department</label>
<input asp-for="Department" class="form-control" />
<span asp-validation-for="Department" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="Position" class="form-label">Position</label>
<input asp-for="Position" class="form-control" />
<span asp-validation-for="Position" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="HireDate" class="form-label">Hire Date</label>
<input asp-for="HireDate" class="form-control" type="date" />
<span asp-validation-for="HireDate" class="text-danger"></span>
</div>
</div>
<div class="d-flex align-items-center gap-2 mb-3 pb-2 border-bottom">
<h5 class="card-title mb-0">Permissions</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Permissions"
data-bs-content="Fine-grained permissions let you grant specific capabilities beyond what the Role provides. Company Admin automatically receives all permissions and the checkboxes will be locked. For other roles, check only what this user needs. Permissions can be updated at any time from the Edit page.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div id="companyAdminAlert" class="alert alert-info" style="display: none;">
<i class="bi bi-info-circle me-2"></i>
<strong>Company Admins automatically have all permissions.</strong> These checkboxes are disabled because Company Admins always have full access to all features.
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageJobs" class="form-check-input permission-checkbox" />
<label asp-for="CanManageJobs" class="form-check-label">Can Manage Jobs</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageInventory" class="form-check-input permission-checkbox" />
<label asp-for="CanManageInventory" class="form-check-label">Can Manage Inventory</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageCustomers" class="form-check-input permission-checkbox" />
<label asp-for="CanManageCustomers" class="form-check-label">Can Manage Customers</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanCreateQuotes" class="form-check-input permission-checkbox" />
<label asp-for="CanCreateQuotes" class="form-check-label">Can Create Quotes</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanApproveQuotes" class="form-check-input permission-checkbox" />
<label asp-for="CanApproveQuotes" class="form-check-label">Can Approve Quotes</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageCalendar" class="form-check-input permission-checkbox" />
<label asp-for="CanManageCalendar" class="form-check-label">Can Manage Calendar</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanViewCalendar" class="form-check-input permission-checkbox" />
<label asp-for="CanViewCalendar" class="form-check-label">Can View Calendar</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageProducts" class="form-check-input permission-checkbox" />
<label asp-for="CanManageProducts" class="form-check-label">Can Manage Products</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanViewProducts" class="form-check-input permission-checkbox" />
<label asp-for="CanViewProducts" class="form-check-label">Can View Products</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageEquipment" class="form-check-input permission-checkbox" />
<label asp-for="CanManageEquipment" class="form-check-label">Can Manage Equipment</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageVendors" class="form-check-input permission-checkbox" />
<label asp-for="CanManageVendors" class="form-check-label">Can Manage Vendors</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageMaintenance" class="form-check-input permission-checkbox" />
<label asp-for="CanManageMaintenance" class="form-check-label">Can Manage Maintenance</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageInvoices" class="form-check-input permission-checkbox" />
<label asp-for="CanManageInvoices" class="form-check-label">Can Manage Invoices</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanViewReports" class="form-check-input permission-checkbox" />
<label asp-for="CanViewReports" class="form-check-label">Can View Reports</label>
</div>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>Create User
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{
await Html.RenderPartialAsync("_ValidationScriptsPartial");
}
<script>
document.addEventListener('DOMContentLoaded', function() {
const roleSelect = document.getElementById('CompanyRole');
const permissionCheckboxes = document.querySelectorAll('.permission-checkbox');
const adminAlert = document.getElementById('companyAdminAlert');
function updatePermissionState() {
const isCompanyAdmin = roleSelect.value === 'CompanyAdmin';
// Show/hide alert
adminAlert.style.display = isCompanyAdmin ? 'block' : 'none';
// Check all and disable if Company Admin, otherwise enable
permissionCheckboxes.forEach(checkbox => {
if (isCompanyAdmin) {
checkbox.checked = true;
checkbox.disabled = true;
} else {
checkbox.disabled = false;
}
});
}
// Run on page load
updatePermissionState();
// Run when role changes
roleSelect.addEventListener('change', updatePermissionState);
});
</script>
}
@@ -0,0 +1,283 @@
@model PowderCoating.Application.DTOs.User.UpdateCompanyUserDto
@{
ViewData["Title"] = "Edit User";
ViewData["PageIcon"] = "bi-person-gear";
ViewData["PageHelpTitle"] = "Edit User";
ViewData["PageHelpContent"] = "Update this user&apos;s account details, role, and permissions. Unchecking User Active prevents the user from logging in without deleting their account or history. Changing the email here also changes their login username — notify them so they can log in with the new address.";
}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-end mb-4">
@if (!string.IsNullOrEmpty(ViewBag.ReturnUrl))
{
<a href="@ViewBag.ReturnUrl" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
}
else
{
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to List
</a>
}
</div>
<div class="card shadow-sm">
<div class="card-body">
<form asp-action="Edit" method="post">
<input type="hidden" asp-for="Id" />
<partial name="_ValidationSummary" />
<div class="d-flex align-items-center gap-2 mb-3 pb-2 border-bottom">
<h5 class="card-title mb-0">Basic Information</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Basic Information"
data-bs-content="Email is this user's login username — changing it here means they must use the new address to log in. User Active controls whether the account can sign in; deactivating preserves all data without deleting the account. To reset a password, the user can use Forgot Password on the login page, or a SuperAdmin can set one directly.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label asp-for="FirstName" class="form-label">First Name *</label>
<input asp-for="FirstName" class="form-control" />
<span asp-validation-for="FirstName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="LastName" class="form-label">Last Name *</label>
<input asp-for="LastName" class="form-control" />
<span asp-validation-for="LastName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Email" class="form-label">Email *</label>
<input asp-for="Email" class="form-control" type="email" />
<span asp-validation-for="Email" class="text-danger"></span>
<small class="form-text text-muted">This is also the user's login address.</small>
</div>
<div class="col-md-6">
<label asp-for="EmployeeNumber" class="form-label">Employee Number</label>
<input asp-for="EmployeeNumber" class="form-control" />
<span asp-validation-for="EmployeeNumber" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Phone" class="form-label">Phone</label>
<input asp-for="Phone" class="form-control" />
<span asp-validation-for="Phone" class="text-danger"></span>
</div>
<div class="col-md-6">
<div class="form-check form-switch mt-4">
<input asp-for="IsActive" class="form-check-input" />
<label asp-for="IsActive" class="form-check-label">User Active</label>
</div>
</div>
</div>
<div class="d-flex align-items-center gap-2 mb-3 pb-2 border-bottom">
<h5 class="card-title mb-0">Role &amp; Department</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Role &amp; Department"
data-bs-content="Changing the Role updates the user's base access level immediately on save. Termination Date is informational — to actually prevent login, also uncheck User Active above. Department and Position appear on the user's profile card and in the Manage Users list.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3 mb-4">
<div class="col-md-4">
<label asp-for="CompanyRole" class="form-label">Role *</label>
<select asp-for="CompanyRole" class="form-select">
<option value="Viewer">Viewer (Read-only)</option>
<option value="Worker">Worker</option>
<option value="Manager">Manager</option>
<option value="CompanyAdmin">Company Admin</option>
</select>
<span asp-validation-for="CompanyRole" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="Department" class="form-label">Department</label>
<input asp-for="Department" class="form-control" />
<span asp-validation-for="Department" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="Position" class="form-label">Position</label>
<input asp-for="Position" class="form-control" />
<span asp-validation-for="Position" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="HireDate" class="form-label">Hire Date</label>
<input asp-for="HireDate" class="form-control" type="date" />
<span asp-validation-for="HireDate" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="TerminationDate" class="form-label">Termination Date</label>
<input asp-for="TerminationDate" class="form-control" type="date" />
<span asp-validation-for="TerminationDate" class="text-danger"></span>
</div>
</div>
<div class="d-flex align-items-center gap-2 mb-3 pb-2 border-bottom">
<h5 class="card-title mb-0">Permissions</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Permissions"
data-bs-content="Fine-grained permissions let you grant specific capabilities beyond what the Role provides. Company Admin automatically receives all permissions and the checkboxes will be locked. SuperAdmins can override individual permissions regardless of role. Changes take effect immediately on save.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div id="companyAdminAlert" class="alert alert-info" style="display: none;">
<i class="bi bi-info-circle me-2"></i>
<strong>Company Admins automatically have all permissions.</strong> These checkboxes are disabled because Company Admins always have full access to all features.
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageJobs" class="form-check-input permission-checkbox" />
<label asp-for="CanManageJobs" class="form-check-label">Can Manage Jobs</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageInventory" class="form-check-input permission-checkbox" />
<label asp-for="CanManageInventory" class="form-check-label">Can Manage Inventory</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageCustomers" class="form-check-input permission-checkbox" />
<label asp-for="CanManageCustomers" class="form-check-label">Can Manage Customers</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanCreateQuotes" class="form-check-input permission-checkbox" />
<label asp-for="CanCreateQuotes" class="form-check-label">Can Create Quotes</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanApproveQuotes" class="form-check-input permission-checkbox" />
<label asp-for="CanApproveQuotes" class="form-check-label">Can Approve Quotes</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageCalendar" class="form-check-input permission-checkbox" />
<label asp-for="CanManageCalendar" class="form-check-label">Can Manage Calendar</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanViewCalendar" class="form-check-input permission-checkbox" />
<label asp-for="CanViewCalendar" class="form-check-label">Can View Calendar</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageProducts" class="form-check-input permission-checkbox" />
<label asp-for="CanManageProducts" class="form-check-label">Can Manage Products</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanViewProducts" class="form-check-input permission-checkbox" />
<label asp-for="CanViewProducts" class="form-check-label">Can View Products</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageEquipment" class="form-check-input permission-checkbox" />
<label asp-for="CanManageEquipment" class="form-check-label">Can Manage Equipment</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageVendors" class="form-check-input permission-checkbox" />
<label asp-for="CanManageVendors" class="form-check-label">Can Manage Vendors</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageMaintenance" class="form-check-input permission-checkbox" />
<label asp-for="CanManageMaintenance" class="form-check-label">Can Manage Maintenance</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageInvoices" class="form-check-input permission-checkbox" />
<label asp-for="CanManageInvoices" class="form-check-label">Can Manage Invoices</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanViewReports" class="form-check-input permission-checkbox" />
<label asp-for="CanViewReports" class="form-check-label">Can View Reports</label>
</div>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
@if (!string.IsNullOrEmpty(ViewBag.ReturnUrl))
{
<input type="hidden" name="returnUrl" value="@ViewBag.ReturnUrl" />
<a href="@ViewBag.ReturnUrl" class="btn btn-secondary">Cancel</a>
}
else
{
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
}
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{
await Html.RenderPartialAsync("_ValidationScriptsPartial");
}
<script>
document.addEventListener('DOMContentLoaded', function() {
const roleSelect = document.getElementById('CompanyRole');
const permissionCheckboxes = document.querySelectorAll('.permission-checkbox');
const adminAlert = document.getElementById('companyAdminAlert');
const isSuperAdmin = @((ViewBag.IsSuperAdmin as bool? ?? false) ? "true" : "false");
function updatePermissionState() {
const isCompanyAdmin = roleSelect.value === 'CompanyAdmin';
if (isSuperAdmin) {
// SuperAdmins can always edit individual permissions
adminAlert.style.display = 'none';
permissionCheckboxes.forEach(checkbox => { checkbox.disabled = false; });
return;
}
// Show/hide alert
adminAlert.style.display = isCompanyAdmin ? 'block' : 'none';
// Check all and disable if Company Admin, otherwise enable
permissionCheckboxes.forEach(checkbox => {
if (isCompanyAdmin) {
checkbox.checked = true;
checkbox.disabled = true;
} else {
checkbox.disabled = false;
}
});
}
// Run on page load
updatePermissionState();
// Run when role changes
roleSelect.addEventListener('change', updatePermissionState);
});
</script>
}
@@ -0,0 +1,278 @@
@model PagedResult<PowderCoating.Application.DTOs.User.CompanyUserListDto>
@{
ViewData["Title"] = "Manage Users";
ViewData["PageIcon"] = "bi-people";
ViewData["PageHelpTitle"] = "Manage Users";
ViewData["PageHelpContent"] = "Add and manage user accounts for your company. Each user has a Role (Viewer, Worker, Manager, Company Admin) and individual permissions that control what they can access. Inactive users cannot log in but their history is preserved. Click a row to edit a user, or use the pause/play button to quickly toggle their active status.";
}
<div class="container-fluid">
<div class="d-flex justify-content-end mb-4">
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-person-plus me-1"></i>Add New User
</a>
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
@if (Model != null && Model.Items.Any())
{
<!-- Desktop Table View -->
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th sortable="Name" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Name</th>
<th sortable="Email" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Email</th>
<th sortable="CompanyRole" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Role</th>
<th sortable="Department" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Department</th>
<th>Hire Date</th>
<th sortable="LastLoginDate" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Last Login</th>
<th sortable="IsActive" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var user in Model.Items)
{
<tr style="cursor:pointer" onclick="window.location='@Url.Action("Edit", new { id = user.Id })'">
<td>
<strong>@user.FullName</strong>
</td>
<td>@user.Email</td>
<td>
@if (!string.IsNullOrEmpty(user.CompanyRole))
{
<span class="badge @(user.CompanyRole switch
{
"CompanyAdmin" => "bg-danger",
"Manager" => "bg-primary",
"Worker" => "bg-info",
_ => "bg-secondary"
})">@user.CompanyRole</span>
}
</td>
<td>@(user.Department ?? "N/A")</td>
<td>
<small class="text-muted">@user.HireDate.ToString("MMM d, yyyy")</small>
</td>
<td>
@if (user.LastLoginDate.HasValue)
{
<small class="text-muted">@user.LastLoginDate.Value.ToString("MMM d, yyyy")</small>
}
else
{
<small class="text-muted">Never</small>
}
</td>
<td>
@if (user.IsBanned)
{
<span class="badge bg-danger"><i class="bi bi-slash-circle"></i> Banned</span>
}
else if (user.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-secondary">Inactive</span>
}
</td>
<td class="text-end" onclick="event.stopPropagation()">
<div class="btn-group btn-group-sm" role="group">
<a asp-action="Edit" asp-route-id="@user.Id"
class="btn btn-outline-primary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<form asp-action="ToggleActive" asp-route-id="@user.Id"
method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit"
class="btn @(user.IsActive ? "btn-outline-warning" : "btn-outline-success")"
title="@(user.IsActive ? "Deactivate" : "Activate")">
<i class="bi bi-@(user.IsActive ? "pause" : "play")"></i>
</button>
</form>
@if (!user.IsBanned)
{
<button type="button"
class="btn btn-outline-danger"
title="Ban User"
onclick="openBanModal('@user.Id', '@Html.Encode(user.FullName)')">
<i class="bi bi-slash-circle"></i>
</button>
}
else
{
<form asp-action="UnbanUser" asp-route-id="@user.Id" method="post" class="d-inline"
onsubmit="return confirm('Lift the ban on @Html.Encode(user.FullName)?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-success" title="Unban User">
<i class="bi bi-check-circle"></i>
</button>
</form>
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var user in Model.Items)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: @(user.CompanyRole switch
{
"CompanyAdmin" => "linear-gradient(135deg, #eb3349 0%, #f45c43 100%)",
"Manager" => "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
"Worker" => "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)",
_ => "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
});">
<i class="bi bi-person-circle"></i>
</div>
<div class="mobile-card-title">
<h6>@user.FullName</h6>
<small>
@if (!string.IsNullOrEmpty(user.CompanyRole))
{
<span class="badge @(user.CompanyRole switch
{
"CompanyAdmin" => "bg-danger",
"Manager" => "bg-primary",
"Worker" => "bg-info",
_ => "bg-secondary"
})">@user.CompanyRole</span>
}
</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Email</span>
<span class="mobile-card-value">@user.Email</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Department</span>
<span class="mobile-card-value">@(user.Department ?? "N/A")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Hire Date</span>
<span class="mobile-card-value text-muted">@user.HireDate.ToString("MMM d, yyyy")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Last Login</span>
<span class="mobile-card-value text-muted">
@if (user.LastLoginDate.HasValue)
{
@user.LastLoginDate.Value.ToString("MMM d, yyyy")
}
else
{
<span>Never</span>
}
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@if (user.IsBanned)
{
<span class="badge bg-danger"><i class="bi bi-slash-circle"></i> Banned</span>
}
else if (user.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-secondary">Inactive</span>
}
</span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-action="Edit" asp-route-id="@user.Id" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil me-1"></i>Edit
</a>
<form asp-action="ToggleActive" asp-route-id="@user.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm @(user.IsActive ? "btn-outline-warning" : "btn-outline-success")">
<i class="bi bi-@(user.IsActive ? "pause" : "play") me-1"></i>@(user.IsActive ? "Deactivate" : "Activate")
</button>
</form>
</div>
</div>
}
</div>
</div>
}
else
{
<div class="text-center py-5">
<i class="bi bi-people" style="font-size: 3rem; color: #ccc;"></i>
<p class="text-muted mt-3">No users found.</p>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-person-plus me-1"></i>Add Your First User
</a>
</div>
}
</div>
@if (Model != null && Model.TotalCount > 0)
{
<div class="card-footer bg-white">
@await Html.PartialAsync("_Pagination", Model)
</div>
}
</div>
</div>
<!-- Ban User Modal -->
<div class="modal fade" id="banModal" tabindex="-1" aria-labelledby="banModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="banModalLabel"><i class="bi bi-slash-circle"></i> Ban User</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form asp-action="BanUser" method="post" id="banForm">
@Html.AntiForgeryToken()
<input type="hidden" name="id" id="banUserId" />
<div class="modal-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
Banning <strong id="banUserName"></strong> will immediately prevent them from logging in.
</div>
<div class="mb-3">
<label for="banReason" class="form-label">Reason <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="banReason" name="reason"
placeholder="e.g. Unauthorized access attempt" required maxlength="500" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger"><i class="bi bi-slash-circle"></i> Ban User</button>
</div>
</form>
</div>
</div>
</div>
@section Scripts {
<script>
function openBanModal(userId, userName) {
document.getElementById('banUserId').value = userId;
document.getElementById('banUserName').textContent = userName;
document.getElementById('banReason').value = '';
new bootstrap.Modal(document.getElementById('banModal')).show();
}
</script>
}
@@ -0,0 +1,175 @@
@using PowderCoating.Web.Controllers
@model ContactViewModel
@{
ViewData["Title"] = "Contact Us";
ViewData["PageIcon"] = "bi-envelope";
}
<div class="d-flex align-items-center gap-2 mb-4">
<h1 class="h3 mb-0"><i class="bi bi-envelope text-primary me-2"></i>Contact Us</h1>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent alert-dismissible fade show mb-4" role="alert">
<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-permanent alert-dismissible fade show mb-4" role="alert">
<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: Resources ── *@
<div class="col-lg-4">
<h2 class="h6 fw-semibold text-muted text-uppercase mb-3" style="letter-spacing:.05em;font-size:.7rem;">
Self-Service Resources
</h2>
@* Help Center *@
<a asp-controller="Help" asp-action="Index" class="card border-0 shadow-sm mb-3 text-decoration-none text-reset d-block">
<div class="card-body d-flex align-items-center gap-3 py-3">
<div class="rounded-3 bg-primary bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-book-half text-primary fs-4"></i>
</div>
<div>
<div class="fw-semibold">Help Center</div>
<div class="text-muted small">Step-by-step articles for every part of the system.</div>
</div>
<i class="bi bi-arrow-right ms-auto text-muted"></i>
</div>
</a>
@* AI Assistant *@
<div class="card border-0 shadow-sm mb-3">
<div class="card-body d-flex align-items-center gap-3 py-3">
<div class="rounded-3 bg-success bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-robot text-success fs-4"></i>
</div>
<div class="flex-grow-1">
<div class="fw-semibold">AI Assistant</div>
<div class="text-muted small">Ask anything — available 24/7 in the bottom-right corner.</div>
</div>
<button type="button" class="btn btn-sm btn-outline-success flex-shrink-0"
onclick="document.getElementById('aiHelpTrigger')?.click()">
Ask Now
</button>
</div>
</div>
@* Facebook Community *@
<a href="https://www.facebook.com/share/g/19CtDWf61N/" target="_blank" rel="noopener noreferrer"
class="card border-0 shadow-sm mb-3 text-decoration-none text-reset d-block">
<div class="card-body d-flex align-items-center gap-3 py-3">
<div class="rounded-3 p-2 flex-shrink-0" style="background:rgba(24,119,242,.1);">
<i class="bi bi-facebook fs-4" style="color:#1877F2;"></i>
</div>
<div>
<div class="fw-semibold">Community Group</div>
<div class="text-muted small">Connect with other users, share tips, and get peer support.</div>
</div>
<i class="bi bi-box-arrow-up-right ms-auto text-muted small"></i>
</div>
</a>
<div class="card border-0 bg-body-secondary rounded-3 p-3 mt-2">
<p class="small text-muted mb-1">
<i class="bi bi-clock me-1"></i><strong>Response time:</strong> We typically reply within 1 business day.
</p>
<p class="small text-muted mb-0">
<i class="bi bi-envelope me-1"></i>You can also email us directly at
<a href="mailto:support@powdercoatinglogix.com">support@powdercoatinglogix.com</a>.
</p>
</div>
</div>
@* ── Right column: Contact form ── *@
<div class="col-lg-8">
<h2 class="h6 fw-semibold text-muted text-uppercase mb-3" style="letter-spacing:.05em;font-size:.7rem;">
Send Us a Message
</h2>
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form asp-action="Submit" method="post">
@Html.AntiForgeryToken()
<div class="row g-3 mb-3">
<div class="col-md-6">
<label asp-for="Form.Name" class="form-label fw-semibold"></label>
<input asp-for="Form.Name" class="form-control" />
<span asp-validation-for="Form.Name" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Form.Email" class="form-label fw-semibold"></label>
<input asp-for="Form.Email" class="form-control" type="email" />
<span asp-validation-for="Form.Email" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Form.CompanyName" class="form-label fw-semibold"></label>
<input asp-for="Form.CompanyName" class="form-control" />
<span asp-validation-for="Form.CompanyName" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Form.Category" class="form-label fw-semibold"></label>
<select asp-for="Form.Category" class="form-select">
<option value="">— Select a Category —</option>
@foreach (var cat in ContactFormModel.Categories)
{
<option value="@cat" selected="@(Model.Form.Category == cat)">@cat</option>
}
</select>
<span asp-validation-for="Form.Category" class="text-danger small"></span>
</div>
<div class="col-12">
<label asp-for="Form.Subject" class="form-label fw-semibold"></label>
<input asp-for="Form.Subject" class="form-control" placeholder="Brief summary of your question or issue" />
<span asp-validation-for="Form.Subject" class="text-danger small"></span>
</div>
<div class="col-12">
<label asp-for="Form.Message" class="form-label fw-semibold"></label>
<textarea asp-for="Form.Message" class="form-control" rows="6"
placeholder="Describe your question or issue in detail. The more context you provide, the faster we can help."></textarea>
<div class="d-flex justify-content-between">
<span asp-validation-for="Form.Message" class="text-danger small"></span>
<span class="text-muted small mt-1" id="msgCount">0 / 4000</span>
</div>
</div>
</div>
<div class="d-flex justify-content-end gap-2">
<a asp-controller="Help" asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-book-half me-1"></i>Browse Help First
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-send me-1"></i>Send Message
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
(function () {
const ta = document.querySelector('textarea[name="Form.Message"]');
const counter = document.getElementById('msgCount');
if (!ta || !counter) return;
function update() { counter.textContent = ta.value.length + ' / 4000'; }
ta.addEventListener('input', update);
update();
})();
</script>
}
@@ -0,0 +1,124 @@
@using PowderCoating.Web.Controllers
@model List<PowderCoating.Core.Entities.ContactSubmission>
@{
ViewData["Title"] = "Contact Submissions";
ViewData["PageIcon"] = "bi-envelope";
var unread = Model.Count(s => !s.IsRead);
}
<div class="d-flex align-items-center justify-content-between mb-4">
<div class="d-flex align-items-center gap-2">
<h1 class="h3 mb-0"><i class="bi bi-envelope text-primary me-2"></i>Contact Submissions</h1>
@if (unread > 0)
{
<span class="badge bg-danger">@unread unread</span>
}
</div>
</div>
@if (!Model.Any())
{
<div class="card border-0 shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-inbox fs-1 mb-3 d-block opacity-25"></i>
<p class="mb-0">No contact submissions yet.</p>
</div>
</div>
}
else
{
<div class="d-flex flex-column gap-3">
@foreach (var s in Model)
{
<div class="card border-0 shadow-sm @(s.IsRead ? "" : "border-start border-4 border-primary")">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-1 flex-wrap">
@if (!s.IsRead)
{
<span class="badge bg-primary">New</span>
}
<span class="badge bg-secondary bg-opacity-10 text-secondary border">@s.Category</span>
<span class="fw-semibold">@s.Subject</span>
</div>
<div class="small text-muted mb-2">
<i class="bi bi-person me-1"></i>@s.SenderName
&nbsp;&middot;&nbsp;
<a href="mailto:@s.SenderEmail">@s.SenderEmail</a>
&nbsp;&middot;&nbsp;
<i class="bi bi-building me-1"></i>@s.CompanyName
&nbsp;&middot;&nbsp;
<i class="bi bi-clock me-1"></i>@s.CreatedAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM d, yyyy h:mm tt")
</div>
<p class="mb-0" style="white-space:pre-wrap;">@s.Message</p>
@if (!string.IsNullOrWhiteSpace(s.AdminNotes))
{
<div class="mt-2 p-2 rounded bg-warning bg-opacity-10 border border-warning border-opacity-25 small">
<i class="bi bi-pencil me-1 text-warning"></i><strong>Admin note:</strong> @s.AdminNotes
</div>
}
@if (s.IsRead && s.ReadAt.HasValue)
{
<div class="mt-2 small text-muted">
<i class="bi bi-check2 me-1 text-success"></i>
Marked read by @(s.ReadByUserName ?? "admin") on @s.ReadAt.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM d, yyyy h:mm tt")
</div>
}
</div>
<div class="flex-shrink-0">
<div class="d-flex gap-2">
<a href="mailto:@s.SenderEmail?subject=Re: @Uri.EscapeDataString(s.Subject)"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-reply me-1"></i>Reply
</a>
@if (!s.IsRead)
{
<button type="button" class="btn btn-sm btn-outline-secondary"
data-bs-toggle="modal" data-bs-target="#markReadModal-@s.Id">
<i class="bi bi-check2 me-1"></i>Mark Read
</button>
}
</div>
</div>
</div>
</div>
</div>
@* Mark Read modal *@
@if (!s.IsRead)
{
<div class="modal fade" id="markReadModal-@s.Id" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form asp-action="MarkRead" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@s.Id" />
<div class="modal-header">
<h5 class="modal-title">Mark as Read</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-3">Optionally add an internal note before marking this submission as read.</p>
<div class="mb-0">
<label class="form-label fw-semibold">Admin Note <span class="text-muted fw-normal">(optional)</span></label>
<textarea name="adminNotes" class="form-control" rows="3"
placeholder="e.g. Replied via email, escalated to billing, resolved…"></textarea>
</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-check2 me-1"></i>Mark as Read
</button>
</div>
</form>
</div>
</div>
</div>
}
}
</div>
}
@@ -0,0 +1,473 @@
@using PowderCoating.Application.DTOs.Job
@using PowderCoating.Application.DTOs.Quote
@using PowderCoating.Application.DTOs.Common
@{
ViewData["Title"] = $"Activity - {ViewBag.CustomerName}";
ViewData["PageIcon"] = "bi-clock-history";
var customerId = (int)ViewBag.CustomerId;
var customerName = (string)ViewBag.CustomerName;
var activeTab = (string)ViewBag.ActiveTab;
var jobs = (PagedResult<JobListDto>)ViewBag.Jobs;
var jobSort = (string)ViewBag.JobSort;
var jobDir = (string)ViewBag.JobDir;
var quotes = (PagedResult<QuoteListDto>)ViewBag.Quotes;
var quoteSort = (string)ViewBag.QuoteSort;
var quoteDir = (string)ViewBag.QuoteDir;
// Helper: build a URL that changes only the job sort column/direction
string JobSortUrl(string col)
{
var newDir = jobSort == col && jobDir == "asc" ? "desc" : "asc";
return Url.Action("Activity", new
{
id = customerId,
activeTab = "jobs",
jobSort = col,
jobDir = newDir,
jobPage = 1,
jobSize = jobs.PageSize,
quoteSort,
quoteDir,
quotePage = quotes.PageNumber,
quoteSize = quotes.PageSize
})!;
}
string JobSortIcon(string col)
{
if (jobSort != col) return "bi-arrow-down-up";
return jobDir == "asc" ? "bi-arrow-up" : "bi-arrow-down";
}
// Helper: build a URL that changes only the quote sort column/direction
string QuoteSortUrl(string col)
{
var newDir = quoteSort == col && quoteDir == "asc" ? "desc" : "asc";
return Url.Action("Activity", new
{
id = customerId,
activeTab = "quotes",
jobSort,
jobDir,
jobPage = jobs.PageNumber,
jobSize = jobs.PageSize,
quoteSort = col,
quoteDir = newDir,
quotePage = 1,
quoteSize = quotes.PageSize
})!;
}
string QuoteSortIcon(string col)
{
if (quoteSort != col) return "bi-arrow-down-up";
return quoteDir == "asc" ? "bi-arrow-up" : "bi-arrow-down";
}
// Helper: build page-change URL for jobs
string JobPageUrl(int page)
{
return Url.Action("Activity", new
{
id = customerId,
activeTab = "jobs",
jobSort,
jobDir,
jobPage = page,
jobSize = jobs.PageSize,
quoteSort,
quoteDir,
quotePage = quotes.PageNumber,
quoteSize = quotes.PageSize
})!;
}
// Helper: build page-change URL for quotes
string QuotePageUrl(int page)
{
return Url.Action("Activity", new
{
id = customerId,
activeTab = "quotes",
jobSort,
jobDir,
jobPage = jobs.PageNumber,
jobSize = jobs.PageSize,
quoteSort,
quoteDir,
quotePage = page,
quoteSize = quotes.PageSize
})!;
}
}
<div class="d-flex justify-content-end gap-2 mb-4">
<a asp-controller="Jobs" asp-action="Create" asp-route-customerId="@customerId" class="btn btn-success">
<i class="bi bi-plus-circle me-2"></i>New Job
</a>
<a asp-controller="Quotes" asp-action="Create" asp-route-customerId="@customerId" class="btn btn-outline-primary">
<i class="bi bi-file-text me-2"></i>New Quote
</a>
<a asp-action="Details" asp-route-id="@customerId" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to Customer
</a>
</div>
<!-- Summary Badges -->
<div class="d-flex gap-3 mb-4">
<span class="badge bg-primary bg-opacity-10 text-primary fs-6 px-3 py-2">
<i class="bi bi-briefcase me-2"></i>@jobs.TotalCount Job@(jobs.TotalCount == 1 ? "" : "s")
</span>
<span class="badge bg-info bg-opacity-10 text-info fs-6 px-3 py-2">
<i class="bi bi-file-text me-2"></i>@quotes.TotalCount Quote@(quotes.TotalCount == 1 ? "" : "s")
</span>
</div>
<!-- Tabs -->
<ul class="nav nav-tabs mb-0" id="activityTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link @(activeTab == "jobs" ? "active" : "")" id="jobs-tab"
data-bs-toggle="tab" data-bs-target="#jobs-panel"
type="button" role="tab">
<i class="bi bi-briefcase me-2"></i>Jobs
<span class="badge bg-secondary ms-1">@jobs.TotalCount</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(activeTab == "quotes" ? "active" : "")" id="quotes-tab"
data-bs-toggle="tab" data-bs-target="#quotes-panel"
type="button" role="tab">
<i class="bi bi-file-text me-2"></i>Quotes
<span class="badge bg-secondary ms-1">@quotes.TotalCount</span>
</button>
</li>
</ul>
<div class="tab-content" id="activityTabContent">
<!-- ===== JOBS TAB ===== -->
<div class="tab-pane fade @(activeTab == "jobs" ? "show active" : "")" id="jobs-panel" role="tabpanel">
<div class="card border-0 shadow-sm border-top-0" style="border-top-left-radius:0; border-top-right-radius:0;">
<div class="card-body p-0">
@if (!jobs.Items.Any())
{
<div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No jobs found</h5>
<p class="text-muted mb-4">This customer has no jobs yet</p>
<a asp-controller="Jobs" asp-action="Create" asp-route-customerId="@customerId" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Create First Job
</a>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th class="ps-4">
<a href="@JobSortUrl("JobNumber")" class="text-decoration-none" style="color:inherit">
Job Number <i class="bi @JobSortIcon("JobNumber")"></i>
</a>
</th>
<th>Description</th>
<th>
<a href="@JobSortUrl("Status")" class="text-decoration-none" style="color:inherit">
Status <i class="bi @JobSortIcon("Status")"></i>
</a>
</th>
<th>
<a href="@JobSortUrl("Priority")" class="text-decoration-none" style="color:inherit">
Priority <i class="bi @JobSortIcon("Priority")"></i>
</a>
</th>
<th>
<a href="@JobSortUrl("DueDate")" class="text-decoration-none" style="color:inherit">
Due Date <i class="bi @JobSortIcon("DueDate")"></i>
</a>
</th>
<th>
<a href="@JobSortUrl("FinalPrice")" class="text-decoration-none" style="color:inherit">
Price <i class="bi @JobSortIcon("FinalPrice")"></i>
</a>
</th>
<th>
<a href="@JobSortUrl("CreatedAt")" class="text-decoration-none" style="color:inherit">
Created <i class="bi @JobSortIcon("CreatedAt")"></i>
</a>
</th>
<th class="text-end pe-4">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var job in jobs.Items)
{
<tr style="cursor:pointer;" onclick="window.location.href='@Url.Action("Details", "Jobs", new { id = job.Id })'">
<td class="ps-4">
<div class="d-flex align-items-center gap-2">
<div class="rounded-circle d-flex align-items-center justify-content-center"
style="width:38px;height:38px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:white;">
<i class="bi bi-briefcase"></i>
</div>
<span class="fw-semibold">@job.JobNumber</span>
</div>
</td>
<td class="text-truncate" style="max-width:220px;">@job.Description</td>
<td>
<span class="badge bg-@job.StatusColorClass bg-opacity-10 text-@job.StatusColorClass">
@job.StatusDisplayName
</span>
</td>
<td>
<span class="badge bg-@job.PriorityColorClass">@job.PriorityDisplayName</span>
</td>
<td>
@if (job.DueDate.HasValue)
{
var overdue = job.DueDate.Value < DateTime.Now
&& job.StatusCode != "COMPLETED"
&& job.StatusCode != "DELIVERED";
<span class="@(overdue ? "text-danger fw-semibold" : "")">
@job.DueDate.Value.ToString("MMM dd, yyyy")
@if (overdue) { <i class="bi bi-exclamation-triangle ms-1"></i> }
</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="fw-semibold">@job.FinalPrice.ToString("C")</td>
<td class="text-muted small">@job.CreatedAt.ToString("MMM dd, yyyy")</td>
<td class="text-end pe-4" onclick="event.stopPropagation()">
<div class="btn-group btn-group-sm">
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@job.Id"
class="btn btn-outline-primary" title="View">
<i class="bi bi-eye"></i>
</a>
<a asp-controller="Jobs" asp-action="Edit" asp-route-id="@job.Id"
class="btn btn-outline-warning" title="Edit">
<i class="bi bi-pencil"></i>
</a>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Jobs Pagination -->
@if (jobs.TotalPages > 1)
{
<div class="card-footer bg-transparent d-flex justify-content-between align-items-center py-3 px-4">
<div class="text-muted small">
Showing @jobs.StartIndex@jobs.EndIndex of @jobs.TotalCount jobs
</div>
<div class="d-flex align-items-center gap-3">
<div class="d-flex align-items-center gap-1">
<span class="text-muted small me-1">Rows:</span>
@foreach (var sz in new[] { 10, 25, 50 })
{
var szUrl = Url.Action("Activity", new
{
id = customerId, activeTab = "jobs",
jobSort, jobDir, jobPage = 1, jobSize = sz,
quoteSort, quoteDir, quotePage = quotes.PageNumber, quoteSize = quotes.PageSize
});
<a href="@szUrl" class="btn btn-sm @(jobs.PageSize == sz ? "btn-primary" : "btn-outline-secondary")">@sz</a>
}
</div>
<nav>
<ul class="pagination pagination-sm mb-0">
<li class="page-item @(jobs.PageNumber == 1 ? "disabled" : "")">
<a class="page-link" href="@JobPageUrl(jobs.PageNumber - 1)">
<i class="bi bi-chevron-left"></i>
</a>
</li>
@for (var p = Math.Max(1, jobs.PageNumber - 2); p <= Math.Min(jobs.TotalPages, jobs.PageNumber + 2); p++)
{
<li class="page-item @(p == jobs.PageNumber ? "active" : "")">
<a class="page-link" href="@JobPageUrl(p)">@p</a>
</li>
}
<li class="page-item @(jobs.PageNumber == jobs.TotalPages ? "disabled" : "")">
<a class="page-link" href="@JobPageUrl(jobs.PageNumber + 1)">
<i class="bi bi-chevron-right"></i>
</a>
</li>
</ul>
</nav>
</div>
</div>
}
}
</div>
</div>
</div>
<!-- ===== QUOTES TAB ===== -->
<div class="tab-pane fade @(activeTab == "quotes" ? "show active" : "")" id="quotes-panel" role="tabpanel">
<div class="card border-0 shadow-sm border-top-0" style="border-top-left-radius:0; border-top-right-radius:0;">
<div class="card-body p-0">
@if (!quotes.Items.Any())
{
<div class="text-center py-5">
<i class="bi bi-file-text" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No quotes found</h5>
<p class="text-muted mb-4">This customer has no quotes yet</p>
<a asp-controller="Quotes" asp-action="Create" asp-route-customerId="@customerId" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Create First Quote
</a>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th class="ps-4">
<a href="@QuoteSortUrl("QuoteNumber")" class="text-decoration-none" style="color:inherit">
Quote # <i class="bi @QuoteSortIcon("QuoteNumber")"></i>
</a>
</th>
<th>
<a href="@QuoteSortUrl("Status")" class="text-decoration-none" style="color:inherit">
Status <i class="bi @QuoteSortIcon("Status")"></i>
</a>
</th>
<th>
<a href="@QuoteSortUrl("QuoteDate")" class="text-decoration-none" style="color:inherit">
Quote Date <i class="bi @QuoteSortIcon("QuoteDate")"></i>
</a>
</th>
<th>
<a href="@QuoteSortUrl("Expiration")" class="text-decoration-none" style="color:inherit">
Expires <i class="bi @QuoteSortIcon("Expiration")"></i>
</a>
</th>
<th>
<a href="@QuoteSortUrl("Total")" class="text-decoration-none" style="color:inherit">
Total <i class="bi @QuoteSortIcon("Total")"></i>
</a>
</th>
<th class="text-end pe-4">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var quote in quotes.Items)
{
<tr style="cursor:pointer;" onclick="window.location.href='@Url.Action("Details", "Quotes", new { id = quote.Id })'">
<td class="ps-4">
<div class="d-flex align-items-center gap-2">
<div class="rounded-circle d-flex align-items-center justify-content-center"
style="width:38px;height:38px;background:linear-gradient(135deg,#11998e 0%,#38ef7d 100%);color:white;">
<i class="bi bi-file-text"></i>
</div>
<span class="fw-semibold">@quote.QuoteNumber</span>
</div>
</td>
<td>
@{
var isExpired = quote.IsExpired;
}
@if (isExpired)
{
<span class="badge bg-danger bg-opacity-10 text-danger">Expired</span>
}
else
{
<span class="badge bg-@quote.StatusColorClass bg-opacity-10 text-@quote.StatusColorClass">
@quote.StatusDisplayName
</span>
}
</td>
<td>@quote.QuoteDate.ToString("MMM dd, yyyy")</td>
<td>
@if (quote.ExpirationDate.HasValue)
{
<span class="@(isExpired ? "text-danger fw-semibold" : "")">
@quote.ExpirationDate.Value.ToString("MMM dd, yyyy")
@if (isExpired) { <i class="bi bi-exclamation-triangle ms-1"></i> }
</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="fw-semibold">@quote.Total.ToString("C")</td>
<td class="text-end pe-4" onclick="event.stopPropagation()">
<div class="btn-group btn-group-sm">
<a asp-controller="Quotes" asp-action="Details" asp-route-id="@quote.Id"
class="btn btn-outline-primary" title="View">
<i class="bi bi-eye"></i>
</a>
<a asp-controller="Quotes" asp-action="Edit" asp-route-id="@quote.Id"
class="btn btn-outline-warning" title="Edit">
<i class="bi bi-pencil"></i>
</a>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Quotes Pagination -->
@if (quotes.TotalPages > 1)
{
<div class="card-footer bg-transparent d-flex justify-content-between align-items-center py-3 px-4">
<div class="text-muted small">
Showing @quotes.StartIndex@quotes.EndIndex of @quotes.TotalCount quotes
</div>
<div class="d-flex align-items-center gap-3">
<div class="d-flex align-items-center gap-1">
<span class="text-muted small me-1">Rows:</span>
@foreach (var sz in new[] { 10, 25, 50 })
{
var szUrl = Url.Action("Activity", new
{
id = customerId, activeTab = "quotes",
jobSort, jobDir, jobPage = jobs.PageNumber, jobSize = jobs.PageSize,
quoteSort, quoteDir, quotePage = 1, quoteSize = sz
});
<a href="@szUrl" class="btn btn-sm @(quotes.PageSize == sz ? "btn-primary" : "btn-outline-secondary")">@sz</a>
}
</div>
<nav>
<ul class="pagination pagination-sm mb-0">
<li class="page-item @(quotes.PageNumber == 1 ? "disabled" : "")">
<a class="page-link" href="@QuotePageUrl(quotes.PageNumber - 1)">
<i class="bi bi-chevron-left"></i>
</a>
</li>
@for (var p = Math.Max(1, quotes.PageNumber - 2); p <= Math.Min(quotes.TotalPages, quotes.PageNumber + 2); p++)
{
<li class="page-item @(p == quotes.PageNumber ? "active" : "")">
<a class="page-link" href="@QuotePageUrl(p)">@p</a>
</li>
}
<li class="page-item @(quotes.PageNumber == quotes.TotalPages ? "disabled" : "")">
<a class="page-link" href="@QuotePageUrl(quotes.PageNumber + 1)">
<i class="bi bi-chevron-right"></i>
</a>
</li>
</ul>
</nav>
</div>
</div>
}
}
</div>
</div>
</div>
</div>
@@ -0,0 +1,375 @@
@model PowderCoating.Application.DTOs.Customer.CreateCustomerDto
@{
ViewData["Title"] = "Add New Customer";
ViewData["PageIcon"] = "bi-person-plus";
}
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-end align-items-center mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to List
</a>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form asp-action="Create" method="post">
<partial name="_ValidationSummary" />
<!-- Company Information Section -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0"><i class="bi bi-building me-2 text-primary"></i>Company Information</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Company Information"
data-bs-content="Company Name is used on quotes, invoices, and correspondence. Leave it blank for individual (non-business) customers. Customer Type controls which features are available — Commercial customers get payment terms, credit limits, and pricing tier discounts; Individual customers are for simpler one-off work.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-8">
<label asp-for="CompanyName" class="form-label">Company Name</label>
<input asp-for="CompanyName" class="form-control" placeholder="Enter company name" />
<span asp-validation-for="CompanyName" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="IsCommercial" class="form-label">Customer Type
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Customer Type"
data-bs-content="Commercial: businesses with ongoing work, purchase orders, and invoicing. Individual: walk-in customers or one-off jobs. This affects which fields are shown and whether pricing tier discounts apply.">
<i class="bi bi-question-circle"></i>
</a>
</label>
<select asp-for="IsCommercial" class="form-select">
<option value="false">Individual</option>
<option value="true">Commercial</option>
</select>
</div>
</div>
</div>
<!-- Contact Information Section -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-person me-2 text-primary"></i>Contact Information
</h5>
<div class="alert alert-info alert-permanent py-2 px-3 mb-3" style="font-size:.875rem;">
<i class="bi bi-info-circle me-1"></i>
<strong>Required:</strong> At least one of Company Name, First Name, or Last Name — and at least one of Email or Phone.
</div>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="ContactFirstName" class="form-label">First Name <span class="text-muted fw-normal">(required if no company name)</span></label>
<input asp-for="ContactFirstName" class="form-control" placeholder="Enter first name" />
<span asp-validation-for="ContactFirstName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="ContactLastName" class="form-label">Last Name</label>
<input asp-for="ContactLastName" class="form-control" placeholder="Enter last name" />
<span asp-validation-for="ContactLastName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Email" class="form-label">Email <span class="text-danger">*</span> <span class="text-muted fw-normal">(required if no phone number)</span></label>
<input asp-for="Email" type="email" class="form-control" placeholder="name@example.com" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="Phone" class="form-label">Phone <span class="text-danger">*</span> <span class="text-muted fw-normal">(required if no email)</span></label>
<input asp-for="Phone" type="tel" class="form-control" placeholder="(555) 123-4567" />
<span asp-validation-for="Phone" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="MobilePhone" class="form-label">Mobile Phone</label>
<input asp-for="MobilePhone" type="tel" class="form-control" placeholder="(555) 123-4567" />
<span asp-validation-for="MobilePhone" class="text-danger"></span>
</div>
</div>
</div>
<!-- Address Section -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-geo-alt me-2 text-primary"></i>Address
</h5>
<div class="row g-3">
<div class="col-12">
<label asp-for="Address" class="form-label">Street Address</label>
<input asp-for="Address" class="form-control" placeholder="Enter street address" />
<span asp-validation-for="Address" class="text-danger"></span>
</div>
<div class="col-md-5">
<label asp-for="City" class="form-label">City</label>
<input asp-for="City" class="form-control" placeholder="Enter city" />
<span asp-validation-for="City" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="State" class="form-label">State</label>
<input asp-for="State" class="form-control" placeholder="Enter state" />
<span asp-validation-for="State" class="text-danger"></span>
</div>
<div class="col-md-2">
<label asp-for="ZipCode" class="form-label">Zip Code</label>
<input asp-for="ZipCode" class="form-control" placeholder="12345" />
<span asp-validation-for="ZipCode" class="text-danger"></span>
</div>
<div class="col-md-2">
<label asp-for="Country" class="form-label">Country</label>
<select asp-for="Country" class="form-select">
<option value="">-- Select --</option>
<option value="USA">USA</option>
<option value="Canada">Canada</option>
<option value="Mexico">Mexico</option>
<option value="Afghanistan">Afghanistan</option>
<option value="Albania">Albania</option>
<option value="Algeria">Algeria</option>
<option value="Argentina">Argentina</option>
<option value="Australia">Australia</option>
<option value="Austria">Austria</option>
<option value="Bangladesh">Bangladesh</option>
<option value="Belgium">Belgium</option>
<option value="Bolivia">Bolivia</option>
<option value="Brazil">Brazil</option>
<option value="Chile">Chile</option>
<option value="China">China</option>
<option value="Colombia">Colombia</option>
<option value="Costa Rica">Costa Rica</option>
<option value="Croatia">Croatia</option>
<option value="Czech Republic">Czech Republic</option>
<option value="Denmark">Denmark</option>
<option value="Dominican Republic">Dominican Republic</option>
<option value="Ecuador">Ecuador</option>
<option value="Egypt">Egypt</option>
<option value="El Salvador">El Salvador</option>
<option value="Finland">Finland</option>
<option value="France">France</option>
<option value="Germany">Germany</option>
<option value="Ghana">Ghana</option>
<option value="Greece">Greece</option>
<option value="Guatemala">Guatemala</option>
<option value="Honduras">Honduras</option>
<option value="Hungary">Hungary</option>
<option value="India">India</option>
<option value="Indonesia">Indonesia</option>
<option value="Iran">Iran</option>
<option value="Iraq">Iraq</option>
<option value="Ireland">Ireland</option>
<option value="Israel">Israel</option>
<option value="Italy">Italy</option>
<option value="Japan">Japan</option>
<option value="Jordan">Jordan</option>
<option value="Kazakhstan">Kazakhstan</option>
<option value="Kenya">Kenya</option>
<option value="South Korea">South Korea</option>
<option value="Kuwait">Kuwait</option>
<option value="Malaysia">Malaysia</option>
<option value="Netherlands">Netherlands</option>
<option value="New Zealand">New Zealand</option>
<option value="Nicaragua">Nicaragua</option>
<option value="Nigeria">Nigeria</option>
<option value="Norway">Norway</option>
<option value="Pakistan">Pakistan</option>
<option value="Panama">Panama</option>
<option value="Paraguay">Paraguay</option>
<option value="Peru">Peru</option>
<option value="Philippines">Philippines</option>
<option value="Poland">Poland</option>
<option value="Portugal">Portugal</option>
<option value="Puerto Rico">Puerto Rico</option>
<option value="Romania">Romania</option>
<option value="Russia">Russia</option>
<option value="Saudi Arabia">Saudi Arabia</option>
<option value="South Africa">South Africa</option>
<option value="Spain">Spain</option>
<option value="Sweden">Sweden</option>
<option value="Switzerland">Switzerland</option>
<option value="Taiwan">Taiwan</option>
<option value="Thailand">Thailand</option>
<option value="Turkey">Turkey</option>
<option value="Ukraine">Ukraine</option>
<option value="United Arab Emirates">United Arab Emirates</option>
<option value="United Kingdom">United Kingdom</option>
<option value="Uruguay">Uruguay</option>
<option value="Venezuela">Venezuela</option>
<option value="Vietnam">Vietnam</option>
</select>
<span asp-validation-for="Country" class="text-danger"></span>
</div>
</div>
</div>
<!-- Business Information Section -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0"><i class="bi bi-briefcase me-2 text-primary"></i>Business Information</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Business Information"
data-bs-content="These fields govern billing and compliance. Payment Terms sets the default for invoices (e.g., Net 30 = payment due within 30 days). Credit Limit is a soft cap on outstanding balance — the system will warn when exceeded. Tax Exempt removes tax from all invoices for this customer (upload the exemption certificate on the Edit page).">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="TaxId" class="form-label">Tax ID / EIN</label>
<input asp-for="TaxId" class="form-control" placeholder="Enter tax ID" />
<span asp-validation-for="TaxId" class="text-danger"></span>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center gap-1">
<label asp-for="PaymentTerms" class="form-label mb-0">Payment Terms</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Payment Terms"
data-bs-content="Sets the default due date on invoices for this customer. 'Net 30' means payment is due 30 days after the invoice date. This is a default — you can override it on individual invoices.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<select asp-for="PaymentTerms" class="form-select">
<option value="">Select payment terms</option>
<option value="Net 15">Net 15</option>
<option value="Net 30">Net 30</option>
<option value="Net 45">Net 45</option>
<option value="Net 60">Net 60</option>
<option value="Due on Receipt">Due on Receipt</option>
<option value="Cash on Delivery">Cash on Delivery</option>
</select>
<span asp-validation-for="PaymentTerms" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="PricingTierId" class="form-label">Pricing Tier</label>
<select asp-for="PricingTierId" asp-items="ViewBag.PricingTiers" class="form-select">
<option value="">— No tier —</option>
</select>
<small class="text-muted">Applies a discount to all quotes for this customer.</small>
</div>
<div class="col-md-3">
<div class="d-flex align-items-center gap-1">
<label asp-for="CreditLimit" class="form-label mb-0">Credit Limit</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Credit Limit"
data-bs-content="The maximum outstanding balance you'll extend to this customer before requiring payment. Set to $0 to allow unlimited credit or to require payment upfront. The system will warn (but not block) when the balance exceeds this limit.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="CreditLimit" type="number" step="0.01" min="0" value="0" class="form-control" placeholder="0.00" />
</div>
<span asp-validation-for="CreditLimit" class="text-danger"></span>
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-md-6">
<div class="form-check form-switch">
<input asp-for="IsTaxExempt" class="form-check-input" type="checkbox" />
<label asp-for="IsTaxExempt" class="form-check-label">Tax Exempt Customer</label>
</div>
<small class="text-muted">Check this box if the customer is tax exempt</small>
</div>
</div>
</div>
<!-- Notes Section -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-journal-text me-2 text-primary"></i>Notes
</h5>
<div class="row g-3">
<div class="col-12">
<label asp-for="GeneralNotes" class="form-label">General Notes</label>
<textarea asp-for="GeneralNotes" class="form-control" rows="4" placeholder="Enter any additional notes about this customer"></textarea>
<span asp-validation-for="GeneralNotes" class="text-danger"></span>
</div>
</div>
</div>
<!-- Notification Preferences -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0"><i class="bi bi-bell me-2 text-primary"></i>Notification Preferences</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Notification Preferences"
data-bs-content="Controls when the customer receives automatic updates. Email notifications send status change alerts (e.g., job ready for pickup) to the customer's email address. SMS requires separate TCPA consent and a mobile phone number.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<!-- Email -->
<div class="col-md-6">
<div class="form-check form-switch">
<input asp-for="NotifyByEmail" class="form-check-input" type="checkbox" role="switch" />
<label asp-for="NotifyByEmail" class="form-check-label">
<i class="bi bi-envelope me-1"></i>Email Notifications
</label>
<div class="form-text">Receive quote and job status updates by email.</div>
</div>
</div>
</div>
@if (ViewBag.SmsEnabled == true)
{
<!-- SMS Consent (TCPA compliance) -->
<div class="mt-3">
<div class="card border-warning">
<div class="card-header bg-warning-subtle text-warning-emphasis d-flex align-items-center gap-2 py-2">
<i class="bi bi-shield-exclamation fs-5"></i>
<span class="fw-semibold">SMS Consent Requirement (TCPA)</span>
<span class="badge bg-warning text-dark ms-auto">Required before enabling SMS</span>
</div>
<div class="card-body pb-2">
<p class="mb-2">
Federal law (TCPA) requires <strong>explicit prior written or verbal consent</strong> before sending SMS messages to a customer.
Before enabling SMS notifications, you must:
</p>
<ol class="mb-2 ps-3">
<li>Inform the customer they will receive automated text messages for job updates and pickup alerts.</li>
<li>Inform them that message and data rates may apply.</li>
<li>Explain they can reply <strong>STOP</strong> at any time to opt out.</li>
<li>Obtain their clear verbal or written agreement.</li>
</ol>
<p class="mb-2 small text-muted">
Only check the box below <strong>after</strong> the customer has given consent.
A confirmation text will be sent automatically to verify enrollment.
</p>
</div>
</div>
<div class="card border-secondary bg-body-secondary p-3">
<div class="form-check">
<input asp-for="SmsConsentGranted" class="form-check-input" type="checkbox" id="SmsConsentGranted" />
<label class="form-check-label fw-semibold" for="SmsConsentGranted">
<i class="bi bi-shield-check me-1 text-success"></i>
Customer has verbally consented to receive SMS notifications
</label>
<div class="form-text">
Checking this box records consent on behalf of the customer and triggers a confirmation text.
A mobile phone number must be entered above.
</div>
</div>
</div>
</div>
}
</div>
<!-- Form Actions -->
<div class="d-flex gap-2 justify-content-end pt-3 border-top">
<a asp-action="Index" class="btn btn-outline-secondary px-4">Cancel</a>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-2"></i>Create Customer
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
@@ -0,0 +1,183 @@
@model PowderCoating.Application.DTOs.Customer.CustomerDto
@{
ViewData["Title"] = "Delete Customer";
ViewData["PageIcon"] = "bi-people";
}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="d-flex justify-content-end align-items-center mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to List
</a>
</div>
<!-- Warning Banner -->
<div class="alert alert-danger d-flex align-items-start mb-4">
<i class="bi bi-exclamation-triangle-fill me-3" style="font-size: 1.5rem;"></i>
<div>
<h5 class="alert-heading mb-2">Are you sure you want to delete this customer?</h5>
<p class="mb-0">This action will mark the customer as deleted. All related records (jobs, quotes, notes) will be preserved but the customer will no longer appear in active listings.</p>
</div>
</div>
<div class="card border-danger shadow-sm">
<div class="card-header bg-danger bg-opacity-10 border-danger">
<h5 class="mb-0 text-danger">
<i class="bi bi-person-x me-2"></i>Customer to be Deleted
</h5>
</div>
<div class="card-body">
<!-- Company Information -->
<div class="mb-4">
<h6 class="text-muted small text-uppercase mb-2">Company Information</h6>
<div class="row g-3">
<div class="col-md-8">
<label class="text-muted small mb-1">Company Name</label>
<p class="fw-semibold mb-0">@Model.CompanyName</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Customer Type</label>
<p class="mb-0">
@if (Model.IsCommercial)
{
<span class="badge bg-primary">Commercial</span>
}
else
{
<span class="badge bg-secondary">Individual</span>
}
</p>
</div>
</div>
</div>
<hr />
<!-- Contact Information -->
<div class="mb-4">
<h6 class="text-muted small text-uppercase mb-2">Contact Information</h6>
<div class="row g-3">
<div class="col-md-6">
<label class="text-muted small mb-1">Contact Name</label>
<p class="mb-0">
@if (!string.IsNullOrEmpty(Model.ContactFirstName) || !string.IsNullOrEmpty(Model.ContactLastName))
{
<span>@Model.ContactFirstName @Model.ContactLastName</span>
}
else
{
<span class="text-muted">Not provided</span>
}
</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Email</label>
<p class="mb-0">@Model.Email</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Phone</label>
<p class="mb-0">@(Model.Phone ?? "Not provided")</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Mobile Phone</label>
<p class="mb-0">@(Model.MobilePhone ?? "Not provided")</p>
</div>
</div>
</div>
<hr />
<!-- Address -->
<div class="mb-4">
<h6 class="text-muted small text-uppercase mb-2">Address</h6>
@if (!string.IsNullOrEmpty(Model.Address))
{
<p class="mb-1">@Model.Address</p>
<p class="mb-0">
@if (!string.IsNullOrEmpty(Model.City))
{
<span>@Model.City</span>
}
@if (!string.IsNullOrEmpty(Model.State))
{
<span>, @Model.State</span>
}
@if (!string.IsNullOrEmpty(Model.ZipCode))
{
<span> @Model.ZipCode</span>
}
</p>
}
else
{
<p class="text-muted mb-0">No address provided</p>
}
</div>
<hr />
<!-- Financial Information -->
<div class="mb-4">
<h6 class="text-muted small text-uppercase mb-2">Financial Information</h6>
<div class="row g-3">
<div class="col-md-4">
<label class="text-muted small mb-1">Current Balance</label>
<p class="mb-0 fw-semibold @(Model.CurrentBalance > 0 ? "text-danger" : "text-success")">
@Model.CurrentBalance.ToString("C")
</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Credit Limit</label>
<p class="mb-0 fw-semibold">@Model.CreditLimit.ToString("C")</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Payment Terms</label>
<p class="mb-0">@(Model.PaymentTerms ?? "Not set")</p>
</div>
</div>
</div>
@if (Model.CurrentBalance > 0)
{
<div class="alert alert-warning d-flex align-items-center">
<i class="bi bi-exclamation-circle me-2"></i>
<div>
<strong>Warning:</strong> This customer has an outstanding balance of @Model.CurrentBalance.ToString("C"). Please ensure all balances are settled before deletion.
</div>
</div>
}
<!-- Delete Form -->
<div class="d-flex gap-2 justify-content-end pt-3 border-top mt-4">
<a asp-action="Index" class="btn btn-outline-secondary px-4">
<i class="bi bi-x-circle me-2"></i>Cancel
</a>
<form asp-action="Delete" method="post" class="d-inline">
<input type="hidden" asp-for="Id" />
<button type="submit" class="btn btn-danger px-4">
<i class="bi bi-trash me-2"></i>Delete Customer
</button>
</form>
</div>
</div>
</div>
<!-- Additional Information -->
<div class="card border-0 shadow-sm mt-4">
<div class="card-body">
<h6 class="mb-3">
<i class="bi bi-info-circle me-2 text-info"></i>What happens when you delete a customer?
</h6>
<ul class="mb-0">
<li>The customer will be marked as deleted (soft delete)</li>
<li>They will no longer appear in active customer listings</li>
<li>All related jobs, quotes, and notes will be preserved</li>
<li>Historical records and reports will still include this customer</li>
<li>Administrators can restore deleted customers if needed</li>
</ul>
</div>
</div>
</div>
</div>
@@ -0,0 +1,487 @@
@model PowderCoating.Application.DTOs.Customer.CustomerDto
@{
ViewData["Title"] = !string.IsNullOrWhiteSpace(Model.CompanyName)
? Model.CompanyName
: $"{Model.ContactFirstName} {Model.ContactLastName}".Trim();
ViewData["PageIcon"] = "bi-person-circle";
}
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-end gap-2 mb-4">
<div class="d-flex gap-2">
<a asp-action="Activity" asp-route-id="@Model.Id" class="btn btn-outline-info">
<i class="bi bi-clock-history me-2"></i>View Activity
</a>
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
<i class="bi bi-pencil me-2"></i>Edit
</a>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to List
</a>
</div>
</div>
<!-- Status Banner -->
<div class="alert @(Model.IsActive ? "alert-success" : "alert-danger") alert-permanent d-flex align-items-center mb-4">
<i class="bi @(Model.IsActive ? "bi-check-circle" : "bi-x-circle") me-2" style="font-size: 1.5rem;"></i>
<div>
<strong>Status:</strong> @(Model.IsActive ? "Active Customer" : "Inactive Customer")
@if (!Model.IsActive)
{
<span class="ms-2">- This customer is currently inactive</span>
}
</div>
</div>
<div class="row g-4">
<!-- Left Column -->
<div class="col-lg-8">
<!-- Company Information -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-building me-2 text-primary"></i>Company Information
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-8">
<label class="text-muted small mb-1">Company Name</label>
<p class="fw-semibold mb-0">@Model.CompanyName</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Customer Type</label>
<p class="mb-0">
@if (Model.IsCommercial)
{
<span class="badge bg-primary bg-opacity-10 text-primary">
<i class="bi bi-building me-1"></i>Commercial
</span>
}
else
{
<span class="badge bg-secondary bg-opacity-10 text-secondary">
<i class="bi bi-person me-1"></i>Individual
</span>
}
</p>
</div>
</div>
</div>
</div>
<!-- Contact Information -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-person me-2 text-primary"></i>Contact Information
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="text-muted small mb-1">Contact Name</label>
<p class="mb-0">
@if (!string.IsNullOrEmpty(Model.ContactFirstName) || !string.IsNullOrEmpty(Model.ContactLastName))
{
<span>@Model.ContactFirstName @Model.ContactLastName</span>
}
else
{
<span class="text-muted">Not provided</span>
}
</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Email</label>
<p class="mb-0">
<a href="mailto:@Model.Email" class="text-decoration-none">
<i class="bi bi-envelope me-1"></i>@Model.Email
</a>
</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Phone</label>
<p class="mb-0">
@if (!string.IsNullOrEmpty(Model.Phone))
{
<a href="tel:@Model.Phone" class="text-decoration-none">
<i class="bi bi-telephone me-1"></i>@Model.Phone
</a>
}
else
{
<span class="text-muted">Not provided</span>
}
</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Mobile Phone</label>
<p class="mb-0">
@if (!string.IsNullOrEmpty(Model.MobilePhone))
{
<a href="tel:@Model.MobilePhone" class="text-decoration-none">
<i class="bi bi-phone me-1"></i>@Model.MobilePhone
</a>
}
else
{
<span class="text-muted">Not provided</span>
}
</p>
</div>
</div>
</div>
</div>
<!-- Address Information -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-geo-alt me-2 text-primary"></i>Address
</h5>
</div>
<div class="card-body">
@if (!string.IsNullOrEmpty(Model.Address))
{
<p class="mb-2">@Model.Address</p>
<p class="mb-0">
@if (!string.IsNullOrEmpty(Model.City))
{
<span>@Model.City</span>
}
@if (!string.IsNullOrEmpty(Model.State))
{
<span>, @Model.State</span>
}
@if (!string.IsNullOrEmpty(Model.ZipCode))
{
<span> @Model.ZipCode</span>
}
</p>
@if (!string.IsNullOrEmpty(Model.Country))
{
<p class="mb-0 text-muted">@Model.Country</p>
}
}
else
{
<p class="text-muted mb-0">No address provided</p>
}
</div>
</div>
<!-- Business Information -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-briefcase me-2 text-primary"></i>Business Information
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="text-muted small mb-1">Tax ID / EIN</label>
<p class="mb-0">@(Model.TaxId ?? "Not provided")</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Payment Terms</label>
<p class="mb-0">@(Model.PaymentTerms ?? "Not set")</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Credit Limit</label>
<p class="mb-0 fw-semibold">@Model.CreditLimit.ToString("C")</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Pricing Tier</label>
<p class="mb-0">@(Model.PricingTierName ?? "Standard")</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Tax Status</label>
<p class="mb-0">
@if (Model.IsTaxExempt)
{
<span class="badge bg-success">
<i class="bi bi-check-circle"></i> Tax Exempt
</span>
}
else
{
<span class="badge bg-secondary">Taxable</span>
}
</p>
</div>
@if (Model.HasTaxExemptCertificate)
{
<div class="col-md-12">
<label class="text-muted small mb-1">Tax Exempt Certificate</label>
<div class="alert alert-success d-flex justify-content-between align-items-center mb-0 mt-2">
<div>
<i class="bi bi-file-earmark-check me-2"></i>
<strong>File on record:</strong> @Model.TaxExemptCertificateFileName
</div>
<a asp-action="TaxExemptCertificate" asp-route-id="@Model.Id" class="btn btn-sm btn-outline-dark" target="_blank">
<i class="bi bi-download"></i> Download
</a>
</div>
</div>
}
@if (Model.IsTaxExempt && !Model.HasTaxExemptCertificate)
{
<div class="col-md-12">
<label class="text-muted small mb-1">Tax Exempt Certificate</label>
<div class="alert alert-warning mb-0 mt-2">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>No certificate on file.</strong> Please upload a tax exempt certificate to complete the record.
</div>
</div>
}
</div>
</div>
</div>
<!-- Notes -->
@if (!string.IsNullOrEmpty(Model.GeneralNotes))
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-journal-text me-2 text-primary"></i>Notes
</h5>
</div>
<div class="card-body">
<p class="mb-0" style="white-space: pre-wrap;">@Model.GeneralNotes</p>
</div>
</div>
}
</div>
<!-- Right Column - Statistics -->
<div class="col-lg-4">
<!-- Financial Summary -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-currency-dollar me-2 text-primary"></i>Financial Summary
</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="text-muted small mb-1">Outstanding Balance</label>
<h3 class="mb-0 @(Model.CurrentBalance > 0 ? "text-danger" : "text-success")">
@Model.CurrentBalance.ToString("C")
</h3>
</div>
@if (Model.CreditLimit > 0)
{
<div class="mb-3">
<label class="text-muted small mb-1">Credit Limit</label>
<p class="mb-0 fw-semibold">@Model.CreditLimit.ToString("C")</p>
</div>
<div class="mb-3">
<label class="text-muted small mb-1">Available Credit</label>
<p class="mb-0 fw-semibold text-success">
@((Model.CreditLimit - Model.CurrentBalance).ToString("C"))
</p>
</div>
}
<hr class="my-2" />
<div class="d-flex justify-content-between align-items-center">
<div>
<label class="text-muted small mb-1">Store Credit Balance</label>
<h4 class="mb-0 @(Model.CreditBalance > 0 ? "text-success fw-bold" : "text-muted")">
@Model.CreditBalance.ToString("C")
</h4>
<small class="text-muted">Available for future invoices</small>
</div>
@if (User.IsInRole("SuperAdmin") || User.IsInRole("Administrator"))
{
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#addCreditModal">
<i class="bi bi-plus-circle me-1"></i>Add Credit
</button>
}
</div>
</div>
</div>
<!-- Store Credit History -->
@{
var creditMemos = ViewBag.CreditMemos as List<PowderCoating.Core.Entities.CreditMemo>;
}
@if (creditMemos != null && creditMemos.Count > 0)
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-credit-card me-2 text-primary"></i>Store Credit History
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th class="ps-3">Memo #</th>
<th>Issued</th>
<th class="text-end">Amount</th>
<th class="text-end pe-3">Remaining</th>
</tr>
</thead>
<tbody>
@foreach (var memo in creditMemos)
{
<tr>
<td class="ps-3">
<span class="fw-semibold small">@memo.MemoNumber</span>
<div class="text-muted" style="font-size:0.75rem;">@memo.Reason</div>
</td>
<td class="small text-muted align-middle">@memo.IssueDate.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yy")</td>
<td class="text-end align-middle small">@memo.Amount.ToString("C")</td>
<td class="text-end pe-3 align-middle">
@if (memo.RemainingBalance > 0)
{
<span class="badge bg-success">@memo.RemainingBalance.ToString("C")</span>
}
else
{
<span class="badge bg-secondary">Used</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
<!-- Activity -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-clock-history me-2 text-primary"></i>Activity
</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="text-muted small mb-1">Last Contact</label>
<p class="mb-0">
@if (Model.LastContactDate.HasValue)
{
<span>@Model.LastContactDate.Value.ToString("MMMM dd, yyyy")</span>
}
else
{
<span class="text-muted">No contact recorded</span>
}
</p>
</div>
<div>
<label class="text-muted small mb-1">Customer Since</label>
<p class="mb-0">@Model.CreatedAt.ToString("MMMM dd, yyyy")</p>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-lightning me-2 text-primary"></i>Quick Actions
</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-primary">
<i class="bi bi-pencil me-2"></i>Edit Customer
</a>
<a asp-action="JobHistory" asp-route-id="@Model.Id" class="btn btn-outline-success">
<i class="bi bi-clock-history me-2"></i>Job History
</a>
<a asp-action="Invoices" asp-route-id="@Model.Id" class="btn btn-outline-warning">
<i class="bi bi-receipt me-2"></i>View Invoices
</a>
<a asp-controller="Jobs" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-success">
<i class="bi bi-plus-circle me-2"></i>New Job
</a>
<a asp-controller="Quotes" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-info">
<i class="bi bi-file-text me-2"></i>New Quote
</a>
@if (User.IsInRole("SuperAdmin") || User.IsInRole("Administrator"))
{
<button type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#addCreditModal">
<i class="bi bi-wallet2 me-2"></i>Add Store Credit
</button>
}
<a asp-action="Delete" asp-route-id="@Model.Id" class="btn btn-outline-danger">
<i class="bi bi-trash me-2"></i>Delete Customer
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Add Store Credit Modal -->
@if (User.IsInRole("SuperAdmin") || User.IsInRole("Administrator"))
{
<div class="modal fade" id="addCreditModal" tabindex="-1" aria-labelledby="addCreditModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form asp-action="AddCredit" asp-route-id="@Model.Id" method="post">
@Html.AntiForgeryToken()
<div class="modal-header">
<h5 class="modal-title" id="addCreditModalLabel">
<i class="bi bi-wallet2 me-2 text-success"></i>Add Store Credit
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-3">
Credits can be applied to any future invoice for this customer.
Current balance: <strong class="text-success">@Model.CreditBalance.ToString("C")</strong>
</p>
<div class="mb-3">
<label class="form-label fw-semibold">Amount <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" name="Amount" class="form-control" step="0.01" min="0.01" max="99999.99" required placeholder="0.00" />
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Reason <span class="text-danger">*</span></label>
<select name="Reason" class="form-select" required id="creditReasonSelect">
<option value="">— Select reason —</option>
<option value="Pre-payment / Deposit">Pre-payment / Deposit</option>
<option value="Gift / Gift Card">Gift / Gift Card</option>
<option value="Overpayment credit">Overpayment credit</option>
<option value="Goodwill credit">Goodwill credit</option>
<option value="Other">Other</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Notes</label>
<textarea name="Notes" class="form-control" rows="2" maxlength="1000" placeholder="Optional details..."></textarea>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Expiry Date <span class="text-muted fw-normal">(optional)</span></label>
<input type="date" name="ExpiryDate" class="form-control" />
<div class="form-text">Leave blank for no expiry.</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-plus-circle me-1"></i>Add Credit
</button>
</div>
</form>
</div>
</div>
</div>
}
@@ -0,0 +1,439 @@
@model PowderCoating.Application.DTOs.Customer.UpdateCustomerDto
@{
ViewData["Title"] = "Edit Customer";
ViewData["PageIcon"] = "bi-pencil-square";
}
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-end align-items-center mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to List
</a>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form asp-action="Edit" method="post">
<input type="hidden" asp-for="Id" />
<partial name="_ValidationSummary" />
<!-- Company Information Section -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0"><i class="bi bi-building me-2 text-primary"></i>Company Information</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Company Information"
data-bs-content="Company Name is used on quotes, invoices, and correspondence. Customer Type controls which features are available — Commercial customers get payment terms, credit limits, and pricing tier discounts. Status Inactive hides the customer from new quote/job dropdowns but preserves all history.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="CompanyName" class="form-label">Company Name</label>
<input asp-for="CompanyName" class="form-control" placeholder="Enter company name" />
<span asp-validation-for="CompanyName" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="IsCommercial" class="form-label">Customer Type
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Customer Type"
data-bs-content="Commercial: businesses with ongoing work, purchase orders, and invoicing. Individual: walk-in customers or one-off jobs. This affects which fields are shown and whether pricing tier discounts apply.">
<i class="bi bi-question-circle"></i>
</a>
</label>
<select asp-for="IsCommercial" class="form-select">
<option value="false">Individual</option>
<option value="true">Commercial</option>
</select>
</div>
<div class="col-md-3">
<label asp-for="IsActive" class="form-label">Status</label>
<select asp-for="IsActive" class="form-select">
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
</div>
<!-- Contact Information Section -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-person me-2 text-primary"></i>Contact Information
</h5>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="ContactFirstName" class="form-label">First Name</label>
<input asp-for="ContactFirstName" class="form-control" placeholder="Enter first name" />
<span asp-validation-for="ContactFirstName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="ContactLastName" class="form-label">Last Name</label>
<input asp-for="ContactLastName" class="form-control" placeholder="Enter last name" />
<span asp-validation-for="ContactLastName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Email" class="form-label">Email</label>
<input asp-for="Email" type="email" class="form-control" placeholder="name@example.com" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="Phone" class="form-label">Phone</label>
<input asp-for="Phone" type="tel" class="form-control" placeholder="(555) 123-4567" />
<span asp-validation-for="Phone" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="MobilePhone" class="form-label">Mobile Phone</label>
<input asp-for="MobilePhone" type="tel" class="form-control" placeholder="(555) 123-4567" />
<span asp-validation-for="MobilePhone" class="text-danger"></span>
</div>
</div>
</div>
<!-- Address Section -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-geo-alt me-2 text-primary"></i>Address
</h5>
<div class="row g-3">
<div class="col-12">
<label asp-for="Address" class="form-label">Street Address</label>
<input asp-for="Address" class="form-control" placeholder="Enter street address" />
<span asp-validation-for="Address" class="text-danger"></span>
</div>
<div class="col-md-5">
<label asp-for="City" class="form-label">City</label>
<input asp-for="City" class="form-control" placeholder="Enter city" />
<span asp-validation-for="City" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="State" class="form-label">State</label>
<input asp-for="State" class="form-control" placeholder="Enter state" />
<span asp-validation-for="State" class="text-danger"></span>
</div>
<div class="col-md-2">
<label asp-for="ZipCode" class="form-label">Zip Code</label>
<input asp-for="ZipCode" class="form-control" placeholder="12345" />
<span asp-validation-for="ZipCode" class="text-danger"></span>
</div>
<div class="col-md-2">
<label asp-for="Country" class="form-label">Country</label>
<select asp-for="Country" class="form-select">
<option value="">-- Select --</option>
<option value="USA">USA</option>
<option value="Canada">Canada</option>
<option value="Mexico">Mexico</option>
<option value="Afghanistan">Afghanistan</option>
<option value="Albania">Albania</option>
<option value="Algeria">Algeria</option>
<option value="Argentina">Argentina</option>
<option value="Australia">Australia</option>
<option value="Austria">Austria</option>
<option value="Bangladesh">Bangladesh</option>
<option value="Belgium">Belgium</option>
<option value="Bolivia">Bolivia</option>
<option value="Brazil">Brazil</option>
<option value="Chile">Chile</option>
<option value="China">China</option>
<option value="Colombia">Colombia</option>
<option value="Costa Rica">Costa Rica</option>
<option value="Croatia">Croatia</option>
<option value="Czech Republic">Czech Republic</option>
<option value="Denmark">Denmark</option>
<option value="Dominican Republic">Dominican Republic</option>
<option value="Ecuador">Ecuador</option>
<option value="Egypt">Egypt</option>
<option value="El Salvador">El Salvador</option>
<option value="Finland">Finland</option>
<option value="France">France</option>
<option value="Germany">Germany</option>
<option value="Ghana">Ghana</option>
<option value="Greece">Greece</option>
<option value="Guatemala">Guatemala</option>
<option value="Honduras">Honduras</option>
<option value="Hungary">Hungary</option>
<option value="India">India</option>
<option value="Indonesia">Indonesia</option>
<option value="Iran">Iran</option>
<option value="Iraq">Iraq</option>
<option value="Ireland">Ireland</option>
<option value="Israel">Israel</option>
<option value="Italy">Italy</option>
<option value="Japan">Japan</option>
<option value="Jordan">Jordan</option>
<option value="Kazakhstan">Kazakhstan</option>
<option value="Kenya">Kenya</option>
<option value="South Korea">South Korea</option>
<option value="Kuwait">Kuwait</option>
<option value="Malaysia">Malaysia</option>
<option value="Netherlands">Netherlands</option>
<option value="New Zealand">New Zealand</option>
<option value="Nicaragua">Nicaragua</option>
<option value="Nigeria">Nigeria</option>
<option value="Norway">Norway</option>
<option value="Pakistan">Pakistan</option>
<option value="Panama">Panama</option>
<option value="Paraguay">Paraguay</option>
<option value="Peru">Peru</option>
<option value="Philippines">Philippines</option>
<option value="Poland">Poland</option>
<option value="Portugal">Portugal</option>
<option value="Puerto Rico">Puerto Rico</option>
<option value="Romania">Romania</option>
<option value="Russia">Russia</option>
<option value="Saudi Arabia">Saudi Arabia</option>
<option value="South Africa">South Africa</option>
<option value="Spain">Spain</option>
<option value="Sweden">Sweden</option>
<option value="Switzerland">Switzerland</option>
<option value="Taiwan">Taiwan</option>
<option value="Thailand">Thailand</option>
<option value="Turkey">Turkey</option>
<option value="Ukraine">Ukraine</option>
<option value="United Arab Emirates">United Arab Emirates</option>
<option value="United Kingdom">United Kingdom</option>
<option value="Uruguay">Uruguay</option>
<option value="Venezuela">Venezuela</option>
<option value="Vietnam">Vietnam</option>
</select>
<span asp-validation-for="Country" class="text-danger"></span>
</div>
</div>
</div>
<!-- Business Information Section -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0"><i class="bi bi-briefcase me-2 text-primary"></i>Business Information</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Business Information"
data-bs-content="Payment Terms sets the default due date on invoices (e.g., Net 30 = 30 days from invoice date). Credit Limit is a soft warning cap — the system alerts when exceeded. Tax Exempt removes tax from all invoices; upload the exemption certificate in the Tax Exempt Certificate section below.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="TaxId" class="form-label">Tax ID / EIN</label>
<input asp-for="TaxId" class="form-control" placeholder="Enter tax ID" />
<span asp-validation-for="TaxId" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="PaymentTerms" class="form-label">Payment Terms</label>
<select asp-for="PaymentTerms" class="form-select">
<option value="">Select payment terms</option>
<option value="Net 15">Net 15</option>
<option value="Net 30">Net 30</option>
<option value="Net 45">Net 45</option>
<option value="Net 60">Net 60</option>
<option value="Due on Receipt">Due on Receipt</option>
<option value="Cash on Delivery">Cash on Delivery</option>
</select>
<span asp-validation-for="PaymentTerms" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="PricingTierId" class="form-label">Pricing Tier</label>
<select asp-for="PricingTierId" asp-items="ViewBag.PricingTiers" class="form-select">
<option value="">— No tier —</option>
</select>
<small class="text-muted">Applies a discount to all quotes for this customer.</small>
</div>
<div class="col-md-3">
<label asp-for="CreditLimit" class="form-label">Credit Limit</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="CreditLimit" type="number" step="0.01" min="0" class="form-control" placeholder="0.00" />
</div>
<span asp-validation-for="CreditLimit" class="text-danger"></span>
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-md-6">
<div class="form-check form-switch">
<input asp-for="IsTaxExempt" class="form-check-input" type="checkbox" />
<label asp-for="IsTaxExempt" class="form-check-label">Tax Exempt Customer</label>
</div>
<small class="text-muted">Check this box if the customer is tax exempt</small>
</div>
</div>
</div>
<!-- Notes Section -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-journal-text me-2 text-primary"></i>Notes
</h5>
<div class="row g-3">
<div class="col-12">
<label asp-for="GeneralNotes" class="form-label">General Notes</label>
<textarea asp-for="GeneralNotes" class="form-control" rows="4" placeholder="Enter any additional notes about this customer"></textarea>
<span asp-validation-for="GeneralNotes" class="text-danger"></span>
</div>
</div>
</div>
<!-- Notification Preferences -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0"><i class="bi bi-bell me-2 text-primary"></i>Notification Preferences</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Notification Preferences"
data-bs-content="Controls when the customer receives automatic updates. Email notifications send status change alerts (e.g., job ready for pickup) to the customer's email address. SMS requires separate TCPA consent — uncheck 'SMS Notifications Active' to temporarily pause without revoking consent.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<!-- Email -->
<div class="col-md-6">
<div class="form-check form-switch">
<input asp-for="NotifyByEmail" class="form-check-input" type="checkbox" role="switch" />
<label asp-for="NotifyByEmail" class="form-check-label">
<i class="bi bi-envelope me-1"></i>Email Notifications
</label>
<div class="form-text">Receive quote and job status updates by email.</div>
</div>
</div>
</div>
@if (ViewBag.SmsEnabled == true)
{
<!-- SMS: show consent status or consent capture depending on existing consent -->
<div class="mt-3">
@if (Model.SmsConsentedAt.HasValue)
{
<!-- Consent already recorded — show status and allow pause/resume -->
<div class="card border-success bg-success-subtle p-3 mb-2">
<div class="d-flex align-items-start gap-3">
<i class="bi bi-shield-fill-check text-success fs-4 mt-1"></i>
<div class="flex-fill">
<div class="fw-semibold text-success mb-1">SMS Consent Recorded</div>
<div class="small text-body-secondary">
Consent method: <strong>@Model.SmsConsentMethod</strong><br />
Recorded: <strong>@Model.SmsConsentedAt.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy 'at' h:mm tt")</strong>
</div>
<div class="mt-2">
<div class="form-check form-switch">
<input asp-for="NotifyBySms" class="form-check-input" type="checkbox" role="switch" />
<label asp-for="NotifyBySms" class="form-check-label">
<i class="bi bi-phone me-1"></i>SMS Notifications Active
</label>
<div class="form-text">Uncheck to temporarily pause SMS without revoking consent.</div>
</div>
</div>
</div>
</div>
</div>
}
else
{
<!-- No consent on file — show the compliance notice and consent checkbox -->
<div class="alert alert-warning border-warning alert-permanent" role="alert">
<h6 class="alert-heading fw-bold mb-2">
<i class="bi bi-exclamation-triangle-fill me-2"></i>SMS Consent Requirement (TCPA)
</h6>
<p class="mb-2">
Federal law (TCPA) requires <strong>explicit prior verbal or written consent</strong> before sending SMS messages.
Before enabling SMS notifications, you must:
</p>
<ol class="mb-2 ps-3">
<li>Inform the customer they will receive automated texts for job updates and pickup alerts.</li>
<li>Inform them that message and data rates may apply.</li>
<li>Explain they can reply <strong>STOP</strong> at any time to opt out.</li>
<li>Obtain their clear verbal or written agreement.</li>
</ol>
<p class="mb-0 small text-muted">
Only check the box below <strong>after</strong> the customer has given consent.
A confirmation text will be sent automatically to verify enrollment.
</p>
</div>
<div class="card border-secondary bg-body-secondary p-3">
<div class="form-check">
<input asp-for="SmsConsentGranted" class="form-check-input" type="checkbox" id="SmsConsentGranted" />
<label class="form-check-label fw-semibold" for="SmsConsentGranted">
<i class="bi bi-shield-check me-1 text-success"></i>
Customer has verbally consented to receive SMS notifications
</label>
<div class="form-text">
Checking this box records consent on behalf of the customer and triggers a confirmation text.
A mobile phone number must be entered above.
</div>
</div>
</div>
}
</div>
}
</div>
<!-- Form Actions -->
<div class="d-flex gap-2 justify-content-end pt-3 border-top">
<a asp-action="Index" class="btn btn-outline-secondary px-4">Cancel</a>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-save me-2"></i>Save Changes
</button>
</div>
</form>
</div>
</div>
<!-- Tax Exempt Certificate Section (Outside main form) -->
<div class="card border-0 shadow-sm mt-4">
<div class="card-body p-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-file-earmark-check me-2 text-primary"></i>Tax Exempt Certificate
</h5>
<div class="row g-3">
<div class="col-md-6">
@if (Model.HasTaxExemptCertificate)
{
<div class="alert alert-success d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-file-earmark-check me-2"></i>
<strong>Certificate on file:</strong> @Model.TaxExemptCertificateFileName
</div>
<div class="btn-group">
<a asp-action="TaxExemptCertificate" asp-route-id="@Model.Id" class="btn btn-sm btn-outline-primary" target="_blank">
<i class="bi bi-download"></i> Download
</a>
<form asp-action="DeleteTaxExemptCertificate" asp-route-id="@Model.Id" method="post" style="display:inline;"
onsubmit="return confirm('Are you sure you want to delete this certificate?');">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i> Delete
</button>
</form>
</div>
</div>
}
else
{
<p class="text-muted">No tax exempt certificate on file.</p>
}
</div>
<div class="col-md-6">
<form asp-action="UploadTaxExemptCertificate" asp-route-id="@Model.Id" method="post" enctype="multipart/form-data">
@Html.AntiForgeryToken()
<div class="mb-2">
<label class="form-label">Upload New Certificate</label>
<input type="file" name="certificateFile" class="form-control" accept=".pdf,.jpg,.jpeg,.png" />
<small class="text-muted">Accepted formats: PDF, JPG, PNG (Max 10 MB)</small>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-upload"></i> Upload Certificate
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
@@ -0,0 +1,309 @@
@model PagedResult<PowderCoating.Application.DTOs.Customer.CustomerListDto>
@{
ViewData["Title"] = "Customers";
ViewData["PageIcon"] = "bi-people";
ViewData["PageHelpTitle"] = "Customers";
ViewData["PageHelpContent"] = "Customers are companies or individuals who bring in work. Commercial customers get business features like payment terms, credit limits, and pricing tier discounts. Individual customers are typically walk-in or one-off jobs. Balance shown here is the total outstanding across all unpaid invoices.";
}
<div class="pcl-metric-strip">
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "TOTAL", Value: Model.TotalCount.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "ACTIVE", Value: Model.Items.Count(c => c.IsActive).ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "COMMERCIAL", Value: Model.Items.Count(c => c.IsCommercial).ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "TOTAL BALANCE", Value: Model.Items.Sum(c => c.CurrentBalance).ToString("C0"), Delta: (string?)null, DeltaDir: (string?)null))
</div>
</div>
<!-- Customers Table Card -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3">
<div class="d-flex flex-column flex-sm-row gap-2 w-100 w-lg-auto">
<form method="get" class="d-flex flex-column flex-sm-row gap-2 flex-grow-1 flex-lg-grow-0">
<div class="input-group" style="max-width: 350px; min-width: 200px;">
<span class="input-group-text bg-white border-end-0">
<i class="bi bi-search text-muted"></i>
</span>
<input type="text" name="searchTerm" class="form-control border-start-0"
placeholder="Search customers..." value="@ViewBag.SearchTerm">
</div>
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm))
{
<a asp-action="Index" class="btn btn-outline-secondary">Clear</a>
}
</form>
<a asp-action="Create" class="btn btn-primary text-nowrap">
<i class="bi bi-plus-circle me-2"></i>
<span class="d-none d-sm-inline">Add Customer</span>
<span class="d-inline d-sm-none">Add</span>
</a>
</div>
</div>
</div>
<div class="card-body p-0">
@if (!Model.Items.Any())
{
<div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No customers found</h5>
<p class="text-muted mb-4">Get started by adding your first customer</p>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Add Your First Customer
</a>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th sortable="CompanyName" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="ps-4">Company</th>
<th sortable="ContactName" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Contact</th>
<th sortable="Email" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Email</th>
<th sortable="Phone" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Phone</th>
<th>Type</th>
<th sortable="CurrentBalance" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Balance</th>
<th sortable="IsActive" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Status</th>
<th class="text-end pe-4">Actions</th>
</tr>
</thead>
<tbody id="customerTable">
@foreach (var customer in Model.Items)
{
<tr class="customer-row" data-customer-id="@customer.Id" style="cursor: pointer;">
<td class="ps-4">
<div class="d-flex align-items-center gap-2">
<div class="rounded-circle d-flex align-items-center justify-content-center"
style="width: 40px; height: 40px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; font-weight: 600;">
@{
var initial = "?";
if (!string.IsNullOrEmpty(customer.CompanyName) && customer.CompanyName.Length > 0)
{
initial = customer.CompanyName.Substring(0, 1).ToUpper();
}
else if (!string.IsNullOrEmpty(customer.ContactName) && customer.ContactName.Length > 0)
{
initial = customer.ContactName.Substring(0, 1).ToUpper();
}
}
@initial
</div>
<div>
<div class="fw-semibold">@(string.IsNullOrEmpty(customer.CompanyName) ? customer.ContactName ?? "Individual Customer" : customer.CompanyName)</div>
@if (customer.LastContactDate.HasValue)
{
<small class="text-muted">Last contact: @customer.LastContactDate.Value.ToString("MMM dd, yyyy")</small>
}
</div>
</div>
</td>
<td>
@if (!string.IsNullOrEmpty(customer.ContactName))
{
<span>@customer.ContactName</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
@if (!string.IsNullOrEmpty(customer.Email))
{
<a href="mailto:@customer.Email" class="text-decoration-none">
<i class="bi bi-envelope me-1"></i>@customer.Email
</a>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
@if (!string.IsNullOrEmpty(customer.Phone))
{
<a href="tel:@customer.Phone" class="text-decoration-none">
<i class="bi bi-telephone me-1"></i>@customer.Phone
</a>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
@await Html.PartialAsync("_StatusChip", (Kind: customer.IsCommercial ? "cool" : "neutral", Text: customer.IsCommercial ? "Commercial" : "Individual"))
</td>
<td>
<span class="@(customer.CurrentBalance > 0 ? "text-danger fw-semibold" : "text-success")">
@customer.CurrentBalance.ToString("C")
</span>
</td>
<td>
@await Html.PartialAsync("_StatusChip", (Kind: StatusChipHelper.Active(customer.IsActive), Text: customer.IsActive ? "Active" : "Inactive"))
</td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm">
<a asp-action="Details" asp-route-id="@customer.Id" class="btn btn-outline-primary" title="View Details">
<i class="bi bi-eye"></i>
</a>
<a asp-action="Edit" asp-route-id="@customer.Id" class="btn btn-outline-warning" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a asp-action="Delete" asp-route-id="@customer.Id" class="btn btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</a>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div class="mobile-card-view">
@if (!Model.Items.Any())
{
<div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No customers found</h5>
<p class="text-muted mb-4">Get started by adding your first customer</p>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Add Your First Customer
</a>
</div>
}
else
{
<div class="mobile-card-list">
@foreach (var customer in Model.Items)
{
<div class="mobile-data-card"
data-id="@customer.Id"
onclick="window.location.href='@Url.Action("Details", new { id = customer.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<i class="bi bi-@(customer.IsCommercial ? "building" : "person")"></i>
</div>
<div class="mobile-card-title">
<h6>@(string.IsNullOrEmpty(customer.CompanyName) ? customer.ContactName ?? "Individual Customer" : customer.CompanyName)</h6>
<small>@customer.Email</small>
</div>
</div>
<div class="mobile-card-body">
@if (!string.IsNullOrEmpty(customer.ContactName))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Contact</span>
<span class="mobile-card-value">@customer.ContactName</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Phone</span>
<span class="mobile-card-value">@(customer.Phone ?? "—")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Type</span>
<span class="mobile-card-value">
@if (customer.IsCommercial)
{
<span class="badge bg-primary bg-opacity-10 text-primary">
<i class="bi bi-building me-1"></i>Commercial
</span>
}
else
{
<span class="badge bg-secondary bg-opacity-10 text-secondary">
<i class="bi bi-person me-1"></i>Individual
</span>
}
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Balance</span>
<span class="mobile-card-value @(customer.CurrentBalance > 0 ? "text-danger fw-semibold" : "text-success")">
@customer.CurrentBalance.ToString("C")
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@if (customer.IsActive)
{
<span class="badge bg-success bg-opacity-10 text-success">
<i class="bi bi-check-circle me-1"></i>Active
</span>
}
else
{
<span class="badge bg-danger bg-opacity-10 text-danger">
<i class="bi bi-x-circle me-1"></i>Inactive
</span>
}
</span>
</div>
@if (customer.LastContactDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Last Contact</span>
<span class="mobile-card-value">@customer.LastContactDate.Value.ToString("MMM dd, yyyy")</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a href="@Url.Action("Details", new { id = customer.Id })"
class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation();">
<i class="bi bi-eye me-1"></i>View
</a>
<a href="@Url.Action("Edit", new { id = customer.Id })"
class="btn btn-sm btn-outline-secondary"
onclick="event.stopPropagation();">
<i class="bi bi-pencil me-1"></i>Edit
</a>
</div>
</div>
}
</div>
}
</div>
}
</div>
@if (Model.TotalCount > 0)
{
@await Html.PartialAsync("_Pagination", Model)
}
</div>
@section Scripts {
<script>
// Make table rows clickable
document.querySelectorAll('.customer-row').forEach(row => {
row.addEventListener('click', function(e) {
// Don't navigate if clicking on action buttons or links
if (e.target.closest('.btn-group') || e.target.closest('a') || e.target.closest('button')) {
return;
}
const customerId = this.getAttribute('data-customer-id');
window.location.href = '@Url.Action("Details", "Customers")/' + customerId;
});
// Hover handled by CSS .table tbody tr:hover
});
</script>
}
@@ -0,0 +1,142 @@
@model PowderCoating.Application.DTOs.Common.PagedResult<PowderCoating.Application.DTOs.Invoice.InvoiceListDto>
@using PowderCoating.Core.Enums
@using PowderCoating.Web.Controllers
@{
ViewData["Title"] = $"Invoices - {ViewBag.CustomerName}";
ViewData["PageIcon"] = "bi-receipt";
}
<div class="d-flex justify-content-end gap-2 mb-4">
<a asp-controller="Invoices" asp-action="Create" asp-route-customerId="@ViewBag.CustomerId" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>New Invoice
</a>
<a asp-action="Details" asp-route-id="@ViewBag.CustomerId" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to Customer
</a>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold">
@Model.TotalCount invoice(s) total
</h5>
</div>
</div>
<div class="card-body p-0">
@if (!Model.Items.Any())
{
<div class="text-center py-5">
<i class="bi bi-receipt" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No invoices found</h5>
<p class="text-muted mb-4">This customer has no invoices yet</p>
<a asp-controller="Invoices" asp-action="Create" asp-route-customerId="@ViewBag.CustomerId" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Create Invoice
</a>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th sortable="InvoiceNumber" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="ps-4">Invoice #</th>
<th>Job #</th>
<th sortable="Status" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Status</th>
<th sortable="DueDate" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Due Date</th>
<th sortable="Total" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="text-end">Total</th>
<th sortable="BalanceDue" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="text-end">Balance Due</th>
<th class="text-end pe-4">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var inv in Model.Items)
{
<tr style="cursor: pointer;" onclick="window.location.href='@Url.Action("Details", "Invoices", new { id = inv.Id })'">
<td class="ps-4">
<div class="d-flex align-items-center gap-2">
<div class="rounded-circle d-flex align-items-center justify-content-center"
style="width: 40px; height: 40px; background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white; font-weight: 600;">
<i class="bi bi-receipt"></i>
</div>
<div>
<div class="fw-semibold">@inv.InvoiceNumber</div>
<small class="text-muted">@inv.InvoiceDate.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM dd, yyyy")</small>
</div>
</div>
</td>
<td class="align-middle">
@if (inv.JobId.HasValue && !string.IsNullOrEmpty(inv.JobNumber))
{
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@inv.JobId"
class="text-decoration-none" onclick="event.stopPropagation()">
@inv.JobNumber
</a>
}
else
{
<span class="text-muted fst-italic">No job</span>
}
</td>
<td class="align-middle">
<span class="badge bg-@InvoicesController.GetStatusColorClass(inv.Status)">
@InvoicesController.GetStatusDisplay(inv.Status)
</span>
@if (inv.IsOverdue)
{
<span class="badge bg-danger ms-1">Overdue</span>
}
</td>
<td class="align-middle">
@if (inv.DueDate.HasValue)
{
<span class="@(inv.IsOverdue ? "text-danger fw-semibold" : "")">
@inv.DueDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM dd, yyyy")
@if (inv.IsOverdue)
{
<i class="bi bi-exclamation-triangle ms-1"></i>
}
</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="align-middle text-end fw-semibold">@inv.Total.ToString("C")</td>
<td class="align-middle text-end">
@if (inv.BalanceDue > 0)
{
<span class="fw-semibold text-danger">@inv.BalanceDue.ToString("C")</span>
}
else
{
<span class="text-success fw-semibold">Paid</span>
}
</td>
<td class="text-end pe-4 align-middle" onclick="event.stopPropagation()">
<div class="btn-group btn-group-sm">
<a asp-controller="Invoices" asp-action="Details" asp-route-id="@inv.Id"
class="btn btn-outline-primary" title="View Invoice">
<i class="bi bi-eye"></i>
</a>
<a asp-controller="Invoices" asp-action="DownloadPdf" asp-route-id="@inv.Id"
class="btn btn-outline-secondary" title="Download PDF">
<i class="bi bi-file-earmark-pdf"></i>
</a>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@if (Model.TotalCount > 0)
{
@await Html.PartialAsync("_Pagination", Model)
}
</div>
@@ -0,0 +1,181 @@
@model PowderCoating.Application.DTOs.Common.PagedResult<PowderCoating.Application.DTOs.Job.JobListDto>
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = $"Job History - {ViewBag.CustomerName}";
ViewData["PageIcon"] = "bi-briefcase";
}
<div class="d-flex justify-content-end gap-2 mb-4">
<a asp-action="Create" asp-controller="Jobs" asp-route-customerId="@ViewBag.CustomerId" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>New Job
</a>
<a asp-action="Details" asp-route-id="@ViewBag.CustomerId" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to Customer
</a>
</div>
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm))
{
<div class="alert alert-info alert-permanent d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-funnel me-2"></i>
Showing <strong>@Model.TotalCount</strong> job(s) matching "<strong>@ViewBag.SearchTerm</strong>"
</div>
<a href="@Url.Action("JobHistory", new { id = ViewBag.CustomerId })" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x me-1"></i>Clear Filter
</a>
</div>
}
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold">
@Model.TotalCount job(s) total
</h5>
<form asp-action="JobHistory" asp-route-id="@ViewBag.CustomerId" method="get" class="d-flex gap-2">
<div class="input-group" style="width: 350px;">
<span class="input-group-text bg-white border-end-0">
<i class="bi bi-search text-muted"></i>
</span>
<input type="text" name="searchTerm" class="form-control border-start-0"
placeholder="Search by job #, description, PO, status..."
value="@ViewBag.SearchTerm"
aria-label="Search jobs">
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm))
{
<a href="@Url.Action("JobHistory", new { id = ViewBag.CustomerId })" class="btn btn-outline-secondary" title="Clear search">
<i class="bi bi-x-lg"></i>
</a>
}
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i>
</button>
</div>
</form>
</div>
</div>
<div class="card-body p-0">
@if (!Model.Items.Any())
{
<div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No jobs found</h5>
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm))
{
<p class="text-muted mb-4">No jobs match your search criteria</p>
}
else
{
<p class="text-muted mb-4">This customer has no jobs yet</p>
<a asp-action="Create" asp-controller="Jobs" asp-route-customerId="@ViewBag.CustomerId" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Create First Job
</a>
}
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th sortable="JobNumber" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="ps-4">Job Number</th>
<th>Description</th>
<th sortable="Status" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Status</th>
<th sortable="Priority" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Priority</th>
<th sortable="ScheduledDate" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Scheduled</th>
<th sortable="DueDate" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Due Date</th>
<th sortable="FinalPrice" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Price</th>
<th class="text-end pe-4">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var job in Model.Items)
{
<tr style="cursor: pointer;" onclick="window.location.href='@Url.Action("Details", "Jobs", new { id = job.Id })'">
<td class="ps-4">
<div class="d-flex align-items-center gap-2">
<div class="rounded-circle d-flex align-items-center justify-content-center"
style="width: 40px; height: 40px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; font-weight: 600;">
<i class="bi bi-briefcase"></i>
</div>
<div>
<div class="fw-semibold">@job.JobNumber</div>
<small class="text-muted">Created @job.CreatedAt.ToString("MMM dd, yyyy")</small>
</div>
</div>
</td>
<td>@job.Description</td>
<td>
<span class="badge bg-@job.StatusColorClass bg-opacity-10 text-@job.StatusColorClass">
@job.StatusDisplayName
</span>
</td>
<td>
<span class="badge bg-@job.PriorityColorClass">@job.PriorityDisplayName</span>
</td>
<td>
@if (job.ScheduledDate.HasValue)
{
<span>@job.ScheduledDate.Value.ToString("MMM dd, yyyy")</span>
}
else
{
<span class="text-muted">Not scheduled</span>
}
</td>
<td>
@if (job.DueDate.HasValue)
{
var isOverdue = job.DueDate.Value < DateTime.Now && job.StatusCode != "COMPLETED" && job.StatusCode != "READYFORPICKUP" && job.StatusCode != "DELIVERED";
<span class="@(isOverdue ? "text-danger fw-semibold" : "")">
@job.DueDate.Value.ToString("MMM dd, yyyy")
@if (isOverdue)
{
<i class="bi bi-exclamation-triangle ms-1"></i>
}
</span>
}
else
{
<span class="text-muted">Not set</span>
}
</td>
<td>
<span class="fw-semibold">@job.FinalPrice.ToString("C")</span>
</td>
<td class="text-end pe-4" onclick="event.stopPropagation()">
<div class="btn-group btn-group-sm">
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@job.Id" class="btn btn-outline-primary" title="View Details">
<i class="bi bi-eye"></i>
</a>
<a asp-controller="Jobs" asp-action="Edit" asp-route-id="@job.Id" class="btn btn-outline-warning" title="Edit">
<i class="bi bi-pencil"></i>
</a>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@if (Model.TotalCount > 0)
{
@await Html.PartialAsync("_Pagination", Model)
}
</div>
@section Scripts {
<script>
function changePageSize(size) {
const url = new URL(window.location.href);
url.searchParams.set('pageSize', size);
url.searchParams.set('pageNumber', '1');
window.location.href = url.toString();
}
</script>
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,276 @@
@using PowderCoating.Application.DTOs.Dashboard
@using PowderCoating.Core.Enums
@model SuperAdminDashboardViewModel
@{
ViewData["Title"] = "Platform Dashboard";
ViewData["PageIcon"] = "bi-shield-check";
// Badge color by position in sorted plan distribution (1st=secondary, 2nd=primary, 3rd=info, 4th+=success)
var planBadgeColors = Model.PlanDistribution.Keys
.Select((k, i) => (k, i))
.ToDictionary(x => x.k, x => x.i switch {
0 => "bg-secondary",
1 => "bg-primary",
2 => "bg-info",
_ => "bg-success"
});
string PlanBadge(int plan) => planBadgeColors.TryGetValue(plan, out var c) ? c : "bg-secondary";
string StatusBadge(SubscriptionStatus status) => status switch {
SubscriptionStatus.Active => "bg-success-subtle text-success",
SubscriptionStatus.GracePeriod => "bg-warning-subtle text-warning",
SubscriptionStatus.Expired => "bg-danger-subtle text-danger",
SubscriptionStatus.Canceled => "bg-secondary-subtle text-secondary",
_ => "bg-secondary-subtle text-secondary"
};
}
<!-- Platform Overview Header -->
<div class="d-flex justify-content-end mb-4">
<span class="badge bg-warning text-dark fs-6 px-3 py-2">
<i class="bi bi-shield-fill me-1"></i>Super Admin Mode
</span>
</div>
<!-- Platform Stats Row -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3" style="background: rgba(79,70,229,0.1);">
<i class="bi bi-building fs-4" style="color: #4f46e5;"></i>
</div>
<div>
<div class="fs-2 fw-bold lh-1">@Model.TotalCompanies</div>
<div class="small text-muted">Total Companies</div>
<div class="small mt-1">
<span class="text-success">@Model.ActiveCompanies active</span>
@if (Model.InactiveCompanies > 0)
{
<span class="text-danger ms-1">· @Model.InactiveCompanies inactive</span>
}
</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3" style="background: rgba(16,185,129,0.1);">
<i class="bi bi-people fs-4" style="color: #10b981;"></i>
</div>
<div>
<div class="fs-2 fw-bold lh-1">@Model.TotalUsers</div>
<div class="small text-muted">Total Users</div>
<div class="small text-muted mt-1">across all companies</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3" style="background: rgba(245,158,11,0.1);">
<i class="bi bi-check-circle fs-4" style="color: #f59e0b;"></i>
</div>
<div>
<div class="fs-2 fw-bold lh-1">@Model.ActiveSubscriptions</div>
<div class="small text-muted">Active Subscriptions</div>
<div class="small mt-1">
@if (Model.GracePeriodCount > 0)
{
<span class="text-warning">@Model.GracePeriodCount grace period</span>
}
@if (Model.ExpiredCount > 0)
{
<span class="text-danger @(Model.GracePeriodCount > 0 ? "ms-1" : "")">@Model.ExpiredCount expired</span>
}
@if (Model.GracePeriodCount == 0 && Model.ExpiredCount == 0)
{
<span class="text-success">all healthy</span>
}
</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3" style="background: rgba(139,92,246,0.1);">
<i class="bi bi-layers fs-4" style="color: #8b5cf6;"></i>
</div>
<div>
<div class="small text-muted mb-2">Plan Distribution</div>
<div class="d-flex flex-column gap-1">
@foreach (var kv in Model.PlanDistribution)
{
<div class="d-flex align-items-center gap-2">
<span class="badge @PlanBadge(kv.Key)" style="min-width:60px;">@kv.Value.DisplayName</span>
<span class="fw-semibold">@kv.Value.Count</span>
</div>
}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<!-- Companies Needing Attention -->
<div class="col-lg-7">
<div class="card border-0 shadow-sm h-100">
<div class="card-header d-flex align-items-center gap-2">
<i class="bi bi-exclamation-triangle text-warning"></i>
<span>Subscriptions Needing Attention</span>
@if (Model.CompanyAlerts.Any())
{
<span class="badge bg-warning text-dark ms-auto">@Model.CompanyAlerts.Count</span>
}
</div>
@if (!Model.CompanyAlerts.Any())
{
<div class="card-body text-center py-5">
<i class="bi bi-check-circle-fill text-success fs-1 mb-3 d-block"></i>
<p class="text-muted mb-0">All subscriptions are in good standing.</p>
</div>
}
else
{
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Company</th>
<th>Plan</th>
<th>Status</th>
<th>Expired</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var alert in Model.CompanyAlerts)
{
var manageUrl = Url.Action("Manage", "SubscriptionManagement", new { id = alert.Id });
<tr style="cursor:pointer" onclick="window.location='@manageUrl'">
<td>
<div class="fw-semibold">@alert.CompanyName</div>
@if (!alert.IsActive)
{
<small class="badge bg-danger-subtle text-danger">Inactive</small>
}
</td>
<td>
<span class="badge @PlanBadge(alert.Plan)">@alert.PlanDisplayName</span>
</td>
<td>
<span class="badge @StatusBadge(alert.Status)">@alert.Status</span>
</td>
<td>
@if (alert.SubscriptionEndDate.HasValue)
{
<span class="text-danger small">@alert.SubscriptionEndDate.Value.ToString("MMM d, yyyy")</span>
<br />
<small class="text-muted">@alert.DaysOverdue day@(alert.DaysOverdue == 1 ? "" : "s") ago</small>
}
</td>
<td class="text-end" onclick="event.stopPropagation()">
<a asp-controller="SubscriptionManagement" asp-action="Manage" asp-route-id="@alert.Id"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
</div>
<!-- Recently Added Companies -->
<div class="col-lg-5">
<div class="card border-0 shadow-sm h-100">
<div class="card-header d-flex align-items-center gap-2">
<i class="bi bi-building-add text-primary"></i>
<span>Recently Added Companies</span>
</div>
@if (!Model.RecentCompanies.Any())
{
<div class="card-body text-center py-5">
<p class="text-muted mb-0">No companies yet.</p>
</div>
}
else
{
<div class="list-group list-group-flush">
@foreach (var company in Model.RecentCompanies)
{
<a asp-controller="Companies" asp-action="Details" asp-route-id="@company.Id"
class="list-group-item list-group-item-action d-flex align-items-center gap-3 py-3 px-4">
<div class="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
style="width:36px;height:36px;background:rgba(79,70,229,0.12);">
<i class="bi bi-building text-primary small"></i>
</div>
<div class="flex-grow-1 overflow-hidden">
<div class="fw-semibold text-truncate">@company.CompanyName</div>
<div class="small text-muted">@company.CreatedAt.ToString("MMM d, yyyy")</div>
</div>
<div class="d-flex flex-column align-items-end gap-1">
<span class="badge @PlanBadge(company.Plan)">@company.PlanDisplayName</span>
@if (!company.IsActive)
{
<span class="badge bg-danger-subtle text-danger">Inactive</span>
}
</div>
</a>
}
</div>
<div class="card-footer text-center py-2">
<a asp-controller="Companies" asp-action="Index" class="small text-muted">
View all companies <i class="bi bi-arrow-right ms-1"></i>
</a>
</div>
}
</div>
</div>
</div>
<!-- Quick Links Row -->
<div class="row g-3 mt-2">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body py-3">
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="text-muted small me-2">Quick Actions:</span>
<a asp-controller="Companies" asp-action="Create" class="btn btn-sm btn-outline-primary">
<i class="bi bi-plus-circle me-1"></i>New Company
</a>
<a asp-controller="PlatformUsers" asp-action="Index" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-people me-1"></i>Platform Users
</a>
<a asp-controller="PlatformSubscription" asp-action="Index" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-layers me-1"></i>Subscription Plans
</a>
<a asp-controller="SystemInfo" asp-action="Index" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-cpu me-1"></i>System Info
</a>
@if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME")))
{
<a asp-controller="Diagnostics" asp-action="ViewLogs" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-text me-1"></i>View Logs
</a>
}
<a asp-controller="SeedData" asp-action="Index" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-database-fill-gear me-1"></i>Seed Data
</a>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,61 @@
@model PowderCoating.Core.Entities.DashboardTip
@{
ViewData["Title"] = "Add Dashboard Tip";
}
<div class="container py-4" style="max-width:700px">
<div class="d-flex align-items-center mb-4">
<a asp-action="Index" class="btn btn-sm btn-outline-secondary me-3">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<h4 class="mb-0"><i class="bi bi-lightbulb me-2 text-warning"></i>Add Tip</h4>
</div>
<div class="card shadow-sm">
<div class="card-body">
<form method="post" asp-action="Create">
@Html.AntiForgeryToken()
<div class="mb-4">
<label asp-for="TipText" class="form-label fw-semibold">Tip Text <span class="text-danger">*</span></label>
<textarea asp-for="TipText" class="form-control" rows="4"
placeholder="Enter the tip shown to users on the dashboard..."
maxlength="500"></textarea>
<div class="d-flex justify-content-between mt-1">
<span asp-validation-for="TipText" class="text-danger small"></span>
<span class="text-muted small" id="charCount">0 / 500</span>
</div>
</div>
<div class="mb-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" asp-for="IsActive" id="IsActive" checked />
<label class="form-check-label fw-semibold" for="IsActive">Active</label>
</div>
<div class="form-text">Inactive tips are stored but never shown to users.</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>Add Tip
</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
@section Scripts {
<script>
const ta = document.querySelector('textarea[name="TipText"]');
const counter = document.getElementById('charCount');
function updateCount() {
const len = ta.value.length;
counter.textContent = len + ' / 500';
counter.className = 'text-' + (len > 450 ? (len >= 500 ? 'danger' : 'warning') : 'muted') + ' small';
}
ta.addEventListener('input', updateCount);
updateCount();
</script>
}
@@ -0,0 +1,64 @@
@model PowderCoating.Core.Entities.DashboardTip
@{
ViewData["Title"] = "Edit Dashboard Tip";
}
<div class="container py-4" style="max-width:700px">
<div class="d-flex align-items-center mb-4">
<a asp-action="Index" class="btn btn-sm btn-outline-secondary me-3">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<h4 class="mb-0"><i class="bi bi-pencil me-2 text-primary"></i>Edit Tip</h4>
</div>
<div class="card shadow-sm">
<div class="card-body">
<form method="post" asp-action="Edit" asp-route-id="@Model.Id">
@Html.AntiForgeryToken()
<div class="mb-4">
<label asp-for="TipText" class="form-label fw-semibold">Tip Text <span class="text-danger">*</span></label>
<textarea asp-for="TipText" class="form-control" rows="4" maxlength="500">@Model.TipText</textarea>
<div class="d-flex justify-content-between mt-1">
<span asp-validation-for="TipText" class="text-danger small"></span>
<span class="text-muted small" id="charCount">0 / 500</span>
</div>
</div>
<div class="mb-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" asp-for="IsActive" id="IsActive" />
<label class="form-check-label fw-semibold" for="IsActive">Active</label>
</div>
<div class="form-text">Inactive tips are stored but never shown to users.</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save Changes
</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
<div class="text-muted small mt-3 ps-1">
<i class="bi bi-clock me-1"></i>Added @Model.CreatedAt.ToString("MMMM d, yyyy")
&nbsp;·&nbsp; ID #@Model.Id
</div>
</div>
@section Scripts {
<script>
const ta = document.querySelector('textarea[name="TipText"]');
const counter = document.getElementById('charCount');
function updateCount() {
const len = ta.value.length;
counter.textContent = len + ' / 500';
counter.className = 'text-' + (len > 450 ? (len >= 500 ? 'danger' : 'warning') : 'muted') + ' small';
}
ta.addEventListener('input', updateCount);
updateCount();
</script>
}
@@ -0,0 +1,272 @@
@model List<PowderCoating.Core.Entities.DashboardTip>
@{
ViewData["Title"] = "Dashboard Tips";
int page = (int)ViewBag.Page;
int totalPages = (int)ViewBag.TotalPages;
int total = (int)ViewBag.Total;
int activeCount = (int)ViewBag.ActiveCount;
int totalCount = (int)ViewBag.TotalCount;
string? search = ViewBag.Search as string;
bool activeOnly = (bool)ViewBag.ActiveOnly;
}
@section Styles {
<style>
[data-bs-theme="dark"] a.text-dark { color: var(--bs-body-color) !important; }
</style>
}
<div class="container-fluid py-4">
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h4 class="mb-0"><i class="bi bi-lightbulb me-2 text-warning"></i>Dashboard Tips</h4>
<p class="text-muted mb-0 small">Tips shown to users on the dashboard welcome section. Displayed randomly, one per session.</p>
</div>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>Add Tip
</a>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent mb-3">@TempData["Success"]</div>
}
<!-- Stats row -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-3 fw-bold text-primary">@totalCount</div>
<div class="small text-muted">Total Tips</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-3 fw-bold text-success">@activeCount</div>
<div class="small text-muted">Active</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-3 fw-bold text-secondary">@(totalCount - activeCount)</div>
<div class="small text-muted">Inactive</div>
</div>
</div>
</div>
</div>
<!-- Filters -->
<form method="get" class="card shadow-sm mb-3">
<div class="card-body py-2">
<div class="row g-2 align-items-end">
<div class="col-md-6">
<input type="text" name="search" value="@search" class="form-control"
placeholder="Search tip text..." />
</div>
<div class="col-md-3 d-flex align-items-center gap-2">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" name="activeOnly" value="true"
id="activeOnly" @(activeOnly ? "checked" : "") onchange="this.form.submit()" />
<label class="form-check-label" for="activeOnly">Active only</label>
</div>
</div>
<div class="col-md-3 d-flex gap-2">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-search me-1"></i>Search
</button>
@if (!string.IsNullOrEmpty(search) || activeOnly)
{
<a asp-action="Index" class="btn btn-outline-danger btn-sm">
<i class="bi bi-x-lg me-1"></i>Clear
</a>
}
</div>
</div>
</div>
</form>
<!-- Table -->
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<span class="fw-semibold">
@total tip@(total == 1 ? "" : "s") found
@if (!string.IsNullOrEmpty(search))
{
<span class="text-muted fw-normal">for "@search"</span>
}
</span>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width:60px">#</th>
<th>Tip Text</th>
<th style="width:100px" class="text-center">Status</th>
<th style="width:130px" class="text-muted small">Added</th>
<th style="width:140px"></th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr>
<td colspan="5" class="text-center text-muted py-4">
<i class="bi bi-lightbulb fs-3 d-block mb-2 opacity-25"></i>
No tips found.
@if (string.IsNullOrEmpty(search) && !activeOnly)
{
<a asp-action="Create">Add the first one</a>
}
</td>
</tr>
}
else
{
@foreach (var tip in Model)
{
<tr class="@(tip.IsActive ? "" : "table-secondary opacity-75")">
<td class="text-muted small">@tip.Id</td>
<td>
<span class="@(tip.IsActive ? "" : "text-muted fst-italic")">
@tip.TipText
</span>
</td>
<td class="text-center">
@if (tip.IsActive)
{
<span class="badge bg-success-subtle text-success border border-success-subtle">Active</span>
}
else
{
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">Inactive</span>
}
</td>
<td class="text-muted small">@tip.CreatedAt.ToString("MMM d, yyyy")</td>
<td class="text-end">
<div class="d-flex gap-1 justify-content-end">
<!-- Toggle Active -->
<form method="post" asp-action="ToggleActive" asp-route-id="@tip.Id" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm @(tip.IsActive ? "btn-outline-secondary" : "btn-outline-success")"
title="@(tip.IsActive ? "Deactivate" : "Activate")">
<i class="bi @(tip.IsActive ? "bi-toggle-on" : "bi-toggle-off")"></i>
</button>
</form>
<a asp-action="Edit" asp-route-id="@tip.Id" class="btn btn-sm btn-outline-primary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<button type="button" class="btn btn-sm btn-outline-danger" title="Delete"
onclick="confirmDelete(@tip.Id, this)">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
<!-- Mobile card view — shown on screens < 992px -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@if (!Model.Any())
{
<p class="text-center text-muted py-4">
No tips found.
@if (string.IsNullOrEmpty(search) && !activeOnly)
{
<a asp-action="Create">Add the first one</a>
}
</p>
}
@foreach (var tip in Model)
{
var tipPreview = tip.TipText.Length > 60 ? tip.TipText.Substring(0, 60) + "…" : tip.TipText;
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon bg-warning"><i class="bi bi-lightbulb"></i></div>
<div class="mobile-card-title">
<h6>@tipPreview</h6>
<small>Added @tip.CreatedAt.ToString("MMM d, yyyy")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@if (tip.IsActive)
{
<span class="badge bg-success-subtle text-success border border-success-subtle">Active</span>
}
else
{
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">Inactive</span>
}
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">ID</span>
<span class="mobile-card-value text-muted">#@tip.Id</span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-action="Edit" asp-route-id="@tip.Id" class="btn btn-sm btn-outline-primary">Edit →</a>
<button type="button" class="btn btn-sm btn-outline-danger ms-1"
onclick="confirmDelete(@tip.Id, this)">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
}
</div>
</div>
@if (totalPages > 1)
{
<div class="card-footer d-flex justify-content-between align-items-center py-2">
<span class="small text-muted">
Page @(page) of @(totalPages)
</span>
<div class="d-flex gap-1">
@if (page > 1)
{
<a asp-action="Index" asp-route-page="@(page - 1)" asp-route-search="@search"
asp-route-activeOnly="@activeOnly" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-chevron-left"></i>
</a>
}
@if (page < totalPages)
{
<a asp-action="Index" asp-route-page="@(page + 1)" asp-route-search="@search"
asp-route-activeOnly="@activeOnly" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-chevron-right"></i>
</a>
}
</div>
</div>
}
</div>
</div>
<!-- Hidden delete form -->
<form id="deleteForm" method="post" style="display:none">
@Html.AntiForgeryToken()
</form>
@section Scripts {
<script>
function confirmDelete(id, btn) {
if (!confirm('Delete this tip? This cannot be undone.')) return;
var form = document.getElementById('deleteForm');
form.action = '/DashboardTips/Delete/' + id;
form.submit();
}
</script>
}
@@ -0,0 +1,349 @@
@using PowderCoating.Web.Controllers
@model List<CompanyExportSummary>
@{
ViewData["Title"] = "Data Export";
}
@section Styles {
<style>
[data-bs-theme="dark"] .table-light th,
[data-bs-theme="dark"] .table-light td {
background-color: var(--bs-secondary-bg);
color: var(--bs-body-color);
}
[data-bs-theme="dark"] .card {
border-color: var(--bs-border-color) !important;
}
[data-bs-theme="dark"] .card-header {
border-color: var(--bs-border-color) !important;
}
[data-bs-theme="dark"] .alert-light {
background-color: var(--bs-secondary-bg);
border-color: var(--bs-border-color);
color: var(--bs-body-color);
}
[data-bs-theme="dark"] .border-top {
border-color: var(--bs-border-color) !important;
}
</style>
}
<div class="container-fluid py-3">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-file-earmark-arrow-down me-2 text-primary"></i>Data Export</h4>
<small class="text-muted">Export company data to Excel for offboarding, audits, GDPR requests, or migration</small>
</div>
</div>
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent mb-3"><i class="bi bi-exclamation-triangle me-2"></i>@TempData["Error"]</div>
}
<div class="row g-3">
@* Left: company list *@
<div class="col-lg-7">
<div class="card border-0 shadow-sm">
<div class="card-header bg-transparent border-bottom py-2">
<div class="d-flex align-items-center justify-content-between">
<h6 class="mb-0">Select a Company</h6>
<input type="text" id="companySearch" class="form-control form-control-sm w-auto"
placeholder="Search…" style="min-width:180px" />
</div>
</div>
<div class="table-responsive" style="max-height:520px;overflow-y:auto">
<table class="table table-hover table-sm align-middle mb-0 small">
<thead class="table-light sticky-top">
<tr>
<th>Company</th>
<th style="width:80px">Plan</th>
<th style="width:70px">Status</th>
<th style="width:100px">Created</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody id="companyTable">
@foreach (var c in Model)
{
<tr class="company-row" data-name="@c.CompanyName.ToLower()">
<td>
<div class="fw-medium">@c.CompanyName</div>
</td>
<td class="text-muted">@c.Plan</td>
<td>
@if (c.IsActive)
{
<span class="badge bg-success-subtle text-success">Active</span>
}
else
{
<span class="badge bg-secondary">Inactive</span>
}
</td>
<td class="text-muted">@c.CreatedAt.ToString("MM/dd/yyyy")</td>
<td>
<button type="button"
class="btn btn-sm btn-outline-primary py-0 px-2 select-company-btn"
data-id="@c.Id"
data-name="@c.CompanyName">
Select
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile card view — shown on screens < 992px -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var c in Model)
{
<div class="mobile-data-card" data-name="@c.CompanyName.ToLower()">
<div class="mobile-card-header">
<div class="mobile-card-icon bg-primary"><i class="bi bi-building"></i></div>
<div class="mobile-card-title">
<h6>@c.CompanyName</h6>
<small>Created @c.CreatedAt.ToString("MM/dd/yyyy")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Plan</span>
<span class="mobile-card-value">@c.Plan</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@if (c.IsActive)
{
<span class="badge bg-success-subtle text-success">Active</span>
}
else
{
<span class="badge bg-secondary">Inactive</span>
}
</span>
</div>
</div>
<div class="mobile-card-footer">
<button type="button"
class="btn btn-sm btn-outline-primary select-company-btn"
data-id="@c.Id"
data-name="@c.CompanyName">
Select for Export
</button>
</div>
</div>
}
</div>
</div>
</div>
</div>
@* Right: export options — always visible *@
<div class="col-lg-5">
<div class="card border-0 shadow-sm" style="position:sticky;top:1rem">
<div class="card-header bg-primary text-white py-2">
<h6 class="mb-0"><i class="bi bi-file-earmark-spreadsheet me-2"></i>Export Options</h6>
</div>
<div class="card-body">
<!-- Company selection banner — shown/hidden by JS -->
<div id="noCompanyBanner" class="alert alert-light alert-permanent border d-flex align-items-center gap-2 mb-3 small">
<i class="bi bi-arrow-left-circle fs-5 text-muted"></i>
<span>Select a company from the list to begin.</span>
</div>
<div id="selectedBanner" class="alert alert-info alert-permanent py-2 mb-3 small" style="display:none">
<i class="bi bi-building me-1"></i>
Exporting: <strong id="selectedCompanyName">—</strong>
</div>
<form method="post" asp-action="Export" id="exportForm">
@Html.AntiForgeryToken()
<input type="hidden" name="companyId" id="companyIdInput" value="0" />
<div class="mb-3">
<label class="form-label fw-semibold">Data to export</label>
<div class="form-check mb-1">
<input class="form-check-input sheet-check" type="checkbox" name="sheets"
value="Customers" id="chkCustomers" checked />
<label class="form-check-label" for="chkCustomers">
<i class="bi bi-people me-1 text-muted"></i>Customers
</label>
</div>
<div class="form-check mb-1">
<input class="form-check-input sheet-check" type="checkbox" name="sheets"
value="Jobs" id="chkJobs" checked />
<label class="form-check-label" for="chkJobs">
<i class="bi bi-briefcase me-1 text-muted"></i>Jobs
</label>
</div>
<div class="form-check mb-1">
<input class="form-check-input sheet-check" type="checkbox" name="sheets"
value="Quotes" id="chkQuotes" checked />
<label class="form-check-label" for="chkQuotes">
<i class="bi bi-file-earmark-text me-1 text-muted"></i>Quotes
</label>
</div>
<div class="form-check mb-1">
<input class="form-check-input sheet-check" type="checkbox" name="sheets"
value="Inventory" id="chkInventory" />
<label class="form-check-label" for="chkInventory">
<i class="bi bi-boxes me-1 text-muted"></i>Inventory
</label>
</div>
<div class="form-check mb-1">
<input class="form-check-input sheet-check" type="checkbox" name="sheets"
value="Equipment" id="chkEquipment" />
<label class="form-check-label" for="chkEquipment">
<i class="bi bi-tools me-1 text-muted"></i>Equipment
</label>
</div>
<div class="form-check mb-1">
<input class="form-check-input sheet-check" type="checkbox" name="sheets"
value="Vendors" id="chkVendors" />
<label class="form-check-label" for="chkVendors">
<i class="bi bi-truck me-1 text-muted"></i>Vendors
</label>
</div>
<div class="form-check mb-1">
<input class="form-check-input sheet-check" type="checkbox" name="sheets"
value="ShopWorkers" id="chkShopWorkers" />
<label class="form-check-label" for="chkShopWorkers">
<i class="bi bi-person-badge me-1 text-muted"></i>Shop Workers
</label>
</div>
<div class="form-check mb-1">
<input class="form-check-input sheet-check" type="checkbox" name="sheets"
value="Invoices" id="chkInvoices" />
<label class="form-check-label" for="chkInvoices">
<i class="bi bi-receipt me-1 text-muted"></i>Invoices
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input sheet-check" type="checkbox" name="sheets"
value="Users" id="chkUsers" />
<label class="form-check-label" for="chkUsers">
<i class="bi bi-person-lock me-1 text-muted"></i>Users
<span class="badge bg-warning text-dark ms-1" style="font-size:0.65rem">GDPR</span>
</label>
</div>
<div class="border-top pt-2 mt-1">
<div class="small text-muted mb-1">
<i class="bi bi-shield-check me-1 text-success"></i>
The <strong>Users</strong> sheet contains personal data (names, emails, login history).
Include only when required for a data access request or offboarding.
</div>
</div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="selectAllSheets">
<label class="form-check-label text-muted small" for="selectAllSheets">
Select / deselect all
</label>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Format</label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="format" id="fmtXlsx" value="xlsx" checked />
<label class="form-check-label" for="fmtXlsx">
<i class="bi bi-file-earmark-spreadsheet me-1 text-success"></i>Excel (.xlsx)
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="format" id="fmtCsv" value="csv" />
<label class="form-check-label" for="fmtCsv">
<i class="bi bi-filetype-csv me-1 text-secondary"></i>CSV (.zip)
<span class="text-muted small">— one file per sheet</span>
</label>
</div>
</div>
</div>
<div id="noCompanyHint" class="alert alert-warning alert-permanent py-2 small mb-2" style="display:none">
<i class="bi bi-exclamation-triangle me-1"></i>Please select a company from the list before downloading.
</div>
<button type="submit" class="btn btn-primary w-100" id="exportBtn">
<i class="bi bi-file-earmark-arrow-down me-2" id="exportBtnIcon"></i><span id="exportBtnLabel">Download Excel (.xlsx)</span>
</button>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
(function () {
var selectedId = null;
// ── Company selection ────────────────────────────────────────────────────
document.querySelectorAll('.select-company-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
// Reset all buttons
document.querySelectorAll('.select-company-btn').forEach(function (b) {
b.textContent = 'Select';
b.className = 'btn btn-sm btn-outline-primary py-0 px-2 select-company-btn';
});
// Mark this one selected
btn.textContent = 'Selected';
btn.className = 'btn btn-sm btn-primary py-0 px-2 select-company-btn';
selectedId = btn.getAttribute('data-id');
var name = btn.getAttribute('data-name');
document.getElementById('companyIdInput').value = selectedId;
document.getElementById('selectedCompanyName').textContent = name;
document.getElementById('noCompanyBanner').style.display = 'none';
document.getElementById('selectedBanner').style.display = '';
document.getElementById('noCompanyHint').style.display = 'none';
});
});
// ── Company search ────────────────────────────────────────────────────────
document.getElementById('companySearch').addEventListener('input', function () {
var q = this.value.toLowerCase();
document.querySelectorAll('.company-row').forEach(function (row) {
row.style.display = row.getAttribute('data-name').indexOf(q) !== -1 ? '' : 'none';
});
document.querySelectorAll('.mobile-card-list .mobile-data-card').forEach(function (card) {
card.style.display = card.getAttribute('data-name').indexOf(q) !== -1 ? '' : 'none';
});
});
// ── Select all sheets ─────────────────────────────────────────────────────
document.getElementById('selectAllSheets').addEventListener('change', function () {
var checked = this.checked;
document.querySelectorAll('.sheet-check').forEach(function (cb) {
cb.checked = checked;
});
});
// ── Format toggle — update button label ──────────────────────────────────
document.querySelectorAll('input[name="format"]').forEach(function (radio) {
radio.addEventListener('change', function () {
var isCsv = this.value === 'csv';
document.getElementById('exportBtnLabel').textContent = isCsv ? 'Download CSV (.zip)' : 'Download Excel (.xlsx)';
document.getElementById('exportBtnIcon').className = isCsv ? 'bi bi-file-zip me-2' : 'bi bi-file-earmark-arrow-down me-2';
});
});
// ── Guard: require company before submit ──────────────────────────────────
document.getElementById('exportForm').addEventListener('submit', function (e) {
if (!selectedId) {
e.preventDefault();
document.getElementById('noCompanyHint').style.display = '';
}
});
})();
</script>
}
@@ -0,0 +1,415 @@
@using PowderCoating.Web.Controllers
@model List<EntityPurgeStat>
@{
ViewData["Title"] = "Data Purge & Cleanup";
var groups = Model.GroupBy(s => s.Group).ToList();
var totalSoftDeleted = Model.Sum(s => s.Total);
}
@section Styles {
<style>
[data-bs-theme="dark"] .table-light th,
[data-bs-theme="dark"] .table-light td {
background-color: var(--bs-secondary-bg);
color: var(--bs-body-color);
}
[data-bs-theme="dark"] .card {
border-color: var(--bs-border-color) !important;
}
[data-bs-theme="dark"] .card-header {
border-color: var(--bs-border-color) !important;
}
[data-bs-theme="dark"] #previewResults .bg-light {
background-color: var(--bs-secondary-bg) !important;
color: var(--bs-body-color);
}
[data-bs-theme="dark"] #confirmSummary {
background-color: rgba(220,53,69,.15) !important;
color: var(--bs-body-color);
}
[data-bs-theme="dark"] .border-top {
border-color: var(--bs-border-color) !important;
}
</style>
}
<div class="container-fluid py-3">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-trash3 me-2 text-danger"></i>Data Purge &amp; Cleanup</h4>
<small class="text-muted">Permanently remove soft-deleted records from the database</small>
</div>
<span class="badge bg-danger fs-6">@totalSoftDeleted.ToString("N0") soft-deleted records</span>
</div>
@* Alert messages *@
@if (TempData["PurgeSuccess"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle me-2"></i>@TempData["PurgeSuccess"]
@if (TempData["PurgeDetail"] != null)
{
<pre class="mb-0 mt-2 small">@TempData["PurgeDetail"]</pre>
}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@* Warning banner *@
<div class="alert alert-warning d-flex gap-3 align-items-start mb-3">
<i class="bi bi-exclamation-triangle-fill fs-4 flex-shrink-0 mt-1"></i>
<div>
<strong>Destructive operation — this cannot be undone.</strong>
Purging permanently deletes records from the database. Soft-deleted records are hidden from users but still occupy database space. Use this tool periodically to reclaim space and keep the database clean.
Job photo blobs in Azure Storage are also deleted when purging job photo records.
</div>
</div>
<div class="row g-3">
@* Left: Entity stats *@
<div class="col-lg-8">
@foreach (var group in groups)
{
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-transparent border-bottom py-2">
<h6 class="mb-0 text-muted text-uppercase fw-semibold" style="font-size:0.75rem;letter-spacing:.05em">
@group.Key
</h6>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0 small">
<thead class="table-light">
<tr>
<th style="width:36px"></th>
<th>Entity</th>
<th class="text-end" style="width:90px">Total</th>
<th class="text-end" style="width:100px">030d</th>
<th class="text-end" style="width:100px">3090d</th>
<th class="text-end" style="width:100px">&gt;90d</th>
<th style="width:130px">Oldest</th>
<th style="width:42px">
<input type="checkbox" class="form-check-input group-select-all"
title="Select all in group"
data-group="@group.Key" />
</th>
</tr>
</thead>
<tbody>
@foreach (var s in group)
{
<tr class="@(s.Total == 0 ? "opacity-50" : "")">
<td class="text-center">
<i class="bi @s.Icon text-secondary"></i>
</td>
<td>@s.Label</td>
<td class="text-end">
@if (s.Total > 0)
{
<span class="badge bg-secondary">@s.Total</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="text-end">
@if (s.DeletedLast30Days > 0)
{
<span class="badge bg-success-subtle text-success">@s.DeletedLast30Days</span>
}
else { <span class="text-muted">—</span> }
</td>
<td class="text-end">
@if (s.Deleted30To90Days > 0)
{
<span class="badge bg-warning-subtle text-warning">@s.Deleted30To90Days</span>
}
else { <span class="text-muted">—</span> }
</td>
<td class="text-end">
@if (s.DeletedOlderThan90Days > 0)
{
<span class="badge bg-danger-subtle text-danger">@s.DeletedOlderThan90Days</span>
}
else { <span class="text-muted">—</span> }
</td>
<td class="text-muted">
@(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")
</td>
<td class="text-center">
<input type="checkbox" class="form-check-input entity-select"
name="entities"
value="@s.EntityName"
data-group="@group.Key"
@(s.Total == 0 ? "disabled" : "") />
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile card view for this group — shown on screens < 992px -->
<div class="mobile-card-view">
<div class="px-3 pt-2 pb-1">
<span class="text-muted text-uppercase fw-semibold" style="font-size:0.7rem;letter-spacing:.05em">@group.Key</span>
</div>
<div class="mobile-card-list">
@foreach (var s in group)
{
<div class="mobile-data-card @(s.Total == 0 ? "opacity-50" : "")">
<div class="mobile-card-header">
<div class="mobile-card-icon @(s.Total > 0 ? "bg-danger" : "bg-secondary")">
<i class="bi @s.Icon"></i>
</div>
<div class="mobile-card-title">
<h6>@s.Label</h6>
<small>Oldest: @(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Total deleted</span>
<span class="mobile-card-value">
@if (s.Total > 0)
{
<span class="badge bg-secondary">@s.Total</span>
}
else { <span class="text-muted">—</span> }
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">030d / 3090d / &gt;90d</span>
<span class="mobile-card-value">@s.DeletedLast30Days / @s.Deleted30To90Days / @s.DeletedOlderThan90Days</span>
</div>
</div>
<div class="mobile-card-footer">
<div class="form-check mb-0">
<input type="checkbox" class="form-check-input entity-select"
value="@s.EntityName"
data-group="@group.Key"
id="mobile-cb-@s.EntityName"
@(s.Total == 0 ? "disabled" : "") />
<label class="form-check-label small" for="mobile-cb-@s.EntityName">
Select for purge
</label>
</div>
</div>
</div>
}
</div>
</div>
</div>
}
</div>
@* Right: Purge controls *@
<div class="col-lg-4">
<div class="card border-0 shadow-sm sticky-top" style="top:1rem">
<div class="card-header bg-danger text-white py-2">
<h6 class="mb-0"><i class="bi bi-trash3 me-2"></i>Purge Options</h6>
</div>
<div class="card-body">
@* Threshold *@
<div class="mb-3">
<label class="form-label fw-semibold">Delete records older than</label>
<select id="olderThanDays" class="form-select">
<option value="30">30 days</option>
<option value="60">60 days</option>
<option value="90" selected>90 days</option>
<option value="180">180 days (6 months)</option>
<option value="365">365 days (1 year)</option>
</select>
<div class="form-text">Only soft-deleted records older than this threshold will be affected.</div>
</div>
@* Select All *@
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="selectAll">
<label class="form-check-label fw-semibold" for="selectAll">Select all entity types</label>
</div>
@* Preview *@
<button type="button" class="btn btn-outline-secondary w-100 mb-2" id="previewBtn">
<i class="bi bi-eye me-2"></i>Preview
</button>
@* Preview results *@
<div id="previewResults" class="d-none mb-3">
<div class="border rounded p-2 bg-light small">
<strong class="d-block mb-1">Would delete:</strong>
<div id="previewList"></div>
<hr class="my-1">
<strong id="previewTotal" class="text-danger"></strong>
</div>
</div>
@* Execute form *@
<form method="post" asp-action="Execute" id="purgeForm">
@Html.AntiForgeryToken()
<input type="hidden" name="olderThanDays" id="hiddenDays" value="90" />
<div id="hiddenEntities"></div>
<button type="button" class="btn btn-danger w-100" id="purgeBtn" disabled>
<i class="bi bi-trash3-fill me-2"></i>Purge Selected
</button>
</form>
<div class="form-text text-danger mt-2">
<i class="bi bi-lock me-1"></i>Action is logged to Audit Log.
</div>
</div>
</div>
</div>
</div>
</div>
@* Confirm modal *@
<div class="modal fade" id="confirmModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title"><i class="bi bi-exclamation-triangle me-2"></i>Confirm Purge</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>You are about to <strong class="text-danger">permanently delete</strong> records from the database. This <strong>cannot be undone</strong>.</p>
<div id="confirmSummary" class="border rounded p-2 bg-danger bg-opacity-10 small mb-2"></div>
<p class="mb-0 text-muted">Are you sure you want to continue?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmPurgeBtn">
<i class="bi bi-trash3-fill me-2"></i>Yes, Purge Permanently
</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
(function () {
const selectAll = document.getElementById('selectAll');
const previewBtn = document.getElementById('previewBtn');
const purgeBtn = document.getElementById('purgeBtn');
const previewRes = document.getElementById('previewResults');
const previewList = document.getElementById('previewList');
const previewTotal = document.getElementById('previewTotal');
const confirmModal = new bootstrap.Modal(document.getElementById('confirmModal'));
const confirmSummary= document.getElementById('confirmSummary');
// ── Select all ──────────────────────────────────────────────────────────
selectAll.addEventListener('change', () => {
document.querySelectorAll('.entity-select:not(:disabled)').forEach(cb => {
cb.checked = selectAll.checked;
});
updatePurgeBtn();
});
// ── Group select all ────────────────────────────────────────────────────
document.querySelectorAll('.group-select-all').forEach(ga => {
ga.addEventListener('change', () => {
document.querySelectorAll(`.entity-select[data-group="${ga.dataset.group}"]:not(:disabled)`)
.forEach(cb => { cb.checked = ga.checked; });
updatePurgeBtn();
});
});
document.querySelectorAll('.entity-select').forEach(cb => {
cb.addEventListener('change', updatePurgeBtn);
});
function getSelectedEntities() {
// Deduplicate since mobile cards duplicate the checkboxes
const seen = new Set();
return [...document.querySelectorAll('.entity-select:checked')]
.filter(cb => { if (seen.has(cb.value)) return false; seen.add(cb.value); return true; })
.map(cb => cb.value);
}
function updatePurgeBtn() {
const any = getSelectedEntities().length > 0;
purgeBtn.disabled = !any;
previewRes.classList.add('d-none');
}
// ── Preview ─────────────────────────────────────────────────────────────
previewBtn.addEventListener('click', async () => {
const entities = getSelectedEntities();
if (!entities.length) {
alert('Select at least one entity type to preview.');
return;
}
previewBtn.disabled = true;
previewBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Loading…';
const days = document.getElementById('olderThanDays').value;
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
try {
const resp = await fetch('@Url.Action("Preview")', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': token
},
body: JSON.stringify({ olderThanDays: parseInt(days), entities })
});
const data = await resp.json();
let html = '';
let total = 0;
data.forEach(r => {
total += r.count;
const cls = r.count > 0 ? 'text-danger' : 'text-muted';
const oldest = r.oldest ? ` <span class="text-muted">(oldest: ${r.oldest})</span>` : '';
html += `<div class="${cls}">${r.entity}: <strong>${r.count}</strong>${oldest}</div>`;
});
previewList.innerHTML = html || '<div class="text-muted">Nothing to delete.</div>';
previewTotal.textContent = `Total: ${total.toLocaleString()} records`;
previewRes.classList.remove('d-none');
} catch (e) {
alert('Preview failed: ' + e.message);
} finally {
previewBtn.disabled = false;
previewBtn.innerHTML = '<i class="bi bi-eye me-2"></i>Preview';
}
});
// ── Purge button → modal ────────────────────────────────────────────────
purgeBtn.addEventListener('click', () => {
const entities = getSelectedEntities();
const days = document.getElementById('olderThanDays').value;
let summary = `<strong>Threshold:</strong> older than ${days} days<br>`;
summary += `<strong>Entity types:</strong> ${entities.join(', ')}`;
confirmSummary.innerHTML = summary;
confirmModal.show();
});
// ── Confirm → submit form ───────────────────────────────────────────────
document.getElementById('confirmPurgeBtn').addEventListener('click', () => {
const entities = getSelectedEntities();
const days = document.getElementById('olderThanDays').value;
document.getElementById('hiddenDays').value = days;
const container = document.getElementById('hiddenEntities');
container.innerHTML = '';
entities.forEach(e => {
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = 'entities';
inp.value = e;
container.appendChild(inp);
});
confirmModal.hide();
document.getElementById('purgeForm').submit();
});
})();
</script>
}
@@ -0,0 +1,189 @@
@model PowderCoating.Web.Controllers.DiagnosticsInfo
@{
ViewData["Title"] = "System Diagnostics";
ViewData["PageIcon"] = "bi-activity";
}
<div class="container mt-4">
<div class="row mt-4">
<div class="col-md-6">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">Application Information</h5>
</div>
<div class="card-body">
<table class="table table-sm">
<tr>
<th>Current Time:</th>
<td>@Model.CurrentTime.ToString("yyyy-MM-dd HH:mm:ss")</td>
</tr>
<tr>
<th>Environment:</th>
<td><span class="badge bg-info">@Model.EnvironmentName</span></td>
</tr>
<tr>
<th>Application Path:</th>
<td><code>@Model.ApplicationPath</code></td>
</tr>
<tr>
<th>Running As:</th>
<td><code>@Model.UserIdentity</code></td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header bg-warning">
<h5 class="mb-0">Permissions Check</h5>
</div>
<div class="card-body">
<table class="table table-sm">
<tr>
<th>Can Write to App Path:</th>
<td>
@if (Model.CanWriteToAppPath)
{
<span class="badge bg-success">✓ YES</span>
}
else
{
<span class="badge bg-danger">✗ NO - PERMISSION ISSUE</span>
}
</td>
</tr>
<tr>
<th>Logs Directory Exists:</th>
<td>
@if (Model.LogsDirectoryExists)
{
<span class="badge bg-success">✓ YES</span>
}
else
{
<span class="badge bg-warning">✗ NO</span>
}
</td>
</tr>
<tr>
<th>Can Write to Logs:</th>
<td>
@if (Model.CanWriteToLogsPath)
{
<span class="badge bg-success">✓ YES</span>
}
else
{
<span class="badge bg-danger">✗ NO - PERMISSION ISSUE</span>
}
</td>
</tr>
<tr>
<th>Logs Path:</th>
<td><code>@Model.LogsPath</code></td>
</tr>
</table>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header @(Model.LoggingTestSuccess ? "bg-success" : "bg-danger") text-white">
<h5 class="mb-0">Logging Test</h5>
</div>
<div class="card-body">
<p><strong>Status:</strong>
@if (Model.LoggingTestSuccess)
{
<span class="badge bg-success">SUCCESS</span>
}
else
{
<span class="badge bg-danger">FAILED</span>
}
</p>
<p><strong>Message:</strong> @Model.LoggingTestMessage</p>
@if (Model.LoggingTestSuccess)
{
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> A test log entry was written. Check the log files below to see if it appears.
</div>
}
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">Log Files</h5>
<a asp-action="ViewLogs" class="btn btn-sm btn-light">
<i class="bi bi-eye"></i> View Logs
</a>
</div>
<div class="card-body">
@if (Model.LogFilesError != null)
{
<div class="alert alert-danger">
<strong>Error reading log files:</strong> @Model.LogFilesError
</div>
}
else if (Model.LogFiles.Any())
{
<table class="table table-striped">
<thead>
<tr>
<th>File Name</th>
<th>Size</th>
<th>Last Modified</th>
<th>Full Path</th>
</tr>
</thead>
<tbody>
@foreach (var file in Model.LogFiles)
{
<tr>
<td><code>@file.Name</code></td>
<td>@(file.Size / 1024.0).ToString("N2") KB</td>
<td>@file.LastModified.ToString("yyyy-MM-dd HH:mm:ss")</td>
<td><small><code>@file.FullPath</code></small></td>
</tr>
}
</tbody>
</table>
}
else
{
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i> <strong>No log files found!</strong>
<hr>
<p class="mb-0">This means logs are not being written. Check the permissions above.</p>
</div>
}
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="alert alert-info">
<h6><i class="bi bi-lightbulb"></i> Troubleshooting Tips</h6>
<ul class="mb-0">
<li>If "Can Write to Logs" shows NO, the IIS Application Pool doesn't have write permissions</li>
<li>Grant "Modify" permissions to <code>IIS AppPool\YourAppPoolName</code> on the application folder</li>
<li>After fixing permissions, restart the Application Pool in IIS Manager</li>
<li>Refresh this page to see if log files appear</li>
</ul>
</div>
</div>
</div>
</div>
@@ -0,0 +1,239 @@
@model PowderCoating.Web.Controllers.LogViewerModel
@{
ViewData["Title"] = "Log Viewer";
ViewData["PageIcon"] = "bi-file-text";
}
<div class="container-fluid mt-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-end mb-3">
<a asp-action="Index" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to Diagnostics
</a>
</div>
@if (TempData["SuccessMessage"] != null)
{
<div class="alert alert-success alert-dismissible fade show">
<i class="bi bi-check-circle"></i> @TempData["SuccessMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["ErrorMessage"] != null)
{
<div class="alert alert-danger alert-dismissible fade show">
<i class="bi bi-exclamation-triangle"></i> @TempData["ErrorMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (!string.IsNullOrEmpty(Model.Error))
{
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle"></i> @Model.Error
</div>
}
</div>
</div>
@if (Model.AvailableLogFiles.Any())
{
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="bi bi-funnel"></i> Filter & Options</h5>
</div>
<div class="card-body">
<form method="get" asp-action="ViewLogs" id="filterForm">
<div class="row g-3">
<div class="col-md-4">
<label for="fileName" class="form-label">Log File</label>
<select name="fileName" id="fileName" class="form-select" onchange="document.getElementById('filterForm').submit()">
@foreach (var file in Model.AvailableLogFiles)
{
<option value="@file.Name" selected="@(file.Name == Model.SelectedFileName)">
@file.Name (@((file.Size / 1024.0).ToString("N0")) KB)
</option>
}
</select>
</div>
<div class="col-md-3">
<label for="lines" class="form-label">Lines to Show</label>
<select name="lines" id="lines" class="form-select">
<option value="100" selected="@(Model.SelectedLines == 100)">Last 100</option>
<option value="500" selected="@(Model.SelectedLines == 500)">Last 500</option>
<option value="1000" selected="@(Model.SelectedLines == 1000)">Last 1000</option>
<option value="5000" selected="@(Model.SelectedLines == 5000)">Last 5000</option>
<option value="999999" selected="@(Model.SelectedLines >= 999999)">All</option>
</select>
</div>
<div class="col-md-3">
<label for="search" class="form-label">Search</label>
<input type="text" name="search" id="search" class="form-control"
placeholder="Filter log entries..." value="@Model.SearchTerm">
</div>
<div class="col-md-2">
<label class="form-label">&nbsp;</label>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-search"></i> Apply
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.SelectedFileName))
{
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
<div>
<h5 class="mb-0">
<i class="bi bi-file-earmark-text"></i> @Model.SelectedFileName
</h5>
<small>
Showing @Model.DisplayedLines of @Model.TotalLines lines
@if (!string.IsNullOrWhiteSpace(Model.SearchTerm))
{
<span>(@Model.FilteredLines matches for "@Model.SearchTerm")</span>
}
</small>
</div>
<div>
<a asp-action="DownloadLog" asp-route-fileName="@Model.SelectedFileName"
class="btn btn-sm btn-light">
<i class="bi bi-download"></i> Download
</a>
<button type="button" class="btn btn-sm btn-light" onclick="refreshLogs()">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
</div>
<div class="card-body p-0">
<div class="log-content-wrapper">
<pre class="log-content mb-0" id="logContent">@Model.LogContent</pre>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-warning">
<h6 class="mb-0"><i class="bi bi-gear"></i> Log Management</h6>
</div>
<div class="card-body">
<p>Delete old log files to free up disk space. This will permanently remove log files.</p>
<form method="post" asp-action="ClearOldLogs" onsubmit="return confirm('Are you sure you want to delete old log files?');">
<div class="row g-3 align-items-end">
<div class="col-md-3">
<label for="daysToKeep" class="form-label">Keep logs from last</label>
<select name="daysToKeep" id="daysToKeep" class="form-select">
<option value="7">7 days</option>
<option value="14">14 days</option>
<option value="30" selected>30 days</option>
<option value="60">60 days</option>
<option value="90">90 days</option>
</select>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-warning">
<i class="bi bi-trash"></i> Clear Old Logs
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
}
}
else
{
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> No log files found in <code>@Model.LogsPath</code>
</div>
}
</div>
<style>
.log-content-wrapper {
max-height: 600px;
overflow-y: auto;
background-color: #1e1e1e;
color: #d4d4d4;
}
.log-content {
padding: 15px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Color code log levels */
.log-content {
color: #d4d4d4;
}
pre:has(text) {
background-color: #1e1e1e;
}
</style>
<script>
function refreshLogs() {
location.reload();
}
// Auto-scroll to bottom of logs
window.addEventListener('load', function() {
var logContent = document.querySelector('.log-content-wrapper');
if (logContent) {
logContent.scrollTop = logContent.scrollHeight;
}
});
// Highlight search terms
@if (!string.IsNullOrWhiteSpace(Model.SearchTerm))
{
<text>
window.addEventListener('load', function() {
var content = document.getElementById('logContent');
if (content) {
var searchTerm = '@Html.Raw(System.Web.HttpUtility.JavaScriptStringEncode(Model.SearchTerm))';
var regex = new RegExp('(' + searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
content.innerHTML = content.textContent.replace(regex, '<mark>$1</mark>');
}
});
</text>
}
// Color code log levels
window.addEventListener('load', function() {
var content = document.getElementById('logContent');
if (content) {
var html = content.textContent;
html = html.replace(/\[ERR\]/g, '<span style="color: #f48771; font-weight: bold;">[ERR]</span>');
html = html.replace(/\[WRN\]/g, '<span style="color: #dcdcaa; font-weight: bold;">[WRN]</span>');
html = html.replace(/\[INF\]/g, '<span style="color: #4ec9b0;">[INF]</span>');
html = html.replace(/\[DBG\]/g, '<span style="color: #808080;">[DBG]</span>');
content.innerHTML = html;
}
});
</script>
@@ -0,0 +1,172 @@
@using PowderCoating.Web.Controllers
@model BroadcastForm
@{
ViewData["Title"] = "Email Broadcast";
}
@section Styles {
<style>
[data-bs-theme="dark"] .card {
border-color: var(--bs-border-color) !important;
}
[data-bs-theme="dark"] .card-header {
background-color: var(--bs-secondary-bg) !important;
border-color: var(--bs-border-color) !important;
color: var(--bs-body-color);
}
[data-bs-theme="dark"] .alert-info {
background-color: rgba(13,202,240,.1);
border-color: rgba(13,202,240,.3);
color: var(--bs-body-color);
}
[data-bs-theme="dark"] .alert-warning {
background-color: rgba(255,193,7,.1);
border-color: rgba(255,193,7,.3);
color: var(--bs-body-color);
}
</style>
}
<div class="container-fluid py-3" style="max-width:860px">
<div class="d-flex align-items-center gap-3 mb-3">
<h4 class="mb-0"><i class="bi bi-broadcast me-2 text-primary"></i>Email Broadcast</h4>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent mb-3">@TempData["Success"]</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent mb-3">@TempData["Error"]</div>
}
<form method="post" asp-action="Send" id="broadcast-form">
@Html.AntiForgeryToken()
<div class="row g-3">
@* Left: recipients *@
<div class="col-md-5">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold py-2">Recipients</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label fw-medium">Send to</label>
<select name="Target" id="target-select" class="form-select" onchange="onTargetChange()">
<option value="active" selected="@(Model.Target == "active")">All active companies</option>
<option value="all" selected="@(Model.Target == "all")">All companies (incl. expired)</option>
<option value="status_grace" selected="@(Model.Target == "status_grace")">Grace period companies</option>
<option value="status_expired" selected="@(Model.Target == "status_expired")">Expired companies</option>
<option value="plan" selected="@(Model.Target == "plan")">By subscription plan</option>
<option value="specific" selected="@(Model.Target == "specific")">Specific companies</option>
</select>
</div>
<div id="plan-filter" class="mb-3" style="display:none">
<label class="form-label fw-medium">Plan</label>
<select name="PlanFilter" class="form-select">
@foreach (var p in (IEnumerable<dynamic>)ViewBag.PlanConfigs)
{
<option value="@p.Plan">@p.DisplayName</option>
}
</select>
</div>
<div id="specific-filter" class="mb-3" style="display:none">
<label class="form-label fw-medium">Companies</label>
<select name="CompanyIds" multiple class="form-select" style="height:160px">
@foreach (var c in (IEnumerable<dynamic>)ViewBag.Companies)
{
<option value="@c.Id">@c.CompanyName</option>
}
</select>
<div class="form-text">Hold Ctrl / Cmd to select multiple.</div>
</div>
<div class="alert alert-info py-2 small mb-0" id="recipient-preview">
<span id="recipient-count">@ViewBag.ActiveCount</span> company email(s) will receive this message.
</div>
</div>
</div>
</div>
@* Right: compose *@
<div class="col-md-7">
<div class="card shadow-sm">
<div class="card-header fw-semibold py-2">Compose</div>
<div class="card-body">
<div class="mb-3">
<label asp-for="Subject" class="form-label fw-medium">Subject</label>
<input asp-for="Subject" class="form-control" placeholder="e.g. Scheduled maintenance this Saturday" />
<span asp-validation-for="Subject" class="text-danger small"></span>
</div>
<div class="mb-3">
<label asp-for="Body" class="form-label fw-medium">Message</label>
<textarea asp-for="Body" class="form-control" rows="12"
placeholder="Write your message here. Plain text — line breaks will be preserved."></textarea>
<span asp-validation-for="Body" class="text-danger small"></span>
</div>
<div class="alert alert-warning py-2 small mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>
This will send a real email to the primary contact address of each matching company. Double-check your recipient selection before sending.
</div>
<button type="submit" class="btn btn-primary" id="send-btn">
<i class="bi bi-send me-1"></i>Send Broadcast
</button>
</div>
</div>
</div>
</div>
</form>
</div>
@section Scripts {
<script>
(function () {
const targetSelect = document.getElementById('target-select');
const planFilter = document.getElementById('plan-filter');
const specificFilter = document.getElementById('specific-filter');
const countEl = document.getElementById('recipient-count');
function onTargetChange() {
const val = targetSelect.value;
planFilter.style.display = val === 'plan' ? '' : 'none';
specificFilter.style.display = val === 'specific' ? '' : 'none';
updateCount();
}
async function updateCount() {
const val = targetSelect.value;
const planSel = document.querySelector('[name="PlanFilter"]');
const companySel = document.querySelector('[name="CompanyIds"]');
const params = new URLSearchParams({ target: val });
if (val === 'plan' && planSel) params.set('plan', planSel.value);
if (val === 'specific' && companySel) {
Array.from(companySel.selectedOptions).forEach(o => params.append('companyIds', o.value));
}
try {
const resp = await fetch('@Url.Action("RecipientCount")?' + params.toString());
const data = await resp.json();
countEl.textContent = data.count;
} catch { countEl.textContent = '?'; }
}
window.onTargetChange = onTargetChange;
// Wire up change events for sub-filters
document.querySelector('[name="PlanFilter"]')?.addEventListener('change', updateCount);
document.querySelector('[name="CompanyIds"]')?.addEventListener('change', updateCount);
// Init visibility
onTargetChange();
// Confirm before send
document.getElementById('send-btn').addEventListener('click', function (e) {
const count = countEl.textContent;
if (!confirm(`Send this email to ${count} company recipient(s)?`)) e.preventDefault();
});
})();
</script>
}
@@ -0,0 +1,176 @@
@model PowderCoating.Application.DTOs.Equipment.CreateEquipmentDto
@{
ViewData["Title"] = "Add New Equipment";
ViewData["PageIcon"] = "bi-tools";
ViewData["PageHelpTitle"] = "Add New Equipment";
ViewData["PageHelpContent"] = "Equipment records track physical assets in your shop — ovens, spray booths, compressors, and other machinery. Enter a name, type, and location at minimum. Serial number and warranty details help with service claims. The maintenance interval drives the Next Scheduled date shown on the equipment list.";
var statusList = ViewBag.StatusList as Array ?? Array.Empty<object>();
}
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-end align-items-center mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to List
</a>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form asp-action="Create" method="post">
<partial name="_ValidationSummary" />
<!-- Basic Information Section -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0"><i class="bi bi-info-circle me-2 text-primary"></i>Basic Information</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Basic Information"
data-bs-content="Equipment Name is what appears throughout the system. Type describes the category (e.g., Oven, Spray Booth, Compressor). Equipment Number is an optional internal reference code like EQ-001. Location helps staff quickly find the equipment on the shop floor.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="EquipmentName" class="form-label">Equipment Name <span class="text-danger">*</span></label>
<input asp-for="EquipmentName" class="form-control" placeholder="Enter equipment name" />
<span asp-validation-for="EquipmentName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="EquipmentNumber" class="form-label">Equipment Number</label>
<input asp-for="EquipmentNumber" class="form-control" placeholder="e.g., EQ-001" />
<span asp-validation-for="EquipmentNumber" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="EquipmentType" class="form-label">Type <span class="text-danger">*</span></label>
<input asp-for="EquipmentType" class="form-control" placeholder="e.g., Oven, Spray Booth, Compressor" />
<span asp-validation-for="EquipmentType" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Location" class="form-label">Location</label>
<input asp-for="Location" class="form-control" placeholder="e.g., Shop Floor A, Building 2" />
<span asp-validation-for="Location" class="text-danger"></span>
</div>
</div>
</div>
<!-- Manufacturer Information Section -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0"><i class="bi bi-building me-2 text-primary"></i>Manufacturer Information</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Manufacturer Information"
data-bs-content="Manufacturer, model, and serial number are useful for warranty claims, ordering replacement parts, and scheduling manufacturer-recommended service. The serial number is especially important if the equipment is registered for warranty support.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-4">
<label asp-for="Manufacturer" class="form-label">Manufacturer</label>
<input asp-for="Manufacturer" class="form-control" placeholder="Enter manufacturer name" />
<span asp-validation-for="Manufacturer" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="Model" class="form-label">Model</label>
<input asp-for="Model" class="form-control" placeholder="Enter model number" />
<span asp-validation-for="Model" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="SerialNumber" class="form-label">Serial Number</label>
<input asp-for="SerialNumber" class="form-control" placeholder="Enter serial number" />
<span asp-validation-for="SerialNumber" class="text-danger"></span>
</div>
</div>
</div>
<!-- Purchase & Warranty Section -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0"><i class="bi bi-receipt me-2 text-primary"></i>Purchase &amp; Warranty</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Purchase &amp; Warranty"
data-bs-content="Purchase Date and Price help track the asset value of your equipment. Warranty Expiration is shown in green (active) or red (expired) on the Details page, so you know whether a repair might be covered before calling a technician.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-4">
<label asp-for="PurchaseDate" class="form-label">Purchase Date</label>
<input asp-for="PurchaseDate" type="date" class="form-control" />
<span asp-validation-for="PurchaseDate" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="PurchasePrice" class="form-label">Purchase Price</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="PurchasePrice" type="number" step="0.01" min="0" value="0" class="form-control" placeholder="0.00" />
</div>
<span asp-validation-for="PurchasePrice" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="WarrantyExpiration" class="form-label">Warranty Expiration</label>
<input asp-for="WarrantyExpiration" type="date" class="form-control" />
<span asp-validation-for="WarrantyExpiration" class="text-danger"></span>
</div>
</div>
</div>
<!-- Status Section -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0"><i class="bi bi-toggle-on me-2 text-primary"></i>Status</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Status"
data-bs-content="Operational = working normally. Needs Maintenance = flagged for upcoming service. Under Maintenance = currently being serviced. Out of Service = not working, requires repair before use. Retired = permanently decommissioned. Status is shown on the equipment list and can be updated at any time from the Edit page.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-4">
<label asp-for="Status" class="form-label">Initial Status</label>
<select asp-for="Status" class="form-select">
@foreach (var status in statusList)
{
<option value="@status">@status.ToString()</option>
}
</select>
<span asp-validation-for="Status" class="text-danger"></span>
</div>
</div>
</div>
<!-- Notes Section -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-journal-text me-2 text-primary"></i>Notes
</h5>
<div class="row g-3">
<div class="col-12">
<label asp-for="Notes" class="form-label">Additional Notes</label>
<textarea asp-for="Notes" class="form-control" rows="4" placeholder="Enter any additional notes about this equipment"></textarea>
<span asp-validation-for="Notes" class="text-danger"></span>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="d-flex gap-2 justify-content-end pt-3 border-top">
<a asp-action="Index" class="btn btn-outline-secondary px-4">Cancel</a>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-2"></i>Create Equipment
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
@@ -0,0 +1,108 @@
@model PowderCoating.Application.DTOs.Equipment.EquipmentDto
@{
ViewData["Title"] = "Delete Equipment";
ViewData["PageIcon"] = "bi-tools";
}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="d-flex justify-content-end align-items-center mb-4">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to Details
</a>
</div>
<!-- Warning Banner -->
<div class="alert alert-danger alert-permanent d-flex align-items-start mb-4">
<i class="bi bi-exclamation-triangle me-3" style="font-size: 1.5rem;"></i>
<div>
<strong>Warning: This action cannot be undone!</strong>
<p class="mb-0 mt-1">This equipment record will be marked as deleted. All associated maintenance records will be preserved but the equipment will no longer appear in the active list.</p>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<!-- Equipment Summary -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">Equipment Information</h5>
<div class="row g-3">
<div class="col-md-6">
<label class="text-muted small mb-1">Equipment Name</label>
<p class="fw-semibold mb-0">@Model.EquipmentName</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Equipment Number</label>
<p class="mb-0">@(Model.EquipmentNumber ?? "Not assigned")</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Type</label>
<p class="mb-0">@Model.EquipmentType</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Location</label>
<p class="mb-0">@(Model.Location ?? "—")</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Status</label>
<p class="mb-0">
@{
var statusClass = Model.Status switch
{
"Operational" => "success",
"NeedsMaintenance" => "warning",
"UnderMaintenance" => "info",
"OutOfService" => "danger",
_ => "secondary"
};
}
<span class="badge bg-@statusClass bg-opacity-10 text-@statusClass">
@Model.StatusDisplay
</span>
</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Purchase Price</label>
<p class="mb-0">@Model.PurchasePrice.ToString("C")</p>
</div>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.Manufacturer) || !string.IsNullOrEmpty(Model.Model))
{
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">Manufacturer Information</h5>
<div class="row g-3">
<div class="col-md-4">
<label class="text-muted small mb-1">Manufacturer</label>
<p class="mb-0">@(Model.Manufacturer ?? "—")</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Model</label>
<p class="mb-0">@(Model.Model ?? "—")</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Serial Number</label>
<p class="mb-0">@(Model.SerialNumber ?? "—")</p>
</div>
</div>
</div>
}
<!-- Delete Confirmation Form -->
<form asp-action="Delete" method="post" class="mt-4">
<input type="hidden" asp-for="Id" />
<div class="d-flex gap-2 justify-content-end pt-3 border-top">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary px-4">
<i class="bi bi-x-circle me-2"></i>Cancel
</a>
<button type="submit" class="btn btn-danger px-4">
<i class="bi bi-trash me-2"></i>Delete Equipment
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@@ -0,0 +1,450 @@
@model PowderCoating.Application.DTOs.Equipment.EquipmentDto
@{
ViewData["Title"] = Model.EquipmentName;
ViewData["PageIcon"] = "bi-tools";
ViewData["PageHelpTitle"] = "Equipment Details";
ViewData["PageHelpContent"] = "The status banner shows whether this equipment is currently available. Maintenance Schedule shows the interval and when service is next due. Use Add Maintenance or Schedule Maintenance to log upcoming or completed service. Upload a PDF user manual here so staff can access it without leaving the system.";
var maintenanceHistory = ViewBag.MaintenanceHistory as List<PowderCoating.Application.DTOs.Maintenance.MaintenanceListDto> ?? new List<PowderCoating.Application.DTOs.Maintenance.MaintenanceListDto>();
var hasManual = !string.IsNullOrWhiteSpace(Model.ManualFilePath) || (!string.IsNullOrWhiteSpace(Model.Notes) && Model.Notes.StartsWith("uploads/"));
}
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<p class="text-muted mb-0">@Model.EquipmentType @(!string.IsNullOrEmpty(Model.EquipmentNumber) ? $"• {Model.EquipmentNumber}" : "")</p>
</div>
<div class="d-flex gap-2">
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
<i class="bi bi-pencil me-2"></i>Edit
</a>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to List
</a>
</div>
</div>
<!-- Status Banner -->
@{
var statusClass = Model.Status switch
{
"Operational" => "alert-success",
"NeedsMaintenance" => "alert-warning",
"UnderMaintenance" => "alert-info",
"OutOfService" => "alert-danger",
_ => "alert-secondary"
};
var statusIcon = Model.Status switch
{
"Operational" => "bi-check-circle",
"NeedsMaintenance" => "bi-exclamation-triangle",
"UnderMaintenance" => "bi-wrench",
"OutOfService" => "bi-x-circle",
_ => "bi-info-circle"
};
}
<div class="alert @statusClass alert-permanent d-flex align-items-center mb-4">
<i class="bi @statusIcon me-2" style="font-size: 1.5rem;"></i>
<div>
<strong>Status:</strong> @Model.StatusDisplay
@if (Model.DaysUntilMaintenance.HasValue && Model.DaysUntilMaintenance < 7)
{
<span class="ms-2">• Maintenance due in @Model.DaysUntilMaintenance days</span>
}
</div>
</div>
<div class="row g-4">
<!-- Left Column -->
<div class="col-lg-8">
<!-- Equipment Information -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-info-circle me-2 text-primary"></i>Equipment Information
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="text-muted small mb-1">Equipment Name</label>
<p class="fw-semibold mb-0">@Model.EquipmentName</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Equipment Number</label>
<p class="mb-0">@(Model.EquipmentNumber ?? "Not assigned")</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Type</label>
<p class="mb-0">@Model.EquipmentType</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Location</label>
<p class="mb-0">
@if (!string.IsNullOrEmpty(Model.Location))
{
<span><i class="bi bi-geo-alt me-1"></i>@Model.Location</span>
}
else
{
<span class="text-muted">Not specified</span>
}
</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Manufacturer</label>
<p class="mb-0">@(Model.Manufacturer ?? "—")</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Model</label>
<p class="mb-0">@(Model.Model ?? "—")</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Serial Number</label>
<p class="mb-0">@(Model.SerialNumber ?? "—")</p>
</div>
</div>
</div>
</div>
<!-- Purchase & Warranty -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-receipt me-2 text-primary"></i>Purchase & Warranty
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="text-muted small mb-1">Purchase Date</label>
<p class="mb-0">
@(Model.PurchaseDate.HasValue ? Model.PurchaseDate.Value.ToString("MMM dd, yyyy") : "Not recorded")
</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Purchase Price</label>
<p class="mb-0 fw-semibold">@Model.PurchasePrice.ToString("C")</p>
</div>
<div class="col-md-12">
<label class="text-muted small mb-1">Warranty Expiration</label>
<p class="mb-0">
@if (Model.WarrantyExpiration.HasValue)
{
var isExpired = Model.WarrantyExpiration.Value < DateTime.Now;
<span class="@(isExpired ? "text-danger" : "text-success")">
@Model.WarrantyExpiration.Value.ToString("MMM dd, yyyy")
@(isExpired ? "(Expired)" : "(Active)")
</span>
}
else
{
<span class="text-muted">No warranty information</span>
}
</p>
</div>
</div>
</div>
</div>
<!-- Maintenance Schedule -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3 d-flex align-items-center gap-2">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-calendar-check me-2 text-primary"></i>Maintenance Schedule
</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Maintenance Schedule"
data-bs-content="Maintenance Interval is how many days between scheduled services — set this on the Edit page. Next Scheduled is calculated automatically as Last Maintenance date plus the interval. If no maintenance has been completed yet, Next Scheduled will be blank until the first service is recorded as Completed.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="text-muted small mb-1">Maintenance Interval</label>
<p class="mb-0">@Model.RecommendedMaintenanceIntervalDays days</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Last Maintenance</label>
<p class="mb-0">
@(Model.LastMaintenanceDate.HasValue ? Model.LastMaintenanceDate.Value.ToString("MMM dd, yyyy") : "Never")
</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Next Scheduled</label>
<p class="mb-0">
@if (Model.NextScheduledMaintenance.HasValue)
{
<span>@Model.NextScheduledMaintenance.Value.ToString("MMM dd, yyyy")</span>
@if (Model.DaysUntilMaintenance.HasValue)
{
<br />
<small class="text-muted">(@Model.DaysUntilMaintenance days away)</small>
}
}
else
{
<span class="text-muted">Not scheduled</span>
}
</p>
</div>
</div>
</div>
</div>
<!-- Maintenance History -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-clock-history me-2 text-primary"></i>Maintenance History
</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Maintenance History"
data-bs-content="Shows the 10 most recent maintenance records for this equipment. Click View All Maintenance to see the full history. Completed records update the Last Maintenance date and recalculate the Next Scheduled date. Use Add Maintenance to log an upcoming or completed service task.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<a asp-controller="Maintenance" asp-action="Create" asp-route-equipmentId="@Model.Id" class="btn btn-sm btn-primary">
<i class="bi bi-plus-circle me-1"></i>Add Maintenance
</a>
</div>
<div class="card-body p-0">
@if (!maintenanceHistory.Any())
{
<div class="text-center py-4">
<i class="bi bi-inbox" style="font-size: 3rem; color: #d1d5db;"></i>
<p class="text-muted mt-2 mb-0">No maintenance records yet</p>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>Status</th>
<th>Priority</th>
<th>Cost</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var maintenance in maintenanceHistory.Take(10))
{
<tr>
<td>@maintenance.ScheduledDate.ToString("MMM dd, yyyy")</td>
<td>@maintenance.MaintenanceType</td>
<td>
@{
var badgeClass = maintenance.Status switch
{
"Scheduled" => "bg-primary",
"InProgress" => "bg-warning",
"Completed" => "bg-success",
"Overdue" => "bg-danger",
"Cancelled" => "bg-secondary",
_ => "bg-secondary"
};
}
<span class="badge @badgeClass bg-opacity-10 text-@badgeClass.Replace("bg-", "")">
@maintenance.StatusDisplay
</span>
</td>
<td>
@{
var priorityClass = maintenance.Priority switch
{
"Critical" => "danger",
"High" => "warning",
"Normal" => "primary",
"Low" => "secondary",
_ => "secondary"
};
}
<span class="badge bg-@priorityClass bg-opacity-10 text-@priorityClass">
@maintenance.PriorityDisplay
</span>
</td>
<td>@maintenance.TotalCost.ToString("C")</td>
<td>
<a asp-controller="Maintenance" asp-action="Details" asp-route-id="@maintenance.Id" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
<!-- User Manuals -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-file-earmark-pdf me-2 text-primary"></i>User Manual
</h5>
</div>
<div class="card-body">
@if (hasManual)
{
// Get the filename from new storage or legacy storage
var fileName = !string.IsNullOrWhiteSpace(Model.ManualFileName)
? Model.ManualFileName
: (!string.IsNullOrWhiteSpace(Model.Notes) ? System.IO.Path.GetFileName(Model.Notes) : "User Manual");
// Remove GUID prefix from legacy filenames
if (fileName.Length > 37 && fileName[36] == '-')
{
fileName = fileName.Substring(37);
}
<div class="d-flex justify-content-between align-items-center p-3 bg-light rounded">
<div class="d-flex align-items-center" style="max-width: 70%;">
<i class="bi bi-file-earmark-pdf text-danger me-2" style="font-size: 1.5rem;"></i>
<span class="fw-semibold text-truncate" title="@fileName">@fileName</span>
</div>
<div class="d-flex gap-2">
<a asp-action="DownloadManual" asp-route-id="@Model.Id" class="btn btn-sm btn-primary">
<i class="bi bi-download me-1"></i>Download
</a>
<form asp-action="DeleteManual" method="post" class="d-inline delete-manual-form" data-equipment-id="@Model.Id">
@Html.AntiForgeryToken()
<input type="hidden" name="equipmentId" value="@Model.Id" />
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</div>
}
else
{
<div class="text-center py-3">
<i class="bi bi-file-earmark-x" style="font-size: 3rem; color: #d1d5db;"></i>
<p class="text-muted mt-2 mb-3">No manual uploaded</p>
</div>
}
<form asp-action="UploadManual" method="post" enctype="multipart/form-data" class="mt-3" id="uploadManualForm">
@Html.AntiForgeryToken()
<input type="hidden" name="equipmentId" value="@Model.Id" />
<div class="input-group">
<input type="file" class="form-control" name="manualFile" id="manualFile" accept=".pdf,.doc,.docx,.xls,.xlsx">
<button type="submit" class="btn btn-primary">
<i class="bi bi-upload me-1"></i>Upload
</button>
</div>
<small class="text-muted d-block mt-1">Allowed: PDF, DOC, DOCX, XLS, XLSX (Max 10 MB)</small>
</form>
</div>
</div>
</div>
<!-- Right Column -->
<div class="col-lg-4">
<!-- Quick Actions -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-lightning me-2 text-primary"></i>Quick Actions
</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
<i class="bi bi-pencil me-2"></i>Edit Equipment
</a>
<a asp-controller="Maintenance" asp-action="Create" asp-route-equipmentId="@Model.Id" class="btn btn-primary">
<i class="bi bi-wrench me-2"></i>Schedule Maintenance
</a>
<a asp-controller="Maintenance" asp-action="Index" asp-route-equipmentId="@Model.Id" class="btn btn-outline-primary">
<i class="bi bi-list me-2"></i>View All Maintenance
</a>
<hr />
<a asp-action="Delete" asp-route-id="@Model.Id" class="btn btn-outline-danger">
<i class="bi bi-trash me-2"></i>Delete Equipment
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
// Handle manual upload via AJAX
document.getElementById('uploadManualForm')?.addEventListener('submit', function(e) {
e.preventDefault();
const fileInput = document.getElementById('manualFile');
if (!fileInput.files.length) {
showWarning('Please select a file to upload.', 'No File Selected');
return;
}
const formData = new FormData(this);
const button = this.querySelector('button[type="submit"]');
const originalButtonText = button.innerHTML;
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Uploading...';
fetch(this.action, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
showError(data.message || 'Upload failed', 'Upload Failed');
button.disabled = false;
button.innerHTML = originalButtonText;
}
})
.catch(error => {
showError('An error occurred during upload', 'Upload Error');
button.disabled = false;
button.innerHTML = originalButtonText;
});
});
// Handle manual deletion
document.querySelector('.delete-manual-form')?.addEventListener('submit', function(e) {
e.preventDefault();
if (!confirm('Are you sure you want to delete this manual?')) {
return;
}
const formData = new FormData(this);
fetch(this.action, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
showError(data.message || 'Delete failed', 'Delete Failed');
}
})
.catch(error => {
showError('An error occurred during deletion', 'Delete Error');
});
});
</script>
}
@@ -0,0 +1,187 @@
@model PowderCoating.Application.DTOs.Equipment.UpdateEquipmentDto
@{
ViewData["Title"] = "Edit Equipment";
ViewData["PageIcon"] = "bi-tools";
ViewData["PageHelpTitle"] = "Edit Equipment";
ViewData["PageHelpContent"] = "Update the equipment record details. Changes take effect immediately on save. Marking equipment Inactive removes it from pickers and the default list view but preserves its maintenance history.";
var statusList = ViewBag.StatusList as Array ?? Array.Empty<object>();
}
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-end align-items-center mb-4">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to Details
</a>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form asp-action="Edit" method="post">
<partial name="_ValidationSummary" />
<input type="hidden" asp-for="Id" />
<!-- Basic Information Section -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0"><i class="bi bi-info-circle me-2 text-primary"></i>Basic Information</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Basic Information"
data-bs-content="Equipment Name is what appears throughout the system. Type describes the category (e.g., Oven, Spray Booth, Compressor). Equipment Number is an optional internal reference code like EQ-001. Location helps staff quickly find the equipment on the shop floor.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="EquipmentName" class="form-label">Equipment Name <span class="text-danger">*</span></label>
<input asp-for="EquipmentName" class="form-control" placeholder="Enter equipment name" />
<span asp-validation-for="EquipmentName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="EquipmentNumber" class="form-label">Equipment Number</label>
<input asp-for="EquipmentNumber" class="form-control" placeholder="e.g., EQ-001" />
<span asp-validation-for="EquipmentNumber" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="EquipmentType" class="form-label">Type <span class="text-danger">*</span></label>
<input asp-for="EquipmentType" class="form-control" placeholder="e.g., Oven, Spray Booth, Compressor" />
<span asp-validation-for="EquipmentType" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Location" class="form-label">Location</label>
<input asp-for="Location" class="form-control" placeholder="e.g., Shop Floor A, Building 2" />
<span asp-validation-for="Location" class="text-danger"></span>
</div>
</div>
</div>
<!-- Manufacturer Information Section -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0"><i class="bi bi-building me-2 text-primary"></i>Manufacturer Information</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Manufacturer Information"
data-bs-content="Manufacturer, model, and serial number are useful for warranty claims, ordering replacement parts, and scheduling manufacturer-recommended service. The serial number is especially important if the equipment is registered for warranty support.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-4">
<label asp-for="Manufacturer" class="form-label">Manufacturer</label>
<input asp-for="Manufacturer" class="form-control" placeholder="Enter manufacturer name" />
<span asp-validation-for="Manufacturer" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="Model" class="form-label">Model</label>
<input asp-for="Model" class="form-control" placeholder="Enter model number" />
<span asp-validation-for="Model" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="SerialNumber" class="form-label">Serial Number</label>
<input asp-for="SerialNumber" class="form-control" placeholder="Enter serial number" />
<span asp-validation-for="SerialNumber" class="text-danger"></span>
</div>
</div>
</div>
<!-- Purchase & Warranty Section -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0"><i class="bi bi-receipt me-2 text-primary"></i>Purchase &amp; Warranty</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Purchase &amp; Warranty"
data-bs-content="Purchase Date and Price help track the asset value of your equipment. Warranty Expiration is shown in green (active) or red (expired) on the Details page, so you know whether a repair might be covered before calling a technician.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-4">
<label asp-for="PurchaseDate" class="form-label">Purchase Date</label>
<input asp-for="PurchaseDate" type="date" class="form-control" />
<span asp-validation-for="PurchaseDate" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="PurchasePrice" class="form-label">Purchase Price</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="PurchasePrice" type="number" step="0.01" min="0" class="form-control" placeholder="0.00" />
</div>
<span asp-validation-for="PurchasePrice" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="WarrantyExpiration" class="form-label">Warranty Expiration</label>
<input asp-for="WarrantyExpiration" type="date" class="form-control" />
<span asp-validation-for="WarrantyExpiration" class="text-danger"></span>
</div>
</div>
</div>
<!-- Status Section -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0"><i class="bi bi-toggle-on me-2 text-primary"></i>Status</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Status"
data-bs-content="Operational = working normally. Needs Maintenance = flagged for upcoming service. Under Maintenance = currently being serviced. Out of Service = not working, requires repair. Retired = permanently decommissioned. Unchecking Active hides the equipment from the default list and from dropdowns in other parts of the system.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-4">
<label asp-for="Status" class="form-label">Status</label>
<select asp-for="Status" class="form-select">
@foreach (var status in statusList)
{
<option value="@status">@status.ToString()</option>
}
</select>
<span asp-validation-for="Status" class="text-danger"></span>
</div>
<div class="col-md-8 d-flex align-items-end pb-1">
<div class="form-check">
<input asp-for="IsActive" type="checkbox" class="form-check-input" />
<label asp-for="IsActive" class="form-check-label">
Active Equipment
<small class="text-muted d-block">Inactive equipment will not appear in the main list</small>
</label>
</div>
</div>
</div>
</div>
<!-- Notes Section -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-journal-text me-2 text-primary"></i>Notes
</h5>
<div class="row g-3">
<div class="col-12">
<label asp-for="Notes" class="form-label">Additional Notes</label>
<textarea asp-for="Notes" class="form-control" rows="4" placeholder="Enter any additional notes about this equipment"></textarea>
<span asp-validation-for="Notes" class="text-danger"></span>
<small class="text-muted">Note: User manual file information is stored separately</small>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="d-flex gap-2 justify-content-end pt-3 border-top">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary px-4">Cancel</a>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-2"></i>Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
@@ -0,0 +1,292 @@
@model PagedResult<PowderCoating.Application.DTOs.Equipment.EquipmentListDto>
@{
ViewData["Title"] = "Equipment";
ViewData["PageIcon"] = "bi-tools";
ViewData["PageHelpTitle"] = "Equipment";
ViewData["PageHelpContent"] = "Track all shop equipment — ovens, spray booths, compressors, and other machinery. Status shows whether each piece is Operational, Needs Maintenance, Under Maintenance, or Out of Service. Next Maintenance date is calculated from the last completed maintenance plus the equipment's maintenance interval. Click any row to view full details and maintenance history.";
}
<div class="pcl-metric-strip">
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "TOTAL", Value: Model.TotalCount.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "OPERATIONAL", Value: Model.Items.Count(e => e.Status == "Operational").ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "NEEDS SERVICE", Value: Model.Items.Count(e => e.Status == "NeedsMaintenance").ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "IN MAINTENANCE", Value: Model.Items.Count(e => e.Status == "UnderMaintenance").ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
</div>
<!-- Equipment Table Card -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3">
<div class="d-flex flex-column flex-sm-row gap-2 w-100 w-lg-auto">
<form method="get" class="d-flex flex-column flex-sm-row gap-2 flex-grow-1 flex-lg-grow-0">
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
<input type="hidden" name="pageSize" value="@Model.PageSize" />
<select name="statusFilter" class="form-select" style="max-width: 250px; min-width: 150px;" onchange="this.form.submit()">
<option value="">All Statuses</option>
<option value="@((int)PowderCoating.Core.Enums.EquipmentStatus.Operational)" selected="@(ViewBag.StatusFilter == PowderCoating.Core.Enums.EquipmentStatus.Operational)">Operational</option>
<option value="@((int)PowderCoating.Core.Enums.EquipmentStatus.NeedsMaintenance)" selected="@(ViewBag.StatusFilter == PowderCoating.Core.Enums.EquipmentStatus.NeedsMaintenance)">Needs Maintenance</option>
<option value="@((int)PowderCoating.Core.Enums.EquipmentStatus.UnderMaintenance)" selected="@(ViewBag.StatusFilter == PowderCoating.Core.Enums.EquipmentStatus.UnderMaintenance)">Under Maintenance</option>
<option value="@((int)PowderCoating.Core.Enums.EquipmentStatus.OutOfService)" selected="@(ViewBag.StatusFilter == PowderCoating.Core.Enums.EquipmentStatus.OutOfService)">Out of Service</option>
<option value="@((int)PowderCoating.Core.Enums.EquipmentStatus.Retired)" selected="@(ViewBag.StatusFilter == PowderCoating.Core.Enums.EquipmentStatus.Retired)">Retired</option>
</select>
<div class="input-group" style="max-width: 350px; min-width: 200px;">
<span class="input-group-text bg-white border-end-0">
<i class="bi bi-search text-muted"></i>
</span>
<input type="text" name="searchTerm" class="form-control border-start-0"
placeholder="Search equipment..." value="@ViewBag.SearchTerm">
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i>
</button>
</div>
</form>
<a asp-action="Create" class="btn btn-primary text-nowrap">
<i class="bi bi-plus-circle me-2"></i>
<span class="d-none d-sm-inline">Add Equipment</span>
<span class="d-inline d-sm-none">Add</span>
</a>
</div>
</div>
</div>
<div class="card-body p-0">
@if (!Model.Items.Any())
{
<div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No equipment found</h5>
<p class="text-muted mb-4">Get started by adding your first equipment</p>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Add Your First Equipment
</a>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th sortable="Name" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="ps-4">Equipment</th>
<th sortable="EquipmentCode" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Code</th>
<th>Type</th>
<th sortable="Status" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Status</th>
<th>Location</th>
<th sortable="NextMaintenanceDate" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Next Maintenance</th>
<th class="text-end pe-4">Actions</th>
</tr>
</thead>
<tbody id="equipmentTable">
@foreach (var equipment in Model.Items.Where(e => e.IsActive))
{
<tr class="equipment-row" data-equipment-id="@equipment.Id" style="cursor: pointer;">
<td class="ps-4">
<div class="d-flex align-items-center gap-2">
<div class="rounded-circle d-flex align-items-center justify-content-center"
style="width: 40px; height: 40px; background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white; font-weight: 600;">
@if (!string.IsNullOrEmpty(equipment.EquipmentType))
{
@equipment.EquipmentType.Substring(0, 1).ToUpper()
}
else
{
@equipment.EquipmentName.Substring(0, 1).ToUpper()
}
</div>
<div>
<div class="fw-semibold">@equipment.EquipmentName</div>
@if (!string.IsNullOrEmpty(equipment.EquipmentNumber))
{
<small class="text-muted">@equipment.EquipmentNumber</small>
}
</div>
</div>
</td>
<td>@equipment.EquipmentType</td>
<td>
@await Html.PartialAsync("_StatusChip", (Kind: StatusChipHelper.EquipmentStatus(equipment.Status), Text: equipment.StatusDisplay))
</td>
<td>
@if (!string.IsNullOrEmpty(equipment.Location))
{
<span><i class="bi bi-geo-alt me-1"></i>@equipment.Location</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
@if (equipment.NextScheduledMaintenance.HasValue)
{
<span>@equipment.NextScheduledMaintenance.Value.ToString("MMM dd, yyyy")</span>
}
else
{
<span class="text-muted">Not scheduled</span>
}
</td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm">
<a asp-action="Details" asp-route-id="@equipment.Id" class="btn btn-outline-primary" title="View Details">
<i class="bi bi-eye"></i>
</a>
<a asp-action="Edit" asp-route-id="@equipment.Id" class="btn btn-outline-warning" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a asp-action="Delete" asp-route-id="@equipment.Id" class="btn btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</a>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var equipment in Model.Items.Where(e => e.IsActive))
{
<div class="mobile-data-card"
data-id="@equipment.Id"
onclick="window.location.href='@Url.Action("Details", new { id = equipment.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
<i class="bi bi-tools"></i>
</div>
<div class="mobile-card-title">
<h6>@equipment.EquipmentName</h6>
<small>@equipment.EquipmentType</small>
</div>
</div>
<div class="mobile-card-body">
@if (!string.IsNullOrEmpty(equipment.EquipmentNumber))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Code</span>
<span class="mobile-card-value">@equipment.EquipmentNumber</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@switch (equipment.Status)
{
case "Operational":
<span class="badge bg-success bg-opacity-10 text-success">
<i class="bi bi-check-circle me-1"></i>@equipment.StatusDisplay
</span>
break;
case "NeedsMaintenance":
<span class="badge bg-warning bg-opacity-10 text-warning">
<i class="bi bi-exclamation-triangle me-1"></i>@equipment.StatusDisplay
</span>
break;
case "UnderMaintenance":
<span class="badge bg-info bg-opacity-10 text-info">
<i class="bi bi-wrench me-1"></i>@equipment.StatusDisplay
</span>
break;
case "OutOfService":
<span class="badge bg-danger bg-opacity-10 text-danger">
<i class="bi bi-x-circle me-1"></i>@equipment.StatusDisplay
</span>
break;
default:
<span class="badge bg-secondary bg-opacity-10 text-secondary">
@equipment.StatusDisplay
</span>
break;
}
</span>
</div>
@if (!string.IsNullOrEmpty(equipment.Location))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Location</span>
<span class="mobile-card-value"><i class="bi bi-geo-alt me-1"></i>@equipment.Location</span>
</div>
}
@if (equipment.NextScheduledMaintenance.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Next Maintenance</span>
<span class="mobile-card-value">@equipment.NextScheduledMaintenance.Value.ToString("MMM dd, yyyy")</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a href="@Url.Action("Details", new { id = equipment.Id })"
class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation();">
<i class="bi bi-eye me-1"></i>View
</a>
<a href="@Url.Action("Edit", new { id = equipment.Id })"
class="btn btn-sm btn-outline-secondary"
onclick="event.stopPropagation();">
<i class="bi bi-pencil me-1"></i>Edit
</a>
</div>
</div>
}
</div>
</div>
}
</div>
@if (Model.TotalCount > 0)
{
@await Html.PartialAsync("_Pagination", Model)
}
</div>
@section Scripts {
<script>
// Simple search functionality
document.getElementById('searchInput')?.addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const rows = document.querySelectorAll('#equipmentTable tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(searchTerm) ? '' : 'none';
});
});
// Make table rows clickable
document.querySelectorAll('.equipment-row').forEach(row => {
row.addEventListener('click', function(e) {
// Don't navigate if clicking on action buttons or links
if (e.target.closest('.btn-group') || e.target.closest('a') || e.target.closest('button')) {
return;
}
const equipmentId = this.getAttribute('data-equipment-id');
window.location.href = '@Url.Action("Details", "Equipment")/' + equipmentId;
});
// Add hover effect
row.addEventListener('mouseenter', function() {
this.style.backgroundColor = '#f8f9fa';
});
row.addEventListener('mouseleave', function() {
this.style.backgroundColor = '';
});
});
</script>
}
@@ -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 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-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 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 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>
}
@@ -0,0 +1,134 @@
@model PowderCoating.Application.DTOs.GiftCertificate.CreateGiftCertificateDto
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "New Gift Certificate";
ViewData["PageIcon"] = "bi-gift";
}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="d-flex justify-content-end mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back
</a>
</div>
<form asp-action="Create" method="post">
@Html.AntiForgeryToken()
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-currency-dollar me-2 text-primary"></i>Certificate Details</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label asp-for="Amount" class="form-label fw-semibold"></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="Amount" type="number" step="0.01" min="1" class="form-control" placeholder="0.00" />
</div>
<span asp-validation-for="Amount" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="IssuedReason" class="form-label fw-semibold"></label>
<select asp-for="IssuedReason" class="form-select" id="issuedReasonSelect"
onchange="toggleSaleFields()">
@foreach (var reason in Enum.GetValues<GiftCertificateIssuedReason>())
{
<option value="@((int)reason)">@reason</option>
}
</select>
</div>
<!-- Sale-specific fields -->
<div id="saleFields" class="col-12">
<div class="row g-3">
<div class="col-md-6">
<label asp-for="PurchasePrice" class="form-label fw-semibold">Selling Price <span class="text-muted fw-normal">(what buyer pays)</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="PurchasePrice" type="number" step="0.01" min="0" class="form-control" placeholder="0.00" />
</div>
<div class="form-text">Leave blank if same as face value.</div>
</div>
<div class="col-md-6">
<label asp-for="PurchasingCustomerId" class="form-label fw-semibold">Purchased By</label>
<select asp-for="PurchasingCustomerId" asp-items="ViewBag.Customers" class="form-select"></select>
</div>
</div>
</div>
<div class="col-md-6">
<label asp-for="ExpiryDate" class="form-label fw-semibold"></label>
<input asp-for="ExpiryDate" type="date" class="form-control" />
<div class="form-text">Leave blank for no expiry.</div>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-person me-2 text-primary"></i>Recipient</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12">
<label asp-for="RecipientCustomerId" class="form-label fw-semibold">Existing Customer</label>
<select asp-for="RecipientCustomerId" asp-items="ViewBag.Customers" class="form-select"
onchange="toggleRecipientName()"></select>
<div class="form-text">Select a customer, or leave blank and fill in the name below.</div>
</div>
<div class="col-md-6" id="recipientNameField">
<label asp-for="RecipientName" class="form-label fw-semibold"></label>
<input asp-for="RecipientName" class="form-control" placeholder="Name on certificate" />
</div>
<div class="col-md-6">
<label asp-for="RecipientEmail" class="form-label fw-semibold"></label>
<input asp-for="RecipientEmail" type="email" class="form-control" placeholder="Optional — for emailing the certificate" />
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-journal me-2 text-primary"></i>Notes</h5>
</div>
<div class="card-body">
<textarea asp-for="Notes" class="form-control" rows="3" placeholder="Any internal notes about this certificate..."></textarea>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-gift me-2"></i>Issue Certificate
</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
@section Scripts {
<script>
function toggleSaleFields() {
const reason = parseInt(document.getElementById('issuedReasonSelect').value);
const saleFields = document.getElementById('saleFields');
// GiftCertificateIssuedReason.Sold = 0
saleFields.style.display = reason === 0 ? '' : 'none';
}
function toggleRecipientName() {
const customerId = document.getElementById('RecipientCustomerId').value;
const nameField = document.getElementById('recipientNameField');
nameField.style.display = customerId ? 'none' : '';
}
// Init on load
toggleSaleFields();
toggleRecipientName();
</script>
}
@@ -0,0 +1,246 @@
@model PowderCoating.Application.DTOs.GiftCertificate.GiftCertificateDto
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = $"Gift Certificate {Model.CertificateCode}";
ViewData["PageIcon"] = "bi-gift";
var isActive = Model.Status == GiftCertificateStatus.Active || Model.Status == GiftCertificateStatus.PartiallyRedeemed;
var (statusClass, statusLabel) = Model.Status switch
{
GiftCertificateStatus.Active => ("success", "Active"),
GiftCertificateStatus.PartiallyRedeemed => ("info", "Partially Redeemed"),
GiftCertificateStatus.FullyRedeemed => ("secondary", "Fully Redeemed"),
GiftCertificateStatus.Expired => ("warning", "Expired"),
GiftCertificateStatus.Voided => ("danger", "Voided"),
_ => ("secondary", Model.Status.ToString())
};
}
<div class="d-flex justify-content-end gap-2 mb-4">
<div class="d-flex gap-2">
<a asp-action="DownloadPdf" asp-route-id="@Model.Id" class="btn btn-outline-primary" target="_blank">
<i class="bi bi-filetype-pdf me-2"></i>Download PDF
</a>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to List
</a>
</div>
</div>
<div class="alert alert-@statusClass d-flex align-items-center mb-4">
<i class="bi bi-gift me-2" style="font-size:1.4rem;"></i>
<div>
<strong>@statusLabel</strong>
@if (isActive)
{
<span class="ms-2">— <strong class="fs-5">@Model.RemainingBalance.ToString("C")</strong> remaining of @Model.OriginalAmount.ToString("C") face value</span>
}
@if (Model.ExpiryDate.HasValue)
{
<span class="ms-2 small">· Expires @Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy")</span>
}
</div>
</div>
<div class="row g-4">
<div class="col-lg-8">
<!-- Certificate Info -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-info-circle me-2 text-primary"></i>Certificate Information</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="text-muted small mb-1">Certificate Code</label>
<p class="fw-bold font-monospace mb-0">@Model.CertificateCode</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Face Value</label>
<p class="fw-bold mb-0">@Model.OriginalAmount.ToString("C")</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Issued Reason</label>
<p class="mb-0"><span class="badge bg-secondary-subtle text-secondary">@Model.IssuedReason</span></p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Issue Date</label>
<p class="mb-0">@Model.IssueDate.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy")</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Expiry Date</label>
<p class="mb-0">@(Model.ExpiryDate.HasValue ? Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy") : "No expiry")</p>
</div>
@if (!string.IsNullOrEmpty(Model.IssuedByName))
{
<div class="col-md-4">
<label class="text-muted small mb-1">Issued By</label>
<p class="mb-0">@Model.IssuedByName</p>
</div>
}
@if (Model.PurchasePrice.HasValue)
{
<div class="col-md-4">
<label class="text-muted small mb-1">Selling Price</label>
<p class="mb-0">@Model.PurchasePrice.Value.ToString("C")</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.PurchasingCustomerName))
{
<div class="col-md-4">
<label class="text-muted small mb-1">Purchased By</label>
<p class="mb-0">@Model.PurchasingCustomerName</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.Notes))
{
<div class="col-12">
<label class="text-muted small mb-1">Notes</label>
<p class="mb-0" style="white-space:pre-wrap;">@Model.Notes</p>
</div>
}
</div>
</div>
</div>
<!-- Recipient -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-person me-2 text-primary"></i>Recipient</h5>
</div>
<div class="card-body">
@if (!string.IsNullOrEmpty(Model.RecipientName) || Model.RecipientCustomerId.HasValue)
{
<div class="row g-3">
<div class="col-md-6">
<label class="text-muted small mb-1">Name</label>
<p class="mb-0 fw-semibold">@Model.RecipientName</p>
</div>
@if (!string.IsNullOrEmpty(Model.RecipientEmail))
{
<div class="col-md-6">
<label class="text-muted small mb-1">Email</label>
<p class="mb-0"><a href="mailto:@Model.RecipientEmail">@Model.RecipientEmail</a></p>
</div>
}
@if (Model.RecipientCustomerId.HasValue)
{
<div class="col-md-6">
<a asp-controller="Customers" asp-action="Details" asp-route-id="@Model.RecipientCustomerId"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-person me-1"></i>View Customer
</a>
</div>
}
</div>
}
else
{
<p class="text-muted mb-0">No specific recipient assigned.</p>
}
</div>
</div>
<!-- Redemption History -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-clock-history me-2 text-primary"></i>Redemption History</h5>
</div>
@if (Model.Redemptions.Any())
{
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th class="ps-3">Date</th>
<th>Invoice</th>
<th class="text-end pe-3">Amount Redeemed</th>
</tr>
</thead>
<tbody>
@foreach (var r in Model.Redemptions)
{
<tr>
<td class="ps-3">@r.RedeemedDate.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yyyy")</td>
<td>
@if (!string.IsNullOrEmpty(r.InvoiceNumber))
{
<a asp-controller="Invoices" asp-action="Details" asp-route-id="@r.InvoiceId"
class="text-decoration-none">@r.InvoiceNumber</a>
}
else
{
<span class="text-muted">Invoice #@r.InvoiceId</span>
}
</td>
<td class="text-end pe-3 fw-semibold text-success">@r.AmountRedeemed.ToString("C")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
else
{
<div class="card-body">
<p class="text-muted mb-0">No redemptions recorded yet.</p>
</div>
}
</div>
</div>
<!-- Right: Balance + Actions -->
<div class="col-lg-4">
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-bar-chart me-2 text-primary"></i>Balance</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="text-muted small mb-1">Face Value</label>
<h4 class="mb-0">@Model.OriginalAmount.ToString("C")</h4>
</div>
@if (Model.RedeemedAmount > 0)
{
<div class="mb-3">
<label class="text-muted small mb-1">Amount Used</label>
<p class="mb-0 text-muted">(@Model.RedeemedAmount.ToString("C"))</p>
</div>
}
<hr class="my-2" />
<div>
<label class="text-muted small mb-1">Remaining Balance</label>
<h3 class="mb-0 @(Model.RemainingBalance > 0 ? "text-success" : "text-muted")">
@Model.RemainingBalance.ToString("C")
</h3>
</div>
</div>
</div>
@if (isActive && User.IsInRole("SuperAdmin") || User.IsInRole("Administrator") || User.IsInRole("Manager"))
{
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold"><i class="bi bi-lightning me-2 text-primary"></i>Actions</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
@if (isActive && (User.IsInRole("SuperAdmin") || User.IsInRole("Administrator")))
{
<form asp-action="Void" asp-route-id="@Model.Id" method="post"
onsubmit="return confirm('Void certificate @Model.CertificateCode? This cannot be undone.')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-danger w-100">
<i class="bi bi-x-circle me-2"></i>Void Certificate
</button>
</form>
}
</div>
</div>
</div>
}
</div>
</div>
@@ -0,0 +1,137 @@
@model List<PowderCoating.Application.DTOs.GiftCertificate.GiftCertificateListDto>
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Gift Certificates";
ViewData["PageIcon"] = "bi-gift";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<p class="text-muted mb-0">@ViewBag.TotalActive active certificates — @((ViewBag.TotalValue as decimal? ?? 0m).ToString("C")) outstanding value</p>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>New Certificate
</a>
</div>
<!-- Filters -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-3">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-5">
<input type="text" name="searchTerm" class="form-control" placeholder="Search code, recipient name or email..."
value="@ViewBag.SearchTerm" />
</div>
<div class="col-md-3">
<select name="statusFilter" class="form-select">
<option value="">All statuses</option>
@foreach (var s in Enum.GetValues<GiftCertificateStatus>())
{
<option value="@s" selected="@(ViewBag.StatusFilter == s.ToString())">@s</option>
}
</select>
</div>
<div class="col-md-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="text-center py-5 text-muted">
<i class="bi bi-gift" style="font-size: 3rem;"></i>
<p class="mt-3">No gift certificates found.</p>
<a asp-action="Create" class="btn btn-primary mt-2">Create First Certificate</a>
</div>
}
else
{
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th class="ps-3">Certificate Code</th>
<th>Recipient</th>
<th>Reason</th>
<th class="text-end">Face Value</th>
<th class="text-end">Remaining</th>
<th>Status</th>
<th>Issued</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var cert in Model)
{
<tr>
<td class="ps-3">
<a asp-action="Details" asp-route-id="@cert.Id" class="fw-semibold text-decoration-none font-monospace">
@cert.CertificateCode
</a>
</td>
<td>
@if (!string.IsNullOrEmpty(cert.RecipientName))
{
<span>@cert.RecipientName</span>
}
else
{
<span class="text-muted">—</span>
}
@if (!string.IsNullOrEmpty(cert.RecipientEmail))
{
<div class="text-muted small">@cert.RecipientEmail</div>
}
</td>
<td><span class="badge bg-secondary-subtle text-secondary">@cert.IssuedReason</span></td>
<td class="text-end">@cert.OriginalAmount.ToString("C")</td>
<td class="text-end fw-semibold @(cert.RemainingBalance > 0 ? "text-success" : "text-muted")">
@cert.RemainingBalance.ToString("C")
</td>
<td>
@{
var (badgeClass, label) = cert.Status switch
{
GiftCertificateStatus.Active => ("bg-success", "Active"),
GiftCertificateStatus.PartiallyRedeemed => ("bg-info text-dark", "Partial"),
GiftCertificateStatus.FullyRedeemed => ("bg-secondary", "Used"),
GiftCertificateStatus.Expired => ("bg-warning text-dark", "Expired"),
GiftCertificateStatus.Voided => ("bg-danger", "Voided"),
_ => ("bg-secondary", cert.Status.ToString())
};
}
<span class="badge @badgeClass">@label</span>
</td>
<td class="small text-muted">@cert.IssueDate.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yy")</td>
<td class="small">
@if (cert.ExpiryDate.HasValue)
{
<span class="@(cert.ExpiryDate.Value < DateTime.Now ? "text-danger" : "text-muted")">
@cert.ExpiryDate.Value.ToString("MM/dd/yy")
</span>
}
else
{
<span class="text-muted">No expiry</span>
}
</td>
<td>
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
@@ -0,0 +1,288 @@
@{
ViewData["Title"] = "Accounts Payable";
}
<div class="d-flex align-items-center gap-2 mb-3">
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
<li class="breadcrumb-item active">Accounts Payable</li>
</ol>
</nav>
</div>
<div class="row g-4">
<div class="col-lg-9">
<section id="overview" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-info-circle text-primary me-2"></i>Overview
</h2>
<p>
Accounts Payable (AP) tracks money your shop owes to vendors. When you receive a vendor invoice
for supplies, services, or equipment, you record it in the system as a <strong>bill</strong>.
The system tracks each bill's status, due date, and outstanding balance so you always know what
you owe and when it is due.
</p>
<p>
When you make a payment to a vendor, you record it against the bill and the balance reduces.
This gives you a clear, real-time picture of your upcoming financial obligations and helps you
avoid late payments — and the late fees or strained vendor relationships that come with them.
</p>
<p>
Bills can be created manually or generated automatically from a received Purchase Order.
Creating bills from POs saves time and eliminates the risk of data entry errors. You can find
Accounts Payable under <strong>Accounting &rsaquo; Bills</strong> in the left sidebar.
</p>
</section>
<section id="creating-a-bill" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-plus-circle text-primary me-2"></i>Creating a Bill
</h2>
<h3 class="h6 fw-semibold mt-3 mb-2">From a Purchase Order (recommended)</h3>
<p>
The fastest and most accurate way to create a bill is from a received Purchase Order. Open the
received PO and click <strong>Create Bill</strong>. The system generates a bill pre-filled with
all line items, quantities, and prices from the PO — linked to the vendor and expense accounts
automatically. See the <a asp-controller="Help" asp-action="PurchaseOrders">Purchase Orders help page</a>
for step-by-step instructions.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Manually</h3>
<p>
To create a bill that is not linked to a PO — for example, a utility bill, a service invoice,
or a vendor charge that arrived without a matching order:
</p>
<ol class="mb-3">
<li class="mb-2">Go to <strong>Accounting &rsaquo; Bills</strong> and click <strong>New Bill</strong>.</li>
<li class="mb-2">Select the <strong>Vendor</strong>. The vendor's default expense account and payment terms are applied automatically.</li>
<li class="mb-2">Enter the <strong>Bill Date</strong> (the date on the vendor's invoice) and the <strong>Due Date</strong> (calculated from payment terms, but you can override it).</li>
<li class="mb-2">Enter the vendor's own <strong>Reference Number</strong> (their invoice number) so you can cross-reference it if the vendor contacts you.</li>
<li class="mb-2">
Add one or more <strong>line items</strong>:
<ul class="mt-1">
<li><strong>Expense Account</strong> — the accounting category this cost belongs to (e.g., Cost of Goods Sold, Shop Supplies, Equipment Maintenance).</li>
<li><strong>Description</strong> — a brief note about what this line covers.</li>
<li><strong>Quantity</strong> and <strong>Unit Price</strong>.</li>
</ul>
</li>
<li class="mb-2">Add any internal <strong>Notes</strong>.</li>
<li class="mb-2">Click <strong>Save Bill</strong>. The bill is saved as a Draft.</li>
</ol>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
Draft bills are not yet in your AP ledger. They do not affect your reported AP balance until
you mark them as <strong>Open</strong>. This gives you time to review and verify a bill before
it becomes an official obligation.
</div>
</div>
</section>
<section id="bill-statuses" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-tag text-primary me-2"></i>Bill Statuses
</h2>
<p>
Bills move through the following statuses as they are processed and paid.
</p>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th style="width:25%">Status</th>
<th>What it means</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="badge bg-secondary">Draft</span></td>
<td>The bill has been entered but not yet verified or posted. It is not in the AP ledger and does not affect your reported AP balance. Can be edited freely.</td>
</tr>
<tr>
<td><span class="badge bg-warning text-dark">Open</span></td>
<td>The bill has been verified, posted to the AP ledger, and the balance is officially owed to the vendor. Appears in your AP balance and aging reports.</td>
</tr>
<tr>
<td><span class="badge bg-info text-dark">Partially Paid</span></td>
<td>At least one payment has been recorded against the bill but the full balance has not been settled.</td>
</tr>
<tr>
<td><span class="badge bg-success">Paid</span></td>
<td>The full bill amount has been paid. The balance is zero and the bill is closed.</td>
</tr>
<tr>
<td><span class="badge bg-secondary">Voided</span></td>
<td>The bill was cancelled before payment. Voided bills are kept for record-keeping but do not affect your AP balance.</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="marking-open" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-check-circle text-primary me-2"></i>Marking a Bill as Open
</h2>
<p>
When you have verified that a bill is accurate — it matches the goods you received and the
prices you agreed on — you mark it as Open to post it to your AP ledger.
</p>
<ol class="mb-3">
<li class="mb-2">Open the Draft bill from <strong>Accounting &rsaquo; Bills</strong>.</li>
<li class="mb-2">Review all line items, the vendor reference number, the bill date, and the due date.</li>
<li class="mb-2">Click <strong>Mark as Open</strong>.</li>
<li class="mb-2">The bill status changes to Open and the balance is now included in your total AP balance.</li>
</ol>
<p>
From an accounting perspective, marking a bill as Open records the following entries:
</p>
<ul class="mb-3">
<li>Debits the expense account(s) specified on the bill lines (e.g., Cost of Goods Sold)</li>
<li>Credits Accounts Payable for the total amount owed</li>
</ul>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
Once a bill is marked as Open, its line items are locked for editing. If you discover an
error after marking Open, contact your bookkeeper before making any adjustments. Incorrect
changes can affect your financial statements.
</div>
</div>
</section>
<section id="recording-payments" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-cash-coin text-primary me-2"></i>Recording a Payment
</h2>
<p>
When you pay a vendor — whether in full or as a partial payment — you record the payment against
the open bill. The system supports multiple partial payments on a single bill.
</p>
<ol class="mb-3">
<li class="mb-2">Open the Open or Partially Paid bill from <strong>Accounting &rsaquo; Bills</strong>.</li>
<li class="mb-2">Click <strong>Record Payment</strong>.</li>
<li class="mb-2">
Enter the payment details:
<ul class="mt-1">
<li><strong>Amount</strong> — how much you are paying now. Can be less than the full balance for partial payments.</li>
<li><strong>Payment Method</strong> — Check, ACH / Bank Transfer, Credit Card, Cash, or Wire Transfer.</li>
<li><strong>Payment Date</strong> — the date the payment was made or will be made.</li>
<li><strong>Reference Number</strong> — check number, wire confirmation, or ACH batch ID. Always fill this in for non-cash payments to simplify reconciliation.</li>
<li><strong>Notes</strong> — any additional information about this payment.</li>
</ul>
</li>
<li class="mb-2">Click <strong>Save Payment</strong>.</li>
</ol>
<p>
The bill status updates automatically to <strong>Partially Paid</strong> if a balance remains,
or <strong>Paid</strong> if the full amount has been settled. From an accounting perspective,
paying a bill debits Accounts Payable and credits your bank or cash account for the amount paid.
</p>
</section>
<section id="ai-receipt-scanning" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-robot text-primary me-2"></i>AI Receipt Scanning
</h2>
<p>
Instead of manually entering a vendor bill, you can upload a photo or PDF of the vendor's
paper invoice and let AI extract the details automatically.
</p>
<ol class="mb-3">
<li class="mb-2">Go to <strong>Accounting &rsaquo; Bills</strong> and click <strong>Scan Receipt</strong>.</li>
<li class="mb-2">Upload a photo (JPG/PNG) or a PDF of the vendor's invoice.</li>
<li class="mb-2">The AI reads the document and pre-fills the vendor, bill date, line items, and amounts into a new draft bill.</li>
<li class="mb-2">Review the extracted data — correct any fields that were misread — and save the bill.</li>
</ol>
<p>
The uploaded file is automatically attached to the bill so you always have the original
document on record.
</p>
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>
AI extraction accuracy depends on image quality. For best results, use a well-lit photo
with the full invoice visible and no obstructions. Always review the extracted values
before saving.
</div>
</div>
</section>
<section id="smart-account-categorization" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-tags text-primary me-2"></i>Smart Account Categorization
</h2>
<p>
When entering line items on a bill, the system can suggest the correct expense account for
each line based on the description. After you type or paste a line description and move to
the next field, AI analyzes the text and suggests the most likely account (e.g., "powder
coating materials" → Cost of Goods Sold).
</p>
<p>
You can accept the suggestion or override it with any account from your chart of accounts.
Suggestions are non-binding and do not change anything until you save the bill.
</p>
</section>
<section id="expense-accounts" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-folder2-open text-primary me-2"></i>Expense Accounts
</h2>
<p>
Each line item on a bill must be assigned to an <strong>expense account</strong> — an accounting
category that determines where the cost appears in your financial reports. Common expense accounts
used in a powder coating shop include:
</p>
<ul class="mb-3">
<li class="mb-1"><strong>Cost of Goods Sold (COGS)</strong> — raw materials, powder coatings, and consumables that go directly into jobs.</li>
<li class="mb-1"><strong>Shop Supplies</strong> — items used in the shop that are not directly tied to a specific job (masking tape, gloves, cleaning solvents).</li>
<li class="mb-1"><strong>Equipment Maintenance</strong> — service, repairs, and parts for your oven, sandblaster, and coating booths.</li>
<li class="mb-1"><strong>Utilities</strong> — gas, electricity, and water bills that power the shop.</li>
<li class="mb-1"><strong>Rent / Occupancy</strong> — monthly rent or lease payments for your shop premises.</li>
<li class="mb-1"><strong>Operating Expenses</strong> — general overhead that does not fit another specific category.</li>
</ul>
<p>
The <strong>vendor's default expense account</strong> is set on the vendor record and pre-fills
each new bill line automatically. For example, if your powder supplier is linked to COGS, every
bill line from that vendor starts with COGS selected. You can override the account on any
individual line item as needed.
</p>
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>
Expense accounts are defined in <strong>Settings &rsaquo; Chart of Accounts</strong>.
If you are unsure which account to use for a particular cost, ask your accountant or
bookkeeper before posting the bill. Using the wrong account affects your Profit &amp; Loss
report and can complicate your year-end. See the
<a asp-controller="Help" asp-action="Settings">Settings help page</a> for information
on managing your chart of accounts.
</div>
</div>
</section>
</div>
<div class="col-lg-3 d-none d-lg-block">
@{ await Html.RenderPartialAsync("_HelpNav"); }
<div class="card border-0 shadow-sm sticky-top" style="top:80px">
<div class="card-header bg-transparent fw-semibold small text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">On this page</div>
<div class="card-body p-0">
<nav class="nav flex-column">
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
<a class="nav-link py-1 px-3 small text-body" href="#creating-a-bill">Creating a Bill</a>
<a class="nav-link py-1 px-3 small text-body" href="#bill-statuses">Bill Statuses</a>
<a class="nav-link py-1 px-3 small text-body" href="#marking-open">Marking a Bill as Open</a>
<a class="nav-link py-1 px-3 small text-body" href="#recording-payments">Recording a Payment</a>
<a class="nav-link py-1 px-3 small text-body" href="#ai-receipt-scanning">AI Receipt Scanning</a>
<a class="nav-link py-1 px-3 small text-body" href="#smart-account-categorization">Smart Categorization</a>
<a class="nav-link py-1 px-3 small text-body" href="#expense-accounts">Expense Accounts</a>
</nav>
</div>
</div>
</div>
</div>
@@ -0,0 +1,241 @@
@{
ViewData["Title"] = "Customers";
}
<div class="d-flex align-items-center gap-2 mb-3">
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
<li class="breadcrumb-item active">Customers</li>
</ol>
</nav>
</div>
<div class="row g-4">
<div class="col-lg-9">
<section id="overview" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-info-circle text-primary me-2"></i>Overview
</h2>
<p>
The Customers section is the starting point for all work in the shop. Every quote, job, and invoice
belongs to a customer record. Keeping your customer list accurate and up to date ensures you can
quickly pull up a customer's full history — all their past jobs, outstanding quotes, and unpaid
invoices in one place.
</p>
<p>
You can find Customers under <strong>Operations &rsaquo; Customers</strong> in the left sidebar.
The list is searchable and sortable. Use the search box at the top of the list to find a customer
by name, email, or phone number.
</p>
</section>
<section id="customer-types" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-diagram-2 text-primary me-2"></i>Customer Types
</h2>
<p>
When creating a customer you must choose one of two types. This affects how the customer appears
in reports and how pricing tiers are applied.
</p>
<div class="row g-3">
<div class="col-md-6">
<div class="card border-primary border-opacity-25 h-100">
<div class="card-header bg-primary bg-opacity-10 fw-semibold">
<i class="bi bi-building me-1"></i> Commercial
</div>
<div class="card-body">
<p class="card-text small mb-0">
Use this for businesses — auto body shops, fabricators, manufacturers, or any company
that sends you work regularly. Commercial customers can have a <strong>pricing tier</strong>
applied (e.g., volume discounts) and a <strong>credit limit</strong> set. They typically
have a company name in addition to a contact person.
</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-secondary border-opacity-25 h-100">
<div class="card-header bg-secondary bg-opacity-10 fw-semibold">
<i class="bi bi-person me-1"></i> Non-Commercial
</div>
<div class="card-body">
<p class="card-text small mb-0">
Use this for individuals — homeowners who bring in patio furniture, hobbyists with
motorcycle parts, or anyone who is not representing a business. Non-commercial customers
typically do not have a company name and are priced at standard retail rates.
</p>
</div>
</div>
</div>
</div>
</section>
<section id="adding-a-customer" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-person-plus text-primary me-2"></i>Adding a Customer
</h2>
<p>Follow these steps to add a new customer:</p>
<ol class="mb-3">
<li class="mb-2">Go to <strong>Operations &rsaquo; Customers</strong> and click the <strong>New Customer</strong> button in the top-right corner.</li>
<li class="mb-2">Choose the <strong>Customer Type</strong> — Commercial or Non-Commercial.</li>
<li class="mb-2">
Fill in the customer details:
<ul class="mt-1">
<li><strong>Company Name</strong> — required for Commercial customers.</li>
<li><strong>Contact Name</strong> — the person you deal with day to day.</li>
<li><strong>Email</strong> — used for quote and invoice notifications.</li>
<li><strong>Phone</strong> — primary contact number.</li>
<li><strong>Address</strong> — billing and shipping address fields.</li>
</ul>
</li>
<li class="mb-2">For Commercial customers, optionally set a <strong>Pricing Tier</strong> and <strong>Credit Limit</strong>.</li>
<li class="mb-2">Add any <strong>Notes</strong> that your team should know about this customer (e.g., "Requires 48-hour advance notice before pickup").</li>
<li class="mb-2">Click <strong>Save Customer</strong>.</li>
</ol>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
You can also create a customer automatically when converting an approved prospect quote. The
system pre-fills the customer form with the prospect's details from the quote.
</div>
</div>
</section>
<section id="editing-a-customer" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-pencil text-primary me-2"></i>Editing a Customer
</h2>
<p>
To update a customer's details, find the customer in the list and click their name to open the
Details page. Then click the <strong>Edit</strong> button (pencil icon) in the top-right corner
of the details page.
</p>
<p>
You can also click the <i class="bi bi-pencil"></i> icon directly in the customer list to jump
straight to the edit form.
</p>
<p>
All fields can be updated at any time. Changes take effect immediately and are reflected on all
linked jobs, quotes, and invoices.
</p>
</section>
<section id="customer-details" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-card-text text-primary me-2"></i>Customer Details Page
</h2>
<p>
The Customer Details page is your hub for everything related to a single customer. Open it by
clicking a customer's name anywhere in the system.
</p>
<p>The details page shows:</p>
<ul>
<li><strong>Contact information</strong> — name, email, phone, and address.</li>
<li><strong>Account summary</strong> — current balance, credit limit, and pricing tier.</li>
<li>
<strong>Jobs tab</strong> — every job created for this customer, with status and date. Click
a job number to open it.
</li>
<li>
<strong>Quotes tab</strong> — all quotes sent to this customer, including pending and
historical quotes. Click a quote number to open it.
</li>
<li>
<strong>Invoices tab</strong> — all invoices with payment status. Quickly see who owes money
and how much.
</li>
<li>
<strong>Deposits tab</strong> — all deposits recorded for this customer across any job or quote.
</li>
<li><strong>Notes</strong> — any notes saved against the customer record.</li>
</ul>
</section>
<section id="credit-limit" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-credit-card text-primary me-2"></i>Credit Limit
</h2>
<p>
The credit limit is the maximum amount of unpaid invoices a commercial customer is allowed to carry
at one time. It is set on the customer record and displayed on the Customer Details page alongside
their current outstanding balance.
</p>
<p>
When a customer's outstanding balance approaches or exceeds their credit limit, the system displays
a warning flag on their record and on any new jobs or invoices you try to create for them. This is
a visual warning only — the system does not automatically block new work — but it gives your team
a clear signal to follow up on payment before starting more jobs.
</p>
<p>
If you do not want to set a credit limit, leave the field at <strong>0</strong> (the default), which
means no limit is enforced.
</p>
</section>
<section id="tax-exempt" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-patch-check text-primary me-2"></i>Tax Exempt Customers
</h2>
<p>
If a customer is tax exempt (e.g., a non-profit or a reseller with a valid exemption certificate),
check the <strong>Tax Exempt</strong> box on their customer record. You can also upload their
exemption certificate as an attachment for your records.
</p>
<p>
When a tax-exempt customer is selected on a new quote or invoice, the tax rate automatically
defaults to <strong>0%</strong> — no manual adjustment needed. Tax-exempt customers are marked
with a ★ in the customer dropdown when creating quotes and invoices so your team can spot them
at a glance.
</p>
</section>
<section id="deactivating-a-customer" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-person-dash text-primary me-2"></i>Deactivating a Customer
</h2>
<p>
If a customer no longer does business with you, you can deactivate them rather than deleting them
outright. Deactivating keeps all their historical jobs, quotes, and invoices intact for your records,
but removes them from the active customer list so they do not clutter your search results.
</p>
<p>To deactivate a customer:</p>
<ol class="mb-3">
<li class="mb-1">Open the customer's Details page.</li>
<li class="mb-1">Click the <strong>Delete</strong> (or Deactivate) button.</li>
<li class="mb-1">Confirm the action in the dialog that appears.</li>
</ol>
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>
<strong>Note:</strong> Deactivation is a "soft delete." The customer's record and all linked data
are preserved in the database and visible to Administrators. The customer simply no longer appears
in standard searches and lists. Contact your administrator if you need a record fully restored.
</div>
</div>
</section>
</div>
<div class="col-lg-3 d-none d-lg-block">
@{ await Html.RenderPartialAsync("_HelpNav"); }
<div class="card border-0 shadow-sm sticky-top" style="top:80px">
<div class="card-header bg-transparent fw-semibold small text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">On this page</div>
<div class="card-body p-0">
<nav class="nav flex-column">
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
<a class="nav-link py-1 px-3 small text-body" href="#customer-types">Customer Types</a>
<a class="nav-link py-1 px-3 small text-body" href="#adding-a-customer">Adding a Customer</a>
<a class="nav-link py-1 px-3 small text-body" href="#editing-a-customer">Editing a Customer</a>
<a class="nav-link py-1 px-3 small text-body" href="#customer-details">Customer Details Page</a>
<a class="nav-link py-1 px-3 small text-body" href="#credit-limit">Credit Limit</a>
<a class="nav-link py-1 px-3 small text-body" href="#tax-exempt">Tax Exempt</a>
<a class="nav-link py-1 px-3 small text-body" href="#deactivating-a-customer">Deactivating a Customer</a>
</nav>
</div>
</div>
</div>
</div>
@@ -0,0 +1,309 @@
@{
ViewData["Title"] = "Equipment & Maintenance";
}
<div class="d-flex align-items-center gap-2 mb-3">
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
<li class="breadcrumb-item active">Equipment &amp; Maintenance</li>
</ol>
</nav>
</div>
<div class="row g-4">
<div class="col-lg-9">
<section id="overview" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-info-circle text-primary me-2"></i>Overview
</h2>
<p>
The Equipment module lets you keep a full register of every piece of machinery in your shop —
curing ovens, sandblasters, coating booths, compressors, conveyors, and anything else you
rely on to do the work. Each equipment record includes its current operating status and a
complete maintenance log.
</p>
<p>
Keeping this information up to date pays off in two ways. First, your team always knows
which machines are available and which are down for service — preventing jobs from being
scheduled on equipment that is not ready. Second, the maintenance history gives you a
paper trail for warranty claims, insurance, and resale.
</p>
<p>
Find Equipment under <strong>Equipment</strong> in the left sidebar.
</p>
</section>
<section id="adding-equipment" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-plus-circle text-primary me-2"></i>Adding Equipment
</h2>
<p>To add a piece of equipment to the system:</p>
<ol class="mb-3">
<li class="mb-2">Go to <strong>Equipment</strong> and click <strong>New Equipment</strong>.</li>
<li class="mb-2">
Fill in the equipment details:
<ul class="mt-1">
<li><strong>Name</strong> — a short, descriptive name (e.g., "Main Curing Oven" or "Blast Cabinet #2").</li>
<li><strong>Model / Serial Number</strong> — from the manufacturer's plate on the machine. Useful for warranty and service calls.</li>
<li><strong>Manufacturer</strong> — who made the equipment.</li>
<li><strong>Purchase Date</strong> — when your shop acquired it.</li>
<li><strong>Last Service Date</strong> — the date it was last professionally serviced or inspected.</li>
<li><strong>Next Service Due</strong> — when the next scheduled service is due. This triggers alerts on the Dashboard.</li>
<li><strong>Location</strong> — where in the shop the equipment is located (e.g., "Bay 1", "Back Room").</li>
<li><strong>Notes</strong> — any important operational notes, quirks, or warnings for the team.</li>
</ul>
</li>
<li class="mb-2">Set the initial <strong>Status</strong> (see below).</li>
<li class="mb-2">Click <strong>Save Equipment</strong>.</li>
</ol>
</section>
<section id="equipment-status" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-toggles text-primary me-2"></i>Equipment Status
</h2>
<p>
Every piece of equipment has a status that reflects its current condition. Update the status
whenever the equipment's situation changes — this keeps the Dashboard accurate and lets
supervisors quickly see what is available.
</p>
<div class="table-responsive">
<table class="table table-bordered align-middle">
<thead class="table-light">
<tr>
<th style="width:28%">Status</th>
<th>What it means</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="badge bg-success fs-6 py-1 px-2"><i class="bi bi-check-circle me-1"></i>Operational</span></td>
<td>
The equipment is fully functional and available for use. This is the normal
day-to-day status for working machines.
</td>
</tr>
<tr>
<td><span class="badge bg-warning text-dark fs-6 py-1 px-2"><i class="bi bi-exclamation-triangle me-1"></i>Needs Maintenance</span></td>
<td>
The equipment is still operational but a maintenance task is overdue or a minor
issue has been flagged. The machine can still be used with caution, but maintenance
should be scheduled promptly to avoid a breakdown.
</td>
</tr>
<tr>
<td><span class="badge bg-info text-dark fs-6 py-1 px-2"><i class="bi bi-wrench me-1"></i>Under Maintenance</span></td>
<td>
The equipment is currently being serviced or repaired and is <strong>not available</strong>
for production. Do not schedule jobs that require this equipment until its status
returns to Operational.
</td>
</tr>
<tr>
<td><span class="badge bg-danger fs-6 py-1 px-2"><i class="bi bi-x-circle me-1"></i>Out of Service</span></td>
<td>
The equipment has broken down or failed and cannot be used. A maintenance record
should be created immediately with a priority of High or Critical.
</td>
</tr>
<tr>
<td><span class="badge bg-secondary fs-6 py-1 px-2"><i class="bi bi-archive me-1"></i>Retired</span></td>
<td>
The equipment has been decommissioned and is no longer in service. It remains in
the system for historical records but is excluded from active listings and the
Dashboard.
</td>
</tr>
</tbody>
</table>
</div>
<p class="mt-3">
To update an equipment's status, open its Details page and click <strong>Edit</strong>, then
change the Status field. You can also log a maintenance record (see below), which can update
the status as part of the workflow.
</p>
</section>
<section id="maintenance-records" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-clipboard-check text-primary me-2"></i>Maintenance Records
</h2>
<p>
Every service, repair, inspection, or maintenance task performed on a piece of equipment should
be logged as a maintenance record. Over time this builds a complete service history that is
invaluable for troubleshooting recurring problems, planning replacements, and demonstrating
due diligence.
</p>
<p>To log a maintenance record:</p>
<ol class="mb-3">
<li class="mb-2">Open the equipment's Details page.</li>
<li class="mb-2">Click <strong>Add Maintenance Record</strong>.</li>
<li class="mb-2">
Fill in the details:
<ul class="mt-1">
<li><strong>Task Description</strong> — what was done or needs to be done (e.g., "Replace heating element", "Annual burner service", "Belt tension check").</li>
<li><strong>Type</strong> — choose <strong>Scheduled</strong> for planned preventive maintenance, or <strong>Corrective</strong> for repairs to fix a problem.</li>
<li><strong>Scheduled Date</strong> — when the task is planned for (or when it was done).</li>
<li><strong>Completion Date</strong> — leave blank if the task has not been done yet.</li>
<li><strong>Cost</strong> — what the service cost (parts, labor, contractor fees).</li>
<li><strong>Priority</strong> — how urgent this task is (see below).</li>
<li><strong>Assigned To</strong> — the shop worker responsible for this task.</li>
<li><strong>Notes</strong> — parts used, observations, instructions for next time.</li>
</ul>
</li>
<li class="mb-2">Click <strong>Save</strong>.</li>
</ol>
<p>
Open maintenance records (those without a completion date) appear on the Dashboard as upcoming
tasks. When the work is done, open the record and fill in the <strong>Completion Date</strong>
and any final notes to mark it as done.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Recurring Maintenance</h3>
<p>
For maintenance that happens on a regular schedule (e.g., monthly filter cleaning, quarterly
burner service), set the <strong>Recurrence</strong> field when creating the record — choose
from Daily, Weekly, Monthly, Quarterly, or Annually. When you complete the record, the system
automatically creates the next maintenance record at the appropriate interval. This ensures
preventive maintenance tasks never fall through the cracks.
</p>
</section>
<section id="maintenance-priority" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-flag text-primary me-2"></i>Maintenance Priority
</h2>
<p>
When creating a maintenance record, set the priority to reflect how urgently the task needs
to be completed. Priority affects how records are sorted on the Dashboard and in the
maintenance list.
</p>
<div class="row g-3">
<div class="col-md-6">
<div class="card border-secondary border-opacity-25 h-100">
<div class="card-body py-2 px-3">
<h6 class="mb-1"><span class="badge bg-secondary me-1">Low</span></h6>
<p class="small mb-0 text-muted">
Routine preventive tasks that can be scheduled at the next convenient opportunity.
Example: lubricate conveyor chain, clean filters.
</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-primary border-opacity-25 h-100">
<div class="card-body py-2 px-3">
<h6 class="mb-1"><span class="badge bg-primary me-1">Normal</span></h6>
<p class="small mb-0 text-muted">
Standard scheduled maintenance that should be completed within the planned
service window. Example: annual burner inspection, thermocouple calibration.
</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-warning border-opacity-25 h-100">
<div class="card-body py-2 px-3">
<h6 class="mb-1"><span class="badge bg-warning text-dark me-1">High</span></h6>
<p class="small mb-0 text-muted">
A developing problem that will cause a breakdown if not addressed soon. Production
should be monitored. Example: unusual vibration in the blaster motor, oven
not reaching set temperature.
</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-danger border-opacity-25 h-100">
<div class="card-body py-2 px-3">
<h6 class="mb-1"><span class="badge bg-danger me-1">Critical</span></h6>
<p class="small mb-0 text-muted">
Equipment is down or unsafe to operate. All work on this machine must stop
immediately. Example: oven element failure, electrical fault, safety switch
bypassed.
</p>
</div>
</div>
</div>
</div>
</section>
<section id="oven-scheduler" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-calendar-week text-primary me-2"></i>Oven Scheduler
</h2>
<p>
The <strong>Oven Scheduler</strong> (<a href="/OvenScheduler">/OvenScheduler</a>) lets you
group jobs into oven batches to maximize curing oven utilization. It uses your configured
Named Ovens (set up in <strong>Settings &rsaquo; Company Settings &rsaquo; Named Ovens</strong>)
to show available capacity in square feet as you add jobs to a batch.
</p>
<p>To use the Oven Scheduler:</p>
<ol class="mb-3">
<li class="mb-2">Go to the Oven Scheduler from the sidebar.</li>
<li class="mb-2">Select the oven you are loading.</li>
<li class="mb-2">Add jobs to the batch — the remaining capacity updates as you add items.</li>
<li class="mb-2">Progress batches through Loading → In Progress → Completed as work is done.</li>
</ol>
<p>
To configure your ovens for the scheduler, see
<a asp-controller="Help" asp-action="Settings" fragment="named-ovens">Settings &rsaquo; Named Ovens</a>.
</p>
</section>
<section id="assigning-maintenance" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-person-gear text-primary me-2"></i>Assigning Maintenance Tasks
</h2>
<p>
Each maintenance record can be assigned to one shop worker — typically someone in the
<strong>Maintenance</strong> role, or a <strong>Supervisor</strong> who will coordinate
with an outside service technician.
</p>
<p>
To assign a task, select the worker from the <strong>Assigned To</strong> dropdown when
creating or editing the maintenance record. Only active workers are listed.
</p>
<p>
Assigned tasks appear on the Dashboard grouped by worker, so supervisors can see their
team's maintenance workload at a glance. When a task is completed, the assigned worker
(or a manager) should update the completion date and any notes so the record reflects
exactly what was done and when.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
For work done by an outside contractor (e.g., a refrigeration technician servicing
your blast cabinet compressor), you can either create a temporary worker record for
the contractor or simply note their name and company in the <strong>Notes</strong>
field of the maintenance record.
</div>
</div>
</section>
</div>
<div class="col-lg-3 d-none d-lg-block">
@{ await Html.RenderPartialAsync("_HelpNav"); }
<div class="card border-0 shadow-sm sticky-top" style="top:80px">
<div class="card-header bg-transparent fw-semibold small text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">On this page</div>
<div class="card-body p-0">
<nav class="nav flex-column">
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
<a class="nav-link py-1 px-3 small text-body" href="#adding-equipment">Adding Equipment</a>
<a class="nav-link py-1 px-3 small text-body" href="#equipment-status">Equipment Status</a>
<a class="nav-link py-1 px-3 small text-body" href="#maintenance-records">Maintenance Records</a>
<a class="nav-link py-1 px-3 small text-body" href="#maintenance-priority">Maintenance Priority</a>
<a class="nav-link py-1 px-3 small text-body" href="#oven-scheduler">Oven Scheduler</a>
<a class="nav-link py-1 px-3 small text-body" href="#assigning-maintenance">Assigning Maintenance</a>
</nav>
</div>
</div>
</div>
</div>
@@ -0,0 +1,347 @@
@{
ViewData["Title"] = "Getting Started";
}
<div class="d-flex align-items-center gap-2 mb-3">
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
<li class="breadcrumb-item active">Getting Started</li>
</ol>
</nav>
</div>
<div class="row g-4">
<div class="col-lg-9">
<section id="overview" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-info-circle text-primary me-2"></i>Overview
</h2>
<p>
This system is purpose-built to run a powder coating shop from end to end. At its core it helps you
track every job that comes through the door — from the first quote all the way through coating,
curing, quality check, and final delivery. Along the way it manages your customers, vendors,
inventory of powders and supplies, shop equipment, and the workers on your floor.
</p>
<p>
Everything is connected. When you create a quote for a customer and they approve it, you can convert
that quote into a job with a single click. When a job is invoiced, the customer's account balance
updates automatically. When powder is consumed on a job, the inventory count goes down. You always
have a clear picture of where work stands and what materials you have on hand.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
<strong>Tip:</strong> Use the navigation sidebar on the left to jump between modules. The system
remembers where you left off in most list views, including any search filters you applied.
</div>
</div>
</section>
<section id="logging-in" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-box-arrow-in-right text-primary me-2"></i>Logging In
</h2>
<p>
Open the app in your browser and you will be taken to the login page. Enter your email address and
password, then click <strong>Sign In</strong>.
</p>
<ol class="mb-3">
<li class="mb-1">Enter the email address your administrator set up for you.</li>
<li class="mb-1">Enter your password. Passwords are case-sensitive.</li>
<li class="mb-1">Click <strong>Sign In</strong>. You will land on the Dashboard.</li>
</ol>
<h5 class="fw-semibold">First login — set your permanent password</h5>
<p>
When a new company account is created, the system generates a secure temporary password and emails
it to the address used during signup. The first time you log in with that temporary password, you
will be redirected to a <strong>Set Your Password</strong> page automatically.
</p>
<ol class="mb-3">
<li class="mb-1">Enter the temporary password from your welcome email.</li>
<li class="mb-1">Enter and confirm your new permanent password.</li>
<li class="mb-1">Click <strong>Set Password &amp; Continue</strong>.</li>
</ol>
<p>
You cannot access any other part of the application until this step is complete. Once done, the
temporary password is invalidated and you land directly on the Dashboard.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-3" role="alert">
<i class="bi bi-envelope-check-fill flex-shrink-0 mt-1"></i>
<div>
<strong>Didn't receive the welcome email?</strong> Check your spam or junk folder first.
If it's still not there, contact your company administrator — they can reset your password
from the User Management section.
</div>
</div>
<h5 class="fw-semibold">Forgot your password?</h5>
<p>
On the login page, click the <strong>Forgot your password?</strong> link. Enter your email address
and the system will send you a reset link. Check your inbox (and spam folder) and follow the
instructions in the email. Reset links expire after a short time, so use them promptly.
</p>
<p>
If you do not receive an email or cannot log in, contact your company administrator — they can reset
your password from the User Management section.
</p>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
<i class="bi bi-shield-lock flex-shrink-0 mt-1"></i>
<div>
<strong>Keep your password safe.</strong> Do not share your login details with anyone, even
co-workers. Every action in the system is logged with your account name.
</div>
</div>
</section>
<section id="navigating" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-compass text-primary me-2"></i>Navigating the System
</h2>
<p>
The left sidebar is your main navigation. It is divided into sections so related features are grouped
together. Click any item to go to that section. On smaller screens the sidebar collapses — tap the
menu icon at the top to open it.
</p>
<div class="table-responsive">
<table class="table table-bordered table-sm align-middle">
<thead class="table-light">
<tr>
<th style="width:30%">Section</th>
<th>What you will find there</th>
</tr>
</thead>
<tbody>
<tr>
<td><i class="bi bi-speedometer2 me-1 text-primary"></i><strong>Dashboard</strong></td>
<td>A live summary of today's jobs, outstanding quotes, low-stock alerts, equipment needing attention, and a daily tip.</td>
</tr>
<tr>
<td><i class="bi bi-briefcase me-1 text-primary"></i><strong>Operations</strong></td>
<td>Customers, Quotes, Jobs, Invoices, Appointments/Calendar, Job Priority Board, Gift Certificates, and Shop Workers — the day-to-day work of the shop.</td>
</tr>
<tr>
<td><i class="bi bi-box-seam me-1 text-primary"></i><strong>Inventory</strong></td>
<td>Catalog Items, Inventory Items, Vendors, and Purchase Orders.</td>
</tr>
<tr>
<td><i class="bi bi-bank me-1 text-primary"></i><strong>Accounting</strong></td>
<td>Bills (Accounts Payable), Chart of Accounts, and Accounting Export.</td>
</tr>
<tr>
<td><i class="bi bi-tools me-1 text-primary"></i><strong>Equipment</strong></td>
<td>Equipment records, Maintenance, Oven Scheduler, and Powder Insights.</td>
</tr>
<tr>
<td><i class="bi bi-bar-chart me-1 text-primary"></i><strong>Reports</strong></td>
<td>Financial summaries, AR aging, job performance, AI-powered analysis, cash flow forecasting, and more.</td>
</tr>
<tr>
<td><i class="bi bi-gear me-1 text-primary"></i><strong>Settings</strong></td>
<td>Company info, pricing rates, operating costs, Named Ovens, AI Profile, Pricing Tiers, User Management, and Billing. Visible to Admins and Managers.</td>
</tr>
</tbody>
</table>
</div>
<p class="mt-3">
The top bar shows your profile avatar, a <strong>notification bell</strong>, and a quick-search box.
Use the search box to find customers, jobs, or quotes by name or number without navigating through menus.
</p>
<p>
The <strong>notification bell</strong> (<i class="bi bi-bell-fill"></i>) alerts you to customer
actions in real time — a quote approved or declined online, an invoice or deposit paid online,
and any platform announcements from the Powder Coating Logix team. A red badge shows the count of
unread items. Click the bell to see recent notifications and navigate directly to the related record.
Notifications are stored persistently so you will not miss one if you were not logged in when it
arrived.
</p>
</section>
<section id="roles-and-permissions" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-shield-check text-primary me-2"></i>Roles and Permissions
</h2>
<p>
Every user is assigned a role that controls what they can see and do. Your administrator assigns
your role when they create your account. If you cannot access something you expect to, ask your
administrator — they may need to update your role.
</p>
<div class="table-responsive">
<table class="table table-bordered align-middle">
<thead class="table-light">
<tr>
<th style="width:18%">Role</th>
<th>What they can do</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<span class="badge bg-danger">Admin</span>
</td>
<td>
Full access to all features including user management, company settings, pricing
configuration, and all reports. Can create, edit, and delete any record. Should
be limited to the company owner or office manager.
</td>
</tr>
<tr>
<td>
<span class="badge bg-warning text-dark">Manager</span>
</td>
<td>
Can manage all operational data — customers, jobs, quotes, invoices, inventory,
equipment, and workers. Can view reports and adjust settings, but cannot manage
user accounts or change platform-level configuration.
</td>
</tr>
<tr>
<td>
<span class="badge bg-primary">Employee</span>
</td>
<td>
Can create and edit jobs and quotes, manage customers, and update inventory.
Cannot delete records, manage users, or access financial reports. Suited to
office staff who handle quoting and customer communication.
</td>
</tr>
<tr>
<td>
<span class="badge bg-info text-dark">Shop Floor</span>
</td>
<td>
Can view jobs assigned to them and update job status as work progresses (e.g.,
move a job from <em>Sandblasting</em> to <em>Cleaning</em>). Cannot create or
edit quotes, invoices, or customer records. Designed for workers on the floor
who only need to update what they are working on.
</td>
</tr>
<tr>
<td>
<span class="badge bg-secondary">Read Only</span>
</td>
<td>
View-only access to most parts of the system. Cannot create, edit, or delete
any records. Useful for accountants or auditors who need visibility but should
not make changes.
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="your-first-steps" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-list-check text-primary me-2"></i>Your First Steps
</h2>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-3" role="alert">
<i class="bi bi-magic flex-shrink-0 mt-1"></i>
<div>
<strong>New to the system?</strong> Use the <a href="/SetupWizard">Setup Wizard</a> to
configure your company, operating costs, named ovens, and initial inventory in a guided
10-step walkthrough. The wizard is the fastest way to get your shop configured and ready.
</div>
</div>
<p>
If you prefer to configure things manually, or if you are returning to complete partial setup,
work through these steps in order. Each step builds on the last — for example, inventory items
need vendors before you can record purchases, and jobs need customers before you can create them.
</p>
<div class="list-group shadow-sm">
<div class="list-group-item list-group-item-action d-flex gap-3 align-items-start py-3">
<span class="badge bg-primary rounded-pill mt-1 flex-shrink-0">1</span>
<div>
<h6 class="mb-1 fw-semibold"><i class="bi bi-truck me-1"></i>Set up your vendors</h6>
<p class="mb-0 text-muted small">
Go to <strong>Inventory &rsaquo; Vendors</strong> and add the companies you buy powder and
other supplies from. Include their payment terms and a contact name. Vendors are linked to
inventory items so you always know where to reorder.
</p>
</div>
</div>
<div class="list-group-item list-group-item-action d-flex gap-3 align-items-start py-3">
<span class="badge bg-primary rounded-pill mt-1 flex-shrink-0">2</span>
<div>
<h6 class="mb-1 fw-semibold"><i class="bi bi-box-seam me-1"></i>Add your inventory items</h6>
<p class="mb-0 text-muted small">
Go to <strong>Inventory &rsaquo; Items</strong> and enter the powders, primers, and
consumables you stock. Set each item's unit cost, reorder point, and link it to the vendor
you buy it from. This feeds into job costing and low-stock alerts.
</p>
</div>
</div>
<div class="list-group-item list-group-item-action d-flex gap-3 align-items-start py-3">
<span class="badge bg-primary rounded-pill mt-1 flex-shrink-0">3</span>
<div>
<h6 class="mb-1 fw-semibold"><i class="bi bi-tools me-1"></i>Add your equipment</h6>
<p class="mb-0 text-muted small">
Go to <strong>Equipment</strong> and add your oven, sandblaster, coating booth, and any
other machinery. Recording equipment lets you log maintenance and feeds into the operating
cost calculations used in job pricing.
</p>
</div>
</div>
<div class="list-group-item list-group-item-action d-flex gap-3 align-items-start py-3">
<span class="badge bg-primary rounded-pill mt-1 flex-shrink-0">4</span>
<div>
<h6 class="mb-1 fw-semibold"><i class="bi bi-person-badge me-1"></i>Add your shop workers</h6>
<p class="mb-0 text-muted small">
Go to <strong>Operations &rsaquo; Shop Workers</strong> and add the people who work in your
shop. Assign each worker a role (Coater, Sandblaster, etc.). Workers can then be assigned
to jobs and maintenance tasks.
</p>
</div>
</div>
<div class="list-group-item list-group-item-action d-flex gap-3 align-items-start py-3">
<span class="badge bg-primary rounded-pill mt-1 flex-shrink-0">5</span>
<div>
<h6 class="mb-1 fw-semibold"><i class="bi bi-people me-1"></i>Create your first customer</h6>
<p class="mb-0 text-muted small">
Go to <strong>Operations &rsaquo; Customers</strong> and add a customer. Choose Commercial
for businesses or Non-Commercial for individuals. You will need a customer record before
you can create a job or a quote for them.
</p>
</div>
</div>
<div class="list-group-item list-group-item-action d-flex gap-3 align-items-start py-3">
<span class="badge bg-success rounded-pill mt-1 flex-shrink-0">6</span>
<div>
<h6 class="mb-1 fw-semibold"><i class="bi bi-briefcase me-1"></i>Create your first job</h6>
<p class="mb-0 text-muted small">
Go to <strong>Operations &rsaquo; Jobs</strong> and click <strong>New Job</strong>. Select
the customer, describe the work, add line items, set a due date, and assign a worker.
Then update the job status as it moves through the shop.
</p>
</div>
</div>
</div>
</section>
</div>
<div class="col-lg-3 d-none d-lg-block">
@{ await Html.RenderPartialAsync("_HelpNav"); }
<div class="card border-0 shadow-sm sticky-top" style="top:80px">
<div class="card-header bg-transparent fw-semibold small text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">On this page</div>
<div class="card-body p-0">
<nav class="nav flex-column">
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
<a class="nav-link py-1 px-3 small text-body" href="#logging-in">Logging In</a>
<a class="nav-link py-1 px-3 small text-body" href="#navigating">Navigating the System</a>
<a class="nav-link py-1 px-3 small text-body" href="#roles-and-permissions">Roles and Permissions</a>
<a class="nav-link py-1 px-3 small text-body" href="#your-first-steps">Your First Steps</a>
</nav>
</div>
</div>
</div>
</div>
@@ -0,0 +1,283 @@
@{
ViewData["Title"] = "Help Center";
}
<div class="d-flex align-items-center gap-2 mb-3">
<h1 class="h3 mb-0"><i class="bi bi-question-circle text-primary me-2"></i>Help Center</h1>
</div>
<div class="card border-0 shadow-sm mb-4 bg-primary text-white">
<div class="card-body py-4">
<div class="row align-items-center">
<div class="col">
<h2 class="h4 mb-1 text-white">Welcome to the Help Center</h2>
<p class="mb-0 opacity-75">
Find step-by-step guides for every part of the powder coating shop management system.
Whether you are setting up the shop for the first time or need a quick refresher,
the articles below have you covered.
</p>
</div>
<div class="col-auto d-none d-md-block">
<i class="bi bi-book-half" style="font-size:4rem; opacity:0.3;"></i>
</div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-lg-9">
<!-- Getting Started -->
<h2 class="h6 fw-semibold mb-2 text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">Getting Started</h2>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-primary bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-rocket-takeoff text-primary fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Getting Started</h5>
<p class="card-text text-muted small mb-2">Log in, navigate the system, understand user roles, and take your first steps setting up the shop.</p>
<a asp-controller="Help" asp-action="GettingStarted" class="btn btn-sm btn-outline-primary">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Operations -->
<h2 class="h6 fw-semibold mb-2 text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">Operations</h2>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-primary bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-briefcase text-primary fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Jobs</h5>
<p class="card-text text-muted small mb-2">Create and manage jobs, track status through the shop workflow, assign workers, and manage line items.</p>
<a asp-controller="Help" asp-action="Jobs" class="btn btn-sm btn-outline-primary">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-success bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-file-earmark-text text-success fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Quotes</h5>
<p class="card-text text-muted small mb-2">Build quotes for customers and prospects, use the pricing engine, and convert approved quotes into jobs.</p>
<a asp-controller="Help" asp-action="Quotes" class="btn btn-sm btn-outline-success">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-warning bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-receipt text-warning fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Invoices</h5>
<p class="card-text text-muted small mb-2">Create invoices from completed jobs, send them to customers, record payments, and track outstanding balances.</p>
<a asp-controller="Help" asp-action="Invoices" class="btn btn-sm btn-outline-warning">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-info bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-people text-info fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Customers</h5>
<p class="card-text text-muted small mb-2">Add and manage commercial and non-commercial customers, set credit limits, and view their job and invoice history.</p>
<a asp-controller="Help" asp-action="Customers" class="btn btn-sm btn-outline-info">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Inventory & Purchasing -->
<h2 class="h6 fw-semibold mb-2 text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">Inventory &amp; Purchasing</h2>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-primary bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-box-seam text-primary fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Inventory</h5>
<p class="card-text text-muted small mb-2">Track powders and supplies, monitor stock levels, set reorder points, and record stock transactions.</p>
<a asp-controller="Help" asp-action="Inventory" class="btn btn-sm btn-outline-primary">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-success bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-truck text-success fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Vendors</h5>
<p class="card-text text-muted small mb-2">Manage suppliers, set payment terms, link vendors to inventory items, and track purchase history.</p>
<a asp-controller="Help" asp-action="Vendors" class="btn btn-sm btn-outline-success">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-warning bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-cart3 text-warning fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Purchase Orders</h5>
<p class="card-text text-muted small mb-2">Create POs for vendors, track submission and receipt, and convert received POs into vendor bills.</p>
<a asp-controller="Help" asp-action="PurchaseOrders" class="btn btn-sm btn-outline-warning">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-danger bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-bank text-danger fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Accounts Payable</h5>
<p class="card-text text-muted small mb-2">Record vendor bills, track what you owe, mark bills as paid, and manage your AP ledger.</p>
<a asp-controller="Help" asp-action="AccountsPayable" class="btn btn-sm btn-outline-danger">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Shop Management -->
<h2 class="h6 fw-semibold mb-2 text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">Shop Management</h2>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-info bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-person-badge text-info fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Shop Workers</h5>
<p class="card-text text-muted small mb-2">Add floor staff, assign roles like Coater or Sandblaster, and link workers to jobs and maintenance tasks.</p>
<a asp-controller="Help" asp-action="ShopWorkers" class="btn btn-sm btn-outline-info">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-secondary bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-tools text-secondary fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Equipment &amp; Maintenance</h5>
<p class="card-text text-muted small mb-2">Track your oven, sandblaster, and coating booth. Log maintenance records and schedule upcoming service.</p>
<a asp-controller="Help" asp-action="Equipment" class="btn btn-sm btn-outline-secondary">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Reports & Admin -->
<h2 class="h6 fw-semibold mb-2 text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">Reports &amp; Admin</h2>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-primary bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-bar-chart text-primary fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Reports</h5>
<p class="card-text text-muted small mb-2">Financial summaries, AR aging, job throughput, inventory levels, and equipment status — all in one place.</p>
<a asp-controller="Help" asp-action="Reports" class="btn btn-sm btn-outline-primary">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-success bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-gear text-success fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Settings</h5>
<p class="card-text text-muted small mb-2">Configure company info, pricing rates, operating costs, pricing tiers, and chart of accounts.</p>
<a asp-controller="Help" asp-action="Settings" class="btn btn-sm btn-outline-success">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-secondary bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-person-circle text-secondary fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Your Profile</h5>
<p class="card-text text-muted small mb-2">Update your contact details, change your password, upload a profile photo, and choose your display theme.</p>
<a asp-controller="Help" asp-action="UserProfile" class="btn btn-sm btn-outline-secondary">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3 d-none d-lg-block">
@await Html.PartialAsync("_HelpNav")
</div>
</div>
@@ -0,0 +1,456 @@
@{
ViewData["Title"] = "Inventory";
}
<div class="d-flex align-items-center gap-2 mb-3">
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
<li class="breadcrumb-item active">Inventory</li>
</ol>
</nav>
</div>
<div class="row g-4">
<div class="col-lg-9">
<section id="overview" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-info-circle text-primary me-2"></i>Overview
</h2>
<p>
The Inventory module tracks every powder, primer, consumable, and supply item your shop uses.
Each item has a current stock level, a unit cost that feeds into job pricing calculations, and
a reorder point that triggers a low-stock alert when stock drops to or below it.
</p>
<p>
Keeping inventory accurate matters for two reasons. First, your job and quote pricing is only
as accurate as the unit costs stored in inventory — outdated costs lead to under-pricing.
Second, knowing how much powder you have on hand before a job starts prevents the frustrating
situation of running out of material mid-job.
</p>
<p>
You can find Inventory under <strong>Inventory &rsaquo; Items</strong> in the left sidebar.
</p>
</section>
<section id="adding-items" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-plus-circle text-primary me-2"></i>Adding Inventory Items
</h2>
<p>To add a new item to your inventory:</p>
<ol class="mb-3">
<li class="mb-2">Go to <strong>Inventory &rsaquo; Items</strong> and click <strong>New Item</strong>.</li>
<li class="mb-2">
Fill in the item details:
<ul class="mt-1">
<li><strong>Item Name</strong> — a clear, descriptive name (e.g., "Gloss Black Powder — Tiger Drylac 49/90005").</li>
<li><strong>SKU / Part Number</strong> — the manufacturer's part number or your internal SKU.</li>
<li><strong>Category</strong> — Powder, Primer, Consumable, Shop Supply, or other category as appropriate.</li>
<li><strong>Unit of Measure</strong> — lbs, kg, each, litre, etc.</li>
<li><strong>Unit Cost</strong> — your purchase cost per unit. Used in quote and job pricing calculations.</li>
<li><strong>Current Quantity on Hand</strong> — the number of units you have right now. This becomes the opening stock level.</li>
<li><strong>Reorder Point</strong> — the quantity at which you want to be alerted to reorder. See the Reorder Points section below.</li>
<li><strong>Vendor / Supplier</strong> — the vendor you purchase this item from. Linking a vendor lets you quickly see who to call when stock runs low.</li>
</ul>
</li>
<li class="mb-2">
For powder coatings, the <strong>Coverage Rate</strong> (sq ft per lb) and <strong>Transfer Efficiency %</strong>
default to <strong>30 sq ft/lb</strong> and <strong>65%</strong> respectively — typical starting values for most powder
and application setups. Adjust these to match your specific powder and equipment. Both values are used when
calculating powder needed on quotes and jobs.
</li>
<li class="mb-2">Click <strong>Save Item</strong>.</li>
</ol>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
When you save a new item with an opening stock quantity greater than zero, the system automatically
records an <strong>Initial</strong> transaction for that quantity. This gives you a clean audit trail
from day one without any manual entry.
</div>
</div>
</section>
<section id="stock-levels" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-boxes text-primary me-2"></i>Stock Levels and Reorder Points
</h2>
<p>
The <strong>quantity on hand</strong> for each item is updated automatically whenever a transaction
is recorded — a purchase receipt increases stock, a job consumption decreases it, and a manual
adjustment sets it to the corrected count.
</p>
<p>
The <strong>reorder point</strong> is the safety threshold below which you do not want your stock
to fall. When the quantity on hand reaches or drops below the reorder point, the item appears in
the low-stock alerts section on your Dashboard and is flagged in the Inventory list. Think of it
as the signal to place a new order with your vendor.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Setting a good reorder point</h3>
<p>
A good reorder point accounts for two factors:
</p>
<ul class="mb-3">
<li class="mb-1"><strong>Lead time</strong> — how many days it typically takes for your vendor to deliver after you place an order. If lead time is 5 days, your reorder point should cover at least 5 days of usage.</li>
<li class="mb-1"><strong>Daily usage rate</strong> — how much of the item you typically consume per day based on your job volume.</li>
</ul>
<p>
For example, if you use 3 lbs of a powder per day and your vendor takes 5 days to deliver, a
reorder point of 20 lbs (3 &times; 5 + a small safety buffer) ensures you never run out while
waiting for the delivery.
</p>
</section>
<section id="stock-adjustment" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-plus-slash-minus text-primary me-2"></i>Stock Adjustment
</h2>
<p>
Use the <strong>Stock Adjustment</strong> button on any item's Details page to quickly correct the
quantity on hand without going through the full edit form. This is the fastest way to record a
physical count correction, log a waste event, or add stock received outside of a formal Purchase Order.
</p>
<p>Click <strong>Stock Adjustment</strong> in the Actions panel and choose one of three modes:</p>
<ul class="mb-3">
<li class="mb-2"><strong>Add Stock</strong> — increases the current quantity by the amount you enter. Use for received goods, returns, or found stock.</li>
<li class="mb-2"><strong>Remove Stock</strong> — decreases the current quantity by the amount you enter. Use for waste, spillage, or damage write-offs.</li>
<li class="mb-2"><strong>Set Exact</strong> — sets the quantity on hand to the exact number you enter, regardless of the current value. Use after a physical inventory count to correct the balance.</li>
</ul>
<p>
A <strong>reason</strong> is required for every adjustment. Common reasons are listed in the dropdown
(received from PO, physical count correction, waste, etc.). Add optional notes for additional detail.
The modal shows your current stock and a live preview of the new balance as you type.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-journal-check flex-shrink-0 mt-1"></i>
<div>
Every stock adjustment is automatically recorded as an <strong>Adjustment</strong> transaction in the
item's activity history, including the reason and notes you entered. You can review all past
adjustments on the <strong>Inventory Activity</strong> page.
</div>
</div>
</section>
<section id="transactions" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-arrow-left-right text-primary me-2"></i>Transaction Types
</h2>
<p>
Every stock movement is recorded as a transaction with a date, quantity, and running balance,
giving you a complete audit trail. Transactions are created automatically by the system
(when you create an item, edit a quantity, receive a PO, or record powder usage on a job)
and manually through the Stock Adjustment modal.
</p>
<div class="table-responsive mb-3">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th style="width:25%">Transaction Type</th>
<th>When it is recorded</th>
<th style="width:15%">Effect on stock</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Initial</strong></td>
<td>Opening balance when an item is first created with stock on hand.</td>
<td class="text-success fw-semibold">+ Increases</td>
</tr>
<tr>
<td><strong>Purchase</strong></td>
<td>Stock received from a vendor via a Purchase Order receipt.</td>
<td class="text-success fw-semibold">+ Increases</td>
</tr>
<tr>
<td><strong>Return</strong></td>
<td>Stock returned to inventory from a job or returned from a vendor.</td>
<td class="text-success fw-semibold">+ Increases</td>
</tr>
<tr>
<td><strong>Adjustment</strong></td>
<td>Manual correction via the Stock Adjustment modal, or any direct change to Quantity on Hand through the edit form.</td>
<td class="text-muted">+/&minus;</td>
</tr>
<tr>
<td><strong>Transfer</strong></td>
<td>Stock moved between locations or storage areas.</td>
<td class="text-muted">+/&minus;</td>
</tr>
<tr>
<td><strong>Job Usage</strong></td>
<td>Powder consumed during a job — recorded automatically when actual usage is entered on a job coat.</td>
<td class="text-danger fw-semibold">&minus; Decreases</td>
</tr>
<tr>
<td><strong>Sale</strong></td>
<td>Stock consumed or sold outside of a job.</td>
<td class="text-danger fw-semibold">&minus; Decreases</td>
</tr>
<tr>
<td><strong>Waste</strong></td>
<td>Material scrapped, spilled, or otherwise lost and cannot be used.</td>
<td class="text-danger fw-semibold">&minus; Decreases</td>
</tr>
</tbody>
</table>
</div>
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>
Always add a <strong>note</strong> when recording a Waste or Adjustment. Notes make it much
easier to understand your stock history during audits or when investigating discrepancies
between physical counts and the system.
</div>
</div>
</section>
<section id="activity-ledger" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-journal-text text-primary me-2"></i>Inventory Activity
</h2>
<p>
The <strong>Inventory Activity</strong> page (<strong>Inventory &rsaquo; Inventory Activity</strong> in the
sidebar, or click <strong>View Activity History</strong> on any item's Details page) gives you a
complete view of all stock movements and powder usage across your shop.
</p>
<p>It has two tabs:</p>
<ul class="mb-3">
<li class="mb-2">
<strong>Stock Transactions</strong> — every transaction recorded against your inventory items,
showing date, type, quantity (green for additions, red for deductions), unit cost, total cost,
running balance after the transaction, and a link to the source Purchase Order if applicable.
</li>
<li class="mb-2">
<strong>Powder Usage by Job</strong> — every instance of powder being consumed on a job coat,
showing the job number (linked to the job), customer, color applied, estimated vs actual pounds
used, and the variance. A totals row at the bottom summarises the full filtered selection.
</li>
</ul>
<p>
Use the filter bar at the top to narrow results by <strong>item</strong>, <strong>date range</strong>,
and <strong>transaction type</strong>. Summary pills above the tabs show total lbs received, total
lbs used, and net adjustments for the current filter.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
To see the history for a single powder, open its Details page and click
<strong>View Activity History</strong> — the Inventory Activity page will open pre-filtered
to that item.
</div>
</div>
</section>
<section id="qr-labels" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-qr-code text-primary me-2"></i>QR Code Labels &amp; Mobile Usage Logging
</h2>
<p>
Every inventory item has a printable <strong>QR code label</strong>. Stick it on the bag, bin,
or shelf and shop floor workers can scan it with their phone to log how much they used —
without ever touching a desktop.
</p>
<h3 class="h6 fw-semibold mt-4 mb-2">Printing a label</h3>
<ol class="mb-3">
<li class="mb-1">Open the inventory item's Details page.</li>
<li class="mb-1">Click <strong>Print QR Label</strong> in the Actions panel — the label opens in a new tab.</li>
<li class="mb-1">Click <strong>Print Label</strong> and send it to your printer. The label is sized for a standard 3.5&Prime; label and includes the item name, SKU, colour, finish, and manufacturer.</li>
</ol>
<h3 class="h6 fw-semibold mt-4 mb-2">Scanning and logging usage</h3>
<ol class="mb-3">
<li class="mb-1">Point your phone camera at the QR code on the label. Your browser opens the <strong>Log Usage</strong> page for that item.</li>
<li class="mb-1">
<strong>Select a job</strong> (optional but recommended):
<ul class="mt-1">
<li><em>My Jobs</em> — active jobs assigned to your account appear first.</li>
<li><em>Other Jobs</em> — any other open job in the system.</li>
<li><em>No Job</em> — log usage without a job reference (e.g. a waste event).</li>
</ul>
</li>
<li class="mb-1">Enter the <strong>quantity</strong> used. A live preview shows what the new stock balance will be.</li>
<li class="mb-1">Choose a <strong>reason</strong>: Job Usage, Waste / Spillage, Correction, or Transfer Out.</li>
<li class="mb-1">Add optional notes, then tap <strong>Save Usage Log</strong>.</li>
</ol>
<h3 class="h6 fw-semibold mt-4 mb-2">After saving</h3>
<p class="mb-3">
The success screen gives you two options:
</p>
<ul class="mb-3">
<li class="mb-1"><strong>Log Another Item for This Job</strong> — returns to the scan page with the same job pre-selected, so you can quickly log the next powder without re-picking the job.</li>
<li class="mb-1"><strong>Back to Inventory</strong> or <strong>View Item Details</strong> — returns to a neutral state.</li>
</ul>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
Every scan-based usage log is recorded as a <strong>JobUsage</strong> or <strong>Adjustment</strong>
transaction and immediately reduces the item's quantity on hand. You can review it on the
<a href="/Inventory/Ledger" class="alert-link">Inventory Activity</a> page.
The first time a worker scans on a new device they will be asked to log in — after that the
browser keeps them signed in.
</div>
</div>
</section>
<section id="low-stock-alerts" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-exclamation-triangle text-primary me-2"></i>Stock Status and Alerts
</h2>
<p>Every inventory item displays one of three stock statuses:</p>
<ul class="mb-3">
<li class="mb-2">
<span class="badge bg-success me-1">In Stock</span>
Quantity on hand is above the reorder point. No action needed.
</li>
<li class="mb-2">
<span class="badge bg-danger me-1">Low Stock</span>
Quantity on hand is greater than zero but at or below the reorder point.
This is your signal to place a reorder with your vendor.
</li>
<li class="mb-2">
<span class="badge bg-dark me-1">Out of Stock</span>
Quantity on hand is zero. Jobs using this powder cannot proceed until stock is replenished.
An alert banner is shown on the item's Details page prompting you to use Stock Adjustment to add inventory.
</li>
</ul>
<p>Low Stock and Out of Stock items appear in the Inventory Alerts section on the Dashboard and in the Operations Report. Use the <strong>Low Stock</strong> filter on the Inventory list to see only items needing attention.</p>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
An item continues to show as Low Stock or Out of Stock even after you have placed a Purchase
Order, until the goods are physically received and the PO is marked as Received in the system.
This is intentional — it reminds you that stock has not yet arrived.
</div>
</div>
</section>
<section id="powder-insights" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-graph-up-arrow text-primary me-2"></i>Powder Insights
</h2>
<p>
<strong>Powder Insights</strong> (<a href="/PowderInsights">/PowderInsights</a>) is an
AI-powered analysis of your powder usage patterns, efficiency trends, and cost optimization
opportunities. It is accessible from the Equipment section of the sidebar.
</p>
<ul class="mb-3">
<li class="mb-1">Requires at least <strong>10 jobs</strong> with powder data to generate basic insights.</li>
<li class="mb-1">Predictive and cost-optimization features unlock at <strong>150 jobs</strong>.</li>
<li class="mb-1">Shows usage trends by powder color/type, efficiency benchmarks, and suggestions for reducing waste.</li>
</ul>
<p>
The more accurately you record powder usage and efficiency on your inventory items, the more
useful Powder Insights becomes over time.
</p>
</section>
<section id="inventory-categories" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-tags text-primary me-2"></i>Inventory Categories &amp; the "Is Coating" Flag
</h2>
<p>
Every inventory item belongs to a <strong>category</strong> (e.g. "Powder Coatings", "Primers",
"Shop Supplies"). Categories help you organize items in your list, but one setting on a category
has a direct effect on what appears in your quote and job workflows: the <strong>Is Coating</strong> flag.
</p>
<p>
<strong>Only items whose category has "Is Coating" enabled will appear in the powder color
dropdown</strong> when building a quote or job item. If a category does not have this flag set,
all items in that category are treated as general supplies and are excluded from the color picker.
</p>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
<strong>Blank powder color dropdown?</strong> This is almost always caused by the inventory
category for your powder coatings not having <strong>Is Coating</strong> checked. Follow
the steps below to fix it.
</div>
</div>
<h3 class="h6 fw-semibold mt-3 mb-2">How to enable "Is Coating" on a category</h3>
<ol class="mb-3">
<li class="mb-2">Go to <strong><a asp-controller="CompanySettings" asp-action="Index">Company Settings</a> &rsaquo; Data Lookups &rsaquo; Inventory Categories</strong>.</li>
<li class="mb-2">Find the category that contains your powder coating colors (e.g. "Powder Coatings").</li>
<li class="mb-2">Click the edit icon and check the <strong>Is Coating</strong> checkbox.</li>
<li class="mb-2">Save. Items in that category will immediately appear in the powder color dropdown on all quotes and jobs — no restart required.</li>
</ol>
<h3 class="h6 fw-semibold mt-3 mb-2">Which categories should have "Is Coating" enabled?</h3>
<p>
Only categories that contain actual powder coating colors — the materials that go into the oven
and bond to the part. Do <strong>not</strong> enable this on categories for primers, masking
supplies, consumables, or equipment. Enabling it on non-coating categories will pollute the
color dropdown with irrelevant items and make it harder to find the right powder.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
The "Is Coating" flag also controls where the <strong>sample panel toggle</strong> appears
on an item's Details page. The toggle — "I have a swatch/sample of this color" — only
shows up for items in a coating category, and those items are the ones tracked on the
<strong>Sample Panels</strong> page.
</div>
</div>
</section>
<section id="powder-usage" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-droplet text-primary me-2"></i>Powder Usage on Jobs
</h2>
<p>
When a job item uses a powder coating from your inventory, the system calculates how much powder
will be needed before the job begins. This estimate is based on three values:
</p>
<ul class="mb-3">
<li class="mb-1"><strong>Surface area</strong> — the total square footage to be coated (entered per item in the job or quote wizard).</li>
<li class="mb-1"><strong>Coverage rate</strong> — how many square feet one pound of the selected powder covers (set on the inventory item).</li>
<li class="mb-1"><strong>Number of coats</strong> — selected when you add the coating to the item.</li>
</ul>
<p>
The <strong>Powder Needed</strong> figure appears in the item wizard as you build the quote or job,
and is shown in the Coatings column on the Job Details page. Use it to quickly verify that you
have sufficient stock on hand before scheduling the job for production.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
Coverage rates vary by powder type, application equipment, and operator technique. Use a
conservative (lower) coverage rate in your inventory settings to build in a safety margin.
It is better to order slightly more than to run short mid-job.
</div>
</div>
</section>
</div>
<div class="col-lg-3 d-none d-lg-block">
@{ await Html.RenderPartialAsync("_HelpNav"); }
<div class="card border-0 shadow-sm sticky-top" style="top:80px">
<div class="card-header bg-transparent fw-semibold small text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">On this page</div>
<div class="card-body p-0">
<nav class="nav flex-column">
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
<a class="nav-link py-1 px-3 small text-body" href="#adding-items">Adding Inventory Items</a>
<a class="nav-link py-1 px-3 small text-body" href="#stock-levels">Stock Levels and Reorder Points</a>
<a class="nav-link py-1 px-3 small text-body" href="#stock-adjustment">Stock Adjustment</a>
<a class="nav-link py-1 px-3 small text-body" href="#transactions">Transaction Types</a>
<a class="nav-link py-1 px-3 small text-body" href="#activity-ledger">Inventory Activity</a>
<a class="nav-link py-1 px-3 small text-body" href="#qr-labels">QR Labels &amp; Mobile Logging</a>
<a class="nav-link py-1 px-3 small text-body" href="#low-stock-alerts">Stock Status and Alerts</a>
<a class="nav-link py-1 px-3 small text-body" href="#powder-insights">Powder Insights</a>
<a class="nav-link py-1 px-3 small text-body" href="#inventory-categories">Inventory Categories &amp; Is Coating</a>
<a class="nav-link py-1 px-3 small text-body" href="#powder-usage">Powder Usage on Jobs</a>
</nav>
</div>
</div>
</div>
</div>
@@ -0,0 +1,395 @@
@{
ViewData["Title"] = "Invoices";
}
<div class="d-flex align-items-center gap-2 mb-3">
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
<li class="breadcrumb-item active">Invoices</li>
</ol>
</nav>
</div>
<div class="row g-4">
<div class="col-lg-9">
<section id="overview" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-info-circle text-primary me-2"></i>Overview
</h2>
<p>
Invoices are the formal request for payment you send to customers after their work is complete.
Each job can have one invoice. The system tracks payment status in real time — you can see at a
glance which customers owe money, how much, and how long the balance has been outstanding.
</p>
<p>
Invoices can be emailed to customers directly from the system (when email is configured) and
downloaded as PDFs to print or send manually. Payments — whether in full or in partial
installments — are logged against the invoice, and the customer's outstanding balance on their
account is updated automatically with every transaction.
</p>
<p>
You can find Invoices under <strong>Operations &rsaquo; Invoices</strong> in the left sidebar.
</p>
</section>
<section id="creating-an-invoice" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-plus-circle text-primary me-2"></i>Creating an Invoice
</h2>
<p>
The easiest way to create an invoice is directly from the job it belongs to. This pre-fills
all line items and pricing automatically.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">From a Job (recommended)</h3>
<ol class="mb-3">
<li class="mb-2">Open the job from <strong>Operations &rsaquo; Jobs</strong> and go to its Details page.</li>
<li class="mb-2">Scroll to the <strong>Invoice</strong> section near the bottom of the page.</li>
<li class="mb-2">Click <strong>Create Invoice</strong>. The system generates an invoice pre-filled with all the job's line items and the final pricing.</li>
<li class="mb-2">Review the invoice — check line items, totals, and the due date — then click <strong>Save Invoice</strong>.</li>
</ol>
<h3 class="h6 fw-semibold mt-3 mb-2">From the Invoices list (manual)</h3>
<ol class="mb-3">
<li class="mb-2">Go to <strong>Operations &rsaquo; Invoices</strong> and click <strong>New Invoice</strong>.</li>
<li class="mb-2">Select the customer and then select the job this invoice is for.</li>
<li class="mb-2">Add or adjust line items as needed.</li>
<li class="mb-2">Set the invoice date, due date, and any notes.</li>
<li class="mb-2">Click <strong>Save Invoice</strong>.</li>
</ol>
<p>
Invoice numbers are generated in the format <code>INV-YYMM-####</code>
(for example, <code>INV-2503-0007</code>). Each job can only have one invoice — if an invoice
already exists for a job, the Create Invoice button on the Job Details page is replaced with a
link to the existing invoice.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
You can create an invoice for a job at any time — you do not need to wait until the job
reaches a Completed or Delivered status. Some shops invoice on deposit when a job is
approved; others invoice on pickup. The system is flexible.
</div>
</div>
</section>
<section id="invoice-statuses" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-tag text-primary me-2"></i>Invoice Statuses
</h2>
<p>
Invoices move through statuses that reflect their current payment state. Each status is shown
as a color-coded badge throughout the system.
</p>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th style="width:30%">Status</th>
<th>What it means</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="badge bg-secondary">Draft</span></td>
<td>The invoice has been created but not yet sent to the customer. It can still be edited freely. It does not yet affect the customer's balance.</td>
</tr>
<tr>
<td><span class="badge bg-info text-dark">Sent</span></td>
<td>The invoice has been delivered to the customer and a due date is set. The balance is now reflected in the customer's outstanding account.</td>
</tr>
<tr>
<td><span class="badge bg-warning text-dark">Partially Paid</span></td>
<td>At least one payment has been received but the full balance has not yet been settled.</td>
</tr>
<tr>
<td><span class="badge bg-success">Paid</span></td>
<td>The full invoice amount has been received. The customer's balance is reduced to zero for this invoice.</td>
</tr>
<tr>
<td><span class="badge bg-danger">Overdue</span></td>
<td>The due date has passed and there is still an outstanding balance. Overdue invoices are flagged prominently in the AR Aging report.</td>
</tr>
<tr>
<td><span class="badge bg-secondary">Voided</span></td>
<td>The invoice was cancelled. The balance is reversed on the customer's account. See the Voiding section below for restrictions.</td>
</tr>
<tr>
<td><span class="badge bg-dark">Written Off</span></td>
<td>The balance has been written off as bad debt. The outstanding amount is removed from the customer's account but is recorded for reporting purposes.</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="sending-an-invoice" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-send text-primary me-2"></i>Sending an Invoice
</h2>
<p>
Once you have saved an invoice as a Draft and reviewed it, you are ready to send it to the customer.
</p>
<ol class="mb-3">
<li class="mb-2">Open the invoice from <strong>Operations &rsaquo; Invoices</strong> or from the job's Details page.</li>
<li class="mb-2">Click <strong>Send Invoice</strong>. The status changes from Draft to Sent.</li>
<li class="mb-2">If email notifications are configured, the customer receives an email with the invoice details and total due.</li>
<li class="mb-2">A due date is set automatically based on the customer's payment terms (e.g., Net 30 means the due date is 30 days from today).</li>
</ol>
<p>
You can also click <strong>Download PDF</strong> on any invoice to generate a print-ready PDF
that you can email manually, attach to an existing email thread, or hand to the customer in person.
</p>
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>
Once an invoice is sent, the line items and total are locked for editing. If you need to make
a correction after sending, void the invoice and create a new one. This ensures a clean audit
trail.
</div>
</div>
</section>
<section id="recording-payments" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-cash-coin text-primary me-2"></i>Recording a Payment
</h2>
<p>
When a customer pays — whether in full or as a partial payment — you record it against the invoice.
The system supports multiple partial payments on a single invoice.
</p>
<ol class="mb-3">
<li class="mb-2">Open the invoice and click <strong>Record Payment</strong>.</li>
<li class="mb-2">
Fill in the payment details:
<ul class="mt-1">
<li><strong>Amount</strong> — how much was received this time. Can be less than the full balance for partial payments.</li>
<li><strong>Payment Method</strong> — Cash, Check, Credit/Debit Card, Bank Transfer / ACH, or Digital Payment.</li>
<li><strong>Payment Date</strong> — defaults to today.</li>
<li><strong>Reference Number</strong> — optional. Use for check numbers, transaction IDs, or wire reference numbers.</li>
<li><strong>Notes</strong> — any additional notes about this payment.</li>
</ul>
</li>
<li class="mb-2">Click <strong>Save Payment</strong>.</li>
</ol>
<p>
The invoice status updates automatically — to <strong>Partially Paid</strong> if there is still
a remaining balance, or <strong>Paid</strong> if the full amount has been received. The customer's
outstanding balance on their account is reduced by the payment amount. All payments are shown in
a payment log on the invoice Details page.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
You can delete an individual payment from the payment log on the invoice Details page if it
was recorded in error. Deleting a payment reverses its effect on the invoice status and the
customer balance.
</div>
</div>
</section>
<section id="voiding-an-invoice" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-x-circle text-primary me-2"></i>Voiding an Invoice
</h2>
<p>
If an invoice was created in error — for example, against the wrong job or with incorrect line
items that cannot be corrected — you can void it to remove it from the customer's outstanding
balance.
</p>
<ol class="mb-3">
<li class="mb-2">Open the invoice and click <strong>Void Invoice</strong>.</li>
<li class="mb-2">Confirm the action in the dialog that appears.</li>
<li class="mb-2">The invoice status changes to Voided and the balance is reversed on the customer's account.</li>
</ol>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
<strong>Restrictions:</strong> Only invoices in <strong>Draft</strong> or <strong>Sent</strong>
status can be voided. If an invoice has payments recorded against it, you must delete those
payments first before you can void the invoice. Invoices that are Partially Paid or Paid
cannot be voided directly — consider writing off the remaining balance instead if needed.
</div>
</div>
</section>
<section id="customer-balance" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-wallet2 text-primary me-2"></i>Customer Balance
</h2>
<p>
Every customer in the system has a running <strong>current balance</strong> that represents
their total outstanding amount across all unpaid invoices. The balance is updated automatically
whenever an invoice is sent, a payment is recorded, or an invoice is voided.
</p>
<p>You can see the current balance on the Customer Details page, shown alongside the customer's credit limit.</p>
<ul class="mb-3">
<li class="mb-1">When an invoice is <strong>sent</strong>, the balance increases by the invoice total.</li>
<li class="mb-1">When a <strong>payment is recorded</strong>, the balance decreases by the payment amount.</li>
<li class="mb-1">When an invoice is <strong>voided</strong>, the balance decreases by the invoice total.</li>
</ul>
<p>
If a customer's outstanding balance is approaching or has exceeded their credit limit, a warning
flag is shown on their customer record, on new jobs you try to create for them, and on new invoices.
This is a visual warning only — the system does not automatically block new work — but it provides
a clear signal to follow up on payment.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
Use the <strong>AR Aging</strong> table in <a asp-controller="Help" asp-action="Reports">Reports &rsaquo; Financial</a>
to see all outstanding balances broken down by how many days past due they are. This is the
quickest way to identify which customers need a payment follow-up call.
</div>
</div>
</section>
<section id="deposits" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-piggy-bank text-primary me-2"></i>Deposits
</h2>
<p>
If a customer pays a deposit before work starts, record it from the <strong>Job Details</strong>
or <strong>Quote Details</strong> page using the <strong>Record Deposit</strong> button in the
Deposits card.
</p>
<p>
When you create an invoice from a job, <strong>all unapplied deposits are automatically applied
as payments</strong> on the new invoice. The invoice's Amount Paid and status update accordingly
— you may find the invoice is already partially or fully paid at creation time.
</p>
<p>
Each deposit generates a receipt (receipt number format: <code>DEP-YYMM-####</code>) that can
be downloaded as a PDF immediately after recording.
</p>
</section>
<section id="gift-certificates" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-gift text-primary me-2"></i>Gift Certificates
</h2>
<p>
Gift certificates are issued and managed at <a href="/GiftCertificates">Gift Certificates</a>
in the Operations section of the sidebar.
</p>
<p>
To apply a gift certificate to an invoice, open the Invoice Details page and click
<strong>Apply Gift Certificate</strong>. Enter the certificate code — the system looks up
the remaining balance and applies it as a payment up to the invoice amount.
</p>
</section>
<section id="online-payments" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-credit-card text-primary me-2"></i>Online Payments
</h2>
<p>
Customers can pay invoices online without logging in. The
<strong>Online Payments</strong> page (<a href="/Invoices/OnlinePayments">/Invoices/OnlinePayments</a>)
lists all open invoices with a shareable payment link. Clicking the link takes the customer to
a Stripe-hosted checkout page where they can pay by credit or debit card. Payment is recorded
automatically and the invoice status updates to <em>Paid</em> or <em>Partially Paid</em>.
Your team receives a bell notification when payment is received.
</p>
<h3 class="h6 fw-semibold mt-4 mb-2">One-Time Setup: Connecting Stripe</h3>
<p>
Online payments require a Stripe Connect account linked to your company. A <strong>Company Admin</strong>
completes this once:
</p>
<ol class="mb-3">
<li class="mb-2">Go to <strong>Settings &rsaquo; Billing</strong> (<a href="/Billing">/Billing</a>).</li>
<li class="mb-2">Click <strong>Connect with Stripe</strong> (or <em>Set Up Online Payments</em>).</li>
<li class="mb-2">You are redirected to Stripe — create a new Stripe account or connect an existing one.</li>
<li class="mb-2">Complete Stripe&rsquo;s onboarding: enter your business details, add a bank account for payouts, and verify your identity as required by Stripe.</li>
<li class="mb-2">Once Stripe approves the account, you are returned to the app and Stripe Connect status shows <strong>Active</strong>.</li>
<li class="mb-2">Payment links now appear on Invoice Details and on the Online Payments page.</li>
</ol>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-3" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
If you don&rsquo;t see a <strong>Connect with Stripe</strong> button, online payments may not be
included in your current subscription plan. Check <a href="/Billing">Settings &rsaquo; Billing</a>
for your plan details or contact Powder Coating Logix support.
</div>
</div>
<h3 class="h6 fw-semibold mt-4 mb-2">Sharing a Payment Link</h3>
<p>
Once Stripe Connect is active, open any Invoice Details page and use one of these options:
</p>
<ul class="mb-3">
<li class="mb-1"><strong>Copy Payment Link</strong> — copies the URL to your clipboard so you can paste it into an email, text, or any other message.</li>
<li class="mb-1"><strong>Send Payment Link</strong> — emails the payment link directly to the customer&rsquo;s email address on file, with a brief message and the invoice amount.</li>
</ul>
<p>
The link is unique to each invoice and does not expire as long as the invoice remains unpaid.
Voided invoices do not generate payment links.
</p>
</section>
<section id="payment-reminders" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-bell text-primary me-2"></i>Automated Payment Reminders
</h2>
<p>
The system can automatically email customers when their invoices become overdue — without you
having to remember to follow up manually. This feature is controlled from
<strong>Settings &rsaquo; Notifications &rsaquo; Automated Payment Reminders</strong>.
</p>
<ul class="mb-3">
<li class="mb-2">
<strong>Enable Payment Reminders</strong> — turn the feature on or off for your company at any time.
When off, no automated emails are sent.
</li>
<li class="mb-2">
<strong>Reminder Days</strong> — a comma-separated list of day milestones past the due date at which
reminders are sent. The default is <code>7,14,30</code> (one reminder at 7 days overdue, another at
14, and a final one at 30).
</li>
</ul>
<p>
Each morning the system checks for overdue invoices in <strong>Sent</strong>,
<strong>Partially Paid</strong>, or <strong>Overdue</strong> status and sends a reminder email
if the invoice has reached one of your configured day thresholds for the first time that day.
Customers who have opted out of email notifications are never contacted.
</p>
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>
See <a asp-controller="Help" asp-action="Settings" fragment="notifications">Settings &rsaquo; Notification Settings</a>
for full details on configuring reminder thresholds and the email sender identity.
</div>
</div>
</section>
</div>
<div class="col-lg-3 d-none d-lg-block">
@{ await Html.RenderPartialAsync("_HelpNav"); }
<div class="card border-0 shadow-sm sticky-top" style="top:80px">
<div class="card-header bg-transparent fw-semibold small text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">On this page</div>
<div class="card-body p-0">
<nav class="nav flex-column">
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
<a class="nav-link py-1 px-3 small text-body" href="#creating-an-invoice">Creating an Invoice</a>
<a class="nav-link py-1 px-3 small text-body" href="#invoice-statuses">Invoice Statuses</a>
<a class="nav-link py-1 px-3 small text-body" href="#sending-an-invoice">Sending an Invoice</a>
<a class="nav-link py-1 px-3 small text-body" href="#recording-payments">Recording a Payment</a>
<a class="nav-link py-1 px-3 small text-body" href="#voiding-an-invoice">Voiding an Invoice</a>
<a class="nav-link py-1 px-3 small text-body" href="#customer-balance">Customer Balance</a>
<a class="nav-link py-1 px-3 small text-body" href="#deposits">Deposits</a>
<a class="nav-link py-1 px-3 small text-body" href="#gift-certificates">Gift Certificates</a>
<a class="nav-link py-1 px-3 small text-body" href="#online-payments">Online Payments</a>
<a class="nav-link py-1 px-3 small text-body" href="#payment-reminders">Payment Reminders</a>
</nav>
</div>
</div>
</div>
</div>
@@ -0,0 +1,619 @@
@{
ViewData["Title"] = "Jobs";
}
<div class="d-flex align-items-center gap-2 mb-3">
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
<li class="breadcrumb-item active">Jobs</li>
</ol>
</nav>
</div>
<div class="row g-4">
<div class="col-lg-9">
<section id="overview" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-info-circle text-primary me-2"></i>Overview
</h2>
<p>
Jobs are the core unit of work in the shop. Every item that comes through the door for coating is
tracked as a job. A job belongs to a customer, has a status that moves it through the shop workflow,
a priority level, a due date, and one or more line items describing the work to be performed.
</p>
<p>
You can find Jobs under <strong>Operations &rsaquo; Jobs</strong> in the left sidebar. The list is
searchable and sortable by job number, status, priority, scheduled date, due date, and price. Jobs
can be created manually or converted automatically from an approved quote — no need to re-enter
information that is already in the system.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
The fastest way to create a job is to start with a quote. Build and approve the quote, then
click <strong>Convert to Job</strong> — all items, coatings, and pricing carry over automatically.
</div>
</div>
</section>
<section id="creating-a-job" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-plus-circle text-primary me-2"></i>Creating a Job
</h2>
<p>To create a job manually:</p>
<ol class="mb-3">
<li class="mb-2">Go to <strong>Operations &rsaquo; Jobs</strong> and click the <strong>New Job</strong> button in the top-right corner.</li>
<li class="mb-2">Select the <strong>Customer</strong> — this field is required. Type to search by name or company.</li>
<li class="mb-2">Enter a <strong>Job Description</strong> summarising the work (e.g., "Powder coat motorcycle frame — gloss black").</li>
<li class="mb-2">Set the <strong>Scheduled Date</strong> (when work is expected to begin) and the <strong>Due Date</strong> (when the customer expects pickup or delivery).</li>
<li class="mb-2">Choose a <strong>Priority</strong> — Normal is the default; see the Job Priority section below for all levels.</li>
<li class="mb-2">Optionally assign a <strong>Worker</strong> from your shop workers list.</li>
<li class="mb-2">Enter the customer's <strong>PO Number</strong> if they require one for their own records.</li>
<li class="mb-2">Add any <strong>Special Instructions</strong> your team needs to know before starting work.</li>
<li class="mb-2">Add one or more <strong>Line Items</strong> describing each piece being coated. See the Job Items section below.</li>
<li class="mb-2">Click <strong>Save Job</strong>.</li>
</ol>
<p>
The system automatically generates a unique job number in the format <code>JOB-YYMM-####</code>
(for example, <code>JOB-2503-0042</code>). This number appears on all documents related to the job.
</p>
</section>
<section id="job-status" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-arrow-right-circle text-primary me-2"></i>Job Status
</h2>
<p>
Each job moves through a defined sequence of statuses as it progresses through the shop. The status
is displayed as a color-coded badge on the job list and details page. You can update a job's status
at any time by opening the job and editing it.
</p>
<p>
The two special statuses — <strong>On Hold</strong> and <strong>Cancelled</strong> — can be applied
at any point in the workflow regardless of the current status. Use On Hold to pause a job temporarily
(waiting for customer approval, missing materials, etc.) and Cancelled to formally close a job that
will not be completed.
</p>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th style="width:30%">Status</th>
<th>What it means</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="badge bg-secondary">Pending</span></td>
<td>Job has been entered but work has not started. Initial state for all new jobs.</td>
</tr>
<tr>
<td><span class="badge bg-info text-dark">Quoted</span></td>
<td>A formal quote has been generated and sent to the customer for this job.</td>
</tr>
<tr>
<td><span class="badge bg-success">Approved</span></td>
<td>The customer has approved the quote and authorised the work to proceed.</td>
</tr>
<tr>
<td><span class="badge bg-primary">In Preparation</span></td>
<td>The job has been pulled from the queue and prep work has begun.</td>
</tr>
<tr>
<td><span class="badge bg-warning text-dark">Sandblasting</span></td>
<td>The parts are being surface-blasted to remove rust, old coatings, or contaminants.</td>
</tr>
<tr>
<td><span class="badge bg-warning text-dark">Masking / Taping</span></td>
<td>Areas that must not be coated are being masked off or taped.</td>
</tr>
<tr>
<td><span class="badge bg-warning text-dark">Cleaning</span></td>
<td>Parts are being chemically cleaned and degreased before coating is applied.</td>
</tr>
<tr>
<td><span class="badge bg-warning text-dark">In Oven</span></td>
<td>Parts are in the pre-heat oven being brought to the correct application temperature.</td>
</tr>
<tr>
<td><span class="badge bg-primary">Coating</span></td>
<td>Powder coating is being applied to the parts via electrostatic spray gun.</td>
</tr>
<tr>
<td><span class="badge bg-warning text-dark">Curing</span></td>
<td>Coated parts are in the curing oven at temperature to flow and bond the powder.</td>
</tr>
<tr>
<td><span class="badge bg-info text-dark">Quality Check</span></td>
<td>Finished parts are being inspected for coverage, adhesion, and surface defects.</td>
</tr>
<tr>
<td><span class="badge bg-success">Completed</span></td>
<td>Work is done and the job has passed quality inspection.</td>
</tr>
<tr>
<td><span class="badge bg-success">Ready for Pickup</span></td>
<td>Parts are packaged and waiting for the customer to collect them.</td>
</tr>
<tr>
<td><span class="badge bg-secondary">Delivered</span></td>
<td>Parts have been collected by the customer or delivered to their location.</td>
</tr>
<tr>
<td><span class="badge bg-warning text-dark">On Hold</span></td>
<td>Job is temporarily paused. Can be applied at any point in the workflow.</td>
</tr>
<tr>
<td><span class="badge bg-danger">Cancelled</span></td>
<td>Job will not be completed. Can be applied at any point in the workflow.</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="job-priority" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-flag text-primary me-2"></i>Job Priority
</h2>
<p>
Every job has a priority level that tells your team how urgently a job needs to move through the shop.
Priorities are color-coded throughout the system — in the job list, on the job details page, and on
any dashboards. Set or change the priority in the Create or Edit form.
</p>
<div class="table-responsive mb-3">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th style="width:30%">Priority</th>
<th>When to use it</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="badge bg-secondary">Low</span></td>
<td>Non-urgent work that can be scheduled when the shop has capacity. No specific deadline pressure.</td>
</tr>
<tr>
<td><span class="badge bg-primary">Normal</span></td>
<td>Standard shop priority. Used for the majority of jobs. Work is completed within the agreed timeframe.</td>
</tr>
<tr>
<td><span class="badge bg-warning text-dark">High</span></td>
<td>Customer has a firm deadline or the job is part of a larger project. Should be worked ahead of Normal jobs.</td>
</tr>
<tr>
<td><span class="badge bg-danger">Urgent</span></td>
<td>Job must be completed very soon. Push to the front of the queue and notify the supervisor.</td>
</tr>
<tr>
<td><span class="badge bg-dark">Rush</span></td>
<td>Same-day or next-day turnaround required. Rush jobs are highlighted prominently in the job list to ensure they are never missed. A rush surcharge may apply.</td>
</tr>
</tbody>
</table>
</div>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
Rush jobs appear highlighted in the job list so they stand out at a glance. Make sure your team
understands that a Rush priority means the job takes precedence over everything else currently
on the floor.
</div>
</div>
</section>
<section id="job-items" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-list-check text-primary me-2"></i>Job Items
</h2>
<p>
Each job contains one or more line items, each describing a distinct piece or group of parts being
coated. Items are added using the item wizard when creating or editing a job.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Item Types</h3>
<ul class="mb-3">
<li class="mb-2">
<strong>Calculated Item</strong> — enter surface area, quantity, and complexity. The system
calculates material, labor, and equipment costs automatically. Select one or more powder
coatings and optional prep services (sandblasting, masking, cleaning).
</li>
<li class="mb-2">
<strong>Custom Work Item</strong> — enter a free-text description and a manual price. Use this for
one-off work that does not fit the standard calculation model.
</li>
<li class="mb-2">
<strong>AI Photo Quote Item</strong> — upload photos of the parts and let AI (Claude) estimate
the surface area, complexity, and labor time. Review and override any value before accepting.
Up to two follow-up rounds of questions are supported.
</li>
<li class="mb-2">
<strong>Labor Item</strong> — a line item representing time and labor charges only, without
material costs.
</li>
</ul>
<h3 class="h6 fw-semibold mt-3 mb-2">Surface Area and Coatings</h3>
<p>
For each item you enter the <strong>surface area in square feet</strong> and the <strong>quantity</strong>
of parts. You then select which powder coating(s) to apply. The system calculates how much powder is
needed per coat based on the surface area, the coating's coverage rate (sq ft per lb), and the
application efficiency factor.
</p>
<p>
The <strong>Powder Needed</strong> estimate is shown live in the wizard as you enter the surface area
and select coatings. This helps you check whether you have enough stock on hand before committing to
the job schedule.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Prep Services</h3>
<p>
Each item can also include optional <strong>prep services</strong> — sandblasting, masking, or
chemical cleaning — that will be carried out before coating. These are selected in the wizard and
appear as sub-lines under the item on the job details page.
</p>
<h3 class="h6 fw-semibold mt-4 mb-2">Save to Product Catalog</h3>
<p>
After completing the coatings and prep services steps, <strong>Calculated</strong> and
<strong>AI Photo Quote</strong> items include one final optional step: <strong>Save to Product Catalog</strong>.
This lets you turn the item you just configured into a reusable catalog entry so it can be selected
instantly on future quotes or jobs — without re-entering dimensions, coatings, or prep services.
</p>
<p>The wizard pre-fills the catalog form with:</p>
<ul class="mb-3">
<li><strong>Name</strong> — taken from the item description (or AI-generated description); you can edit it</li>
<li><strong>Default Price</strong> — copied from the item's calculated or manually adjusted unit price</li>
<li><strong>Description</strong> — the item description</li>
<li><strong>Sandblasting / Masking</strong> — automatically checked if those prep services were selected</li>
<li><strong>Category</strong> — choose from your active catalog categories</li>
</ul>
<p>At the bottom of the step you have two options:</p>
<ul class="mb-3">
<li><strong>Save to Catalog &amp; Add</strong> — saves the catalog item immediately and then adds the line item to the job.</li>
<li><strong>Skip — Add to Job Only</strong> — skips the save and adds the item to the job without creating a catalog entry.</li>
</ul>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-3" role="alert">
<i class="bi bi-info-circle-fill flex-shrink-0 mt-1"></i>
<div>
The catalog save happens immediately when you click <strong>Save to Catalog &amp; Add</strong> — before the job form is submitted. The catalog item is preserved even if you later discard the job. You can view and manage all saved items at <a asp-controller="CatalogItems" asp-action="Index">Catalog Items</a>.
</div>
</div>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
Items on the job details page are grouped by type — Catalog Items, Custom Work, and Labor — to
make it easy to see exactly what work is being performed and which materials are required.
</div>
</div>
</section>
<section id="converting-from-quote" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-arrow-left-right text-primary me-2"></i>Converting from a Quote
</h2>
<p>
When a customer approves a quote, the quickest way to create the job is to convert the quote directly
rather than re-entering all the details manually.
</p>
<ol class="mb-3">
<li class="mb-2">Open the approved quote from <strong>Operations &rsaquo; Quotes</strong>.</li>
<li class="mb-2">Click the <strong>Convert to Job</strong> button on the quote details page.</li>
<li class="mb-2">
The system creates a new job pre-filled with:
<ul class="mt-1">
<li>All line items, surface areas, and quantities from the quote</li>
<li>All coatings and prep services selected for each item</li>
<li>The final pricing calculated on the quote</li>
<li>The customer record the quote was linked to</li>
</ul>
</li>
<li class="mb-2">Review the new job, set the scheduled date and priority, assign a worker if needed, and click <strong>Save Job</strong>.</li>
</ol>
<p>
The quote status changes to <strong>Converted</strong> and is linked to the new job. You can navigate
between the quote and the job at any time using the link shown on each record's details page.
</p>
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>
If the quote was created for a prospect (not yet a customer), you must first convert the prospect
to a customer before converting the quote to a job. See the
<a asp-controller="Help" asp-action="Quotes">Quotes help page</a> for details on prospect conversion.
</div>
</div>
</section>
<section id="creating-an-invoice" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-receipt text-primary me-2"></i>Creating an Invoice from a Job
</h2>
<p>
Once a job is complete — or at any time when you are ready to bill the customer — you can create an
invoice directly from the job's Details page. There is no need to manually re-enter pricing.
</p>
<ol class="mb-3">
<li class="mb-2">Open the job from <strong>Operations &rsaquo; Jobs</strong> and go to its Details page.</li>
<li class="mb-2">
Scroll to the <strong>Invoice</strong> section at the bottom of the page.
<ul class="mt-1">
<li>If no invoice exists yet, you will see a <strong>Create Invoice</strong> button.</li>
<li>If an invoice already exists, you will see a link to open it.</li>
</ul>
</li>
<li class="mb-2">Click <strong>Create Invoice</strong>. The system generates a new invoice pre-filled with all the job's line items and the final pricing.</li>
<li class="mb-2">Review the invoice, confirm the due date, and save it.</li>
</ol>
<p>
Each job can only have one invoice. Once the invoice is created, the Create Invoice button is replaced
with a link to view the existing invoice.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
Any deposits recorded against this job are automatically applied as payments when the invoice
is created. For full details on invoicing, see the
<a asp-controller="Help" asp-action="Invoices">Invoices help page</a>.
</div>
</div>
</section>
<section id="photos-notes" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-camera text-primary me-2"></i>Photos and Notes
</h2>
<h3 class="h6 fw-semibold mt-3 mb-2">Job Photos</h3>
<p>
Upload before, during, and after photos directly from the Job Details page. Photo types include
<strong>Before</strong>, <strong>Progress</strong>, <strong>After</strong>,
<strong>Quality Check</strong>, <strong>Issue</strong>, and <strong>Completed</strong>.
Photos are visible to anyone who opens the job record and help document the work performed.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Job Notes</h3>
<p>
Add internal notes to a job from the Details page. Notes are private — they are not visible
to the customer. Use them for team communication, special handling instructions, or to log
anything notable that happened during production.
</p>
</section>
<section id="time-and-rework" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-clock text-primary me-2"></i>Time Entries and Rework
</h2>
<h3 class="h6 fw-semibold mt-3 mb-2">Time Entries</h3>
<p>
Log labor time against a job from the Job Details page using the <strong>Time Entries</strong>
section. Each entry records who worked on the job, how long, and when. Use this to track
actual hours vs. estimated hours for costing and productivity analysis.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Rework</h3>
<p>
If finished parts fail quality inspection or need to be re-coated, create a rework record
from the Job Details page. Rework records track the rework type, the reason (adhesion failure,
color mismatch, damage, etc.), and the resolution. This data helps identify recurring quality
issues over time.
</p>
</section>
<section id="job-templates" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-clipboard2-check text-primary me-2"></i>Job Templates
</h2>
<p>
If you do the same type of work repeatedly — for example, a standard wheel refinish package —
you can save a job's line items as a template and reuse it for future jobs.
</p>
<p>Templates are managed at <strong>/JobTemplates</strong>. To use a template:</p>
<ol class="mb-3">
<li class="mb-2">When creating a new job, look for the <strong>Load Template</strong> option.</li>
<li class="mb-2">Select the template you want to use.</li>
<li class="mb-2">The line items, coatings, and prep services from the template are pre-filled into the new job.</li>
<li class="mb-2">Adjust any fields as needed for this specific job, then save.</li>
</ol>
</section>
<section id="shop-display-and-board" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-display text-primary me-2"></i>Shop Display and Priority Board
</h2>
<h3 class="h6 fw-semibold mt-3 mb-2">Shop Display</h3>
<p>
The <strong>Shop Display</strong> (<a href="/Jobs/ShopDisplay">/Jobs/ShopDisplay</a>) opens a
full-screen view of all active jobs and their current statuses, designed for a TV or large
monitor mounted on the shop floor. Workers can see at a glance what jobs are in their queue
without needing to log in or use a computer.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Job Priority Board</h3>
<p>
The <strong>Priority Board</strong> (<a href="/JobsPriority">/JobsPriority</a>) is a
Kanban-style view of all active jobs, sorted by priority and status. It is the best view for
supervisors who need to triage work and decide what gets done first. Rush and Urgent jobs are
highlighted prominently.
</p>
</section>
<section id="part-intake" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-box-seam text-primary me-2"></i>Part Intake
</h2>
<p>
The <strong>Part Intake</strong> workflow lets you formally check in a customer's parts at
drop-off — before any work begins. This creates a timestamped record of how many pieces
arrived, their condition, and who received them, which protects the shop if a customer later
disputes pre-existing damage or a missing piece.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">How to record an intake</h3>
<ol class="mb-3">
<li class="mb-2">Open the <strong>Job Details</strong> page for the job.</li>
<li class="mb-2">Click the <strong>Intake</strong> button in the top-right button group. The button is highlighted in blue when intake has not yet been recorded, and shows a checkmark (Intake ✓) once complete.</li>
<li class="mb-2">
On the intake form you will see:
<ul class="mt-1">
<li>A <strong>Job Summary</strong> card showing expected part count (from line items), due date, and any special instructions.</li>
<li><strong>Actual Part Count</strong> — enter the number of pieces physically received. If this differs from the expected count, a warning appears prompting you to note the discrepancy.</li>
<li><strong>Condition Notes</strong> — describe the condition of the parts (existing scratches, rust, missing hardware, special handling requirements, etc.).</li>
<li><strong>Advance to In Preparation</strong> — toggle on to automatically move the job status to <em>In Preparation</em> at the same time. Leave off if the customer is dropping parts off early and work hasn't started yet.</li>
<li><strong>Before Photos</strong> — upload photos documenting the condition at drop-off. Photos are saved as "Before" type on the job and appear in the Photos section of Job Details.</li>
</ul>
</li>
<li class="mb-2">Click <strong>Complete Intake</strong>.</li>
</ol>
<h3 class="h6 fw-semibold mt-3 mb-2">Viewing intake records</h3>
<p>
Once an intake is recorded, the <strong>Part Intake</strong> card on the Job Details page shows
the check-in date and time, who performed the intake, the actual part count, and any condition
notes. You can update an existing intake at any time by clicking the <strong>Edit</strong> link
in that card.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
The intake form is optimised for use on a tablet at the front desk — the layout is
touch-friendly and the photo upload works directly from a tablet camera.
</div>
</div>
</section>
<section id="shop-mobile" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-phone text-primary me-2"></i>Shop Mobile
</h2>
<p>
<strong>Shop Mobile</strong> (<a href="/Jobs/ShopMobile">/Jobs/ShopMobile</a>) is a
phone and tablet-optimised view of all active jobs, designed for workers on the shop floor.
Unlike the Shop Display (which is a passive TV view) and the full desktop UI, Shop Mobile is
built for one-handed use — large touch targets, no sidebar, and instant status advancement
with a single tap.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">What you see</h3>
<p>Jobs are grouped by their current status stage and ordered by due date. Each job card shows:</p>
<ul class="mb-3">
<li>A <strong>priority colour strip</strong> on the left edge (green = Low, blue = Normal, orange = High, red = Urgent, purple = Rush)</li>
<li>Job number, customer name, and piece count</li>
<li>Assigned worker, sandblasting/masking flags, and due date</li>
<li>Line items (up to 3 shown; tap the details button for the full list)</li>
<li>Powder colours</li>
<li>Special instructions (amber callout — hard to miss)</li>
<li>An <strong>intake indicator</strong> — a green box icon means parts were checked in; an amber box icon means intake is still pending (tap it to go directly to the intake form)</li>
<li>An <strong>overdue badge</strong> when the due date has passed</li>
</ul>
<h3 class="h6 fw-semibold mt-3 mb-2">Advancing a job status</h3>
<p>
Each card has a large <strong>Advance to [Next Status]</strong> button. Tap it to move the
job to its next stage. The button shows a spinner while saving, then the page reloads with the
updated status. No login to a desktop required.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Filtering by worker</h3>
<p>
Tap any worker chip in the filter bar at the top to show only jobs assigned to that person.
Tap <strong>All</strong> to return to the full list.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Auto-refresh</h3>
<p>
The page automatically refreshes every 60 seconds so workers always see the current state
without manually reloading. The green pulsing dot in the header indicates auto-refresh is active.
</p>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
<i class="bi bi-phone flex-shrink-0 mt-1"></i>
<div>
<strong>Tip:</strong> Add Shop Mobile to your phone's home screen using your browser's
"Add to Home Screen" option for quick one-tap access every morning.
Navigate to <a href="/Jobs/ShopMobile">/Jobs/ShopMobile</a> in your mobile browser, then use the share menu to save it.
</div>
</div>
</section>
<section id="blank-work-order" class="mb-5">
<h2 class="h5 fw-semibold mb-3">Blank Work Order</h2>
<p>
The Blank Work Order feature lets you instantly print a pre-formatted paper work order to hand to a
customer at drop-off — before a digital job record has been created. It uses your company logo,
address, and customizable terms so it looks professional right out of the box.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">How to print a blank work order</h3>
<ol>
<li>Go to <strong>Jobs</strong> in the sidebar.</li>
<li>Click the <strong><i class="bi bi-printer"></i> Blank Work Order</strong> button in the top-right toolbar (next to the Jobs Board button).</li>
<li>A PDF opens in a new browser tab — print it or save it to PDF.</li>
</ol>
<p>You can also navigate directly to <a href="/WorkOrder/Blank">/WorkOrder/Blank</a> to open the PDF at any time.</p>
<h3 class="h6 fw-semibold mt-3 mb-2">What's on the work order</h3>
<ul>
<li>Your company logo and address in the header</li>
<li>A <strong>Drop Off Date</strong> field opposite the "WORK ORDER" title</li>
<li>Client Name, Client Phone, and Due Date fields</li>
<li>12-row parts table with columns for Part Description, Color, and Quote</li>
<li>A Notes box for special instructions</li>
<li>Your customizable Terms &amp; Conditions text</li>
<li>A Customer Signature line with Date</li>
</ul>
<h3 class="h6 fw-semibold mt-3 mb-2">Customizing the work order</h3>
<p>
Go to <strong>Company Settings → PDF Templates → Work Order</strong> to customize:
</p>
<ul>
<li><strong>Accent Color</strong> — the color used for table headers, the title bar, and section labels. Defaults to dark gray.</li>
<li><strong>Terms &amp; Conditions</strong> — up to 2,000 characters of text printed in italic below the notes box. Use this for your shop's standard policies, liability disclaimer, or payment terms.</li>
</ul>
<p>Click <strong>Save</strong> to apply changes, or <strong>Preview</strong> to open the PDF instantly without saving.</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb flex-shrink-0 mt-1"></i>
<div>
<strong>Tip:</strong> Print a stack of blank work orders and keep them at your front counter. When a customer drops off parts, fill one out by hand and staple it to their receipt. Once you create the digital job, attach the signed copy as a photo for your records.
</div>
</div>
</section>
</div>
<div class="col-lg-3 d-none d-lg-block">
@{ await Html.RenderPartialAsync("_HelpNav"); }
<div class="card border-0 shadow-sm sticky-top" style="top:80px">
<div class="card-header bg-transparent fw-semibold small text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">On this page</div>
<div class="card-body p-0">
<nav class="nav flex-column">
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
<a class="nav-link py-1 px-3 small text-body" href="#creating-a-job">Creating a Job</a>
<a class="nav-link py-1 px-3 small text-body" href="#job-status">Job Status</a>
<a class="nav-link py-1 px-3 small text-body" href="#job-priority">Job Priority</a>
<a class="nav-link py-1 px-3 small text-body" href="#job-items">Job Items</a>
<a class="nav-link py-1 px-3 small text-body" href="#converting-from-quote">Converting from a Quote</a>
<a class="nav-link py-1 px-3 small text-body" href="#creating-an-invoice">Creating an Invoice</a>
<a class="nav-link py-1 px-3 small text-body" href="#photos-notes">Photos and Notes</a>
<a class="nav-link py-1 px-3 small text-body" href="#time-and-rework">Time Entries and Rework</a>
<a class="nav-link py-1 px-3 small text-body" href="#job-templates">Job Templates</a>
<a class="nav-link py-1 px-3 small text-body" href="#shop-display-and-board">Shop Display &amp; Priority Board</a>
<a class="nav-link py-1 px-3 small text-body" href="#part-intake">Part Intake</a>
<a class="nav-link py-1 px-3 small text-body" href="#shop-mobile">Shop Mobile</a>
<a class="nav-link py-1 px-3 small text-body" href="#blank-work-order">Blank Work Order</a>
</nav>
</div>
</div>
</div>
</div>
@@ -0,0 +1,215 @@
@{
ViewData["Title"] = "Purchase Orders";
}
<div class="d-flex align-items-center gap-2 mb-3">
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
<li class="breadcrumb-item active">Purchase Orders</li>
</ol>
</nav>
</div>
<div class="row g-4">
<div class="col-lg-9">
<section id="overview" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-info-circle text-primary me-2"></i>Overview
</h2>
<p>
Purchase Orders (POs) are the formal documents you create when ordering supplies from vendors.
Recording POs in the system lets you track exactly what you ordered, from whom, at what price,
and whether the goods have arrived.
</p>
<p>
The PO workflow is designed to eliminate double data entry. When you receive a PO, stock levels
update automatically for any linked inventory items. When you are ready to pay, you can convert
the received PO directly into a vendor bill in Accounts Payable — all the line items, quantities,
and prices carry over without retyping.
</p>
<p>
You can find Purchase Orders under <strong>Inventory &rsaquo; Purchase Orders</strong> in the
left sidebar.
</p>
</section>
<section id="creating-a-po" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-plus-circle text-primary me-2"></i>Creating a Purchase Order
</h2>
<p>To create a new Purchase Order:</p>
<ol class="mb-3">
<li class="mb-2">Go to <strong>Inventory &rsaquo; Purchase Orders</strong> and click <strong>New PO</strong>.</li>
<li class="mb-2">Select the <strong>Vendor</strong> you are ordering from. The vendor's contact information and default payment terms are pulled in automatically.</li>
<li class="mb-2">Set the <strong>Order Date</strong> (defaults to today) and an optional <strong>Expected Delivery Date</strong>.</li>
<li class="mb-2">
Add line items — click <strong>Add Line</strong> for each product you are ordering:
<ul class="mt-1">
<li>Start typing an item name in the search field to look up items from your inventory. If the item exists, select it to link the PO line directly to your stock record.</li>
<li>If the item is not in your inventory yet (a new product), enter a description manually. You can create the inventory item later after receiving the goods.</li>
<li>Enter the <strong>Quantity Ordered</strong> and <strong>Unit Price</strong> for each line.</li>
</ul>
</li>
<li class="mb-2">Enter a <strong>Shipping Cost</strong> if the vendor charges for delivery. This is added to the PO total.</li>
<li class="mb-2">Add any <strong>Notes for the Vendor</strong> — these appear on the printed PO document.</li>
<li class="mb-2">Add any internal <strong>Notes</strong> for your own team (not shown to the vendor).</li>
<li class="mb-2">Click <strong>Save PO</strong>. The PO is saved as a Draft.</li>
</ol>
<p>
A PO number is generated automatically in a sequential format. You can reference this number
when communicating with the vendor.
</p>
</section>
<section id="submitting-a-po" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-send text-primary me-2"></i>Submitting a PO
</h2>
<p>
When you are ready to place the order with the vendor, open the PO and click <strong>Submit</strong>.
This changes the status from Draft to Submitted, recording that the order has been officially placed.
</p>
<p>
Submitting is an internal tracking step in the system. The system does not automatically contact
the vendor. To send the order to your vendor, use one of the following methods:
</p>
<ul class="mb-3">
<li class="mb-1">Click <strong>Download PDF</strong> on the PO Details page to generate a print-ready document, then email or fax it to your vendor.</li>
<li class="mb-1">Phone the vendor and use the PO number as the reference for your verbal order.</li>
</ul>
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>
A Submitted PO is locked for editing. If you need to change quantities or pricing after
submitting, cancel the PO and create a new one, or contact your vendor to confirm any
changes by phone and update your records accordingly once the goods arrive.
</div>
</div>
</section>
<section id="receiving-a-po" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-box-seam text-primary me-2"></i>Receiving a PO
</h2>
<p>
When the ordered goods arrive, open the Submitted PO and click <strong>Receive</strong>.
This records the receipt of goods and updates your inventory stock levels.
</p>
<ol class="mb-3">
<li class="mb-2">Open the submitted PO from <strong>Inventory &rsaquo; Purchase Orders</strong>.</li>
<li class="mb-2">Click <strong>Receive</strong>.</li>
<li class="mb-2">Review the received quantities. If all items have arrived as ordered, confirm and save. If only some items have arrived (a partial shipment), adjust the received quantities for each line accordingly.</li>
<li class="mb-2">Click <strong>Confirm Receipt</strong>.</li>
</ol>
<p>
For any PO lines that are linked to inventory items, the quantity on hand is increased
automatically by the received amount. The PO status changes to <strong>Received</strong> if
all items are fully received, or <strong>Partially Received</strong> if some are still
outstanding. Partially received POs remain open until the remaining items arrive.
</p>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
Always check the physical goods against the PO before confirming receipt. Receiving an
incorrect quantity will update your stock levels immediately. If there is a discrepancy,
contact the vendor first and adjust the received quantity to match what actually arrived.
</div>
</div>
</section>
<section id="creating-a-bill" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-file-earmark-text text-primary me-2"></i>Creating a Bill from a PO
</h2>
<p>
After a PO has been received, you can convert it into a vendor bill in Accounts Payable with
one click — no need to re-enter any of the line item details.
</p>
<ol class="mb-3">
<li class="mb-2">Open the received PO and click <strong>Create Bill</strong>.</li>
<li class="mb-2">
The system creates a new vendor bill pre-filled with:
<ul class="mt-1">
<li>All PO line items, quantities, and unit prices</li>
<li>The vendor's default expense account for each line (can be overridden)</li>
<li>The vendor's payment terms and a calculated due date</li>
</ul>
</li>
<li class="mb-2">Review the bill — confirm the due date, check that expense accounts are correct, and add any notes.</li>
<li class="mb-2">Click <strong>Save Bill</strong>. The bill is saved as a Draft in Accounts Payable.</li>
<li class="mb-2">When you have verified the bill against the vendor's paper invoice, click <strong>Mark as Open</strong> to post it to your AP ledger. See the <a asp-controller="Help" asp-action="AccountsPayable">Accounts Payable help page</a> for details.</li>
</ol>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
The PO and the bill remain linked in the system. From the bill you can navigate back to
the originating PO, and from the PO you can see the bill status. This makes it easy to
match vendor invoices to your purchase records during bookkeeping.
</div>
</div>
</section>
<section id="po-statuses" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-tag text-primary me-2"></i>PO Statuses
</h2>
<p>
Purchase Orders move through the following statuses as they progress from planning to receipt.
</p>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th style="width:30%">Status</th>
<th>What it means</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="badge bg-secondary">Draft</span></td>
<td>The PO is being prepared. It has not been sent to the vendor and can be edited freely.</td>
</tr>
<tr>
<td><span class="badge bg-info text-dark">Submitted</span></td>
<td>The order has been placed with the vendor. The PO is locked and is awaiting delivery.</td>
</tr>
<tr>
<td><span class="badge bg-warning text-dark">Partially Received</span></td>
<td>Some items from the order have arrived but others are still outstanding. The PO remains open.</td>
</tr>
<tr>
<td><span class="badge bg-success">Received</span></td>
<td>All ordered items have been received and stock levels have been updated. Ready to create a bill.</td>
</tr>
<tr>
<td><span class="badge bg-danger">Cancelled</span></td>
<td>The order was cancelled before receipt. No stock changes are made. The PO is kept for record-keeping.</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<div class="col-lg-3 d-none d-lg-block">
@{ await Html.RenderPartialAsync("_HelpNav"); }
<div class="card border-0 shadow-sm sticky-top" style="top:80px">
<div class="card-header bg-transparent fw-semibold small text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">On this page</div>
<div class="card-body p-0">
<nav class="nav flex-column">
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
<a class="nav-link py-1 px-3 small text-body" href="#creating-a-po">Creating a Purchase Order</a>
<a class="nav-link py-1 px-3 small text-body" href="#submitting-a-po">Submitting a PO</a>
<a class="nav-link py-1 px-3 small text-body" href="#receiving-a-po">Receiving a PO</a>
<a class="nav-link py-1 px-3 small text-body" href="#creating-a-bill">Creating a Bill from a PO</a>
<a class="nav-link py-1 px-3 small text-body" href="#po-statuses">PO Statuses</a>
</nav>
</div>
</div>
</div>
</div>
@@ -0,0 +1,423 @@
@{
ViewData["Title"] = "Quotes";
}
<div class="d-flex align-items-center gap-2 mb-3">
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
<li class="breadcrumb-item active">Quotes</li>
</ol>
</nav>
</div>
<div class="row g-4">
<div class="col-lg-9">
<section id="overview" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-info-circle text-primary me-2"></i>Overview
</h2>
<p>
Quotes let you provide customers and prospects with a formal price estimate before work begins.
The quoting engine calculates material costs, labor, equipment time, overhead, and profit margin
automatically based on the surface area and complexity you enter — no spreadsheet required.
</p>
<p>
Quotes can be created for existing customers or for <strong>prospects</strong> — people or
businesses who have not yet become customers. Prospect quotes let you capture all contact details
and pricing in one place so nothing is lost while you are waiting for a decision. If the prospect
accepts, you can convert them to a customer and the quote to a job in just a few clicks.
</p>
<p>
You can find Quotes under <strong>Operations &rsaquo; Quotes</strong> in the left sidebar.
The list is searchable by quote number or customer name, and filterable by status.
</p>
</section>
<section id="creating-a-quote" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-plus-circle text-primary me-2"></i>Creating a Quote
</h2>
<p>To create a new quote:</p>
<ol class="mb-3">
<li class="mb-2">Go to <strong>Operations &rsaquo; Quotes</strong> and click <strong>New Quote</strong>.</li>
<li class="mb-2">
Choose whether this quote is for an existing <strong>Customer</strong> or a <strong>Prospect</strong>:
<ul class="mt-1">
<li><strong>Customer</strong> — select from your existing customer list. The customer's pricing tier discount is applied automatically.</li>
<li><strong>Prospect</strong> — enter their first name, last name, company name (optional), email, and phone. These details are stored on the quote and can be used to create a customer record later.</li>
</ul>
</li>
<li class="mb-2">Set the <strong>Quote Date</strong> (defaults to today) and the <strong>Expiry Date</strong> (defaults to the system's configured validity period).</li>
<li class="mb-2">Add a <strong>Subject</strong> or description to identify the work being quoted.</li>
<li class="mb-2">Add one or more <strong>Line Items</strong> — see the Quote Items section below for item types.</li>
<li class="mb-2">Add any <strong>Notes</strong> for the customer (these appear on the printed quote).</li>
<li class="mb-2">Add any internal <strong>Notes</strong> that are for your team only.</li>
<li class="mb-2">Click <strong>Save Quote</strong>. The quote is saved as a Draft.</li>
</ol>
<p>
The system automatically generates a unique quote number in the format <code>QT-YYMM-####</code>
(for example, <code>QT-2503-0015</code>).
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
You can save a quote as a Draft and come back to it later. Drafts are not visible to customers
and do not count as sent until you explicitly click <strong>Send Quote</strong>.
</div>
</div>
</section>
<section id="quote-items" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-list-check text-primary me-2"></i>Quote Items
</h2>
<p>
Each line item on a quote describes a distinct piece or group of parts to be coated. Items are
added using the item wizard, which walks you through selecting the type, entering details, and
choosing coatings and prep services. The total price updates in real time as you enter information.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Item Types</h3>
<div class="row g-3 mb-3">
<div class="col-md-4">
<div class="card border-primary border-opacity-25 h-100">
<div class="card-header bg-primary bg-opacity-10 fw-semibold small">
<i class="bi bi-calculator me-1"></i> Calculated
</div>
<div class="card-body small">
Enter the surface area in square feet, quantity, and complexity. The system calculates
material cost, labor, and equipment time automatically. Choose one or more coatings
and optional prep services (sandblasting, masking, cleaning). Best for standard
powder coating work where you know the dimensions.
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-secondary border-opacity-25 h-100">
<div class="card-header bg-secondary bg-opacity-10 fw-semibold small">
<i class="bi bi-pencil me-1"></i> Custom Work
</div>
<div class="card-body small">
Enter a free-text description and type a price manually. Use this for one-off work,
repairs, or services that do not fit the standard surface-area calculation model.
No automatic pricing calculation — you set the price directly.
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-success border-opacity-25 h-100">
<div class="card-header bg-success bg-opacity-10 fw-semibold small">
<i class="bi bi-camera me-1"></i> AI Photo Quote
</div>
<div class="card-body small">
Upload photos of the parts and let the AI estimate the surface area and complexity.
The AI analyses the images and returns a suggested surface area (sq ft), complexity
rating, estimated minutes, and a confidence score. You can review and override any
value before accepting the estimate. Up to two follow-up rounds of questions are
supported.
</div>
</div>
</div>
</div>
<h3 class="h6 fw-semibold mt-3 mb-2">Coatings and Prep Services</h3>
<p>
For Calculated and AI Photo items, after entering the surface area you proceed to the coatings
step. Select one or more powder coatings from your inventory. The wizard shows how much powder
will be needed per coat based on the coverage rate and your surface area. You then choose any
prep services — sandblasting, masking, and/or cleaning — that will be performed before coating.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Save to Product Catalog</h3>
<p>
After completing the prep services step, Calculated and AI Photo items display one final step:
<strong>Save to Product Catalog?</strong> This lets you add the item directly to your catalog
so it can be reused on future quotes and jobs without re-entering all the details.
</p>
<ul>
<li>The item <strong>Name</strong> is pre-filled from your description (or the AI-generated description for AI Photo items).</li>
<li>The <strong>Default Price</strong> is pre-filled with the accepted price for AI Photo items. For Calculated items, enter a fixed catalog price (the system-calculated price varies by dimensions, so a flat catalog price is set by you).</li>
<li>Choose a <strong>Category</strong> from your existing catalog categories.</li>
<li>Optionally add or edit a <strong>Description</strong> and flag whether the item typically requires sandblasting or masking.</li>
<li>Click <strong>Save to Catalog &amp; Add</strong> to save the item to the catalog and add it to the quote simultaneously.</li>
<li>Click <strong>Skip — Add to Quote Only</strong> to add the item to this quote without saving it to the catalog.</li>
</ul>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-3" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
<strong>Catalog item prices are final.</strong> When a catalog item is added to a quote or job,
the price you entered is used exactly as-is — no markup, no prep service charges, and no
complexity adjustments are added on top. Make sure the price you save already includes your
labor, materials, and margin.
</div>
</div>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-bookmark-star-fill flex-shrink-0 mt-1"></i>
<div>
The catalog save happens <strong>immediately</strong> — the item is added to your catalog even
if you later abandon the quote. This is intentional: once you take the time to describe and
price a part, it's worth keeping for next time. Manage saved items at
<a href="/CatalogItems">Catalog Items</a>.
</div>
</div>
<div class="alert alert-permanent alert-warning d-flex gap-2 mt-3 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
The live pricing calculator at the bottom of the quote form updates the subtotal, discounts,
tax, and grand total every time you add or change a line item. There is no need to manually
recalculate — the total shown when you save is the total the customer will see.
</div>
</div>
</section>
<section id="quote-statuses" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-tag text-primary me-2"></i>Quote Statuses
</h2>
<p>
Quotes move through a series of statuses that reflect where they are in the approval process.
Each status is shown as a color-coded badge on the quote list and details page.
</p>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th style="width:30%">Status</th>
<th>What it means</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="badge bg-secondary">Draft</span></td>
<td>The quote is being prepared. It has not been sent to the customer and can be edited freely.</td>
</tr>
<tr>
<td><span class="badge bg-info text-dark">Sent</span></td>
<td>The quote has been delivered to the customer and is awaiting their response.</td>
</tr>
<tr>
<td><span class="badge bg-success">Approved</span></td>
<td>The customer has accepted the quote and authorised the work. Ready to convert to a job.</td>
</tr>
<tr>
<td><span class="badge bg-danger">Rejected</span></td>
<td>The customer has declined the quote. Useful to track win/loss rates over time.</td>
</tr>
<tr>
<td><span class="badge bg-warning text-dark">Expired</span></td>
<td>The quote's validity period has passed without a customer decision. Pricing may need to be revised before resubmitting.</td>
</tr>
<tr>
<td><span class="badge bg-primary">Converted</span></td>
<td>The quote was approved and converted into a job. The quote is now linked to the resulting job record.</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="sending-a-quote" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-send text-primary me-2"></i>Sending a Quote
</h2>
<p>
Once a quote is saved as a Draft and you are happy with the pricing and details, you can mark it
as sent to the customer.
</p>
<ol class="mb-3">
<li class="mb-2">Open the quote from the Quotes list and go to its Details page.</li>
<li class="mb-2">Click <strong>Send Quote</strong>. The status changes from Draft to Sent.</li>
<li class="mb-2">If email notifications are configured for your company, the customer will automatically receive an email with the quote details.</li>
</ol>
<p>
You can also manually mark a quote as <strong>Approved</strong> or <strong>Rejected</strong> when
you hear back from the customer verbally or by phone, without going through a formal email send.
Use the status buttons on the quote Details page to do this.
</p>
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>
You can download a PDF of any quote to print or send via your own email client. Click the
<strong>Download PDF</strong> button on the quote Details page.
</div>
</div>
</section>
<section id="converting-to-job" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-arrow-right-circle text-primary me-2"></i>Converting to a Job
</h2>
<p>
When a quote is in the <strong>Approved</strong> status, you can convert it to a job in one click.
</p>
<ol class="mb-3">
<li class="mb-2">Open the approved quote and go to its Details page.</li>
<li class="mb-2">Click <strong>Convert to Job</strong>.</li>
<li class="mb-2">
A new job is created automatically, pre-filled with:
<ul class="mt-1">
<li>All line items from the quote (surface areas, quantities, coatings, prep services)</li>
<li>The final pricing calculated on the quote</li>
<li>The customer the quote was linked to</li>
</ul>
</li>
<li class="mb-2">Set the job's scheduled date, priority, and assigned worker, then save.</li>
</ol>
<p>
The quote status changes to <strong>Converted</strong> and a link to the new job appears on the
quote Details page. The job record also shows which quote it originated from.
</p>
</section>
<section id="prospect-conversion" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-person-check text-primary me-2"></i>Converting a Prospect to a Customer
</h2>
<p>
If a quote was created for a prospect (not yet in your customer list) and they decide to go ahead,
you can convert the prospect to a full customer record before converting the quote to a job.
</p>
<ol class="mb-3">
<li class="mb-2">Open the approved prospect quote.</li>
<li class="mb-2">Click <strong>Convert Prospect to Customer</strong>.</li>
<li class="mb-2">
The system opens a pre-filled customer creation form using the prospect's details from the
quote (name, company, email, phone). Review and complete any missing fields such as address,
customer type, or pricing tier.
</li>
<li class="mb-2">Save the new customer record.</li>
<li class="mb-2">The quote is now linked to the new customer. You can then click <strong>Convert to Job</strong> as normal.</li>
</ol>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
Only <strong>Approved</strong> prospect quotes show the Convert Prospect to Customer button.
If the quote is still in Draft or Sent status, approve it first before converting.
</div>
</div>
</section>
<section id="customer-approval-portal" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-person-check text-primary me-2"></i>Customer Approval Portal
</h2>
<p>
When you send a quote by email, the customer receives a link to a self-service approval portal
at <strong>/QuoteApproval</strong>. No login is required — the link is unique to that quote.
</p>
<p>From the approval portal the customer can:</p>
<ul class="mb-3">
<li class="mb-1">View the full quote with pricing breakdown.</li>
<li class="mb-1">Click <strong>Approve</strong> to accept the quote (status changes to Approved automatically).</li>
<li class="mb-1">Click <strong>Reject</strong> with an optional reason.</li>
<li class="mb-1">Pay a deposit online (if Stripe Connect is configured).</li>
</ul>
<p>
When a customer approves or rejects via the portal, you receive a notification and the quote
status updates in real time. You can then convert an approved quote to a job without needing
to contact the customer.
</p>
<p>
Approval links expire after the number of days configured in
<strong>Settings &rsaquo; App Settings &rsaquo; Quote Approval Token Days</strong> (default: 30 days).
</p>
</section>
<section id="deposits" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-piggy-bank text-primary me-2"></i>Deposits on Quotes
</h2>
<p>
You can record a deposit against an approved quote from the Quote Details page using the
<strong>Record Deposit</strong> button in the Deposits card. This is useful when a customer
pays a deposit before you start work and before a job or invoice has been created.
</p>
<p>
Deposits recorded on a quote carry over to the linked job automatically when you convert
the quote. They are then applied as payments when an invoice is created from that job.
</p>
</section>
<section id="pricing-breakdown" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-bar-chart text-primary me-2"></i>Understanding the Pricing Breakdown
</h2>
<p>
On the Quote Details page, a full pricing breakdown section shows exactly how the grand total was
calculated. This transparency is useful when explaining pricing to customers and when reviewing
whether your rates are covering costs. The breakdown includes:
</p>
<ul class="mb-3">
<li class="mb-1"><strong>Material Costs</strong> — powder and consumables cost based on surface area and the configured cost-per-sq-ft rate.</li>
<li class="mb-1"><strong>Shop Supplies</strong> — a small percentage of material and labor costs covering miscellaneous shop consumables (tape, abrasives, etc.).</li>
<li class="mb-1"><strong>Labor Costs</strong> — base labor calculated from estimated job minutes. Sandblasting prep is charged at 1.5× the standard labor rate; masking at 0.5×. Additional coats beyond the first are charged at the configured additional coat labor percentage.</li>
<li class="mb-1"><strong>Equipment Costs</strong> — hourly rates for the curing oven, sandblaster, and coating booth, applied for the estimated time each piece of equipment is in use.</li>
<li class="mb-1"><strong>Complexity Adjustment</strong> — a percentage added based on the item's complexity rating (Simple, Moderate, Complex, or Extreme). Simple items have no adjustment; Extreme items can add 25% or more.</li>
<li class="mb-1"><strong>General Markup</strong> — your configured profit percentage applied to the cost subtotal. The exact formula depends on the <em>Pricing Mode</em> set in Company Settings: <em>Markup on Materials</em> adds the percentage on top of costs; <em>Target Margin on Total Cost</em> back-calculates price from a gross-margin target.</li>
<li class="mb-1"><strong>Rush Charge</strong> — applied automatically to jobs with Rush or Urgent priority, either as a percentage or a fixed amount depending on your settings.</li>
<li class="mb-1"><strong>Pricing Tier Discount</strong> — if the customer has a pricing tier assigned (e.g., Preferred Shop — 10% off), the discount is shown as a line item reduction. See <em>Hide Discount from Customer</em> below to control whether this line appears on the customer-facing PDF.</li>
<li class="mb-1"><strong>Tax</strong> — the configured tax rate applied to the final subtotal. Zero for tax-exempt customers.</li>
<li class="mb-1"><strong>Grand Total</strong> — the amount the customer will be invoiced.</li>
</ul>
<h3 class="h6 fw-semibold mt-4 mb-2">Per-Item Cost Breakdown</h3>
<p>
Each line item on the Quote Details page can be expanded to show a cost breakdown for that
individual item — click the row to open it. The per-item breakdown shows how material, labor,
equipment, complexity, and markup were calculated for that specific piece. This is useful for
spotting underpriced items or understanding where costs are concentrated across a multi-item quote.
</p>
<h3 class="h6 fw-semibold mt-4 mb-2">Hide Discount from Customer</h3>
<p>
When creating or editing a quote, you can check <strong>Hide Discount from Customer</strong>.
When this option is enabled:
</p>
<ul class="mb-3">
<li class="mb-1">The pricing tier discount line is <strong>not shown</strong> on the customer-facing quote PDF or on the online approval portal.</li>
<li class="mb-1">The discount is <strong>still applied</strong> to the total — the customer pays the discounted price, they just don&rsquo;t see the discount as a separate line item.</li>
<li class="mb-1">The full breakdown (including the discount) remains visible to your staff on the internal Quote Details page.</li>
</ul>
<p>
Use this when you want to honor a negotiated rate or volume discount without advertising
your tier structure to the customer.
</p>
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>
All rates used in the breakdown — labor, equipment, markup, shop supplies, complexity multipliers,
rush charge, and tax — are configured in <strong>Settings &rsaquo; Company Settings &rsaquo; Operating Costs</strong>.
See the <a asp-controller="Help" asp-action="Settings">Settings help page</a> for details on Pricing Mode and all rate settings.
</div>
</div>
</section>
</div>
<div class="col-lg-3 d-none d-lg-block">
@{ await Html.RenderPartialAsync("_HelpNav"); }
<div class="card border-0 shadow-sm sticky-top" style="top:80px">
<div class="card-header bg-transparent fw-semibold small text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">On this page</div>
<div class="card-body p-0">
<nav class="nav flex-column">
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
<a class="nav-link py-1 px-3 small text-body" href="#creating-a-quote">Creating a Quote</a>
<a class="nav-link py-1 px-3 small text-body" href="#quote-items">Quote Items</a>
<a class="nav-link py-1 px-3 small text-body" href="#quote-statuses">Quote Statuses</a>
<a class="nav-link py-1 px-3 small text-body" href="#sending-a-quote">Sending a Quote</a>
<a class="nav-link py-1 px-3 small text-body" href="#converting-to-job">Converting to a Job</a>
<a class="nav-link py-1 px-3 small text-body" href="#prospect-conversion">Converting a Prospect</a>
<a class="nav-link py-1 px-3 small text-body" href="#customer-approval-portal">Approval Portal</a>
<a class="nav-link py-1 px-3 small text-body" href="#deposits">Deposits</a>
<a class="nav-link py-1 px-3 small text-body" href="#pricing-breakdown">Pricing Breakdown</a>
</nav>
</div>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More