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,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>