Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,282 @@
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
@{
var token = Antiforgery.GetAndStoreTokens(Context).RequestToken;
}
<!-- AI Help Widget -->
<div id="ai-help-widget" class="ai-help-widget" aria-live="polite" aria-label="AI Help Assistant">
<!-- Trigger button -->
<button id="ai-help-btn" class="ai-help-trigger" title="Ask AI Help Assistant" aria-label="Open AI Help Assistant">
<i class="bi bi-stars fs-5"></i>
<span class="ai-help-label">Help</span>
</button>
<!-- Chat panel -->
<div id="ai-help-panel" class="ai-help-panel" role="dialog" aria-modal="true" aria-label="AI Help Assistant" hidden>
<!-- Drag-to-resize handle -->
<div id="ai-help-resize" class="ai-help-resize" title="Drag to resize"></div>
<div class="ai-help-header">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-stars text-warning"></i>
<span class="fw-semibold">AI Help Assistant</span>
<span class="badge bg-secondary" style="font-size:0.65rem;">Beta</span>
</div>
<div class="d-flex gap-1">
<button id="ai-help-clear" class="btn btn-sm btn-outline-secondary py-0 px-2" title="Start new chat" style="font-size:0.75rem;">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
<button id="ai-help-close" class="btn btn-sm btn-outline-secondary py-0 px-2" title="Close" aria-label="Close AI Help">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div id="ai-help-messages" class="ai-help-messages">
<!-- Welcome message -->
<div class="ai-help-msg ai-help-msg--assistant">
<div class="ai-help-msg-bubble">
<p class="mb-1">Hi! I'm your AI Help Assistant. I can answer questions like:</p>
<ul class="mb-2 ps-3" style="font-size:0.85rem;">
<li>Where do I find invoices?</li>
<li>How do I convert a quote to a job?</li>
<li>Can I track deposits?</li>
<li>What does job status "Curing" mean?</li>
</ul>
<p class="mb-0" style="font-size:0.85rem;">What can I help you with?</p>
</div>
</div>
</div>
<!-- Suggested starters (hidden after first message) -->
<div id="ai-help-starters" class="ai-help-starters">
<button class="ai-help-starter-btn" data-q="Where do I find invoices?">Find invoices</button>
<button class="ai-help-starter-btn" data-q="How do I create a quote?">Create a quote</button>
<button class="ai-help-starter-btn" data-q="How do I add a customer?">Add a customer</button>
<button class="ai-help-starter-btn" data-q="What are the job statuses?">Job statuses</button>
</div>
<div class="ai-help-input-area">
<div class="ai-help-typing d-none" id="ai-help-typing">
<span class="ai-help-typing-dot"></span>
<span class="ai-help-typing-dot"></span>
<span class="ai-help-typing-dot"></span>
</div>
<div class="d-flex gap-2">
<textarea id="ai-help-input"
class="form-control form-control-sm"
placeholder="Ask a question..."
rows="2"
maxlength="1000"
aria-label="Your question"></textarea>
<button id="ai-help-send" class="btn btn-primary btn-sm align-self-end" aria-label="Send">
<i class="bi bi-send-fill"></i>
</button>
</div>
</div>
</div>
</div>
<input type="hidden" id="ai-help-token" value="@token" />
<script src="~/js/ai-help-widget.js" asp-append-version="true"></script>
<style>
.ai-help-widget {
position: fixed;
bottom: 80px;
right: 24px;
z-index: 1050;
font-family: inherit;
}
.ai-help-trigger {
display: flex;
align-items: center;
gap: 6px;
background: #1A1A1C; /* fixed graphite — never flips with surface */
color: #FAFAF7;
border: none;
border-radius: 50px;
padding: 10px 18px;
box-shadow: 0 4px 14px rgba(0,0,0,0.35);
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
}
.ai-help-trigger:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0,0,0,0.4);
background: oklch(0.68 0.17 50); /* ember */
color: #fff;
}
.ai-help-panel {
position: absolute;
bottom: 60px;
right: 0;
width: 360px;
height: 520px;
min-height: 300px;
max-height: 85vh;
display: flex;
flex-direction: column;
background: var(--bs-body-bg, #fff);
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0,0,0,0.15);
overflow: hidden;
}
.ai-help-panel[hidden] { display: none !important; }
.ai-help-resize {
height: 6px;
cursor: ns-resize;
background: transparent;
flex-shrink: 0;
border-radius: 12px 12px 0 0;
position: relative;
}
.ai-help-resize::after {
content: '';
position: absolute;
left: 50%;
top: 2px;
transform: translateX(-50%);
width: 32px;
height: 3px;
border-radius: 2px;
background: var(--bs-border-color, #dee2e6);
transition: background 0.15s;
}
.ai-help-resize:hover::after,
.ai-help-resize.dragging::after {
background: var(--bs-secondary-color, #6c757d);
}
.ai-help-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: var(--bs-secondary-bg, #f8f9fa);
border-bottom: 1px solid var(--bs-border-color, #dee2e6);
flex-shrink: 0;
}
.ai-help-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
scroll-behavior: smooth;
}
.ai-help-msg { display: flex; }
.ai-help-msg--user { justify-content: flex-end; }
.ai-help-msg--assistant { justify-content: flex-start; }
.ai-help-msg-bubble {
max-width: 85%;
padding: 8px 12px;
border-radius: 12px;
font-size: 0.875rem;
line-height: 1.5;
word-break: break-word;
}
.ai-help-msg--user .ai-help-msg-bubble {
background: #1A1A1C; /* fixed — matches trigger */
color: #FAFAF7;
border-bottom-right-radius: 4px;
}
.ai-help-msg--assistant .ai-help-msg-bubble {
background: var(--bs-secondary-bg, #f1f3f5);
color: var(--bs-body-color, #212529);
border-bottom-left-radius: 4px;
}
/* Markdown-style rendering in assistant bubbles */
.ai-help-msg--assistant .ai-help-msg-bubble a {
color: var(--bs-primary, #0d6efd);
text-decoration: underline;
}
.ai-help-msg--assistant .ai-help-msg-bubble ul,
.ai-help-msg--assistant .ai-help-msg-bubble ol {
margin-bottom: 0.5rem;
padding-left: 1.25rem;
}
.ai-help-msg--assistant .ai-help-msg-bubble p:last-child { margin-bottom: 0; }
.ai-help-starters {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--bs-border-color, #dee2e6);
flex-shrink: 0;
}
.ai-help-starter-btn {
font-size: 0.75rem;
padding: 3px 10px;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 20px;
background: transparent;
color: var(--bs-body-color);
cursor: pointer;
white-space: nowrap;
transition: background 0.1s;
}
.ai-help-starter-btn:hover {
background: var(--bs-secondary-bg, #f1f3f5);
}
.ai-help-input-area {
padding: 10px 12px;
border-top: 1px solid var(--bs-border-color, #dee2e6);
flex-shrink: 0;
}
.ai-help-typing {
display: flex;
gap: 4px;
padding: 4px 0 6px;
align-items: center;
}
.ai-help-typing-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--bs-secondary-color, #6c757d);
animation: ai-help-bounce 1.2s infinite ease-in-out;
}
.ai-help-typing-dot:nth-child(2) { animation-delay: 0.2s; }
.ai-help-typing-dot:nth-child(3) { animation-delay: 0.4s; }
@@keyframes ai-help-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; }
40% { transform: scale(1); opacity: 1; }
}
/* Mobile responsiveness */
@@media (max-width: 480px) {
.ai-help-widget { bottom: 16px; right: 16px; }
.ai-help-panel { width: calc(100vw - 32px); right: 0; }
.ai-help-label { display: none; }
.ai-help-trigger { padding: 10px 14px; }
}
</style>
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - Powder Coating Logix</title>
<!-- Bootstrap 5 CSS -->
<link href="~/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="~/lib/bootstrap-icons/font/bootstrap-icons.css">
<!-- Toastr CSS -->
<link rel="stylesheet" href="~/lib/toastr/toastr.min.css" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f8fafc;
color: #1e293b;
}
:root {
--primary-color: #4f46e5;
--primary-hover: #4338ca;
--accent-color: #4fc3f7;
}
.btn-primary {
background-color: var(--primary-color);
border-color: var(--primary-color);
font-weight: 500;
}
.btn-primary:hover {
background-color: var(--primary-hover);
border-color: var(--primary-hover);
}
.form-control:focus, .form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(79, 70, 229, 0.15);
}
.card {
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
</style>
@await RenderSectionAsync("Styles", required: false)
</head>
<body>
@* Hidden containers for TempData messages (read by toast-notifications.js) *@
@if (TempData["Success"] != null)
{
<div id="tempdata-success-message" style="display:none;">@TempData["Success"]</div>
}
@if (TempData["Error"] != null)
{
<div id="tempdata-error-message" style="display:none;">@TempData["Error"]</div>
}
@if (TempData["Warning"] != null)
{
<div id="tempdata-warning-message" style="display:none;">@TempData["Warning"]</div>
}
@if (TempData["Info"] != null)
{
<div id="tempdata-info-message" style="display:none;">@TempData["Info"]</div>
}
@RenderBody()
<!-- jQuery -->
<script src="~/lib/jquery/jquery.min.js"></script>
<!-- Bootstrap 5 JS Bundle -->
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<!-- Toastr JS -->
<script src="~/lib/toastr/toastr.min.js"></script>
<!-- Toast Notification System -->
<script src="~/js/toast-notifications.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
@using Microsoft.AspNetCore.Identity
<div class="user-menu">
@if (User.Identity?.IsAuthenticated == true)
{
var hasProfilePicLP = User.FindFirst("HasProfilePicture")?.Value == "true";
var profilePicVersion = User.FindFirst("ProfilePictureVersion")?.Value ?? "1";
<div class="dropdown">
@if (hasProfilePicLP)
{
<img src="@Url.Action("Photo", "Profile")?v=@profilePicVersion" class="user-avatar-img" data-bs-toggle="dropdown" aria-expanded="false" alt="Profile" />
}
else
{
<div class="user-avatar" data-bs-toggle="dropdown" aria-expanded="false">
@(User.Identity.Name?.Substring(0, 1).ToUpper() ?? "U")
</div>
}
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" asp-controller="Profile" asp-action="Index"><i class="bi bi-person me-2"></i>Profile</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<form asp-area="Identity" asp-page="/Account/Logout" method="post" class="d-inline">
<button type="submit" class="dropdown-item">
<i class="bi bi-box-arrow-right me-2"></i>Logout
</button>
</form>
</li>
</ul>
</div>
}
else
{
<a asp-area="Identity" asp-page="/Account/Login" class="btn btn-primary">
<i class="bi bi-box-arrow-in-right me-2"></i>Login
</a>
}
</div>
@@ -0,0 +1,15 @@
@* Usage: @await Html.PartialAsync("_Metric", (Label: "Active Jobs", Value: "24", Delta: "+3", DeltaDir: "up"))
DeltaDir: "up" (green arrow-up-right) | "down" (red arrow-down-right) | null (no delta) *@
@model (string Label, string Value, string? Delta, string? DeltaDir)
<div class="pcl-metric">
<span class="pcl-metric-kicker">@Model.Label</span>
<span class="pcl-metric-value">@Model.Value</span>
@if (!string.IsNullOrEmpty(Model.Delta))
{
var dir = Model.DeltaDir ?? "up";
var icon = dir == "down" ? "bi-arrow-down-right" : "bi-arrow-up-right";
<span class="pcl-metric-delta @dir">
<i class="bi @icon"></i>@Model.Delta
</span>
}
</div>
@@ -0,0 +1,86 @@
@using PowderCoating.Application.DTOs.Common
@model dynamic
@if (Model.TotalCount > 0)
{
var request = Context.Request;
var queryParams = request.Query.ToDictionary(k => k.Key, v => v.Value.ToString());
string BuildUrl(int page, int? pageSize = null)
{
var urlParams = new Dictionary<string, string>(queryParams);
urlParams["pageNumber"] = page.ToString();
if (pageSize.HasValue)
{
urlParams["pageSize"] = pageSize.Value.ToString();
}
var queryString = string.Join("&", urlParams.Select(kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}"));
return $"{request.Path}?{queryString}";
}
<div class="row mt-4">
<div class="col-md-6">
<div class="d-flex align-items-center">
<span class="me-2">Showing @Model.StartIndex-@Model.EndIndex of @Model.TotalCount results</span>
<span class="me-2">|</span>
<label class="me-2 mb-0">Page size:</label>
<select class="form-select form-select-sm w-auto" onchange="changePageSize(this.value)">
<option value="10" selected="@(Model.PageSize == 10)">10</option>
<option value="25" selected="@(Model.PageSize == 25)">25</option>
<option value="50" selected="@(Model.PageSize == 50)">50</option>
<option value="100" selected="@(Model.PageSize == 100)">100</option>
</select>
</div>
</div>
<div class="col-md-6">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-end mb-0">
<!-- First Page -->
<li class="page-item @(!Model.HasPreviousPage ? "disabled" : "")">
<a class="page-link" href="@BuildUrl(1)" aria-label="First">
<i class="bi bi-chevron-double-left"></i>
</a>
</li>
<!-- Previous Page -->
<li class="page-item @(!Model.HasPreviousPage ? "disabled" : "")">
<a class="page-link" href="@BuildUrl(Model.PageNumber - 1)" aria-label="Previous">
<i class="bi bi-chevron-left"></i>
</a>
</li>
@{
// Calculate page range to show (5 pages at a time)
var startPage = Math.Max(1, Model.PageNumber - 2);
var endPage = Math.Min(Model.TotalPages, startPage + 4);
if (endPage - startPage < 4)
{
startPage = Math.Max(1, endPage - 4);
}
for (var i = startPage; i <= endPage; i++)
{
<li class="page-item @(i == Model.PageNumber ? "active" : "")">
<a class="page-link" href="@BuildUrl(i)">@i</a>
</li>
}
}
<!-- Next Page -->
<li class="page-item @(!Model.HasNextPage ? "disabled" : "")">
<a class="page-link" href="@BuildUrl(Model.PageNumber + 1)" aria-label="Next">
<i class="bi bi-chevron-right"></i>
</a>
</li>
<!-- Last Page -->
<li class="page-item @(!Model.HasNextPage ? "disabled" : "")">
<a class="page-link" href="@BuildUrl(Model.TotalPages)" aria-label="Last">
<i class="bi bi-chevron-double-right"></i>
</a>
</li>
</ul>
</nav>
</div>
</div>
}
@@ -0,0 +1,10 @@
@* Usage: @await Html.PartialAsync("_PowderSwatch", (ColorHex: "#C0392B", Name: "Gloss Red", Size: (int?)null))
Size: optional override in px (default 9). *@
@model (string ColorHex, string Name, int? Size)
@{
var px = (Model.Size ?? 9).ToString();
var hex = string.IsNullOrEmpty(Model.ColorHex) ? "#CCCCCC" : Model.ColorHex;
}
<span class="pcl-swatch"
style="background:@hex;width:@(px)px;height:@(px)px;"
title="@Model.Name"></span>
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - @ViewBag.CompanyName</title>
<!-- Bootstrap 5 CSS -->
<link href="~/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="~/lib/bootstrap-icons/font/bootstrap-icons.css">
<style>
* { box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f8fafc;
min-height: 100vh;
color: #1e293b;
}
.card {
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.btn-success {
background-color: #16a34a;
border-color: #16a34a;
font-weight: 600;
}
.btn-success:hover {
background-color: #15803d;
border-color: #15803d;
}
.approval-header {
border-bottom: 1px solid #e2e8f0;
padding-bottom: 1rem;
margin-bottom: 1.5rem;
}
</style>
</head>
<body>
<div class="container" style="max-width:720px;padding:2rem 1rem;">
<div class="text-center mb-4 approval-header">
<h5 class="fw-bold text-muted mb-0">@ViewBag.CompanyName</h5>
</div>
@RenderBody()
<div class="text-center mt-5 text-muted" style="font-size:0.8rem;">
<p class="mb-0">Powered by Powder Coating Logix</p>
</div>
</div>
<!-- Bootstrap 5 JS Bundle -->
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
@@ -0,0 +1,42 @@
@using PowderCoating.Web.ViewModels.Reports
@model ReportViewModelBase
@* Usage: <partial name="_ReportHeader" model="Model" /> *@
<div class="d-flex align-items-start gap-2 mb-3 no-print">
<a asp-controller="Reports" asp-action="Landing" class="btn btn-sm btn-outline-secondary mt-1">
<i class="bi bi-arrow-left"></i>
</a>
<div class="flex-grow-1">
<h1 class="h3 mb-0">@Model.ReportTitle</h1>
@if (!string.IsNullOrEmpty(Model.ReportDescription))
{
<p class="text-muted mb-0 small">@Model.ReportDescription</p>
}
</div>
<form method="get" class="d-flex align-items-center gap-2">
<label class="text-muted small mb-0 text-nowrap">Period:</label>
<select name="months" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
@foreach (var opt in new[] { (3, "Last 3 months"), (6, "Last 6 months"), (12, "Last 12 months"), (24, "Last 24 months") })
{
if (opt.Item1 == Model.SelectedMonths)
{
<option value="@opt.Item1" selected>@opt.Item2</option>
}
else
{
<option value="@opt.Item1">@opt.Item2</option>
}
}
</select>
<button type="button" class="btn btn-sm btn-outline-secondary" disabled title="PDF export coming soon">
<i class="bi bi-file-pdf me-1"></i>Export PDF
</button>
</form>
</div>
<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; }
}
</style>
@@ -0,0 +1,13 @@
@* Usage: @await Html.PartialAsync("_SectionHeader", (Kicker: "FLOOR", Title: "Active jobs", Meta: (string?)null))
Replaces: <div class="card-header fw-bold">…</div> *@
@model (string Kicker, string Title, string? Meta)
<div class="pcl-section-header d-flex align-items-baseline justify-content-between gap-3">
<div>
<div class="pcl-metric-kicker">@Model.Kicker</div>
<h2 class="pcl-section-title mb-0">@Model.Title</h2>
</div>
@if (!string.IsNullOrEmpty(Model.Meta))
{
<span class="mono pcl-section-meta text-nowrap">@Model.Meta</span>
}
</div>
@@ -0,0 +1,6 @@
@* Usage: @await Html.PartialAsync("_StatusChip", (Kind: "ok", Text: "Completed"))
Kinds: neutral · ok · warn · bad · cool · ember *@
@model (string Kind, string Text)
<span class="pcl-chip pcl-chip-@Model.Kind">
<span class="pcl-chip-dot"></span>@Model.Text
</span>
@@ -0,0 +1,8 @@
<environment include="Development">
<script src="~/lib/jquery-validation/dist/jquery.validate.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js"></script>
</environment>
<environment exclude="Development">
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
</environment>
@@ -0,0 +1,15 @@
@*
Modern Validation Summary Partial View
Replaces traditional validation-summary-danger divs with a cleaner design
ModelState errors are automatically shown as toast notifications via toast-notifications.js
This partial only shows a subtle inline indicator that errors exist
*@
@if (!ViewData.ModelState.IsValid && ViewData.ModelState.ErrorCount > 0)
{
<div class="alert alert-danger alert-dismissible alert-permanent fade show mb-3" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>Please correct the errors below</strong>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}