Initial commit
This commit is contained in:
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user