e476b4744d
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>
358 lines
21 KiB
Plaintext
358 lines
21 KiB
Plaintext
@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 — 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 → 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 — 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 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 — 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 ? ` <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 & 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 & 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>
|
||
}
|