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,177 @@
@{
ViewData["Title"] = "Anomaly Detection";
ViewData["PageIcon"] = "bi-shield-exclamation";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center gap-2">
<a asp-controller="Reports" asp-action="Landing" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i>
</a>
<p class="text-muted mb-0 small">AI scans your recent bills and expense trends for duplicates, unusual amounts, and accounts running over budget.</p>
</div>
<button id="btnRunAnalysis" class="btn btn-warning text-dark">
<i class="bi bi-magic me-1"></i>Run Analysis
</button>
</div>
<!-- Idle state -->
<div id="idleState" class="text-center py-5">
<div class="mb-3" style="font-size:3rem;color:var(--bs-border-color);">
<i class="bi bi-shield-exclamation"></i>
</div>
<h5 class="text-muted">No analysis run yet</h5>
<p class="text-muted small mb-3">Click <strong>Run Analysis</strong> to scan your bills and expense accounts for anomalies.</p>
<button class="btn btn-warning btn-sm text-dark" onclick="runAnalysis()">
<i class="bi bi-magic me-1"></i>Run Analysis
</button>
</div>
<!-- Loading state -->
<div id="loadingState" class="text-center py-5 d-none">
<div class="spinner-border text-warning mb-3" role="status"></div>
<p class="text-muted">Scanning bills and expense accounts for anomalies…<br><small>This usually takes 510 seconds.</small></p>
</div>
<!-- Error state -->
<div id="errorState" class="d-none">
<div class="alert alert-danger alert-permanent d-flex align-items-center gap-2">
<i class="bi bi-exclamation-triangle-fill"></i>
<span id="errorMessage">An error occurred.</span>
</div>
</div>
<!-- Results -->
<div id="analysisResults" class="d-none">
<!-- Summary badges -->
<div class="d-flex align-items-center gap-3 mb-4 flex-wrap">
<div id="badgeCritical" class="d-flex align-items-center gap-2 px-3 py-2 rounded" style="background:#fef2f2;">
<i class="bi bi-exclamation-octagon-fill text-danger fs-5"></i>
<div>
<span id="cntCritical" class="fw-bold fs-5 text-danger">0</span>
<span class="text-danger small ms-1">Critical</span>
</div>
</div>
<div class="d-flex align-items-center gap-2 px-3 py-2 rounded" style="background:#fffbeb;">
<i class="bi bi-exclamation-triangle-fill text-warning fs-5"></i>
<div>
<span id="cntWarning" class="fw-bold fs-5 text-warning">0</span>
<span class="text-warning small ms-1">Warnings</span>
</div>
</div>
<div class="d-flex align-items-center gap-2 px-3 py-2 rounded" style="background:#eff6ff;">
<i class="bi bi-info-circle-fill text-info fs-5"></i>
<div>
<span id="cntInfo" class="fw-bold fs-5 text-info">0</span>
<span class="text-info small ms-1">Info</span>
</div>
</div>
<div id="allClearBadge" class="d-none d-flex align-items-center gap-2 px-3 py-2 rounded" style="background:#f0fdf4;">
<i class="bi bi-shield-check text-success fs-5"></i>
<span class="text-success fw-semibold">All Clear — no anomalies detected</span>
</div>
</div>
<!-- Flags list -->
<div id="flagsList"></div>
<div class="text-muted small text-end mt-3">
<i class="bi bi-robot me-1"></i>Generated by Claude AI &middot; <span id="analysisTimestamp"></span>
&middot; <a href="#" onclick="runAnalysis(); return false;">Re-run</a>
</div>
</div>
@section Scripts {
<script>
document.getElementById('btnRunAnalysis').addEventListener('click', runAnalysis);
const typeLabels = {
duplicate: { label: 'Duplicate Bill', icon: 'bi-files', color: 'danger' },
amount_spike: { label: 'Amount Spike', icon: 'bi-graph-up-arrow', color: 'warning' },
unusual_vendor: { label: 'Unusual Vendor', icon: 'bi-person-exclamation', color: 'warning' },
account_overrun: { label: 'Account Over Budget', icon: 'bi-bar-chart-fill', color: 'info' }
};
const severityConfig = {
critical: { cls: 'border-danger', headerCls: 'bg-danger text-white', badgeCls: 'bg-danger' },
warning: { cls: 'border-warning', headerCls: 'bg-warning text-dark', badgeCls: 'bg-warning text-dark' },
info: { cls: 'border-info', headerCls: 'bg-info text-white', badgeCls: 'bg-info' }
};
async function runAnalysis() {
document.getElementById('idleState').classList.add('d-none');
document.getElementById('loadingState').classList.remove('d-none');
document.getElementById('errorState').classList.add('d-none');
document.getElementById('analysisResults').classList.add('d-none');
try {
const resp = await fetch('/Reports/RunAnomalyDetection', {
method: 'POST',
headers: { 'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '' }
});
const data = await resp.json();
document.getElementById('loadingState').classList.add('d-none');
if (!data.success) {
document.getElementById('errorMessage').textContent = data.errorMessage || 'An error occurred.';
document.getElementById('errorState').classList.remove('d-none');
return;
}
document.getElementById('cntCritical').textContent = data.criticalCount;
document.getElementById('cntWarning').textContent = data.warningCount;
document.getElementById('cntInfo').textContent = data.infoCount;
const totalFlags = (data.flags || []).length;
document.getElementById('allClearBadge').classList.toggle('d-none', totalFlags > 0);
document.getElementById('badgeCritical').parentElement.querySelectorAll(':scope > div:not(#allClearBadge)')
.forEach(el => el.classList.toggle('d-none', totalFlags === 0));
const container = document.getElementById('flagsList');
container.innerHTML = '';
// Sort: critical first, then warning, then info
const sorted = [...(data.flags || [])].sort((a, b) => {
const order = { critical: 0, warning: 1, info: 2 };
return (order[a.severity] ?? 3) - (order[b.severity] ?? 3);
});
sorted.forEach(flag => {
const typeInfo = typeLabels[flag.type] || { label: flag.type, icon: 'bi-exclamation-circle', color: 'secondary' };
const sev = severityConfig[flag.severity] || severityConfig.warning;
const card = document.createElement('div');
card.className = `card mb-3 border ${sev.cls}`;
card.innerHTML = `
<div class="card-header d-flex align-items-center gap-2 ${sev.headerCls} py-2">
<i class="bi ${typeInfo.icon}"></i>
<span class="fw-semibold">${flag.title}</span>
<span class="badge ${sev.badgeCls} ms-auto text-uppercase" style="font-size:0.65rem;">${flag.severity}</span>
<span class="badge bg-light text-dark" style="font-size:0.65rem;">${typeInfo.label}</span>
${flag.billNumber ? `<span class="badge bg-secondary ms-1" style="font-size:0.65rem;">${flag.billNumber}</span>` : ''}
</div>
<div class="card-body py-2">
<p class="mb-1">${flag.description}</p>
${flag.recommendedAction ? `
<div class="d-flex align-items-start gap-2 mt-2 p-2 rounded" style="background:var(--bs-tertiary-bg);">
<i class="bi bi-arrow-right-circle-fill text-primary mt-1 flex-shrink-0"></i>
<span class="small"><strong>Recommended:</strong> ${flag.recommendedAction}</span>
</div>` : ''}
</div>`;
container.appendChild(card);
});
document.getElementById('analysisTimestamp').textContent = new Date().toLocaleString();
document.getElementById('analysisResults').classList.remove('d-none');
} catch (e) {
document.getElementById('loadingState').classList.add('d-none');
document.getElementById('errorMessage').textContent = 'Network error — please try again.';
document.getElementById('errorState').classList.remove('d-none');
}
}
</script>
@Html.AntiForgeryToken()
}
@@ -0,0 +1,242 @@
@model PowderCoating.Application.DTOs.Accounting.ArAgingReportDto
@{
ViewData["Title"] = "AR Aging";
ViewData["PageIcon"] = "bi-hourglass-split";
var today = DateTime.Today;
}
<style>
@@media print {
.no-print { display: none !important; }
.card { border: 1px solid #dee2e6 !important; box-shadow: none !important; }
body { font-size: 11px; }
.table { font-size: 11px; }
}
.aging-current { color: #198754; }
.aging-1-30 { color: #fd7e14; }
.aging-31-60 { color: #dc6c02; }
.aging-61-90 { color: #dc3545; }
.aging-over90 { color: #842029; font-weight: 700; }
.customer-row { background: #f8f9fa; font-weight: 600; }
</style>
<!-- Header -->
<div class="d-flex align-items-center gap-2 mb-3 no-print">
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<p class="text-muted mb-0">As of @Model.AsOf.ToString("MMMM d, yyyy") · @Model.Customers.Sum(c => c.Invoices.Count) open invoices</p>
<div class="ms-auto d-flex gap-2">
<a href="@Url.Action("ArAgingPdf", new { asOf = Model.AsOf.ToString("yyyy-MM-dd") })"
class="btn btn-sm btn-outline-danger no-print" target="_blank">
<i class="bi bi-file-pdf me-1"></i>Download PDF
</a>
<a href="@Url.Action("ArAgingPdf", new { asOf = Model.AsOf.ToString("yyyy-MM-dd"), inline = true })"
class="btn btn-sm btn-outline-secondary no-print" target="_blank">
<i class="bi bi-printer me-1"></i>Print
</a>
</div>
</div>
<!-- Date filter -->
<div class="card shadow-sm mb-4 no-print">
<div class="card-body py-3">
<form method="get" class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label form-label-sm mb-1">As of Date</label>
<input type="date" name="asOf" class="form-control form-control-sm" value="@Model.AsOf.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>Run Report</button>
</div>
<div class="col-auto ms-2">
<div class="btn-group btn-group-sm">
<a href="@Url.Action("ArAging", new { asOf = today.ToString("yyyy-MM-dd") })" class="btn btn-outline-secondary">Today</a>
<a href="@Url.Action("ArAging", new { asOf = new DateTime(today.Year, today.Month, 1).AddDays(-1).ToString("yyyy-MM-dd") })" class="btn btn-outline-secondary">End of Last Month</a>
</div>
</div>
</form>
</div>
</div>
<!-- Print header -->
<div class="text-center mb-4 d-none d-print-block">
<h4 class="fw-bold">@Model.CompanyName</h4>
<h5>Accounts Receivable Aging</h5>
<p class="text-muted">As of @Model.AsOf.ToString("MMMM d, yyyy")</p>
</div>
<!-- Aging summary cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-lg">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h6 text-success mb-1">@Model.TotalCurrent.ToString("C0")</div>
<div class="text-muted small">Current</div>
</div>
</div>
</div>
<div class="col-6 col-lg">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h6 aging-1-30 mb-1">@Model.Total1to30.ToString("C0")</div>
<div class="text-muted small">130 Days</div>
</div>
</div>
</div>
<div class="col-6 col-lg">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h6 aging-31-60 mb-1">@Model.Total31to60.ToString("C0")</div>
<div class="text-muted small">3160 Days</div>
</div>
</div>
</div>
<div class="col-6 col-lg">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h6 aging-61-90 mb-1">@Model.Total61to90.ToString("C0")</div>
<div class="text-muted small">6190 Days</div>
</div>
</div>
</div>
<div class="col-6 col-lg">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h6 aging-over90 mb-1">@Model.TotalOver90.ToString("C0")</div>
<div class="text-muted small">Over 90 Days</div>
</div>
</div>
</div>
<div class="col-6 col-lg">
<div class="card shadow-sm text-center h-100 border-primary border-opacity-25">
<div class="card-body py-3">
<div class="h6 text-primary fw-bold mb-1">@Model.TotalOutstanding.ToString("C0")</div>
<div class="text-muted small">Total Outstanding</div>
</div>
</div>
</div>
</div>
@if (!Model.Customers.Any())
{
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-check-circle text-success fs-1 d-block mb-2"></i>
<p class="mb-0 fw-semibold">All invoices are paid!</p>
<p class="small mb-0">No outstanding balances as of @Model.AsOf.ToString("MMMM d, yyyy").</p>
</div>
</div>
}
else
{
<!-- Summary table -->
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold">
<i class="bi bi-table me-1"></i>Aging Summary by Customer
</div>
<div class="table-responsive">
<table class="table table-hover table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Customer</th>
<th class="text-end">Current</th>
<th class="text-end">130 Days</th>
<th class="text-end">3160 Days</th>
<th class="text-end">6190 Days</th>
<th class="text-end">Over 90</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody>
@foreach (var cust in Model.Customers)
{
<tr>
<td>
<a asp-controller="Customers" asp-action="Details" asp-route-id="@cust.CustomerId" class="text-decoration-none fw-medium">
@cust.CustomerName
</a>
<span class="badge bg-secondary ms-1">@cust.Invoices.Count inv.</span>
</td>
<td class="text-end aging-current">@(cust.TotalCurrent > 0 ? cust.TotalCurrent.ToString("C") : "—")</td>
<td class="text-end aging-1-30">@(cust.Total1to30 > 0 ? cust.Total1to30.ToString("C") : "—")</td>
<td class="text-end aging-31-60">@(cust.Total31to60 > 0 ? cust.Total31to60.ToString("C") : "—")</td>
<td class="text-end aging-61-90">@(cust.Total61to90 > 0 ? cust.Total61to90.ToString("C") : "—")</td>
<td class="text-end aging-over90">@(cust.TotalOver90 > 0 ? cust.TotalOver90.ToString("C") : "—")</td>
<td class="text-end fw-semibold">@cust.TotalBalance.ToString("C")</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-bold">
<tr>
<td>Total</td>
<td class="text-end aging-current">@Model.TotalCurrent.ToString("C")</td>
<td class="text-end aging-1-30">@Model.Total1to30.ToString("C")</td>
<td class="text-end aging-31-60">@Model.Total31to60.ToString("C")</td>
<td class="text-end aging-61-90">@Model.Total61to90.ToString("C")</td>
<td class="text-end aging-over90">@Model.TotalOver90.ToString("C")</td>
<td class="text-end">@Model.TotalOutstanding.ToString("C")</td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Detail by customer -->
<div class="card shadow-sm">
<div class="card-header fw-semibold">
<i class="bi bi-list-ul me-1"></i>Invoice Detail
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Invoice</th>
<th>Date</th>
<th>Due Date</th>
<th class="text-end">Balance Due</th>
<th class="text-end">Age</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach (var cust in Model.Customers)
{
<tr class="customer-row">
<td colspan="6" class="py-2">@cust.CustomerName</td>
</tr>
@foreach (var inv in cust.Invoices.OrderBy(i => i.DaysOverdue))
{
string ageBadge = inv.DaysOverdue <= 0 ? "bg-success-subtle text-success"
: inv.DaysOverdue <= 30 ? "bg-warning-subtle text-warning"
: inv.DaysOverdue <= 60 ? "bg-orange-subtle text-warning"
: inv.DaysOverdue <= 90 ? "bg-danger-subtle text-danger"
: "bg-danger text-white";
string ageLabel = inv.DaysOverdue <= 0 ? "Current" : $"{inv.DaysOverdue}d overdue";
<tr>
<td class="ps-4">
<a asp-controller="Invoices" asp-action="Details" asp-route-id="@inv.InvoiceId" class="text-decoration-none fw-medium">
@inv.InvoiceNumber
</a>
</td>
<td class="text-muted small">@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
<td class="text-muted small">@(inv.DueDate?.ToString("MM/dd/yyyy") ?? "—")</td>
<td class="text-end fw-semibold @(inv.DaysOverdue > 30 ? "text-danger" : "")">@inv.BalanceDue.ToString("C")</td>
<td class="text-end"><span class="badge @ageBadge">@ageLabel</span></td>
<td></td>
</tr>
}
<tr class="table-light">
<td colspan="3" class="ps-4 fw-semibold text-end small">@cust.CustomerName subtotal</td>
<td class="text-end fw-semibold">@cust.TotalBalance.ToString("C")</td>
<td colspan="2"></td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<div class="text-muted small mt-2 no-print">
<i class="bi bi-info-circle me-1"></i>
Generated @DateTime.Now.ToString("MMM d, yyyy h:mm tt") · Includes all open invoices (excluding Draft and Voided). Age calculated from due date.
</div>
@@ -0,0 +1,239 @@
@model PowderCoating.Application.DTOs.Accounting.BalanceSheetDto
@{
ViewData["Title"] = "Balance Sheet";
ViewData["PageIcon"] = "bi-scale";
var today = DateTime.Today;
}
<style>
@@media print {
.no-print { display: none !important; }
.card { border: 1px solid #dee2e6 !important; box-shadow: none !important; }
body { font-size: 12px; }
}
.report-section-header { background: #f8f9fa; font-weight: 600; }
.report-subtotal-row { border-top: 1px solid #dee2e6; font-weight: 600; }
.report-total-row { border-top: 2px solid #343a40; font-weight: 700; background: #f1f3f5; }
.bs-balanced { color: #198754; }
.bs-unbalanced { color: #dc3545; }
</style>
<!-- Header -->
<div class="d-flex align-items-center gap-2 mb-3 no-print">
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<p class="text-muted mb-0">As of @Model.AsOf.ToString("MMMM d, yyyy")</p>
<div class="ms-auto d-flex gap-2">
<a href="@Url.Action("BalanceSheetPdf", new { asOf = Model.AsOf.ToString("yyyy-MM-dd") })"
class="btn btn-sm btn-outline-danger no-print" target="_blank">
<i class="bi bi-file-pdf me-1"></i>Download PDF
</a>
<a href="@Url.Action("BalanceSheetPdf", new { asOf = Model.AsOf.ToString("yyyy-MM-dd"), inline = true })"
class="btn btn-sm btn-outline-secondary no-print" target="_blank">
<i class="bi bi-printer me-1"></i>Print
</a>
</div>
</div>
<!-- Date filter -->
<div class="card shadow-sm mb-4 no-print">
<div class="card-body py-3">
<form method="get" class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label form-label-sm mb-1">As of Date</label>
<input type="date" name="asOf" class="form-control form-control-sm" value="@Model.AsOf.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>Run Report</button>
</div>
<div class="col-auto ms-2">
<div class="btn-group btn-group-sm">
<a href="@Url.Action("BalanceSheet", new { asOf = today.ToString("yyyy-MM-dd") })" class="btn btn-outline-secondary">Today</a>
<a href="@Url.Action("BalanceSheet", new { asOf = new DateTime(today.Year, today.Month, 1).AddDays(-1).ToString("yyyy-MM-dd") })" class="btn btn-outline-secondary">End of Last Month</a>
<a href="@Url.Action("BalanceSheet", new { asOf = new DateTime(today.Year, 12, 31).ToString("yyyy-MM-dd") })" class="btn btn-outline-secondary">Year End</a>
</div>
</div>
</form>
</div>
</div>
<!-- Print header -->
<div class="text-center mb-4 d-none d-print-block">
<h4 class="fw-bold">@Model.CompanyName</h4>
<h5>Balance Sheet</h5>
<p class="text-muted">As of @Model.AsOf.ToString("MMMM d, yyyy")</p>
</div>
<!-- KPI row -->
<div class="row g-3 mb-4 no-print">
<div class="col-md-4">
<div class="card shadow-sm text-center h-100 border-primary border-opacity-25">
<div class="card-body py-3">
<div class="h5 text-primary fw-bold mb-1">@Model.TotalAssets.ToString("C")</div>
<div class="text-muted small">Total Assets</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm text-center h-100 border-danger border-opacity-25">
<div class="card-body py-3">
<div class="h5 text-danger fw-bold mb-1">@Model.TotalLiabilities.ToString("C")</div>
<div class="text-muted small">Total Liabilities</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm text-center h-100 border-success border-opacity-25">
<div class="card-body py-3">
<div class="h5 text-success fw-bold mb-1">@Model.TotalEquity.ToString("C")</div>
<div class="text-muted small">Total Equity</div>
@if (!Model.IsBalanced)
{
<small class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>Sheet does not balance</small>
}
</div>
</div>
</div>
</div>
<div class="row g-4">
<!-- ASSETS column -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold">
<i class="bi bi-safe text-primary me-2"></i>Assets
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<tbody>
@{
void RenderSection(string title, List<PowderCoating.Application.DTOs.Accounting.FinancialReportLine> lines, string subtotalLabel)
{
if (!lines.Any()) return;
<tr class="report-section-header">
<td colspan="2" class="py-2 small text-uppercase text-muted">@title</td>
</tr>
foreach (var line in lines)
{
<tr>
<td class="ps-3">@line.AccountNumber <span class="text-muted">@line.AccountName</span></td>
<td class="text-end">@line.Amount.ToString("C")</td>
</tr>
}
<tr class="report-subtotal-row">
<td class="ps-3 fw-semibold">@subtotalLabel</td>
<td class="text-end fw-semibold">@lines.Sum(l => l.Amount).ToString("C")</td>
</tr>
<tr><td colspan="2" class="py-1"></td></tr>
}
}
@{ RenderSection("Current Assets", Model.CurrentAssets, "Total Current Assets"); }
@{ RenderSection("Fixed Assets", Model.FixedAssets, "Total Fixed Assets"); }
@{ RenderSection("Other Assets", Model.OtherAssets, "Total Other Assets"); }
</tbody>
<tfoot>
<tr class="report-total-row">
<td>Total Assets</td>
<td class="text-end text-primary">@Model.TotalAssets.ToString("C")</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<!-- LIABILITIES + EQUITY column -->
<div class="col-lg-6">
<!-- Liabilities -->
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold">
<i class="bi bi-credit-card text-danger me-2"></i>Liabilities
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<tbody>
@{
void RenderLiabSection(string title, List<PowderCoating.Application.DTOs.Accounting.FinancialReportLine> lines, string subtotalLabel)
{
if (!lines.Any()) return;
<tr class="report-section-header">
<td colspan="2" class="py-2 small text-uppercase text-muted">@title</td>
</tr>
foreach (var line in lines)
{
<tr>
<td class="ps-3">@line.AccountNumber <span class="text-muted">@line.AccountName</span></td>
<td class="text-end">@line.Amount.ToString("C")</td>
</tr>
}
<tr class="report-subtotal-row">
<td class="ps-3 fw-semibold">@subtotalLabel</td>
<td class="text-end fw-semibold">@lines.Sum(l => l.Amount).ToString("C")</td>
</tr>
<tr><td colspan="2" class="py-1"></td></tr>
}
}
@{ RenderLiabSection("Current Liabilities", Model.CurrentLiabilities, "Total Current Liabilities"); }
@{ RenderLiabSection("Long-Term Liabilities", Model.LongTermLiabilities, "Total Long-Term Liabilities"); }
</tbody>
<tfoot>
<tr class="report-total-row">
<td>Total Liabilities</td>
<td class="text-end text-danger">@Model.TotalLiabilities.ToString("C")</td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Equity -->
<div class="card shadow-sm">
<div class="card-header fw-semibold">
<i class="bi bi-bar-chart-line text-success me-2"></i>Equity
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<tbody>
@if (Model.EquityLines.Any())
{
<tr class="report-section-header">
<td colspan="2" class="py-2 small text-uppercase text-muted">Owner's Equity</td>
</tr>
@foreach (var line in Model.EquityLines)
{
<tr>
<td class="ps-3">@line.AccountNumber <span class="text-muted">@line.AccountName</span></td>
<td class="text-end">@line.Amount.ToString("C")</td>
</tr>
}
}
<tr class="@(Model.RetainedEarnings < 0 ? "text-danger" : "")">
<td class="ps-3"><span class="text-muted">Retained Earnings (Net Income)</span></td>
<td class="text-end">@Model.RetainedEarnings.ToString("C")</td>
</tr>
</tbody>
<tfoot>
<tr class="report-total-row">
<td>Total Equity</td>
<td class="text-end text-success">@Model.TotalEquity.ToString("C")</td>
</tr>
<tr class="report-total-row" style="border-top:3px double #343a40;">
<td>Total Liabilities &amp; Equity</td>
<td class="text-end @(Model.IsBalanced ? "bs-balanced" : "bs-unbalanced")">
@Model.TotalLiabilitiesAndEquity.ToString("C")
@if (!Model.IsBalanced)
{
<i class="bi bi-exclamation-triangle ms-1" title="Sheet does not balance — check account setup"></i>
}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
<div class="text-muted small mt-3 no-print">
<i class="bi bi-info-circle me-1"></i>
Generated @DateTime.Now.ToString("MMM d, yyyy h:mm tt") · Balances include opening balances plus all recorded transactions through @Model.AsOf.ToString("MMM d, yyyy").
</div>
@@ -0,0 +1,234 @@
@{
ViewData["Title"] = "Cash Flow Forecast";
ViewData["PageIcon"] = "bi-cash-stack";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center gap-2">
<a asp-controller="Reports" asp-action="Landing" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i>
</a>
<p class="text-muted mb-0 small">AI-projected 30/60/90-day cash position based on open invoices, outstanding bills, and your job pipeline.</p>
</div>
<button id="btnRunForecast" class="btn btn-success">
<i class="bi bi-magic me-1"></i>Generate Forecast
</button>
</div>
<!-- Idle state -->
<div id="idleState" class="text-center py-5">
<div class="mb-3" style="font-size:3rem;color:var(--bs-border-color);">
<i class="bi bi-cash-stack"></i>
</div>
<h5 class="text-muted">No forecast generated yet</h5>
<p class="text-muted small mb-3">Click <strong>Generate Forecast</strong> to analyse your open invoices, outstanding bills, and active job pipeline.</p>
<button class="btn btn-success btn-sm" onclick="runForecast()">
<i class="bi bi-magic me-1"></i>Generate Forecast
</button>
</div>
<!-- Loading state -->
<div id="loadingState" class="text-center py-5 d-none">
<div class="spinner-border text-success mb-3" role="status"></div>
<p class="text-muted">Analysing your AR, AP, and job pipeline…<br><small>This usually takes 510 seconds.</small></p>
</div>
<!-- Error state -->
<div id="errorState" class="d-none">
<div class="alert alert-danger alert-permanent d-flex align-items-center gap-2">
<i class="bi bi-exclamation-triangle-fill"></i>
<span id="errorMessage">An error occurred.</span>
</div>
</div>
<!-- Results -->
<div id="forecastResults" class="d-none">
<!-- Outlook banner -->
<div id="outlookBanner" class="alert alert-permanent d-flex align-items-center gap-3 mb-4">
<div id="outlookIcon" style="font-size:1.8rem;"></div>
<div>
<div id="outlookTitle" class="fw-bold fs-5"></div>
<div id="outlookSub" class="small"></div>
</div>
</div>
<!-- Period cards -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card h-100">
<div class="card-header fw-semibold d-flex align-items-center gap-2">
<i class="bi bi-calendar-event text-primary"></i> Next 30 Days
</div>
<div class="card-body">
<div class="row text-center g-2 mb-3">
<div class="col-4">
<div class="small text-muted">Inflows</div>
<div id="in30" class="fw-bold text-success fs-6"></div>
</div>
<div class="col-4">
<div class="small text-muted">Outflows</div>
<div id="out30" class="fw-bold text-danger fs-6"></div>
</div>
<div class="col-4">
<div class="small text-muted">Net</div>
<div id="net30" class="fw-bold fs-6"></div>
</div>
</div>
<ul id="items30" class="list-unstyled mb-0" style="font-size:0.8rem;"></ul>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100">
<div class="card-header fw-semibold d-flex align-items-center gap-2">
<i class="bi bi-calendar2-event text-primary"></i> Next 60 Days
</div>
<div class="card-body">
<div class="row text-center g-2 mb-3">
<div class="col-4">
<div class="small text-muted">Inflows</div>
<div id="in60" class="fw-bold text-success fs-6"></div>
</div>
<div class="col-4">
<div class="small text-muted">Outflows</div>
<div id="out60" class="fw-bold text-danger fs-6"></div>
</div>
<div class="col-4">
<div class="small text-muted">Net</div>
<div id="net60" class="fw-bold fs-6"></div>
</div>
</div>
<ul id="items60" class="list-unstyled mb-0" style="font-size:0.8rem;"></ul>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100">
<div class="card-header fw-semibold d-flex align-items-center gap-2">
<i class="bi bi-calendar3-event text-primary"></i> Next 90 Days
</div>
<div class="card-body">
<div class="row text-center g-2 mb-3">
<div class="col-4">
<div class="small text-muted">Inflows</div>
<div id="in90" class="fw-bold text-success fs-6"></div>
</div>
<div class="col-4">
<div class="small text-muted">Outflows</div>
<div id="out90" class="fw-bold text-danger fs-6"></div>
</div>
<div class="col-4">
<div class="small text-muted">Net</div>
<div id="net90" class="fw-bold fs-6"></div>
</div>
</div>
<ul id="items90" class="list-unstyled mb-0" style="font-size:0.8rem;"></ul>
</div>
</div>
</div>
</div>
<!-- Insights -->
<div class="card mb-4">
<div class="card-header fw-semibold d-flex align-items-center gap-2">
<i class="bi bi-lightbulb text-warning"></i> AI Insights
</div>
<div class="card-body">
<ul id="insightsList" class="mb-0" style="line-height:1.8;"></ul>
</div>
</div>
<div class="text-muted small text-end">
<i class="bi bi-robot me-1"></i>Generated by Claude AI &middot; <span id="forecastTimestamp"></span>
&middot; <a href="#" onclick="runForecast(); return false;">Refresh</a>
</div>
</div>
@section Scripts {
<script>
document.getElementById('btnRunForecast').addEventListener('click', runForecast);
function fmt(n) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n);
}
function setNetColor(el, val) {
el.textContent = fmt(val);
el.className = 'fw-bold fs-6 ' + (val >= 0 ? 'text-success' : 'text-danger');
}
function fillPeriod(suffix, period) {
document.getElementById('in' + suffix).textContent = fmt(period.expectedInflows);
document.getElementById('out' + suffix).textContent = fmt(period.expectedOutflows);
setNetColor(document.getElementById('net' + suffix), period.netCashFlow);
const ul = document.getElementById('items' + suffix);
ul.innerHTML = '';
(period.keyItems || []).forEach(item => {
const li = document.createElement('li');
li.className = 'text-muted mb-1';
li.innerHTML = '<i class="bi bi-dot"></i>' + item;
ul.appendChild(li);
});
}
async function runForecast() {
document.getElementById('idleState').classList.add('d-none');
document.getElementById('loadingState').classList.remove('d-none');
document.getElementById('errorState').classList.add('d-none');
document.getElementById('forecastResults').classList.add('d-none');
try {
const resp = await fetch('/Reports/GenerateCashFlowForecast', {
method: 'POST',
headers: { 'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '' }
});
const data = await resp.json();
document.getElementById('loadingState').classList.add('d-none');
if (!data.success) {
document.getElementById('errorMessage').textContent = data.errorMessage || 'An error occurred.';
document.getElementById('errorState').classList.remove('d-none');
return;
}
// Outlook banner
const outlookMap = {
strong: { cls: 'alert-success', icon: '💪', title: 'Strong Cash Position', sub: 'Your projected cash flow looks healthy across all three periods.' },
moderate: { cls: 'alert-info', icon: '👍', title: 'Moderate Cash Position', sub: 'Cash flow looks manageable — a few items to watch.' },
tight: { cls: 'alert-warning', icon: '⚠️', title: 'Tight Cash Position', sub: 'Cash flow may be constrained — consider following up on open invoices.' },
concerning: { cls: 'alert-danger', icon: '🚨', title: 'Concerning Cash Position', sub: 'Projected cash flow is under pressure — immediate action may be needed.' }
};
const outlook = outlookMap[data.outlook] || outlookMap.moderate;
const banner = document.getElementById('outlookBanner');
banner.className = 'alert alert-permanent d-flex align-items-center gap-3 mb-4 ' + outlook.cls;
document.getElementById('outlookIcon').textContent = outlook.icon;
document.getElementById('outlookTitle').textContent = outlook.title;
document.getElementById('outlookSub').textContent = outlook.sub;
fillPeriod('30', data.next30Days);
fillPeriod('60', data.next60Days);
fillPeriod('90', data.next90Days);
const ul = document.getElementById('insightsList');
ul.innerHTML = '';
(data.insights || []).forEach(ins => {
const li = document.createElement('li');
li.className = 'mb-2';
li.innerHTML = '<i class="bi bi-check-circle-fill text-success me-2"></i>' + ins;
ul.appendChild(li);
});
document.getElementById('forecastTimestamp').textContent = new Date().toLocaleString();
document.getElementById('forecastResults').classList.remove('d-none');
} catch (e) {
document.getElementById('loadingState').classList.add('d-none');
document.getElementById('errorMessage').textContent = 'Network error — please try again.';
document.getElementById('errorState').classList.remove('d-none');
}
}
</script>
@Html.AntiForgeryToken()
}
@@ -0,0 +1,128 @@
@model PowderCoating.Web.ViewModels.Reports.CustomerOverviewViewModel
@{ ViewData["Title"] = "Customer Overview"; }
<partial name="_ReportHeader" model="Model" />
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="card text-bg-primary">
<div class="card-body py-2">
<div class="small">Active Customers</div>
<div class="fs-5 fw-bold">@Model.ActiveCustomersCount</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-bg-success">
<div class="card-body py-2">
<div class="small">Retention Rate</div>
<div class="fs-5 fw-bold">@Model.CustomerRetentionRate.ToString("N1")%</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-bg-info">
<div class="card-body py-2">
<div class="small">Quote Win Rate</div>
<div class="fs-5 fw-bold">@Model.QuoteFunnel.ConversionRate.ToString("N1")%</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-secondary">
<div class="card-body py-2 text-center">
<div class="small text-muted">Total Quotes</div>
<div class="fs-5 fw-bold">@Model.QuoteFunnel.Total</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-lg-8">
<div class="card h-100">
<div class="card-header fw-semibold">New Customers per Month</div>
<div class="card-body">
<canvas id="newCustChart" height="200"></canvas>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header fw-semibold">Quote Conversion Funnel</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<tbody>
<tr><td>Draft</td><td class="text-end">@Model.QuoteFunnel.Draft</td></tr>
<tr><td>Sent</td><td class="text-end">@Model.QuoteFunnel.Sent</td></tr>
<tr class="table-success"><td><strong>Approved</strong></td><td class="text-end fw-bold">@Model.QuoteFunnel.Approved</td></tr>
<tr class="table-success"><td><strong>Converted</strong></td><td class="text-end fw-bold">@Model.QuoteFunnel.Converted</td></tr>
<tr class="table-danger"><td>Rejected</td><td class="text-end text-danger">@Model.QuoteFunnel.Rejected</td></tr>
<tr class="table-warning"><td>Expired</td><td class="text-end text-warning">@Model.QuoteFunnel.Expired</td></tr>
<tr class="table-light fw-bold"><td>Total</td><td class="text-end">@Model.QuoteFunnel.Total</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold">Top Customers by Lifetime Value</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr><th>Customer</th><th class="text-end">Revenue</th><th class="text-end">Jobs</th><th class="text-end">Avg Order</th></tr>
</thead>
<tbody>
@foreach (var c in Model.CustomerLifetimeValue)
{
<tr>
<td class="small">@c.CustomerName</td>
<td class="text-end fw-semibold">@c.TotalRevenue.ToString("C")</td>
<td class="text-end text-muted">@c.JobCount</td>
<td class="text-end text-muted">@c.AvgOrderValue.ToString("C")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold">Catalog Items by Category</div>
<div class="card-body">
<canvas id="catalogChart" height="220"></canvas>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="~/lib/chartjs/chart.umd.min.js"></script>
<script>
(function () {
const labels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthLabels));
const newCusts = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.NewCustomersPerMonth));
new Chart(document.getElementById('newCustChart'), {
type: 'bar',
data: { labels, datasets: [{ label: 'New Customers', data: newCusts, backgroundColor: 'rgba(13,110,253,0.7)' }] },
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } } }
});
const catLabels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.CatalogByCategory.Keys));
const catData = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.CatalogByCategory.Values));
const palette = ['#0d6efd','#6610f2','#6f42c1','#d63384','#dc3545','#fd7e14','#ffc107','#198754'];
new Chart(document.getElementById('catalogChart'), {
type: 'doughnut',
data: { labels: catLabels, datasets: [{ data: catData, backgroundColor: palette }] },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { font: { size: 11 } } } } }
});
})();
</script>
}
@@ -0,0 +1,126 @@
@model PowderCoating.Web.ViewModels.Reports.CustomerRetentionViewModel
@{ ViewData["Title"] = "Customer Retention"; }
<partial name="_ReportHeader" model="Model" />
<div class="row g-3 mb-3">
<div class="col-6 col-md">
<div class="card text-center border-success">
<div class="card-body py-2">
<div class="fs-4 fw-bold text-success">@Model.ActiveCount</div>
<div class="small text-muted">Active</div>
<div class="small text-muted">(≤ 30 days)</div>
</div>
</div>
</div>
<div class="col-6 col-md">
<div class="card text-center border-warning">
<div class="card-body py-2">
<div class="fs-4 fw-bold text-warning">@Model.AtRiskCount</div>
<div class="small text-muted">At Risk</div>
<div class="small text-muted">(3160 days)</div>
</div>
</div>
</div>
<div class="col-6 col-md">
<div class="card text-center border-orange" style="border-color:#fd7e14!important">
<div class="card-body py-2">
<div class="fs-4 fw-bold" style="color:#fd7e14">@Model.LapsingCount</div>
<div class="small text-muted">Lapsing</div>
<div class="small text-muted">(6190 days)</div>
</div>
</div>
</div>
<div class="col-6 col-md">
<div class="card text-center border-danger">
<div class="card-body py-2">
<div class="fs-4 fw-bold text-danger">@Model.ChurnedCount</div>
<div class="small text-muted">Churned</div>
<div class="small text-muted">(> 90 days)</div>
</div>
</div>
</div>
<div class="col-6 col-md">
<div class="card text-center border-secondary">
<div class="card-body py-2">
<div class="fs-4 fw-bold text-secondary">@Model.NeverOrderedCount</div>
<div class="small text-muted">Never Ordered</div>
<div class="small text-muted">&nbsp;</div>
</div>
</div>
</div>
</div>
@{
var statusOrder = new[] { "Churned", "Lapsing", "At Risk", "Active", "Never Ordered" };
var grouped = Model.Items.GroupBy(i => i.RetentionStatus).OrderBy(g => Array.IndexOf(statusOrder, g.Key));
}
@foreach (var group in grouped)
{
var badgeClass = group.Key switch {
"Active" => "bg-success",
"At Risk" => "bg-warning text-dark",
"Lapsing" => "bg-orange",
"Churned" => "bg-danger",
_ => "bg-secondary"
};
var headerClass = group.Key switch {
"Active" => "table-success",
"At Risk" => "table-warning",
"Lapsing" => "table-orange",
"Churned" => "table-danger",
_ => "table-secondary"
};
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center @headerClass">
<span class="fw-semibold">@group.Key</span>
<span class="badge @badgeClass">@group.Count()</span>
</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>Customer</th>
<th>Email</th>
<th>Phone</th>
<th class="text-end">Jobs</th>
<th class="text-end">Lifetime Revenue</th>
<th>Last Job</th>
<th class="text-end">Days Since</th>
</tr>
</thead>
<tbody>
@foreach (var item in group.OrderByDescending(i => i.LifetimeRevenue))
{
<tr>
<td>
<a asp-controller="Customers" asp-action="Details" asp-route-id="@item.CustomerId">
@item.CustomerName
</a>
</td>
<td class="small">@(item.Email ?? "—")</td>
<td class="small">@(item.Phone ?? "—")</td>
<td class="text-end">@item.TotalJobs</td>
<td class="text-end">@item.LifetimeRevenue.ToString("C")</td>
<td>@(item.LastJobDate?.ToString("MMM d, yyyy") ?? "—")</td>
<td class="text-end">
@if (item.DaysSinceLastJob < 0)
{
<span class="text-muted">—</span>
}
else
{
<span>@item.DaysSinceLastJob</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
@@ -0,0 +1,150 @@
@model PowderCoating.Web.ViewModels.Reports.ExpensesApViewModel
@{ ViewData["Title"] = "Expenses & AP"; }
<partial name="_ReportHeader" model="Model" />
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="card text-bg-primary">
<div class="card-body py-2">
<div class="small">Total Billed (AP)</div>
<div class="fs-5 fw-bold">@Model.TotalBilled.ToString("C")</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-bg-success">
<div class="card-body py-2">
<div class="small">Bills Paid</div>
<div class="fs-5 fw-bold">@Model.TotalBillsPaid.ToString("C")</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-bg-warning">
<div class="card-body py-2">
<div class="small">AP Outstanding</div>
<div class="fs-5 fw-bold">@Model.TotalApOutstanding.ToString("C")</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-secondary">
<div class="card-body py-2 text-center">
<div class="small text-muted">Direct Expenses</div>
<div class="fs-5 fw-bold">@Model.TotalDirectExpenses.ToString("C")</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-lg-8">
<div class="card h-100">
<div class="card-header fw-semibold">Monthly P&amp;L Overview</div>
<div class="card-body">
<canvas id="plChart" height="200"></canvas>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header fw-semibold">AP Aging</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr><th>Bucket</th><th class="text-end">Amount</th><th class="text-end">Bills</th></tr>
</thead>
<tbody>
@foreach (var b in Model.ApAgingBuckets)
{
<tr>
<td>@b.Label</td>
<td class="text-end fw-semibold">@b.Amount.ToString("C")</td>
<td class="text-end text-muted">@b.Count</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold">Vendor Spend</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr><th>Vendor</th><th class="text-end">Billed</th><th class="text-end">Paid</th><th class="text-end">Due</th><th class="text-end">Bills</th></tr>
</thead>
<tbody>
@foreach (var v in Model.VendorSpend)
{
<tr>
<td>@v.VendorName</td>
<td class="text-end">@v.TotalBilled.ToString("C")</td>
<td class="text-end text-success">@v.TotalPaid.ToString("C")</td>
<td class="text-end @(v.BalanceDue > 0 ? "text-warning fw-semibold" : "")">@v.BalanceDue.ToString("C")</td>
<td class="text-end text-muted">@v.BillCount</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold">Expenses by Account</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr><th>Account</th><th class="text-end">Amount</th><th class="text-end">Entries</th></tr>
</thead>
<tbody>
@foreach (var e in Model.ExpensesByAccount)
{
<tr>
<td class="small">@e.AccountName</td>
<td class="text-end fw-semibold">@e.Amount.ToString("C")</td>
<td class="text-end text-muted">@e.Count</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="~/lib/chartjs/chart.umd.min.js"></script>
<script>
(function () {
const labels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthLabels));
const revenue = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.PlRevenue));
const expenses = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.PlExpenses));
const net = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.PlNetIncome));
new Chart(document.getElementById('plChart'), {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Revenue', data: revenue, backgroundColor: 'rgba(25,135,84,0.7)' },
{ label: 'Expenses', data: expenses, backgroundColor: 'rgba(220,53,69,0.6)' },
{ label: 'Net', data: net, type: 'line', borderColor: '#0d6efd', backgroundColor: 'transparent', tension: 0.3, yAxisID: 'y' }
]
},
options: {
responsive: true,
plugins: { legend: { position: 'top' } },
scales: { y: { beginAtZero: false, ticks: { callback: v => '$' + v.toLocaleString() } } }
}
});
})();
</script>
}
@@ -0,0 +1,149 @@
@model PowderCoating.Web.ViewModels.Reports.FinancialSummaryViewModel
@{ ViewData["Title"] = "Financial Summary"; }
<partial name="_ReportHeader" model="Model" />
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="card text-bg-primary">
<div class="card-body py-2">
<div class="small">Total Invoiced</div>
<div class="fs-5 fw-bold">@Model.TotalInvoiced.ToString("C")</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-bg-success">
<div class="card-body py-2">
<div class="small">Total Collected</div>
<div class="fs-5 fw-bold">@Model.TotalCollected.ToString("C")</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-bg-warning">
<div class="card-body py-2">
<div class="small">Outstanding</div>
<div class="fs-5 fw-bold">@Model.TotalOutstanding.ToString("C")</div>
<div class="small">@Model.InvoicesOverdueCount overdue</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-bg-danger">
<div class="card-body py-2">
<div class="small">Overdue Balance</div>
<div class="fs-5 fw-bold">@Model.TotalOverdue.ToString("C")</div>
<div class="small">Avg @Model.AvgDaysToPayment.ToString("N0") days to pay</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-lg-8">
<div class="card h-100">
<div class="card-header fw-semibold">Monthly Invoiced vs Collected</div>
<div class="card-body">
<canvas id="monthlyChart" height="200"></canvas>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header fw-semibold">AR Aging Summary</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr><th>Bucket</th><th class="text-end">Amount</th><th class="text-end">Count</th></tr>
</thead>
<tbody>
@foreach (var b in Model.AgingBuckets)
{
<tr>
<td>@b.Label</td>
<td class="text-end fw-semibold">@b.Amount.ToString("C")</td>
<td class="text-end text-muted">@b.Count</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold">Top Outstanding Customers</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr><th>Customer</th><th class="text-end">Outstanding</th><th class="text-end">Invoices</th></tr>
</thead>
<tbody>
@foreach (var c in Model.TopOutstandingCustomers)
{
<tr>
<td>@c.CustomerName</td>
<td class="text-end fw-semibold text-warning">@c.OutstandingBalance.ToString("C")</td>
<td class="text-end">@c.OpenInvoiceCount</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold">Recent Payments</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr><th>Invoice</th><th>Customer</th><th class="text-end">Amount</th><th>Date</th></tr>
</thead>
<tbody>
@foreach (var p in Model.RecentPayments)
{
<tr>
<td class="small">@p.InvoiceNumber</td>
<td class="small">@p.CustomerName</td>
<td class="text-end text-success fw-semibold">@p.Amount.ToString("C")</td>
<td class="small">@p.PaymentDate.ToString("MMM d")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="~/lib/chartjs/chart.umd.min.js"></script>
<script>
(function () {
const labels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthLabels));
const invoiced = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthlyInvoiced));
const collected = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthlyCollected));
new Chart(document.getElementById('monthlyChart'), {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Invoiced', data: invoiced, backgroundColor: 'rgba(13,110,253,0.7)' },
{ label: 'Collected', data: collected, backgroundColor: 'rgba(25,135,84,0.7)' }
]
},
options: {
responsive: true,
plugins: { legend: { position: 'top' } },
scales: { y: { beginAtZero: true, ticks: { callback: v => '$' + v.toLocaleString() } } }
}
});
})();
</script>
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
@model PowderCoating.Web.ViewModels.Reports.InventoryTurnoverViewModel
@{ ViewData["Title"] = "Inventory Turnover"; }
<partial name="_ReportHeader" model="Model" />
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">Stock Consumption Rates</span>
<div class="d-flex gap-2 small">
<span class="badge bg-danger">Critical ≤ 7 days</span>
<span class="badge bg-warning text-dark">Low ≤ 30 days</span>
<span class="badge bg-success">Normal</span>
<span class="badge bg-info">Overstocked</span>
</div>
</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>Item / SKU</th>
<th>Color</th>
<th class="text-end">Current Stock (lbs)</th>
<th class="text-end">Consumed (lbs)</th>
<th class="text-end">Purchased (lbs)</th>
<th class="text-end">Daily Use (lbs)</th>
<th class="text-end">Days to Stockout</th>
<th class="text-end">Turnover Rate</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
var statusBadge = item.StockStatus switch {
"Critical" => "bg-danger",
"Low" => "bg-warning text-dark",
"Normal" => "bg-success",
"Overstocked" => "bg-info",
_ => "bg-secondary"
};
var daysClass = item.StockStatus switch {
"Critical" => "text-danger fw-bold",
"Low" => "text-warning fw-semibold",
_ => ""
};
<tr>
<td>
<div class="fw-semibold">@item.ItemName</div>
@if (!string.IsNullOrEmpty(item.SKU))
{
<div class="small text-muted">@item.SKU</div>
}
</td>
<td>@(item.ColorName ?? "—")</td>
<td class="text-end">@item.CurrentStockLbs.ToString("N1")</td>
<td class="text-end">@item.TotalConsumedLbs.ToString("N1")</td>
<td class="text-end">@item.TotalPurchasedLbs.ToString("N1")</td>
<td class="text-end">@item.DailyConsumptionLbs.ToString("N3")</td>
<td class="text-end @daysClass">
@(item.DaysToStockout >= 9999 ? "∞" : item.DaysToStockout.ToString("N0"))
</td>
<td class="text-end">@item.TurnoverRate.ToString("N2")x</td>
<td><span class="badge @statusBadge">@item.StockStatus</span></td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,110 @@
@model PowderCoating.Web.ViewModels.Reports.InvoiceAgingDetailViewModel
@{ ViewData["Title"] = "Invoice Aging Detail"; }
<partial name="_ReportHeader" model="Model" />
<div class="row g-3 mb-3">
<div class="col-sm-6 col-md-3">
<div class="card text-bg-danger">
<div class="card-body py-2">
<div class="small">Total Outstanding</div>
<div class="fs-5 fw-bold">@Model.TotalBalance.ToString("C")</div>
</div>
</div>
</div>
<div class="col-sm-6 col-md-3">
<div class="card border-secondary">
<div class="card-body py-2 text-center">
<div class="small text-muted">Open Invoices</div>
<div class="fs-5 fw-bold">@Model.Items.Count</div>
</div>
</div>
</div>
<div class="col-sm-6 col-md-3">
<div class="card border-warning">
<div class="card-body py-2 text-center">
<div class="small text-muted">Overdue (130 days)</div>
<div class="fs-5 fw-bold text-warning">
@Model.Items.Where(i => i.AgingBucket == "130 Days").Sum(i => i.BalanceDue).ToString("C")
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-md-3">
<div class="card border-danger">
<div class="card-body py-2 text-center">
<div class="small text-muted">Overdue (90+ days)</div>
<div class="fs-5 fw-bold text-danger">
@Model.Items.Where(i => i.AgingBucket == "90+ Days").Sum(i => i.BalanceDue).ToString("C")
</div>
</div>
</div>
</div>
</div>
<div class="card">
<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>Invoice #</th>
<th>Customer</th>
<th>Invoice Date</th>
<th>Due Date</th>
<th class="text-end">Total</th>
<th class="text-end">Paid</th>
<th class="text-end">Balance Due</th>
<th class="text-end">Days Overdue</th>
<th>Aging Bucket</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
var bucketClass = item.AgingBucket switch {
"Current" => "text-success",
"130 Days" => "text-warning",
"3160 Days" => "text-orange",
"6190 Days" => "text-danger",
"90+ Days" => "fw-bold text-danger",
_ => ""
};
<tr>
<td>
<a asp-controller="Invoices" asp-action="Details" asp-route-id="@item.InvoiceId">
@item.InvoiceNumber
</a>
</td>
<td>
@item.CustomerName
@if (!string.IsNullOrEmpty(item.CustomerEmail))
{
<div class="small text-muted">@item.CustomerEmail</div>
}
</td>
<td>@item.InvoiceDate.ToString("MMM d, yyyy")</td>
<td>@(item.DueDate?.ToString("MMM d, yyyy") ?? "—")</td>
<td class="text-end">@item.Total.ToString("C")</td>
<td class="text-end text-success">@item.AmountPaid.ToString("C")</td>
<td class="text-end fw-semibold">@item.BalanceDue.ToString("C")</td>
<td class="text-end @bucketClass">
@(item.DaysOverdue > 0 ? item.DaysOverdue.ToString() : "—")
</td>
<td><span class="badge @bucketClass bg-opacity-10 border">@item.AgingBucket</span></td>
<td><span class="badge bg-secondary-subtle text-secondary">@item.StatusDisplay</span></td>
</tr>
}
</tbody>
<tfoot class="table-light fw-bold">
<tr>
<td colspan="6">Total Outstanding</td>
<td class="text-end">@Model.TotalBalance.ToString("C")</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
@@ -0,0 +1,106 @@
@model PowderCoating.Web.ViewModels.Reports.JobCycleTimeViewModel
@{ ViewData["Title"] = "Job Cycle Time"; }
<partial name="_ReportHeader" model="Model" />
<div class="row g-3 mb-3">
<div class="col-sm-4">
<div class="card text-bg-primary">
<div class="card-body py-2">
<div class="small">Overall Avg Cycle Time</div>
<div class="fs-5 fw-bold">@Model.OverallAvgCycleDays.ToString("N1") days</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="card border-secondary">
<div class="card-body py-2 text-center">
<div class="small text-muted">Stages Tracked</div>
<div class="fs-5 fw-bold">@Model.Items.Count</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="card border-secondary">
<div class="card-body py-2 text-center">
<div class="small text-muted">Slowest Stage</div>
@if (Model.Items.Any())
{
var slowest = Model.Items.OrderByDescending(i => i.AvgDays).First();
<div class="fs-6 fw-bold text-warning">@slowest.StatusName (@slowest.AvgDays.ToString("N1") d)</div>
}
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold">Average Days per Stage</div>
<div class="card-body">
<canvas id="cycleChart" height="300"></canvas>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold">Stage Detail</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Stage</th>
<th class="text-end">Avg Days</th>
<th class="text-end">Min</th>
<th class="text-end">Max</th>
<th class="text-end">Jobs</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
<tr>
<td>@item.StatusName</td>
<td class="text-end fw-semibold">@item.AvgDays.ToString("N1")</td>
<td class="text-end text-success">@item.MinDays.ToString("N1")</td>
<td class="text-end text-danger">@item.MaxDays.ToString("N1")</td>
<td class="text-end text-muted">@item.JobCount</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="~/lib/chartjs/chart.umd.min.js"></script>
<script>
(function () {
const labels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.Items.Select(i => i.StatusName)));
const avgDays = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.Items.Select(i => i.AvgDays)));
const minDays = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.Items.Select(i => i.MinDays)));
const maxDays = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.Items.Select(i => i.MaxDays)));
new Chart(document.getElementById('cycleChart'), {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Avg Days', data: avgDays, backgroundColor: 'rgba(13,110,253,0.7)' },
{ label: 'Min Days', data: minDays, backgroundColor: 'rgba(25,135,84,0.5)' },
{ label: 'Max Days', data: maxDays, backgroundColor: 'rgba(220,53,69,0.4)' }
]
},
options: {
indexAxis: 'y',
responsive: true,
plugins: { legend: { position: 'top' } },
scales: { x: { beginAtZero: true, title: { display: true, text: 'Days' } } }
}
});
})();
</script>
}
@@ -0,0 +1,76 @@
@model PowderCoating.Web.ViewModels.Reports.JobStatusAgingViewModel
@{ ViewData["Title"] = "Job Status Aging"; }
<partial name="_ReportHeader" model="Model" />
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">Active Jobs by Days in Current Status</span>
<span class="text-muted small">@Model.Items.Count active jobs</span>
</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>Job #</th>
<th>Customer</th>
<th>Status</th>
<th>Priority</th>
<th class="text-end">Days in Status</th>
<th>Due Date</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
var rowClass = item.IsOverdue ? "table-danger" : item.DaysInCurrentStatus > 7 ? "table-warning" : "";
<tr class="@rowClass">
<td>
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@item.JobId">
@item.JobNumber
</a>
</td>
<td>@item.CustomerName</td>
<td><span class="badge bg-secondary">@item.StatusName</span></td>
<td>
@{
var pBadge = item.PriorityCode switch {
"RUSH" => "bg-danger",
"URGENT" => "bg-warning text-dark",
"HIGH" => "bg-orange text-white",
"LOW" => "bg-secondary",
_ => "bg-primary"
};
}
<span class="badge @pBadge">@item.PriorityName</span>
</td>
<td class="text-end fw-semibold">
@item.DaysInCurrentStatus day@(item.DaysInCurrentStatus != 1 ? "s" : "")
</td>
<td>
@if (item.DueDate.HasValue)
{
if (item.IsOverdue)
{
<span class="text-danger fw-semibold">
<i class="bi bi-exclamation-triangle me-1"></i>@item.DueDate.Value.ToString("MMM d, yyyy")
</span>
}
else
{
<span>@item.DueDate.Value.ToString("MMM d, yyyy")</span>
}
}
else
{
<span class="text-muted">—</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,165 @@
@model PowderCoating.Web.ViewModels.Reports.KpiDashboardViewModel
@{ ViewData["Title"] = "KPI Dashboard"; }
<partial name="_ReportHeader" model="Model" />
<div class="row g-3 mb-3">
<div class="col-6 col-xl-3">
<div class="card text-bg-primary">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="small opacity-75">Total Revenue</div>
<div class="fs-5 fw-bold">@Model.TotalRevenue.ToString("C")</div>
</div>
<i class="bi bi-currency-dollar fs-2 opacity-50"></i>
</div>
@if (Model.MonthOverMonthGrowth != 0)
{
<div class="small mt-1">
<i class="bi bi-@(Model.MonthOverMonthGrowth >= 0 ? "arrow-up" : "arrow-down")"></i>
@Math.Abs(Model.MonthOverMonthGrowth).ToString("N1")% MoM
</div>
}
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="card text-bg-success">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="small opacity-75">Active Jobs</div>
<div class="fs-5 fw-bold">@Model.ActiveJobsCount</div>
</div>
<i class="bi bi-briefcase fs-2 opacity-50"></i>
</div>
<div class="small mt-1">@Model.CompletedJobsThisMonth completed this month</div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="card text-bg-info">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="small opacity-75">Active Customers</div>
<div class="fs-5 fw-bold">@Model.ActiveCustomersCount</div>
</div>
<i class="bi bi-people fs-2 opacity-50"></i>
</div>
<div class="small mt-1">Quote win rate @Model.QuoteWinRate.ToString("N1")%</div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="card text-bg-warning">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="small opacity-75">Avg Job Value</div>
<div class="fs-5 fw-bold">@Model.AverageJobValue.ToString("C")</div>
</div>
<i class="bi bi-graph-up fs-2 opacity-50"></i>
</div>
<div class="small mt-1">@Model.AppointmentsThisMonth appts this month</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-lg-8">
<div class="card h-100">
<div class="card-header fw-semibold">Monthly Revenue</div>
<div class="card-body">
<canvas id="revenueChart" height="180"></canvas>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header fw-semibold">Jobs by Status</div>
<div class="card-body">
<canvas id="statusChart" height="220"></canvas>
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold">Top Customers by Revenue</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr><th>Customer</th><th class="text-end">Revenue</th><th class="text-end">Jobs</th></tr>
</thead>
<tbody>
@foreach (var c in Model.TopCustomers)
{
<tr>
<td>@c.Name</td>
<td class="text-end fw-semibold">@c.Revenue.ToString("C")</td>
<td class="text-end text-muted">@c.JobCount</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header fw-semibold">Equipment by Status</div>
<div class="card-body">
<canvas id="equipChart" height="200"></canvas>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="~/lib/chartjs/chart.umd.min.js"></script>
<script>
(function () {
const monthLabels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthLabels));
const revenue = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthlyRevenue));
new Chart(document.getElementById('revenueChart'), {
type: 'bar',
data: {
labels: monthLabels,
datasets: [
{ label: 'Revenue', data: revenue, backgroundColor: 'rgba(13,110,253,0.7)' }
]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { callback: v => '$' + v.toLocaleString() } } }
}
});
const statusLabels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.JobsByStatus.Keys));
const statusData = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.JobsByStatus.Values));
const palette = ['#0d6efd','#6610f2','#6f42c1','#d63384','#dc3545','#fd7e14','#ffc107','#198754','#20c997','#0dcaf0','#adb5bd','#343a40','#6c757d','#495057','#212529','#ced4da'];
new Chart(document.getElementById('statusChart'), {
type: 'doughnut',
data: { labels: statusLabels, datasets: [{ data: statusData, backgroundColor: palette }] },
options: { responsive: true, plugins: { legend: { position: 'right', labels: { font: { size: 10 } } } } }
});
const equipLabels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.EquipmentByStatus.Keys));
const equipData = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.EquipmentByStatus.Values));
new Chart(document.getElementById('equipChart'), {
type: 'bar',
data: { labels: equipLabels, datasets: [{ label: 'Equipment', data: equipData, backgroundColor: 'rgba(108,117,125,0.7)' }] },
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } } }
});
})();
</script>
}
@@ -0,0 +1,320 @@
@{
ViewData["Title"] = "Reports";
ViewData["PageIcon"] = "bi-bar-chart-line";
var allowAccounting = Context.Items["AllowAccounting"] as bool? ?? false;
}
@section Styles {
<style>
.report-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
.report-card {
display: flex;
flex-direction: column;
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0.875rem;
padding: 1.25rem;
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
}
.report-card:hover {
border-color: var(--bs-primary);
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.12);
transform: translateY(-2px);
color: inherit;
}
.report-card-icon {
width: 44px;
height: 44px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
margin-bottom: 0.75rem;
flex-shrink: 0;
}
.report-card h5 {
font-weight: 700;
font-size: 0.95rem;
margin-bottom: 0.3rem;
}
.report-card p {
font-size: 0.82rem;
color: var(--bs-secondary-color);
margin: 0;
line-height: 1.45;
flex: 1;
}
.report-card .report-arrow {
margin-top: 1rem;
font-size: 0.78rem;
font-weight: 600;
color: var(--bs-primary);
display: flex;
align-items: center;
gap: 0.3rem;
}
.section-heading {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--bs-secondary-color);
margin-bottom: 0.75rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--bs-border-color);
}
</style>
}
<!-- ══ SECTION 1: CHARTS & DASHBOARDS ═══════════════════════════════════ -->
<div class="mb-2">
<h5 class="fw-bold mb-1"><i class="bi bi-bar-chart-line me-2 text-primary"></i>Charts &amp; Dashboards</h5>
<p class="text-muted small mb-3">Interactive visuals, trend charts, and at-a-glance summaries.</p>
</div>
<!-- Overview -->
<div class="mb-4">
<div class="section-heading">Overview</div>
<div class="report-grid">
<a asp-controller="Reports" asp-action="KpiDashboard" class="report-card">
<div class="report-card-icon" style="background:#ede9fe;color:#6d28d9;">
<i class="bi bi-speedometer2"></i>
</div>
<h5>KPI Dashboard</h5>
<p>High-level KPIs — revenue, active jobs, customers, and job counts with monthly trends and equipment status.</p>
<div class="report-arrow">View dashboard <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="Analytics" class="report-card">
<div class="report-card-icon" style="background:#eff6ff;color:#2563eb;">
<i class="bi bi-graph-up"></i>
</div>
<h5>Charts &amp; Analytics</h5>
<p>Tabbed visual dashboard covering revenue trends, operations, customers, financials, and powder usage with interactive charts.</p>
<div class="report-arrow">View charts <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="CashFlowForecast" class="report-card">
<div class="report-card-icon" style="background:#f0fdf4;color:#16a34a;">
<i class="bi bi-cash-stack"></i>
</div>
<h5>Cash Flow Forecast</h5>
<p>AI-projected 30/60/90-day cash position based on open invoices, outstanding bills, and active job pipeline.</p>
<div class="report-arrow">View forecast <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="AnomalyDetection" class="report-card">
<div class="report-card-icon" style="background:#fffbeb;color:#b45309;">
<i class="bi bi-shield-exclamation"></i>
</div>
<h5>Anomaly Detection</h5>
<p>AI scans recent bills and expense trends for duplicate entries, unusual amounts, and accounts running over their historical average.</p>
<div class="report-arrow">Run analysis <i class="bi bi-arrow-right"></i></div>
</a>
</div>
</div>
<hr class="my-4" />
<!-- ══ SECTION 2: DETAILED REPORTS ══════════════════════════════════════ -->
<div class="mb-2">
<h5 class="fw-bold mb-1"><i class="bi bi-file-earmark-text me-2 text-secondary"></i>Detailed Reports</h5>
<p class="text-muted small mb-3">Printable data reports for accounting, operations, and inventory.</p>
</div>
@if (allowAccounting)
{
<!-- Finance -->
<div class="mb-4">
<div class="section-heading">Finance</div>
<div class="report-grid">
<a asp-controller="Reports" asp-action="FinancialSummary" class="report-card">
<div class="report-card-icon" style="background:#ecfdf5;color:#059669;">
<i class="bi bi-wallet2"></i>
</div>
<h5>Financial Summary</h5>
<p>Invoiced vs collected, outstanding balances, AR aging buckets, and recent payment activity.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="ProfitAndLoss" class="report-card">
<div class="report-card-icon" style="background:#f0fdf4;color:#16a34a;">
<i class="bi bi-file-earmark-bar-graph"></i>
</div>
<h5>Profit &amp; Loss</h5>
<p>Revenue, COGS, gross profit, and operating expenses for any date range. Exportable to PDF.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="ExpensesAp" class="report-card">
<div class="report-card-icon" style="background:#fff7ed;color:#c2410c;">
<i class="bi bi-receipt"></i>
</div>
<h5>Expenses &amp; AP</h5>
<p>Vendor spend, accounts payable aging, expense breakdown by account, and monthly P&amp;L comparison.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="ArAging" class="report-card">
<div class="report-card-icon" style="background:#fef9c3;color:#a16207;">
<i class="bi bi-clock-history"></i>
</div>
<h5>AR Aging</h5>
<p>Outstanding customer balances by age — current, 30, 60, and 90+ days. Exportable to PDF.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="InvoiceAgingDetail" class="report-card">
<div class="report-card-icon" style="background:#fef2f2;color:#dc2626;">
<i class="bi bi-exclamation-circle"></i>
</div>
<h5>Invoice Aging Detail</h5>
<p>Every open invoice with aging bucket, days overdue, contact info, and balance due.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="BalanceSheet" class="report-card">
<div class="report-card-icon" style="background:#f0f9ff;color:#0369a1;">
<i class="bi bi-bank"></i>
</div>
<h5>Balance Sheet</h5>
<p>Snapshot of assets, liabilities, and equity as of any date.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
</div>
</div>
}
<!-- Sales & Customers -->
<div class="mb-4">
<div class="section-heading">Sales &amp; Customers</div>
<div class="report-grid">
<a asp-controller="Reports" asp-action="SalesByCustomer" class="report-card">
<div class="report-card-icon" style="background:#f0fdf4;color:#15803d;">
<i class="bi bi-currency-dollar"></i>
</div>
<h5>Sales by Customer</h5>
<p>Revenue, collections, outstanding balance, and invoice count ranked per customer.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="SalesAndIncome" class="report-card">
<div class="report-card-icon" style="background:#ecfdf5;color:#059669;">
<i class="bi bi-bar-chart-steps"></i>
</div>
<h5>Sales &amp; Income</h5>
<p>Detailed breakdown of sales by customer, job type, and period.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="CustomerOverview" class="report-card">
<div class="report-card-icon" style="background:#eff6ff;color:#2563eb;">
<i class="bi bi-people"></i>
</div>
<h5>Customer Overview</h5>
<p>New customer acquisition trends, lifetime value rankings, and quote conversion funnel.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="CustomerRetention" class="report-card">
<div class="report-card-icon" style="background:#fef9c3;color:#b45309;">
<i class="bi bi-person-check"></i>
</div>
<h5>Customer Retention</h5>
<p>Customers segmented by recency — active, at risk, lapsing, churned, or never ordered.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
</div>
</div>
<!-- Operations -->
<div class="mb-4">
<div class="section-heading">Operations</div>
<div class="report-grid">
<a asp-controller="Reports" asp-action="OperationsReport" class="report-card">
<div class="report-card-icon" style="background:#f5f3ff;color:#7c3aed;">
<i class="bi bi-gear-wide-connected"></i>
</div>
<h5>Operations Report</h5>
<p>Job status breakdown, appointment metrics, worker performance, and equipment health.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="JobStatusAging" class="report-card">
<div class="report-card-icon" style="background:#fff7ed;color:#ea580c;">
<i class="bi bi-hourglass-split"></i>
</div>
<h5>Job Status Aging</h5>
<p>Active jobs ranked by days spent in their current status. Quickly spot bottlenecks and overdue work.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="JobCycleTime" class="report-card">
<div class="report-card-icon" style="background:#f0f9ff;color:#0284c7;">
<i class="bi bi-stopwatch"></i>
</div>
<h5>Job Cycle Time</h5>
<p>Average, min, and max days spent in each workflow stage for completed jobs.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="RevenueTrends" class="report-card">
<div class="report-card-icon" style="background:#ede9fe;color:#7c3aed;">
<i class="bi bi-trending-up"></i>
</div>
<h5>Revenue Trends</h5>
<p>Monthly revenue, job count, and average order value trends with breakdowns by customer type and priority.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
</div>
</div>
<!-- Inventory & Powder -->
<div class="mb-4">
<div class="section-heading">Inventory &amp; Powder</div>
<div class="report-grid">
<a asp-controller="Reports" asp-action="PowderUsage" class="report-card">
<div class="report-card-icon" style="background:#fdf4ff;color:#a21caf;">
<i class="bi bi-droplet-half"></i>
</div>
<h5>Powder Usage</h5>
<p>Powder consumption by color with monthly lbs and cost trends. See which colors drive the most usage.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="PowderConsumption" class="report-card">
<div class="report-card-icon" style="background:#f5f3ff;color:#6d28d9;">
<i class="bi bi-arrow-left-right"></i>
</div>
<h5>Powder Consumption vs Purchase</h5>
<p>Compare purchased vs consumed powder per SKU. Track waste, variance, and restocking needs.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="InventoryTurnover" class="report-card">
<div class="report-card-icon" style="background:#ecfdf5;color:#047857;">
<i class="bi bi-arrow-repeat"></i>
</div>
<h5>Inventory Turnover</h5>
<p>Daily consumption rates and days-to-stockout per item. Critical and low-stock items highlighted.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
</div>
</div>
@if ((bool)(ViewBag.SmsEnabled ?? false))
{
<!-- Compliance -->
<div class="mb-4">
<div class="section-heading">Compliance</div>
<div class="report-grid">
<a asp-controller="SmsConsentAudit" asp-action="Index" class="report-card">
<div class="report-card-icon" style="background:#f0f9ff;color:#0369a1;">
<i class="bi bi-phone-vibrate"></i>
</div>
<h5>SMS Consent Audit</h5>
<p>Per-customer TCPA consent status — who opted in, who opted out, and when. Export to CSV for compliance records.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
</div>
</div>
}
@@ -0,0 +1,172 @@
@model PowderCoating.Web.ViewModels.Reports.OperationsReportViewModel
@{ ViewData["Title"] = "Operations Report"; }
<partial name="_ReportHeader" model="Model" />
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="card text-bg-primary">
<div class="card-body py-2">
<div class="small">Active Jobs</div>
<div class="fs-5 fw-bold">@Model.ActiveJobsCount</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-bg-info">
<div class="card-body py-2">
<div class="small">Appointments (period)</div>
<div class="fs-5 fw-bold">@Model.TotalAppointments</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-bg-success">
<div class="card-body py-2">
<div class="small">Appt Completion Rate</div>
<div class="fs-5 fw-bold">@Model.AppointmentCompletionRate.ToString("N1")%</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-bg-warning">
<div class="card-body py-2">
<div class="small">Low Stock Items</div>
<div class="fs-5 fw-bold">@Model.LowStockItems.Count</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header fw-semibold">Jobs by Status</div>
<div class="card-body">
<canvas id="statusChart" height="250"></canvas>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header fw-semibold">Active Jobs by Priority</div>
<div class="card-body">
<canvas id="priorityChart" height="250"></canvas>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header fw-semibold">Appointments by Day of Week</div>
<div class="card-body">
<canvas id="apptDayChart" height="250"></canvas>
</div>
</div>
</div>
</div>
@if (Model.WorkerStats.Any())
{
<div class="card mb-3">
<div class="card-header fw-semibold">Worker Performance</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr><th>Worker</th><th>Role</th><th class="text-end">Jobs Assigned</th><th class="text-end">Jobs Completed</th><th class="text-end">Completion Rate</th><th class="text-end">Appts</th></tr>
</thead>
<tbody>
@foreach (var w in Model.WorkerStats)
{
<tr>
<td>@w.Name</td>
<td class="text-muted small">@w.Role</td>
<td class="text-end">@w.JobsAssigned</td>
<td class="text-end">@w.JobsCompleted</td>
<td class="text-end">
<div class="d-flex align-items-center justify-content-end gap-2">
<div class="progress flex-grow-1" style="height:6px;max-width:80px">
<div class="progress-bar bg-success" style="width:@w.CompletionRate%"></div>
</div>
@w.CompletionRate.ToString("N0")%
</div>
</td>
<td class="text-end">@w.AppointmentsAssigned</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@if (Model.LowStockItems.Any())
{
<div class="card">
<div class="card-header fw-semibold d-flex justify-content-between">
<span>Low Stock Alert</span>
<a asp-controller="Inventory" asp-action="Index" class="btn btn-sm btn-outline-warning">View Inventory</a>
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr><th>Item</th><th>Color</th><th class="text-end">On Hand</th><th class="text-end">Reorder Point</th><th>Unit</th><th>Status</th></tr>
</thead>
<tbody>
@foreach (var i in Model.LowStockItems)
{
<tr class="@(i.QuantityOnHand == 0 ? "table-danger" : "table-warning")">
<td>@i.Name</td>
<td>@(i.ColorName ?? "—")</td>
<td class="text-end fw-semibold text-danger">@i.QuantityOnHand.ToString("N1")</td>
<td class="text-end">@i.ReorderPoint.ToString("N1")</td>
<td class="text-muted small">@i.UnitOfMeasure</td>
<td>
@if (i.QuantityOnHand == 0)
{
<span class="badge bg-dark">Out of Stock</span>
}
else
{
<span class="badge bg-warning text-dark">Low Stock</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@section Scripts {
<script src="~/lib/chartjs/chart.umd.min.js"></script>
<script>
(function () {
const palette = ['#0d6efd','#6610f2','#6f42c1','#d63384','#dc3545','#fd7e14','#ffc107','#198754','#20c997','#0dcaf0','#adb5bd','#343a40','#6c757d','#495057','#212529','#ced4da'];
const statusLabels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.JobsByStatus.Keys));
const statusData = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.JobsByStatus.Values));
new Chart(document.getElementById('statusChart'), {
type: 'doughnut',
data: { labels: statusLabels, datasets: [{ data: statusData, backgroundColor: palette }] },
options: { responsive: true, plugins: { legend: { position: 'right', labels: { font: { size: 10 } } } } }
});
const prioLabels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.ActiveJobsByPriority.Keys));
const prioData = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.ActiveJobsByPriority.Values));
new Chart(document.getElementById('priorityChart'), {
type: 'bar',
data: { labels: prioLabels, datasets: [{ label: 'Jobs', data: prioData, backgroundColor: ['#6c757d','#0d6efd','#ffc107','#dc3545','#842029'] }] },
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } } }
});
const dayLabels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AppointmentsByDayOfWeek.Keys));
const dayData = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AppointmentsByDayOfWeek.Values));
new Chart(document.getElementById('apptDayChart'), {
type: 'bar',
data: { labels: dayLabels, datasets: [{ label: 'Appointments', data: dayData, backgroundColor: 'rgba(13,202,240,0.7)' }] },
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } } }
});
})();
</script>
}
@@ -0,0 +1,90 @@
@model PowderCoating.Web.ViewModels.Reports.PowderConsumptionViewModel
@{ ViewData["Title"] = "Powder Consumption vs Purchase"; }
<partial name="_ReportHeader" model="Model" />
<div class="row g-3 mb-3">
<div class="col-sm-4">
<div class="card text-bg-primary">
<div class="card-body py-2">
<div class="small">Total Purchased</div>
<div class="fs-5 fw-bold">@Model.TotalPurchasedLbs.ToString("N1") lbs</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="card text-bg-success">
<div class="card-body py-2">
<div class="small">Total Consumed</div>
<div class="fs-5 fw-bold">@Model.TotalConsumedLbs.ToString("N1") lbs</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="card @(Model.TotalPurchasedLbs - Model.TotalConsumedLbs >= 0 ? "text-bg-info" : "text-bg-warning")">
<div class="card-body py-2">
<div class="small">Net Variance</div>
<div class="fs-5 fw-bold">@((Model.TotalPurchasedLbs - Model.TotalConsumedLbs).ToString("N1")) lbs</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header fw-semibold">Purchased vs Consumed per SKU</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>Item / SKU</th>
<th>Color</th>
<th>Manufacturer</th>
<th class="text-end">Purchased (lbs)</th>
<th class="text-end">Consumed (lbs)</th>
<th class="text-end">Variance (lbs)</th>
<th class="text-end">Purchases</th>
<th class="text-end">Jobs Used</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
var varianceClass = item.VarianceLbs < 0 ? "text-danger" : item.VarianceLbs == 0 ? "" : "text-success";
<tr>
<td>
<div class="fw-semibold">@item.ItemName</div>
@if (!string.IsNullOrEmpty(item.SKU))
{
<div class="small text-muted">@item.SKU</div>
}
</td>
<td>
@(item.ColorName ?? "—")
@if (!string.IsNullOrEmpty(item.ColorCode))
{
<span class="badge bg-secondary-subtle text-secondary ms-1">@item.ColorCode</span>
}
</td>
<td class="text-muted">@(item.Manufacturer ?? "—")</td>
<td class="text-end">@item.TotalPurchasedLbs.ToString("N1")</td>
<td class="text-end">@item.TotalConsumedLbs.ToString("N1")</td>
<td class="text-end fw-semibold @varianceClass">@item.VarianceLbs.ToString("N1")</td>
<td class="text-end">@item.PurchaseCount</td>
<td class="text-end">@item.UsageJobCount</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-bold">
<tr>
<td colspan="3">Totals</td>
<td class="text-end">@Model.TotalPurchasedLbs.ToString("N1")</td>
<td class="text-end">@Model.TotalConsumedLbs.ToString("N1")</td>
<td class="text-end">@((Model.TotalPurchasedLbs - Model.TotalConsumedLbs).ToString("N1"))</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
@@ -0,0 +1,133 @@
@model PowderCoating.Web.ViewModels.Reports.PowderUsageViewModel
@{ ViewData["Title"] = "Powder Usage"; }
<partial name="_ReportHeader" model="Model" />
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="card text-bg-primary">
<div class="card-body py-2">
<div class="small">Total Powder Used</div>
<div class="fs-5 fw-bold">@Model.TotalPowderUsedLbs.ToString("N1") lbs</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-bg-success">
<div class="card-body py-2">
<div class="small">Total Powder Cost</div>
<div class="fs-5 fw-bold">@Model.TotalPowderCost.ToString("C")</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-secondary">
<div class="card-body py-2 text-center">
<div class="small text-muted">Jobs with Usage</div>
<div class="fs-5 fw-bold">@Model.TotalJobsWithPowderUsage</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-secondary">
<div class="card-body py-2 text-center">
<div class="small text-muted">Colors Used</div>
<div class="fs-5 fw-bold">@Model.ColorsUsedCount</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-lg-8">
<div class="card h-100">
<div class="card-header fw-semibold">Monthly Powder Usage</div>
<div class="card-body">
<canvas id="monthlyChart" height="200"></canvas>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header fw-semibold">Top Colors by Usage</div>
<div class="card-body">
<canvas id="colorChart" height="250"></canvas>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header fw-semibold">Usage by Color</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>Color</th>
<th>SKU</th>
<th>Manufacturer</th>
<th class="text-end">Total Used (lbs)</th>
<th class="text-end">Total Cost</th>
<th class="text-end">Jobs</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.TopColorsByUsage)
{
<tr>
<td>
@item.DisplayLabel
</td>
<td class="text-muted small">@(item.SKU ?? "—")</td>
<td class="text-muted small">@(item.Manufacturer ?? "—")</td>
<td class="text-end fw-semibold">@item.TotalLbsUsed.ToString("N1")</td>
<td class="text-end">@item.TotalCost.ToString("C")</td>
<td class="text-end">@item.JobCount</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@section Scripts {
<script src="~/lib/chartjs/chart.umd.min.js"></script>
<script>
(function () {
const monthLabels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthLabels));
const lbsData = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthlyPowderUsageLbs));
const costData = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthlyPowderUsageCost));
new Chart(document.getElementById('monthlyChart'), {
type: 'bar',
data: {
labels: monthLabels,
datasets: [
{ label: 'Lbs Used', data: lbsData, backgroundColor: 'rgba(13,110,253,0.7)', yAxisID: 'y' },
{ label: 'Cost ($)', data: costData, type: 'line', borderColor: 'rgba(220,53,69,0.9)', backgroundColor: 'transparent', yAxisID: 'y2', tension: 0.3 }
]
},
options: {
responsive: true,
plugins: { legend: { position: 'top' } },
scales: {
y: { beginAtZero: true, position: 'left', title: { display: true, text: 'lbs' } },
y2: { beginAtZero: true, position: 'right', title: { display: true, text: '$' }, grid: { drawOnChartArea: false } }
}
}
});
const topColors = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.TopColorsByUsage.Take(8).Select(c => c.DisplayLabel)));
const topLbs = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.TopColorsByUsage.Take(8).Select(c => c.TotalLbsUsed)));
const palette = ['#0d6efd','#6610f2','#6f42c1','#d63384','#dc3545','#fd7e14','#ffc107','#198754'];
new Chart(document.getElementById('colorChart'), {
type: 'doughnut',
data: { labels: topColors, datasets: [{ data: topLbs, backgroundColor: palette }] },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { font: { size: 11 } } } } }
});
})();
</script>
}
@@ -0,0 +1,216 @@
@model PowderCoating.Application.DTOs.Accounting.ProfitAndLossDto
@{
ViewData["Title"] = "Profit & Loss";
ViewData["PageIcon"] = "bi-graph-up-arrow";
var today = DateTime.Today;
var ytdFrom = new DateTime(today.Year, 1, 1).ToString("yyyy-MM-dd");
var ytdTo = today.ToString("yyyy-MM-dd");
var q1From = new DateTime(today.Year, 1, 1).ToString("yyyy-MM-dd");
var q1To = new DateTime(today.Year, 3, 31).ToString("yyyy-MM-dd");
var lastYrFrom = new DateTime(today.Year - 1, 1, 1).ToString("yyyy-MM-dd");
var lastYrTo = new DateTime(today.Year - 1, 12, 31).ToString("yyyy-MM-dd");
var thisMonthFrom = new DateTime(today.Year, today.Month, 1).ToString("yyyy-MM-dd");
var thisMonthTo = today.ToString("yyyy-MM-dd");
}
<style>
@@media print {
.no-print { display: none !important; }
.card { border: 1px solid #dee2e6 !important; box-shadow: none !important; }
body { font-size: 12px; }
}
.report-section-header { background: #f8f9fa; font-weight: 600; }
.report-total-row { border-top: 2px solid #dee2e6; font-weight: 700; }
.report-subtotal-row { border-top: 1px solid #dee2e6; font-weight: 600; }
.report-net-row { background: #e8f5e9; font-weight: 700; font-size: 1.05em; }
.report-net-negative { background: #fdecea; }
</style>
<!-- Header -->
<div class="d-flex align-items-center gap-2 mb-3 no-print">
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<p class="text-muted mb-0">Income Statement — @Model.From.ToString("MMM d") @Model.To.ToString("MMM d, yyyy")</p>
<div class="ms-auto d-flex gap-2">
<a href="@Url.Action("ProfitAndLossPdf", new { from = Model.From.ToString("yyyy-MM-dd"), to = Model.To.ToString("yyyy-MM-dd") })"
class="btn btn-sm btn-outline-danger no-print" target="_blank">
<i class="bi bi-file-pdf me-1"></i>Download PDF
</a>
<a href="@Url.Action("ProfitAndLossPdf", new { from = Model.From.ToString("yyyy-MM-dd"), to = Model.To.ToString("yyyy-MM-dd"), inline = true })"
class="btn btn-sm btn-outline-secondary no-print" target="_blank">
<i class="bi bi-printer me-1"></i>Print
</a>
</div>
</div>
<!-- Date filter -->
<div class="card shadow-sm mb-4 no-print">
<div class="card-body py-3">
<form method="get" class="row g-2 align-items-end">
<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>Run Report</button>
</div>
<div class="col-auto ms-2">
<div class="btn-group btn-group-sm">
<a href="@Url.Action("ProfitAndLoss", new { from = thisMonthFrom, to = thisMonthTo })" class="btn btn-outline-secondary">This Month</a>
<a href="@Url.Action("ProfitAndLoss", new { from = ytdFrom, to = ytdTo })" class="btn btn-outline-secondary">YTD</a>
<a href="@Url.Action("ProfitAndLoss", new { from = lastYrFrom, to = lastYrTo })" class="btn btn-outline-secondary">Last Year</a>
</div>
</div>
</form>
</div>
</div>
<!-- Print header -->
<div class="text-center mb-4 d-none d-print-block">
<h4 class="fw-bold">@Model.CompanyName</h4>
<h5>Profit &amp; Loss</h5>
<p class="text-muted">@Model.From.ToString("MMMM d, yyyy") @Model.To.ToString("MMMM d, yyyy")</p>
</div>
<!-- KPI Summary -->
<div class="row g-3 mb-4 no-print">
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h5 text-success mb-1">@Model.TotalRevenue.ToString("C")</div>
<div class="text-muted small">Total Revenue</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h5 text-warning mb-1">@Model.TotalCogs.ToString("C")</div>
<div class="text-muted small">Cost of Goods</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h5 text-danger mb-1">@Model.TotalExpenses.ToString("C")</div>
<div class="text-muted small">Operating Expenses</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center h-100 @(Model.NetIncome >= 0 ? "border-success border-opacity-50" : "border-danger border-opacity-50")">
<div class="card-body py-3">
<div class="h5 fw-bold @(Model.NetIncome >= 0 ? "text-success" : "text-danger") mb-1">@Model.NetIncome.ToString("C")</div>
<div class="text-muted small">Net Income</div>
</div>
</div>
</div>
</div>
<!-- P&L Statement -->
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold"><i class="bi bi-file-earmark-bar-graph me-1"></i>Income Statement</span>
<span class="text-muted small">@Model.From.ToString("MMM d") @Model.To.ToString("MMM d, yyyy")</span>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Account</th>
<th class="text-end" style="width:160px">Amount</th>
<th class="text-end" style="width:120px">% of Revenue</th>
</tr>
</thead>
<tbody>
<!-- Revenue -->
<tr class="report-section-header">
<td colspan="3" class="py-2"><i class="bi bi-graph-up-arrow text-success me-2"></i>Revenue</td>
</tr>
@if (!Model.RevenueLines.Any())
{
<tr><td colspan="3" class="text-muted ps-4 small">No revenue recorded for this period.</td></tr>
}
@foreach (var line in Model.RevenueLines)
{
<tr>
<td class="ps-4">@line.AccountNumber <span class="text-muted">@line.AccountName</span></td>
<td class="text-end">@line.Amount.ToString("C")</td>
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "—" : (line.Amount / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
</tr>
}
<tr class="report-subtotal-row">
<td class="ps-4 fw-semibold">Total Revenue</td>
<td class="text-end fw-semibold text-success">@Model.TotalRevenue.ToString("C")</td>
<td class="text-end text-muted small">100%</td>
</tr>
<!-- COGS -->
@if (Model.CogsLines.Any())
{
<tr><td colspan="3" class="py-1"></td></tr>
<tr class="report-section-header">
<td colspan="3" class="py-2"><i class="bi bi-box-seam text-warning me-2"></i>Cost of Goods Sold</td>
</tr>
@foreach (var line in Model.CogsLines)
{
<tr>
<td class="ps-4">@line.AccountNumber <span class="text-muted">@line.AccountName</span></td>
<td class="text-end">@line.Amount.ToString("C")</td>
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "—" : (line.Amount / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
</tr>
}
<tr class="report-subtotal-row">
<td class="ps-4 fw-semibold">Total COGS</td>
<td class="text-end fw-semibold text-warning">(@Model.TotalCogs.ToString("C"))</td>
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "—" : (Model.TotalCogs / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
</tr>
<tr class="report-subtotal-row">
<td class="ps-2 fw-semibold">Gross Profit</td>
<td class="text-end fw-semibold @(Model.GrossProfit >= 0 ? "text-success" : "text-danger")">@Model.GrossProfit.ToString("C")</td>
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "—" : Model.GrossMarginPercent.ToString("F1") + "%")</td>
</tr>
}
<!-- Expenses -->
<tr><td colspan="3" class="py-1"></td></tr>
<tr class="report-section-header">
<td colspan="3" class="py-2"><i class="bi bi-receipt-cutoff text-danger me-2"></i>Operating Expenses</td>
</tr>
@if (!Model.ExpenseLines.Any())
{
<tr><td colspan="3" class="text-muted ps-4 small">No expenses recorded for this period.</td></tr>
}
@foreach (var line in Model.ExpenseLines)
{
<tr>
<td class="ps-4">@line.AccountNumber <span class="text-muted">@line.AccountName</span></td>
<td class="text-end">@line.Amount.ToString("C")</td>
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "—" : (line.Amount / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
</tr>
}
<tr class="report-subtotal-row">
<td class="ps-4 fw-semibold">Total Expenses</td>
<td class="text-end fw-semibold text-danger">(@Model.TotalExpenses.ToString("C"))</td>
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "—" : (Model.TotalExpenses / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
</tr>
</tbody>
<tfoot>
<tr class="report-net-row @(Model.NetIncome < 0 ? "report-net-negative" : "")">
<td class="ps-2">Net Income</td>
<td class="text-end @(Model.NetIncome >= 0 ? "text-success" : "text-danger")">@Model.NetIncome.ToString("C")</td>
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "—" : (Model.NetIncome / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="text-muted small mt-2 no-print">
<i class="bi bi-info-circle me-1"></i>
Generated @DateTime.Now.ToString("MMM d, yyyy h:mm tt") · Accrual basis · Revenue from sent/paid invoices; expenses from bills and direct expenses.
</div>
@@ -0,0 +1,131 @@
@model PowderCoating.Web.ViewModels.Reports.RevenueTrendsViewModel
@{ ViewData["Title"] = "Revenue Trends"; }
<partial name="_ReportHeader" model="Model" />
<div class="row g-3 mb-3">
<div class="col-6 col-md-4">
<div class="card text-bg-primary">
<div class="card-body py-2">
<div class="small">Total Revenue</div>
<div class="fs-5 fw-bold">@Model.TotalRevenue.ToString("C")</div>
</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="card border-secondary">
<div class="card-body py-2 text-center">
<div class="small text-muted">Completed Jobs</div>
<div class="fs-5 fw-bold">@Model.TotalCompletedJobs</div>
</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="card border-secondary">
<div class="card-body py-2 text-center">
<div class="small text-muted">Avg Job Value</div>
<div class="fs-5 fw-bold">@Model.AverageJobValue.ToString("C")</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-12">
<div class="card">
<div class="card-header fw-semibold">Revenue & Job Count by Month</div>
<div class="card-body">
<canvas id="revenueChart" height="160"></canvas>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header fw-semibold">Revenue by Customer Type</div>
<div class="card-body">
<canvas id="typeChart" height="220"></canvas>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header fw-semibold">Revenue by Priority</div>
<div class="card-body">
<canvas id="priorityChart" height="220"></canvas>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header fw-semibold">Top Customers</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr><th>Customer</th><th class="text-end">Revenue</th><th class="text-end">Jobs</th></tr>
</thead>
<tbody>
@foreach (var c in Model.TopCustomers)
{
<tr>
<td class="small">@c.Name</td>
<td class="text-end fw-semibold">@c.Revenue.ToString("C")</td>
<td class="text-end text-muted">@c.JobCount</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="~/lib/chartjs/chart.umd.min.js"></script>
<script>
(function () {
const labels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthLabels));
const revenue = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthlyRevenue));
const jobs = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.MonthlyJobCount));
const avgOv = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AverageOrderValueTrend));
new Chart(document.getElementById('revenueChart'), {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Revenue ($)', data: revenue, backgroundColor: 'rgba(13,110,253,0.7)', yAxisID: 'y' },
{ label: 'Jobs', data: jobs, type: 'line', borderColor: '#ffc107', backgroundColor: 'transparent', yAxisID: 'y2', tension: 0.3 },
{ label: 'Avg Order ($)', data: avgOv, type: 'line', borderColor: '#20c997', backgroundColor: 'transparent', yAxisID: 'y', tension: 0.3, borderDash: [5,5] }
]
},
options: {
responsive: true,
plugins: { legend: { position: 'top' } },
scales: {
y: { beginAtZero: true, position: 'left', ticks: { callback: v => '$' + v.toLocaleString() } },
y2: { beginAtZero: true, position: 'right', grid: { drawOnChartArea: false } }
}
}
});
const typeLabels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.RevenueByCustomerType.Keys));
const typeData = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.RevenueByCustomerType.Values));
new Chart(document.getElementById('typeChart'), {
type: 'pie',
data: { labels: typeLabels, datasets: [{ data: typeData, backgroundColor: ['#0d6efd','#6f42c1','#20c997','#fd7e14'] }] },
options: { responsive: true, plugins: { legend: { position: 'bottom' } } }
});
const prioLabels = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.RevenueByPriority.Keys));
const prioData = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.RevenueByPriority.Values));
new Chart(document.getElementById('priorityChart'), {
type: 'bar',
data: { labels: prioLabels, datasets: [{ label: 'Revenue', data: prioData, backgroundColor: ['#6c757d','#0d6efd','#ffc107','#dc3545','#842029'] }] },
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: v => '$' + v.toLocaleString() } } } }
});
})();
</script>
}
@@ -0,0 +1,343 @@
@model PowderCoating.Application.DTOs.Accounting.SalesIncomeReportDto
@{
ViewData["Title"] = "Sales & Income";
ViewData["PageIcon"] = "bi-currency-dollar";
var today = DateTime.Today;
var ytdFrom = new DateTime(today.Year, 1, 1).ToString("yyyy-MM-dd");
var ytdTo = today.ToString("yyyy-MM-dd");
var lastYrFrom = new DateTime(today.Year - 1, 1, 1).ToString("yyyy-MM-dd");
var lastYrTo = new DateTime(today.Year - 1, 12, 31).ToString("yyyy-MM-dd");
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 monthLabels = Model.ByMonth.Select(m => m.Label).ToList();
var monthInvoiced = Model.ByMonth.Select(m => m.TotalInvoiced).ToList();
var monthCollected = Model.ByMonth.Select(m => m.TotalCollected).ToList();
}
<style>
@@media print {
.no-print { display: none !important; }
.card { border: 1px solid #dee2e6 !important; box-shadow: none !important; }
body { font-size: 11px; }
}
</style>
<!-- Header -->
<div class="d-flex align-items-center gap-2 mb-3 no-print">
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<p class="text-muted mb-0">@Model.From.ToString("MMM d") @Model.To.ToString("MMM d, yyyy") · @Model.InvoiceCount invoices · @Model.CustomerCount customers</p>
<div class="ms-auto d-flex gap-2">
<a href="@Url.Action("SalesAndIncomePdf", new { from = Model.From.ToString("yyyy-MM-dd"), to = Model.To.ToString("yyyy-MM-dd") })"
class="btn btn-sm btn-outline-danger no-print" target="_blank">
<i class="bi bi-file-pdf me-1"></i>Download PDF
</a>
<a href="@Url.Action("SalesAndIncomePdf", new { from = Model.From.ToString("yyyy-MM-dd"), to = Model.To.ToString("yyyy-MM-dd"), inline = true })"
class="btn btn-sm btn-outline-secondary no-print" target="_blank">
<i class="bi bi-printer me-1"></i>Print
</a>
</div>
</div>
<!-- Date filter -->
<div class="card shadow-sm mb-4 no-print">
<div class="card-body py-3">
<form method="get" class="row g-2 align-items-end">
<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>Run Report</button>
</div>
<div class="col-auto ms-2">
<div class="btn-group btn-group-sm">
<a href="@Url.Action("SalesAndIncome", new { from = thisMonthFrom, to = thisMonthTo })" class="btn btn-outline-secondary">This Month</a>
<a href="@Url.Action("SalesAndIncome", new { from = lastMonthFrom, to = lastMonthTo })" class="btn btn-outline-secondary">Last Month</a>
<a href="@Url.Action("SalesAndIncome", new { from = ytdFrom, to = ytdTo })" class="btn btn-outline-secondary">YTD</a>
<a href="@Url.Action("SalesAndIncome", new { from = lastYrFrom, to = lastYrTo })" class="btn btn-outline-secondary">Last Year</a>
</div>
</div>
</form>
</div>
</div>
<!-- Print header -->
<div class="text-center mb-4 d-none d-print-block">
<h4 class="fw-bold">@Model.CompanyName</h4>
<h5>Sales &amp; Income Report</h5>
<p class="text-muted">@Model.From.ToString("MMMM d, yyyy") @Model.To.ToString("MMMM d, yyyy")</p>
</div>
<!-- KPI Cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h5 fw-bold mb-1">@Model.TotalInvoiced.ToString("C")</div>
<div class="text-muted small">Total Invoiced</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h5 fw-bold text-success mb-1">@Model.TotalCollected.ToString("C")</div>
<div class="text-muted small">Collected (period)</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h5 fw-bold mb-1">@Model.AverageInvoiceValue.ToString("C")</div>
<div class="text-muted small">Avg Invoice Value</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center h-100">
<div class="card-body py-3">
<div class="h5 fw-bold text-primary mb-1">@Model.CustomerCount</div>
<div class="text-muted small">Active Customers</div>
</div>
</div>
</div>
</div>
@if (!Model.Invoices.Any())
{
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-receipt fs-1 d-block mb-2"></i>
<p class="mb-0">No invoices found for this period.</p>
</div>
</div>
}
else
{
<div class="row g-4 mb-4">
<!-- Monthly trend chart -->
@if (Model.ByMonth.Count > 1)
{
<div class="col-lg-8 no-print">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold">
<i class="bi bi-bar-chart me-1"></i>Monthly Sales Trend
</div>
<div class="card-body">
<canvas id="salesTrendChart" height="120"></canvas>
</div>
</div>
</div>
}
<!-- By month table -->
<div class="col-lg-@(Model.ByMonth.Count > 1 ? "4" : "12")">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold"><i class="bi bi-calendar3 me-1"></i>By Month</div>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Month</th>
<th class="text-end">Invoiced</th>
<th class="text-end">Collected</th>
<th class="text-center">#</th>
</tr>
</thead>
<tbody>
@foreach (var m in Model.ByMonth)
{
<tr>
<td>@m.Label</td>
<td class="text-end">@m.TotalInvoiced.ToString("C")</td>
<td class="text-end text-success">@m.TotalCollected.ToString("C")</td>
<td class="text-center text-muted small">@m.InvoiceCount</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td>Total</td>
<td class="text-end">@Model.TotalInvoiced.ToString("C")</td>
<td class="text-end text-success">@Model.TotalCollected.ToString("C")</td>
<td class="text-center">@Model.InvoiceCount</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
<!-- By Customer -->
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold"><i class="bi bi-people me-1"></i>Sales by Customer</div>
<div class="table-responsive">
<table class="table table-hover table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Customer</th>
<th class="text-center">Invoices</th>
<th class="text-end">Total Invoiced</th>
<th class="text-end">Paid</th>
<th class="text-end">Balance Due</th>
<th class="text-end no-print">% of Sales</th>
</tr>
</thead>
<tbody>
@foreach (var c in Model.ByCustomer)
{
<tr>
<td>
<a asp-controller="Customers" asp-action="Details" asp-route-id="@c.CustomerId" class="text-decoration-none fw-medium">
@c.CustomerName
</a>
</td>
<td class="text-center text-muted small">@c.InvoiceCount</td>
<td class="text-end fw-semibold">@c.TotalInvoiced.ToString("C")</td>
<td class="text-end text-success">@c.TotalPaid.ToString("C")</td>
<td class="text-end @(c.BalanceDue > 0 ? "text-warning" : "text-muted")">@c.BalanceDue.ToString("C")</td>
<td class="text-end text-muted small no-print">@(Model.TotalInvoiced == 0 ? "—" : (c.TotalInvoiced / Model.TotalInvoiced * 100).ToString("F1") + "%")</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td>Total (@Model.CustomerCount customers)</td>
<td class="text-center">@Model.InvoiceCount</td>
<td class="text-end">@Model.TotalInvoiced.ToString("C")</td>
<td class="text-end text-success">@Model.TotalCollected.ToString("C")</td>
<td class="text-end">@Model.Invoices.Sum(i => i.BalanceDue).ToString("C")</td>
<td class="no-print"></td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Invoice detail -->
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold"><i class="bi bi-receipt me-1"></i>Invoice Detail</span>
<span class="badge bg-secondary">@Model.InvoiceCount invoices</span>
</div>
<div class="table-responsive">
<table class="table table-hover table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Invoice</th>
<th>Customer</th>
<th>Date</th>
<th>Due</th>
<th class="text-end">Subtotal</th>
<th class="text-end">Tax</th>
<th class="text-end">Total</th>
<th class="text-end">Paid</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach (var inv in Model.Invoices)
{
string statusBadge = inv.Status switch
{
"Paid" => "bg-success-subtle text-success",
"PartiallyPaid" => "bg-warning-subtle text-warning",
"Sent" => "bg-info-subtle text-info",
"Overdue" => "bg-danger-subtle text-danger",
_ => "bg-secondary-subtle text-secondary"
};
<tr>
<td>
<a asp-controller="Invoices" asp-action="Details" asp-route-id="@inv.InvoiceId" class="text-decoration-none fw-medium">
@inv.InvoiceNumber
</a>
</td>
<td class="text-muted small">@inv.CustomerName</td>
<td class="text-muted small">@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
<td class="text-muted small">@(inv.DueDate?.ToString("MM/dd/yyyy") ?? "—")</td>
<td class="text-end">@inv.SubTotal.ToString("C")</td>
<td class="text-end text-muted small">@(inv.TaxAmount > 0 ? inv.TaxAmount.ToString("C") : "—")</td>
<td class="text-end fw-semibold">@inv.Total.ToString("C")</td>
<td class="text-end text-success">@inv.AmountPaid.ToString("C")</td>
<td><span class="badge @statusBadge">@inv.Status</span></td>
</tr>
}
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td colspan="4">Totals</td>
<td class="text-end">@Model.Invoices.Sum(i => i.SubTotal).ToString("C")</td>
<td class="text-end text-muted">@Model.TotalTax.ToString("C")</td>
<td class="text-end">@Model.TotalInvoiced.ToString("C")</td>
<td class="text-end text-success">@Model.Invoices.Sum(i => i.AmountPaid).ToString("C")</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<div class="text-muted small mt-2 no-print">
<i class="bi bi-info-circle me-1"></i>
Generated @DateTime.Now.ToString("MMM d, yyyy h:mm tt") · Excludes Draft and Voided invoices. "Collected" reflects payments received within the period, regardless of invoice date.
</div>
@if (Model.ByMonth.Count > 1)
{
@section Scripts {
<script src="~/lib/chartjs/chart.umd.min.js"></script>
<script>
(function() {
const isDark = document.documentElement.getAttribute('data-bs-theme') === 'dark';
const gridColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)';
const textColor = isDark ? '#adb5bd' : '#6c757d';
new Chart(document.getElementById('salesTrendChart'), {
type: 'bar',
data: {
labels: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(monthLabels)),
datasets: [
{
label: 'Invoiced',
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(monthInvoiced)),
backgroundColor: 'rgba(79,70,229,0.7)',
borderRadius: 4,
order: 2
},
{
label: 'Collected',
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(monthCollected)),
type: 'line',
borderColor: '#10b981',
backgroundColor: 'rgba(16,185,129,0.1)',
borderWidth: 2,
pointRadius: 4,
fill: false,
tension: 0.3,
order: 1
}
]
},
options: {
responsive: true,
plugins: { legend: { display: true }, tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.y.toLocaleString('en-US', {minimumFractionDigits:2}) } } },
scales: {
y: { ticks: { color: textColor, callback: v => '$' + v.toLocaleString() }, grid: { color: gridColor } },
x: { ticks: { color: textColor }, grid: { display: false } }
}
}
});
})();
</script>
}
}
@@ -0,0 +1,95 @@
@model PowderCoating.Web.ViewModels.Reports.SalesByCustomerViewModel
@{ ViewData["Title"] = "Sales by Customer"; }
<partial name="_ReportHeader" model="Model" />
<div class="row g-3 mb-3">
<div class="col-sm-4">
<div class="card text-bg-primary">
<div class="card-body py-2">
<div class="small">Total Invoiced</div>
<div class="fs-5 fw-bold">@Model.TotalInvoiced.ToString("C")</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="card text-bg-success">
<div class="card-body py-2">
<div class="small">Total Collected</div>
<div class="fs-5 fw-bold">@Model.TotalPaid.ToString("C")</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="card text-bg-warning">
<div class="card-body py-2">
<div class="small">Outstanding Balance</div>
<div class="fs-5 fw-bold">@((Model.TotalInvoiced - Model.TotalPaid).ToString("C"))</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">Customer Sales Summary</span>
<span class="text-muted small">@Model.Items.Count customers</span>
</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>Customer</th>
<th>Type</th>
<th class="text-end">Invoices</th>
<th class="text-end">Total Invoiced</th>
<th class="text-end">Total Paid</th>
<th class="text-end">Balance Due</th>
<th class="text-end">Avg Invoice</th>
<th>Last Invoice</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
<tr>
<td>
<a asp-controller="Customers" asp-action="Details" asp-route-id="@item.CustomerId">
@item.CustomerName
</a>
</td>
<td>
@if (item.IsCommercial)
{
<span class="badge bg-info-subtle text-info">Commercial</span>
}
else
{
<span class="badge bg-secondary-subtle text-secondary">Individual</span>
}
</td>
<td class="text-end">@item.InvoiceCount</td>
<td class="text-end fw-semibold">@item.TotalInvoiced.ToString("C")</td>
<td class="text-end text-success">@item.TotalPaid.ToString("C")</td>
<td class="text-end @(item.BalanceDue > 0 ? "text-warning fw-semibold" : "")">
@item.BalanceDue.ToString("C")
</td>
<td class="text-end">@item.AvgInvoiceValue.ToString("C")</td>
<td>@(item.LastInvoiceDate?.ToString("MMM d, yyyy") ?? "—")</td>
</tr>
}
</tbody>
<tfoot class="table-light fw-bold">
<tr>
<td colspan="3">Totals</td>
<td class="text-end">@Model.TotalInvoiced.ToString("C")</td>
<td class="text-end text-success">@Model.TotalPaid.ToString("C")</td>
<td class="text-end">@((Model.TotalInvoiced - Model.TotalPaid).ToString("C"))</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>