Files
PowderCoatingLogix/src/PowderCoating.Web/Views/SetupWizard/Step4.cshtml
T
spouliot e476b4744d Fix subscription expiry logic and HTML entities in page titles
Subscription expiry (SubscriptionExpiryBackgroundService):
- Trials with no grace period now go directly Active -> Expired instead
  of briefly entering GracePeriod for a day, which was causing repeated
  'Grace Period Started' admin notification emails
- Remove redundant isTrial variable (query already filters to non-Stripe
  companies, so all processed companies are trials by definition)
- Save per-company inside the loop so a single SaveChangesAsync failure
  no longer discards all other companies' status changes and notification
  log entries (which was the other cause of repeated emails)

HTML entities in page titles (33 views):
- Replace – / — with plain ' - ' in ViewData["Title"] C#
  strings; Razor HTML-encodes these when rendering @ViewData["Title"],
  causing browsers to display the literal text '–' instead of a dash

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 09:43:41 -04:00

358 lines
21 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@using PowderCoating.Application.DTOs.Wizard
@model WizardOvensStepDto
@{
ViewData["Title"] = "Setup Wizard - Shop Equipment";
var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto();
int step = ViewBag.Step as int? ?? 4;
}
@section Styles { @await Html.PartialAsync("_WizardStyles") }
<div class="wizard-layout">
@await Html.PartialAsync("_WizardProgress", progress)
<div class="wizard-content">
<div class="wizard-step-header">
<span class="wizard-step-badge">Step @step of @WizardProgressDto.TotalSteps</span>
<h2><i class="bi bi-tools me-2"></i>Shop Equipment</h2>
<p class="text-secondary">Register your ovens and blast setups. Ovens power the Oven Scheduler and quoting engine &mdash; at least one is required. Blast setups define each rig's throughput rate so the AI quote engine can estimate sandblasting time accurately.</p>
</div>
<form asp-action="PostStep4" method="post" onsubmit="return validateStep4()">
@Html.AntiForgeryToken()
<script type="application/json" id="ovensSeedJson">@Html.Raw(Model.OvensJson ?? "[]")</script>
<script type="application/json" id="blastSetupsSeedJson">@Html.Raw(Model.BlastSetupsJson ?? "[]")</script>
<input type="hidden" name="OvensJson" id="ovensJson" value="[]" />
<input type="hidden" name="BlastSetupsJson" id="blastSetupsJson" value="[]" />
<!-- ── Ovens ─────────────────────────────────────────────────────── -->
<div class="wizard-card">
<h5 class="wizard-card-title"><i class="bi bi-fire me-2"></i>Shop Ovens</h5>
<p class="text-secondary small mb-3">
Each oven appears in the Oven Scheduler's capacity planner and the oven selector on quotes.
Enter dimensions to get a suggested max load, and use the cycle time guide to set a realistic batch time.
</p>
<div id="ovensList"></div>
<button type="button" class="btn btn-outline-primary btn-sm mt-2" onclick="addOven()">
<i class="bi bi-plus-circle me-1"></i>Add Oven
</button>
<div id="ovensValidationMsg" class="alert alert-danger alert-permanent d-flex gap-2 mt-3 mb-0 d-none" role="alert">
<i class="bi bi-exclamation-triangle flex-shrink-0 mt-1"></i>
<div class="small">Please add at least one oven before continuing. You can add more ovens later from <strong>Company Settings &rarr; Shop Ovens</strong>.</div>
</div>
</div>
<!-- ── Blast Setups ──────────────────────────────────────────────── -->
<div class="wizard-card">
<h5 class="wizard-card-title"><i class="bi bi-fan me-2"></i>Blast Setups</h5>
<p class="text-secondary small mb-3">
Add each blast rig in your shop (cabinets, pressure pots, blast rooms). The AI quoting engine uses CFM,
nozzle size, and substrate to derive a sqft/hr throughput rate. Mark one as the default &mdash; it will be
pre-selected when quoting. You can also enter a measured rate override if you prefer to use real shop data.
Blast setups are optional; skip this section if you don't do in-house blasting.
</p>
<div id="blastsList"></div>
<button type="button" class="btn btn-outline-primary btn-sm mt-2" onclick="addBlast()">
<i class="bi bi-plus-circle me-1"></i>Add Blast Setup
</button>
</div>
@await Html.PartialAsync("_WizardFooter", step)
</form>
</div>
</div>
@section Scripts {
<script>
// ── Shared helpers ────────────────────────────────────────────────────────
function escHtml(str) {
return (str || '').toString()
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ═══════════════════════════════════════════════════════════════════════════
// OVENS
// ═══════════════════════════════════════════════════════════════════════════
var ovens = JSON.parse(document.getElementById('ovensSeedJson').textContent || '[]');
function serializeOvens() {
document.getElementById('ovensJson').value = JSON.stringify(
ovens.map(function(o) {
return { id: o.id || 0, label: o.label, costPerHour: o.costPerHour,
maxLoadSqFt: o.maxLoadSqFt, defaultCycleMinutes: o.defaultCycleMinutes };
})
);
}
function renderOvens() {
var container = document.getElementById('ovensList');
if (ovens.length === 0) {
container.innerHTML = '<p class="text-secondary small py-2">No ovens added yet. Click <strong>Add Oven</strong> to get started.</p>';
} else {
container.innerHTML = ovens.map(function (o, idx) {
return `<div class="wz-item-row mb-2">
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small fw-semibold mb-1">Oven Name <span class="text-danger">*</span></label>
<input class="form-control form-control-sm" value="${escHtml(o.label)}"
onchange="updateOven(${idx},'label',this.value)"
placeholder="e.g. Main Oven, Oven #2" />
</div>
<div class="col-md-2">
<label class="form-label small fw-semibold mb-1">Cost/hr ($) <i class="bi bi-info-circle text-secondary" title="Overrides the default oven rate from Step 3 when this oven is selected on a quote."></i></label>
<input class="form-control form-control-sm" type="number" min="0" step="0.01"
value="${o.costPerHour || 0}"
onchange="updateOvenNum(${idx},'costPerHour',this.value)" />
</div>
<div class="col-md-2">
<label class="form-label small fw-semibold mb-1">Max Load (sq ft)</label>
<input id="maxLoad_${idx}" class="form-control form-control-sm" type="number" min="0" step="0.1"
value="${o.maxLoadSqFt || ''}"
onchange="updateOvenNum(${idx},'maxLoadSqFt',this.value)"
placeholder="e.g. 80" />
</div>
<div class="col-md-2">
<label class="form-label small fw-semibold mb-1">Cycle Time (min)</label>
<input id="cycle_${idx}" class="form-control form-control-sm" type="number" min="1" step="1"
value="${o.defaultCycleMinutes || ''}"
onchange="updateOvenNum(${idx},'defaultCycleMinutes',this.value)"
placeholder="e.g. 50" />
</div>
<div class="col-md-2 text-end">
<button type="button" class="btn btn-outline-danger btn-sm" onclick="removeOven(${idx})" title="Remove">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="row g-2 mt-1 align-items-end bg-body-tertiary rounded px-2 py-2">
<div class="col-12 mb-1">
<span class="small fw-semibold text-secondary">
<i class="bi bi-rulers me-1"></i>Dimension Calculator
<span class="fw-normal">(optional &mdash; enter interior oven dimensions)</span>
</span>
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Width (ft)</label>
<input class="form-control form-control-sm" type="number" min="0" step="0.1"
id="dimW_${idx}" value="${o._dimW || ''}" oninput="calcDims(${idx})" placeholder="4" />
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Depth (ft)</label>
<input class="form-control form-control-sm" type="number" min="0" step="0.1"
id="dimD_${idx}" value="${o._dimD || ''}" oninput="calcDims(${idx})" placeholder="6" />
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Height (ft)</label>
<input class="form-control form-control-sm" type="number" min="0" step="0.1"
id="dimH_${idx}" value="${o._dimH || ''}" oninput="calcDims(${idx})" placeholder="6" />
</div>
<div class="col-md-3">
<div id="dimSuggestion_${idx}" class="small text-secondary mt-3"></div>
</div>
<div class="col-md-3 small text-secondary">
<div>Typical cycle: 15 min preheat + 20 min cure + 15 min cooldown = <strong>50 min</strong></div>
<button type="button" class="btn btn-link btn-sm p-0 text-primary" onclick="useCycle(${idx})">Use 50 min</button>
</div>
</div>
</div>`;
}).join('');
}
serializeOvens();
}
function addOven() {
ovens.push({ label: '', costPerHour: 0, maxLoadSqFt: null, defaultCycleMinutes: null });
renderOvens();
document.querySelectorAll('#ovensList .wz-item-row:last-child input')[0]?.focus();
}
function removeOven(idx) { ovens.splice(idx, 1); renderOvens(); }
function updateOven(idx, field, value) { ovens[idx][field] = value; serializeOvens(); }
function updateOvenNum(idx, field, value) {
ovens[idx][field] = value === '' ? null : parseFloat(value);
serializeOvens();
}
function calcDims(idx) {
var w = parseFloat(document.getElementById('dimW_' + idx)?.value) || 0;
var d = parseFloat(document.getElementById('dimD_' + idx)?.value) || 0;
var h = parseFloat(document.getElementById('dimH_' + idx)?.value) || 0;
ovens[idx]._dimW = w || null; ovens[idx]._dimD = d || null; ovens[idx]._dimH = h || null;
var el = document.getElementById('dimSuggestion_' + idx);
if (!el) return;
if (w > 0 && h > 0) {
var sqFt = w * h * 0.7;
var volNote = d > 0 ? ` &nbsp;<span class="text-secondary" style="font-size:0.75rem;">(${(w*d*h).toFixed(1)} cu ft)</span>` : '';
el.innerHTML = `Suggested: <strong>${sqFt.toFixed(1)} sq ft</strong>${volNote}<br/>
<span class="text-secondary" style="font-size:0.75rem;">W × H × 70% usable</span><br/>
<button type="button" class="btn btn-link btn-sm p-0 text-primary" onclick="useSqFt(${idx},${sqFt.toFixed(2)})">Use this</button>`;
} else {
el.innerHTML = '<span class="text-secondary" style="font-size:0.75rem;">Enter width &amp; height for a suggestion.</span>';
}
}
function useSqFt(idx, sqFt) {
ovens[idx].maxLoadSqFt = sqFt;
var input = document.getElementById('maxLoad_' + idx);
if (input) input.value = sqFt;
serializeOvens();
}
function useCycle(idx) {
ovens[idx].defaultCycleMinutes = 50;
var input = document.getElementById('cycle_' + idx);
if (input) input.value = 50;
serializeOvens();
}
// ═══════════════════════════════════════════════════════════════════════════
// BLAST SETUPS
// ═══════════════════════════════════════════════════════════════════════════
var blasts = JSON.parse(document.getElementById('blastSetupsSeedJson').textContent || '[]');
function serializeBlasts() {
document.getElementById('blastSetupsJson').value = JSON.stringify(
blasts.map(function(b) {
return { id: b.id || 0, name: b.name, setupType: b.setupType,
compressorCfm: b.compressorCfm, blastNozzleSize: b.blastNozzleSize,
primarySubstrate: b.primarySubstrate,
blastRateSqFtPerHourOverride: b.blastRateSqFtPerHourOverride,
isDefault: b.isDefault };
})
);
}
function renderBlasts() {
var container = document.getElementById('blastsList');
if (blasts.length === 0) {
container.innerHTML = '<p class="text-secondary small py-2">No blast setups added yet. Click <strong>Add Blast Setup</strong> to get started, or skip if you don\'t do in-house blasting.</p>';
} else {
container.innerHTML = blasts.map(function(b, idx) {
return `<div class="wz-item-row mb-2">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small fw-semibold mb-1">Setup Name <span class="text-danger">*</span></label>
<input class="form-control form-control-sm" value="${escHtml(b.name)}"
onchange="updateBlast(${idx},'name',this.value)"
placeholder="e.g. Main Cabinet, Blast Room" />
</div>
<div class="col-md-2">
<label class="form-label small fw-semibold mb-1">Type</label>
<select class="form-select form-select-sm" onchange="updateBlastNum(${idx},'setupType',this.value)">
<option value="0" ${b.setupType==0?'selected':''}>Siphon Cabinet</option>
<option value="1" ${b.setupType==1?'selected':''}>Siphon Pot</option>
<option value="2" ${b.setupType==2?'selected':''}>Pressure Pot</option>
<option value="3" ${b.setupType==3?'selected':''}>Wet Blasting</option>
</select>
</div>
<div class="col-md-1">
<label class="form-label small fw-semibold mb-1">CFM</label>
<input class="form-control form-control-sm" type="number" min="0" max="9999" step="0.5"
value="${b.compressorCfm || ''}"
onchange="updateBlastNum(${idx},'compressorCfm',this.value)"
placeholder="40" />
</div>
<div class="col-md-1">
<label class="form-label small fw-semibold mb-1">Nozzle #</label>
<select class="form-select form-select-sm" onchange="updateBlastNum(${idx},'blastNozzleSize',this.value)">
<option value="2" ${b.blastNozzleSize==2?'selected':''}>#2</option>
<option value="3" ${b.blastNozzleSize==3?'selected':''}>#3</option>
<option value="4" ${b.blastNozzleSize==4?'selected':''}>#4</option>
<option value="5" ${b.blastNozzleSize==5?'selected':''}>#5</option>
<option value="6" ${b.blastNozzleSize==6?'selected':''}>#6</option>
<option value="7" ${b.blastNozzleSize==7?'selected':''}>#7</option>
<option value="8" ${b.blastNozzleSize==8?'selected':''}>#8</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label small fw-semibold mb-1">Primary Substrate</label>
<select class="form-select form-select-sm" onchange="updateBlastNum(${idx},'primarySubstrate',this.value)">
<option value="0" ${b.primarySubstrate==0?'selected':''}>Paint / light</option>
<option value="3" ${b.primarySubstrate==3?'selected':''}>Mixed (typical)</option>
<option value="2" ${b.primarySubstrate==2?'selected':''}>Rust &amp; scale</option>
<option value="1" ${b.primarySubstrate==1?'selected':''}>Existing powder</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label small fw-semibold mb-1">Rate Override <span class="text-secondary fw-normal">(sqft/hr)</span></label>
<input class="form-control form-control-sm" type="number" min="0" step="0.1"
value="${b.blastRateSqFtPerHourOverride != null ? b.blastRateSqFtPerHourOverride : ''}"
onchange="updateBlastOverride(${idx},this.value)"
placeholder="leave blank" />
</div>
<div class="col-md-1 d-flex flex-column align-items-center gap-1">
<label class="form-label small fw-semibold mb-1">Default</label>
<input type="checkbox" class="form-check-input blast-default-cb" ${b.isDefault?'checked':''}
onchange="setBlastDefault(${idx},this.checked)" title="Pre-selected in AI quotes" />
</div>
<div class="col-auto">
<button type="button" class="btn btn-outline-danger btn-sm" onclick="removeBlast(${idx})" title="Remove">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>`;
}).join('');
}
serializeBlasts();
}
function addBlast() {
var isFirst = blasts.length === 0;
blasts.push({ name: '', setupType: 2, compressorCfm: '', blastNozzleSize: 5,
primarySubstrate: 3, blastRateSqFtPerHourOverride: null, isDefault: isFirst });
renderBlasts();
document.querySelectorAll('#blastsList .wz-item-row:last-child input')[0]?.focus();
}
function removeBlast(idx) {
blasts.splice(idx, 1);
// If we just removed the default and there are still items, make the first one default
if (blasts.length > 0 && !blasts.some(function(b) { return b.isDefault; })) {
blasts[0].isDefault = true;
}
renderBlasts();
}
function updateBlast(idx, field, value) { blasts[idx][field] = value; serializeBlasts(); }
function updateBlastNum(idx, field, value) {
blasts[idx][field] = value === '' ? null : parseInt(value, 10);
serializeBlasts();
}
function updateBlastOverride(idx, value) {
blasts[idx].blastRateSqFtPerHourOverride = value === '' ? null : parseFloat(value);
serializeBlasts();
}
function setBlastDefault(idx, checked) {
if (checked) {
blasts.forEach(function(b, i) { b.isDefault = i === idx; });
} else {
blasts[idx].isDefault = false;
}
// Re-render to sync all other checkboxes
renderBlasts();
}
// ═══════════════════════════════════════════════════════════════════════════
// VALIDATION + INIT
// ═══════════════════════════════════════════════════════════════════════════
function validateStep4() {
var valid = ovens.filter(function(o) { return o.label && o.label.trim(); }).length > 0;
document.getElementById('ovensValidationMsg').classList.toggle('d-none', valid);
return valid;
}
renderOvens();
renderBlasts();
</script>
}