Initial commit
This commit is contained in:
@@ -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 5–10 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 · <span id="analysisTimestamp"></span>
|
||||
· <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">1–30 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">31–60 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">61–90 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">1–30 Days</th>
|
||||
<th class="text-end">31–60 Days</th>
|
||||
<th class="text-end">61–90 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 & 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 5–10 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 · <span id="forecastTimestamp"></span>
|
||||
· <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">(31–60 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">(61–90 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"> </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&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 (1–30 days)</div>
|
||||
<div class="fs-5 fw-bold text-warning">
|
||||
@Model.Items.Where(i => i.AgingBucket == "1–30 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",
|
||||
"1–30 Days" => "text-warning",
|
||||
"31–60 Days" => "text-orange",
|
||||
"61–90 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 & 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 & 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 & 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 & AP</h5>
|
||||
<p>Vendor spend, accounts payable aging, expense breakdown by account, and monthly P&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 & 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 & 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 & 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 & 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 & 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>
|
||||
Reference in New Issue
Block a user