Restore all zeroed views + add bulk gift certificate creation

The HTML entity sweep script had a bug where it wrote empty files for any
view that contained no target Unicode characters, zeroing out 215 view files.
All views restored from the pre-sweep commit (cefdf3e).

Bulk gift certificate feature:
- BulkCreateGiftCertificateDto with Quantity (1-500), Amount, Reason, Expiry, Notes
- GenerateBulkGiftCertificatePdfAsync on IPdfService / PdfService: one Letter page
  per cert, reusing the same purple/gold branded ComposeGiftCertificateContent helper
- GiftCertificatesController: BulkCreate GET/POST, BulkResult GET, BulkDownloadPdf POST
- Views: BulkCreate.cshtml (form with live total preview), BulkResult.cshtml (table +
  Download All PDF button that POSTs cert IDs to avoid URL length limits)
- gift-certificate-bulk.js: live preview + spinner/disable on submit
- Index.cshtml: Bulk Create button added alongside New Certificate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 20:09:22 -04:00
parent 3eda91f170
commit 4ec55e7290
240 changed files with 73116 additions and 0 deletions
@@ -0,0 +1,631 @@
@model PowderCoating.Application.DTOs.Scheduling.OvenSchedulerViewModel
@{
ViewData["Title"] = "Oven Scheduler";
var dateStr = Model.ScheduledDate.ToString("yyyy-MM-dd");
var prevDate = Model.ScheduledDate.AddDays(-1).ToString("yyyy-MM-dd");
var nextDate = Model.ScheduledDate.AddDays(1).ToString("yyyy-MM-dd");
}
@section Styles {
<style>
/* ─── Layout ─────────────────────────────────────── */
.scheduler-layout {
display: flex;
gap: 1rem;
min-height: calc(100vh - 200px);
align-items: flex-start;
}
.scheduler-queue {
width: 310px;
flex-shrink: 0;
position: sticky;
top: 1rem;
align-self: flex-start;
max-height: calc(100vh - 120px);
overflow-y: auto;
}
.scheduler-board {
flex: 1;
overflow-x: auto;
}
.oven-columns {
display: flex;
gap: 1rem;
align-items: flex-start;
min-width: fit-content;
}
.oven-column {
width: 340px;
flex-shrink: 0;
}
/* ─── Batch cards ────────────────────────────────── */
.batch-card {
border-radius: 10px;
border: 2px solid transparent;
transition: border-color .15s, box-shadow .15s;
margin-bottom: .75rem;
}
.batch-card.status-planned { border-color: #6c757d; }
.batch-card.status-loading { border-color: #0d6efd; }
.batch-card.status-inprogress{ border-color: #fd7e14; box-shadow: 0 0 8px rgba(253,126,20,.3); }
.batch-card.status-completed { border-color: #198754; opacity: .85; }
.batch-card.drag-over { border-color: #0d6efd !important; box-shadow: 0 0 12px rgba(13,110,253,.4) !important; }
/* ─── Capacity bar ───────────────────────────────── */
.capacity-bar-wrap { height: 6px; border-radius: 3px; background: #e9ecef; overflow: hidden; }
.capacity-bar-fill { height: 100%; border-radius: 3px; transition: width .3s; }
.cap-ok { background: #198754; }
.cap-warn { background: #ffc107; }
.cap-over { background: #dc3545; }
/* ─── Queue items ────────────────────────────────── */
.queue-job-card {
border-radius: 8px;
cursor: grab;
margin-bottom: .5rem;
transition: box-shadow .15s, transform .1s;
border-left: 4px solid #6c757d;
}
.queue-job-card:active { cursor: grabbing; transform: scale(.98); }
.queue-job-card[data-priority="Rush"] { border-left-color: #dc3545; }
.queue-job-card[data-priority="Urgent"] { border-left-color: #fd7e14; }
.queue-job-card[data-priority="High"] { border-left-color: #ffc107; }
.queue-job-card[data-priority="Normal"] { border-left-color: #0d6efd; }
.queue-job-card[data-priority="Low"] { border-left-color: #6c757d; }
.queue-job-card.dragging { opacity: .5; box-shadow: 0 4px 16px rgba(0,0,0,.2); }
.batch-item-row {
cursor: grab;
border-radius: 6px;
padding: .3rem .5rem;
margin-bottom: .25rem;
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
font-size: .82rem;
transition: background .1s;
user-select: none;
-webkit-user-select: none;
}
.batch-item-row:hover { background: var(--bs-secondary-bg); }
.batch-item-row.dragging { opacity: .5; }
/* ─── AI panel ───────────────────────────────────── */
.ai-suggestion-panel {
position: fixed;
top: 0; right: 0;
width: 520px;
height: 100vh;
background: var(--bs-body-bg);
border-left: 2px solid #6f42c1;
box-shadow: -4px 0 24px rgba(0,0,0,.18);
z-index: 1050;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform .3s cubic-bezier(.4,0,.2,1);
overflow-y: auto;
}
.ai-suggestion-panel.open { transform: translateX(0); }
.ai-panel-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,.3);
z-index: 1049;
display: none;
}
.ai-panel-backdrop.open { display: block; }
/* ─── Drop zone hint ─────────────────────────────── */
.drop-zone-empty {
min-height: 80px;
border: 2px dashed var(--bs-border-color);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
color: var(--bs-secondary-color);
font-size: .85rem;
}
.drop-zone-empty.drag-over {
border-color: #0d6efd;
background: rgba(13,110,253,.05);
color: #0d6efd;
}
/* Persistent drop hint strip on batches that already have items */
.batch-drop-hint {
margin-top: .35rem;
padding: .25rem .5rem;
border: 1px dashed var(--bs-border-color);
border-radius: 6px;
text-align: center;
font-size: .75rem;
color: var(--bs-secondary-color);
transition: background .15s, border-color .15s, color .15s;
}
.batch-drop-hint-empty {
min-height: 60px;
display: flex; align-items: center; justify-content: center;
font-size: .85rem;
}
.batch-card.drag-over .batch-drop-hint,
.batch-drop-hint.drag-over {
border-color: #0d6efd;
background: rgba(13,110,253,.06);
color: #0d6efd;
}
#queueContainer.drag-over {
outline: 2px dashed #0d6efd;
outline-offset: -4px;
background: rgba(13,110,253,.04);
border-radius: 6px;
}
.color-dot {
display: inline-block;
width: 12px; height: 12px;
border-radius: 50%;
border: 1px solid rgba(0,0,0,.2);
vertical-align: middle;
margin-right: 4px;
}
</style>
}
<div class="container-fluid py-3">
<!-- Toolbar -->
<div class="d-flex align-items-center flex-wrap gap-2 mb-3">
<h4 class="mb-0 me-2"><i class="bi bi-fire text-danger me-2"></i>Oven Scheduler</h4>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Oven Scheduler"
data-bs-content="Plan which job coat passes go into which oven each day. Jobs needing powder coating appear in the Queue on the left. Create batches per oven and drag coat rows into them. Use Optimize to sort the queue, or hit AI Suggest Batches for an AI-generated schedule. See the info panel below for full setup requirements.">
<i class="bi bi-question-circle"></i>
</a>
<!-- Date navigation -->
<div class="btn-group btn-group-sm" role="group">
<a href="@Url.Action("Index", new { date = prevDate, goal = Model.OptimizationGoal })" class="btn btn-outline-secondary">
<i class="bi bi-chevron-left"></i>
</a>
<button type="button" class="btn btn-outline-secondary px-3 fw-semibold" onclick="openDatePicker()">
@Model.ScheduledDate.ToString("ddd, MMM d yyyy")
</button>
<a href="@Url.Action("Index", new { date = nextDate, goal = Model.OptimizationGoal })" class="btn btn-outline-secondary">
<i class="bi bi-chevron-right"></i>
</a>
</div>
<input type="date" id="datePicker" class="form-control form-control-sm d-none" value="@dateStr"
onchange="window.location.href='@Url.Action("Index")?date=' + this.value + '&goal=@Model.OptimizationGoal'" />
<!-- Today shortcut -->
@if (Model.ScheduledDate.Date != DateTime.Today)
{
<a href="@Url.Action("Index", new { date = DateTime.Today.ToString("yyyy-MM-dd"), goal = Model.OptimizationGoal })"
class="btn btn-sm btn-outline-primary">Today</a>
}
<!-- Optimization goal selector -->
<div class="d-flex align-items-center gap-1">
<span class="small text-muted">Optimize:</span>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="bottom" data-bs-trigger="focus"
data-bs-title="Optimization Mode"
data-bs-content="Throughput: packs the most sq ft per batch to maximize daily output. On-Time: prioritizes jobs with the earliest due dates. Color Changes: groups similar colors together to reduce powder purging between runs. The selected goal is also used by AI Suggest Batches.">
<i class="bi bi-question-circle"></i>
</a>
<div class="btn-group btn-group-sm">
@foreach (var (val, label) in new[] {
("maximize_throughput", "Throughput"),
("minimize_lateness", "On-Time"),
("minimize_color_changes", "Color Changes")
})
{
<a href="@Url.Action("Index", new { date = dateStr, goal = val })"
class="btn @(Model.OptimizationGoal == val ? "btn-primary" : "btn-outline-secondary")">
@label
</a>
}
</div>
</div>
<div class="ms-auto d-flex gap-2">
<!-- Create batch dropdown (one per oven) -->
@if (Model.Ovens.Any())
{
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-plus-circle me-1"></i>New Batch
</button>
<ul class="dropdown-menu dropdown-menu-end">
@foreach (var oven in Model.Ovens)
{
<li>
<a class="dropdown-item @(oven.IsOperational ? "" : "text-muted")"
href="#" onclick="createBatch(@oven.OvenCostId, '@Html.Raw(Html.Encode(oven.Name))', '@dateStr'); return false;">
<i class="bi bi-fire me-2"></i>@oven.Name
@if (!oven.IsOperational) { <span class="badge bg-warning ms-1">@oven.Status</span> }
</a>
</li>
}
</ul>
</div>
}
<!-- AI Suggest button -->
<button class="btn btn-sm btn-purple" id="btnAiSuggest" onclick="openAiPanel()"
style="background:#6f42c1;color:white;border:none;">
<i class="bi bi-stars me-1"></i>AI Suggest Batches
</button>
</div>
</div>
<!-- How-to info panel (collapsible, dismissed via localStorage) -->
<div id="schedulerInfoPanel" class="card border-primary border-opacity-25 shadow-sm mb-3">
<div class="card-header bg-primary bg-opacity-10 border-primary border-opacity-25 d-flex align-items-center py-2">
<i class="bi bi-info-circle-fill text-primary me-2"></i>
<span class="fw-semibold text-primary">How to use the Oven Scheduler</span>
<button type="button" class="btn-close ms-auto" id="btnDismissInfo" title="Dismiss"></button>
</div>
<div class="card-body py-3">
<div class="row g-4">
<!-- What it does -->
<div class="col-md-4">
<h6 class="fw-semibold mb-2"><i class="bi bi-fire text-danger me-1"></i>What this page does</h6>
<p class="small text-muted mb-2">
The Oven Scheduler helps you plan which jobs go into which oven on a given day.
Jobs waiting to be coated appear in the <strong>Queue</strong> on the left.
Each oven gets its own column — create <strong>batches</strong> and drag jobs into them
to build your day's run sheet.
</p>
<p class="small text-muted mb-0">
Use the <strong>Optimize</strong> buttons to let the system sort your queue by
throughput, on-time delivery, or fewest color changes, and hit
<strong>AI Suggest Batches</strong> to get an AI-generated schedule you can accept or adjust.
</p>
</div>
<!-- How to use it -->
<div class="col-md-4">
<h6 class="fw-semibold mb-2"><i class="bi bi-hand-index-thumb text-primary me-1"></i>How to use it</h6>
<ol class="small text-muted mb-0 ps-3">
<li class="mb-1">Use the <strong>date arrows</strong> to navigate to the day you want to schedule.</li>
<li class="mb-1">Click <strong>New Batch</strong> and pick an oven to create a batch slot.</li>
<li class="mb-1"><strong>Drag job coat items</strong> from the Queue into a batch, or drag between batches to reorder.</li>
<li class="mb-1">Set a <strong>start time</strong> on each batch and monitor the capacity bar — it turns yellow at 80 % and red when over capacity.</li>
<li class="mb-1">Use the batch <strong>status buttons</strong> (Planned → Loading → In Progress → Completed) to track real-time progress.</li>
<li class="mb-0">Drag an item back to the Queue to unschedule it.</li>
</ol>
</div>
<!-- Setup requirements -->
<div class="col-md-4">
<h6 class="fw-semibold mb-2"><i class="bi bi-gear-fill text-warning me-1"></i>Setup for best results</h6>
<ul class="small text-muted mb-0 ps-3">
<li class="mb-1">
<strong>Ovens in Equipment</strong> — each oven must exist as an Equipment record
with <em>Type = Oven</em> and Status = Operational for it to appear as a column.
</li>
<li class="mb-1">
<strong>Oven capacity</strong> — set a <em>capacity (sq ft)</em> on each oven so
the capacity bar can warn you before you overload a batch.
</li>
<li class="mb-1">
<strong>Job items with surface area</strong> — enter <em>surface area (sq ft)</em>
on each job item so the scheduler can calculate load accurately.
</li>
<li class="mb-1">
<strong>Coat quantities</strong> — job items need at least one coat defined
(powder color + quantity) so they appear as draggable coat rows in the Queue.
</li>
<li class="mb-0">
<strong>Due dates &amp; priorities</strong> — set due dates and priorities on
jobs so the AI and sorting tools can recommend the most urgent work first.
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- No ovens warning -->
@if (!Model.Ovens.Any())
{
<div class="alert alert-warning alert-permanent">
<i class="bi bi-exclamation-triangle me-2"></i>
No active ovens found. Go to <a asp-controller="Equipment" asp-action="Index">Equipment</a> and add a piece of equipment with Type = "Oven".
</div>
}
<!-- Main layout -->
<div class="scheduler-layout">
<!-- ── JOB QUEUE (left sidebar) ───────────────── -->
<div class="scheduler-queue">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center py-2">
<i class="bi bi-list-task me-2 text-primary"></i>
<span class="fw-semibold">Queue</span>
<a tabindex="0" class="help-icon ms-1" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Job Queue"
data-bs-content="Jobs with pending coat passes that need oven time. Each draggable row is one coat pass (e.g., Primer Pass 1 or Top Coat Pass 2). Drag rows into a batch column to schedule them, or back here to unschedule. The left border color indicates job priority: red = Rush, orange = Urgent, yellow = High.">
<i class="bi bi-question-circle"></i>
</a>
<span class="badge bg-secondary ms-auto" id="queueCount">@Model.QueuedJobs.Count</span>
</div>
<div class="card-body p-2" id="queueContainer">
@if (!Model.QueuedJobs.Any())
{
<div class="text-center text-muted py-3 small">
<i class="bi bi-check-circle-fill text-success d-block fs-3 mb-1"></i>
No jobs waiting for the oven
</div>
}
@foreach (var job in Model.QueuedJobs)
{
<div class="queue-job-card card border-0 shadow-sm"
data-priority="@job.Priority"
id="qjob-@job.JobId">
<div class="card-body p-2">
<div class="d-flex align-items-center mb-1">
<span class="fw-semibold small me-auto">@job.JobNumber</span>
<span class="badge @GetPriorityBadge(job.Priority) ms-1 small">@job.Priority</span>
</div>
<div class="text-muted small mb-1">@job.CustomerName</div>
@if (job.DueDate.HasValue)
{
<div class="small @(job.IsOverdue ? "text-danger fw-semibold" : "text-muted")">
<i class="bi bi-calendar-event me-1"></i>
Due @job.DueDate.Value.ToString("MMM d")
@if (job.IsOverdue) { <span class="badge bg-danger ms-1">Overdue</span> }
</div>
}
<!-- Individual coat items (draggable) -->
<div class="mt-2" id="coats-job-@job.JobId">
@foreach (var coat in job.PendingCoats)
{
<div class="batch-item-row d-flex align-items-center"
data-coat-id="@coat.JobItemCoatId"
data-job-id="@job.JobId"
data-job-item-id="@coat.JobItemId"
data-sqft="@coat.SurfaceAreaSqFt"
data-color="@coat.ColorName"
data-color-code="@coat.ColorCode"
data-pass="@coat.CoatPassNumber"
data-coat-name="@coat.CoatName"
data-job-number="@job.JobNumber"
data-customer="@job.CustomerName"
data-description="@coat.ItemDescription"
data-priority="@job.Priority"
data-due-date="@job.DueDate?.ToString("yyyy-MM-dd")"
id="qcoat-@coat.JobItemCoatId">
<div class="flex-grow-1 overflow-hidden">
<div class="text-truncate" title="@coat.ItemDescription">
@if (!string.IsNullOrEmpty(coat.ColorName))
{
<span class="color-dot" style="background:@GetColorHex(coat.ColorName, coat.ColorCode)"></span>
}
<span class="fw-medium">@coat.CoatName</span>
<span class="text-muted ms-1">— @coat.ItemDescription</span>
</div>
<div class="text-muted" style="font-size:.75rem;">
Pass @coat.CoatPassNumber · @coat.SurfaceAreaSqFt.ToString("F1") sqft
@if (!string.IsNullOrEmpty(coat.ColorName)) { <span>· @coat.ColorName</span> }
</div>
</div>
<i class="bi bi-grip-vertical text-muted ms-1"></i>
</div>
}
</div>
</div>
</div>
}
</div>
</div>
</div>
<!-- ── OVEN COLUMNS ───────────────────────────── -->
<div class="scheduler-board">
<div class="oven-columns" id="ovenColumns">
@foreach (var oven in Model.Ovens)
{
var ovenBatches = Model.Batches.Where(b => b.OvenCostId == oven.OvenCostId).ToList();
<div class="oven-column" id="oven-col-@oven.OvenCostId">
<!-- Oven header -->
<div class="card shadow-sm mb-2">
<div class="card-body py-2 px-3 d-flex align-items-center">
<div>
<div class="fw-semibold d-flex align-items-center gap-2">
<i class="bi bi-fire @(oven.IsOperational ? "text-danger" : "text-muted")"></i>
@oven.Name
@if (!oven.IsOperational)
{
<span class="badge bg-warning text-dark small">@oven.Status</span>
}
</div>
<div class="small text-muted">
@(oven.MaxLoadSqFt.HasValue ? $"{oven.MaxLoadSqFt:F0} sqft max" : "No capacity set")
· @oven.CycleMinutes min cycle
</div>
</div>
<button class="btn btn-sm btn-outline-primary ms-auto"
onclick="createBatch(@oven.OvenCostId, '@Html.Raw(Html.Encode(oven.Name))', '@dateStr')">
<i class="bi bi-plus"></i>
</button>
</div>
</div>
<!-- Drop zone for batches -->
<div class="batch-drop-zone" data-oven-id="@oven.OvenCostId" id="zone-@oven.OvenCostId">
@if (!ovenBatches.Any())
{
<div class="drop-zone-empty" id="empty-@oven.OvenCostId">
<span><i class="bi bi-fire me-1"></i>Drop items here or click + to create a batch</span>
</div>
}
@foreach (var batch in ovenBatches.OrderBy(b => b.ScheduledStartTime))
{
@await Html.PartialAsync("_BatchCard", batch)
}
</div>
</div>
}
</div>
</div>
</div>
</div>
<!-- AI Panel Backdrop -->
<div class="ai-panel-backdrop" id="aiBackdrop" onclick="closeAiPanel()"></div>
<!-- AI Suggestion Panel -->
<div class="ai-suggestion-panel" id="aiPanel">
<div class="p-3 border-bottom d-flex align-items-center" style="background: linear-gradient(135deg,#6f42c1,#0d6efd); color:white;">
<i class="bi bi-stars fs-5 me-2"></i>
<div>
<div class="fw-semibold">AI Batch Optimizer</div>
<div class="small opacity-75">Goal: <span id="goalLabel">Maximize Throughput</span></div>
</div>
<button class="btn btn-sm ms-auto" style="color:white;border:1px solid rgba(255,255,255,.4);" onclick="closeAiPanel()">
<i class="bi bi-x-lg"></i>
</button>
</div>
<!-- Loading state -->
<div id="aiLoading" class="d-none p-4 text-center">
<div class="spinner-border text-purple mb-3" style="color:#6f42c1; width:3rem; height:3rem;"></div>
<div class="fw-semibold">Analyzing your queue...</div>
<div class="text-muted small mt-1">AI is grouping jobs by color, temperature, and priority</div>
</div>
<!-- Error state -->
<div id="aiError" class="d-none p-4">
<div class="alert alert-danger alert-permanent mb-0">
<i class="bi bi-exclamation-triangle me-2"></i>
<span id="aiErrorText"></span>
</div>
</div>
<!-- Results state -->
<div id="aiResults" class="d-none flex-grow-1 d-flex flex-column">
<div class="p-3 border-bottom bg-light">
<div class="d-flex align-items-start gap-2">
<i class="bi bi-chat-dots text-purple mt-1" style="color:#6f42c1;"></i>
<div class="small" id="aiSummary"></div>
</div>
<div id="aiWarnings" class="mt-2 d-none"></div>
</div>
<div class="flex-grow-1 overflow-y-auto p-3" id="aiBatchList"></div>
<div class="p-3 border-top d-flex gap-2">
<button class="btn btn-success flex-grow-1" id="btnAcceptAll" onclick="acceptAllBatches()">
<i class="bi bi-check-all me-1"></i>Accept All Batches
</button>
<button class="btn btn-outline-secondary" onclick="runAiSuggest()">
<i class="bi bi-arrow-clockwise me-1"></i>Re-run
</button>
</div>
</div>
<!-- Initial state (before running) -->
<div id="aiInitial" class="p-4">
<p class="text-muted small">
Our AI agent will analyze all <strong>@Model.QueuedJobs.Count job(s)</strong> in the queue and suggest
optimized batches for your <strong>@Model.Ovens.Count oven(s)</strong>, grouping by:
</p>
<ul class="small text-muted">
<li>Powder color / color compatibility</li>
<li>Cure temperature requirements</li>
<li>Job priority &amp; due dates</li>
<li>Oven capacity limits</li>
<li>Multi-coat sequencing (primer before top coat)</li>
</ul>
@if (!Model.QueuedJobs.Any())
{
<div class="alert alert-info alert-permanent small">
<i class="bi bi-info-circle me-1"></i>The queue is empty — no jobs need oven scheduling right now.
</div>
}
else
{
<button class="btn btn-primary w-100 mt-2" onclick="runAiSuggest()">
<i class="bi bi-stars me-1"></i>Generate AI Schedule
</button>
}
</div>
</div>
@section Scripts {
<script>
// ── Info panel (always visible on load; dismiss hides for this visit only) ─
document.getElementById('btnDismissInfo').addEventListener('click', function () {
document.getElementById('schedulerInfoPanel').style.display = 'none';
});
const SCHEDULED_DATE = '@dateStr';
const OPTIMIZATION_GOAL = '@Model.OptimizationGoal';
const DEFAULT_CYCLE_MINUTES = @Model.DefaultCycleMinutes;
const URLS = {
suggest: '@Url.Action("Suggest")',
acceptSuggestion:'@Url.Action("AcceptSuggestion")',
createBatch: '@Url.Action("CreateBatch")',
addToBatch: '@Url.Action("AddToBatch")',
moveToBatch: '@Url.Action("MoveToBatch")',
removeFromBatch: '@Url.Action("RemoveFromBatch")',
startBatch: '@Url.Action("StartBatch")',
completeBatch: '@Url.Action("CompleteBatch")',
deleteBatch: '@Url.Action("DeleteBatch")',
index: '@Url.Action("Index")'
};
const AI_SUGGESTION_DATA = { batches: [] };
function openDatePicker() {
document.getElementById('datePicker').classList.remove('d-none');
document.getElementById('datePicker').showPicker?.();
}
</script>
<script src="~/js/oven-scheduler.js"></script>
}
@functions {
string GetPriorityBadge(string priority) => priority switch
{
"Rush" => "bg-danger",
"Urgent" => "bg-warning text-dark",
"High" => "bg-primary",
"Normal" => "bg-secondary",
_ => "bg-light text-dark"
};
string GetColorHex(string? name, string? code)
{
if (!string.IsNullOrEmpty(code))
{
// Try to extract hex from RAL or custom codes
var lower = code.ToLower();
if (lower.Contains("9005")) return "#0a0a0a";
if (lower.Contains("9010")) return "#f5f4ef";
if (lower.Contains("3000")) return "#ab2524";
if (lower.Contains("5010")) return "#0e4c96";
if (lower.Contains("6001")) return "#2e5c1e";
}
if (!string.IsNullOrEmpty(name))
{
var lower = name.ToLower();
if (lower.Contains("black")) return "#222222";
if (lower.Contains("white")) return "#f5f5f5";
if (lower.Contains("red")) return "#cc3333";
if (lower.Contains("blue")) return "#1a5fc0";
if (lower.Contains("green")) return "#2d7a2d";
if (lower.Contains("yellow")) return "#f5c518";
if (lower.Contains("silver") || lower.Contains("gray") || lower.Contains("grey")) return "#9e9e9e";
if (lower.Contains("bronze") || lower.Contains("copper")) return "#b87333";
if (lower.Contains("primer")) return "#8e8e8e";
}
return "#aaaaaa";
}
}
@@ -0,0 +1,194 @@
@model PowderCoating.Application.DTOs.Scheduling.OvenBatchDto
@{
var statusClass = Model.Status switch
{
"Planned" => "status-planned",
"Loading" => "status-loading",
"InProgress" => "status-inprogress",
"Completed" => "status-completed",
_ => ""
};
var capPct = Model.MaxLoadSqFt.HasValue ? (double)Model.CapacityPct : 0;
var capClass = capPct >= 100 ? "cap-over" : capPct >= 80 ? "cap-warn" : "cap-ok";
var isEditable = Model.Status == "Planned" || Model.Status == "Loading";
}
<div class="batch-card card shadow-sm @statusClass"
id="batch-@Model.Id"
data-batch-id="@Model.Id"
data-oven-id="@Model.OvenCostId"
data-status="@Model.Status"
data-status-id="@Model.StatusId"
data-total-sqft="@Model.TotalSurfaceAreaSqFt"
data-max-sqft="@Model.MaxLoadSqFt">
<div class="card-header py-2 px-3">
<div class="d-flex align-items-center">
<span class="fw-semibold small me-auto">@Model.BatchNumber</span>
@if (Model.AiSuggested)
{
<span class="badge me-1" style="background:#6f42c1;font-size:.7rem;" title="@Model.AiReasoning">
<i class="bi bi-stars me-1"></i>AI
</span>
}
<span class="badge @GetStatusBadge(Model.Status) small">@Model.Status</span>
</div>
@if (Model.ScheduledStartTime.HasValue)
{
<div class="text-muted small mt-1">
<i class="bi bi-clock me-1"></i>
@Model.ScheduledStartTime.Value.ToString("h:mm tt")
@if (Model.EstimatedEndTime.HasValue)
{
<span> @Model.EstimatedEndTime.Value.ToString("h:mm tt")</span>
}
<span class="ms-1">(@Model.CycleMinutes min)</span>
</div>
}
else
{
<div class="text-muted small mt-1"><i class="bi bi-clock me-1"></i>@Model.CycleMinutes min cycle</div>
}
</div>
<div class="card-body p-2">
<!-- Color + temp badge -->
@if (!string.IsNullOrEmpty(Model.PrimaryColorName))
{
<div class="d-flex align-items-center gap-1 mb-2">
<span class="badge bg-secondary small">
<i class="bi bi-palette me-1"></i>@Model.PrimaryColorName
@if (!string.IsNullOrEmpty(Model.PrimaryColorCode)) { <span>(@Model.PrimaryColorCode)</span> }
</span>
@if (Model.CureTemperatureF.HasValue)
{
<span class="badge bg-danger small"><i class="bi bi-thermometer-half me-1"></i>@Model.CureTemperatureF°F</span>
}
</div>
}
<!-- Capacity bar -->
<div class="mb-2">
<div class="d-flex justify-content-between mb-1" style="font-size:.75rem;">
<span class="text-muted">Capacity</span>
<span class="fw-semibold batch-sqft" data-batch-id="@Model.Id">
@Model.TotalSurfaceAreaSqFt.ToString("F1")
@if (Model.MaxLoadSqFt.HasValue) { <span>/ @Model.MaxLoadSqFt.Value.ToString("F0") sqft</span> }
else { <span>sqft</span> }
</span>
</div>
@if (Model.MaxLoadSqFt.HasValue)
{
<div class="capacity-bar-wrap">
<div class="capacity-bar-fill @capClass batch-cap-bar"
data-batch-id="@Model.Id"
style="width: @(Math.Min(capPct, 100).ToString("F1"))%"></div>
</div>
}
</div>
<!-- Items list (drop target) -->
<div class="batch-items-list" id="items-@Model.Id">
@foreach (var item in Model.Items.OrderBy(i => i.CoatPassNumber))
{
<div class="batch-item-row d-flex align-items-center"
id="bitem-@item.Id"
data-batch-item-id="@item.Id"
data-batch-id="@Model.Id"
data-sqft="@item.SurfaceAreaContribution"
draggable="@(isEditable ? "true" : "false")"
@(isEditable ? "" : "style='cursor:default;'")>
<div class="flex-grow-1 overflow-hidden">
<div class="d-flex align-items-center gap-1 text-truncate">
<span class="badge bg-light text-dark border" style="font-size:.7rem;">Pass @item.CoatPassNumber</span>
@if (!string.IsNullOrEmpty(item.ColorName))
{
<span class="text-truncate fw-medium small">@item.ColorName</span>
}
<span class="text-muted small text-truncate">@item.ItemDescription</span>
</div>
<div class="text-muted d-flex align-items-center gap-2" style="font-size:.73rem;">
<span>@item.JobNumber</span>
<span>·</span>
<span>@item.SurfaceAreaContribution.ToString("F1") sqft</span>
@if (!string.IsNullOrEmpty(item.Priority) && item.Priority != "Normal")
{
<span class="badge @GetPriorityBadge(item.Priority) ms-1" style="font-size:.65rem;">@item.Priority</span>
}
</div>
</div>
@if (isEditable)
{
<div class="d-flex align-items-center ms-1 gap-1">
<i class="bi bi-grip-vertical text-muted"></i>
<button class="btn btn-sm p-0 text-danger" style="line-height:1;"
onclick="removeFromBatch(@item.Id, @Model.Id)" title="Remove from batch">
<i class="bi bi-x"></i>
</button>
</div>
}
</div>
}
@if (isEditable)
{
<div class="batch-drop-hint @(!Model.Items.Any() ? "batch-drop-hint-empty" : "")"
id="empty-items-@Model.Id">
<i class="bi bi-plus-circle me-1"></i>
<span>@(Model.Items.Any() ? "Drop more here" : "Drag coats here")</span>
</div>
}
</div>
<!-- Batch action buttons -->
<div class="d-flex gap-1 mt-2 flex-wrap">
@if (Model.Status == "Planned" || Model.Status == "Loading")
{
<button class="btn btn-sm btn-success flex-grow-1"
onclick="startBatch(@Model.Id)">
<i class="bi bi-play-fill me-1"></i>Start
</button>
<button class="btn btn-sm btn-outline-danger"
onclick="deleteBatch(@Model.Id, '@Model.BatchNumber')">
<i class="bi bi-trash"></i>
</button>
}
else if (Model.Status == "InProgress")
{
<button class="btn btn-sm btn-warning flex-grow-1"
onclick="completeBatch(@Model.Id)">
<i class="bi bi-check-circle me-1"></i>Complete
</button>
}
else if (Model.Status == "Completed")
{
<div class="text-success small w-100 text-center">
<i class="bi bi-check-circle-fill me-1"></i>
Completed @Model.ActualEndTime?.ToString("h:mm tt")
</div>
}
</div>
</div>
</div>
@functions {
string GetStatusBadge(string status) => status switch
{
"Planned" => "bg-secondary",
"Loading" => "bg-primary",
"InProgress" => "bg-warning text-dark",
"Completed" => "bg-success",
"Cancelled" => "bg-danger",
_ => "bg-light text-dark"
};
string GetPriorityBadge(string priority) => priority switch
{
"Rush" => "bg-danger",
"Urgent" => "bg-warning text-dark",
"High" => "bg-primary",
_ => "bg-secondary"
};
}