Files
PowderCoatingLogix/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml
T
spouliot 0deef574c3 Fix formula pages mobile responsiveness; fix missing mobile tabs
- Custom Formulas and Timeclock tabs were completely missing from the mobile
  dropdown selector, making them unreachable on phones; also adds AI Profile
  and Online Payments which were similarly absent
- Formula library header: flex-column on mobile so title and button stack
  cleanly instead of colliding
- Filter bar: icon-only button gets a visible label on mobile; added col-12
  so it renders full-width correctly at xs
- Import modal: add modal-dialog-scrollable so body scrolls on small screens;
  wrap field table in table-responsive to prevent horizontal overflow
- Settings card header: flex-column on mobile + flex-wrap on button group
  so the three buttons don't overflow off the right edge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 22:48:33 -04:00

3760 lines
238 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.
@model PowderCoating.Application.DTOs.Company.CompanySettingsDto
@{
ViewData["Title"] = "Company Settings";
ViewData["PageIcon"] = "bi-building";
}
@section Styles {
<link rel="stylesheet" href="~/css/company-settings-lookups.css" asp-append-version="true" />
}
<div class="container-fluid">
@Html.AntiForgeryToken()
<div class="d-flex justify-content-end align-items-center mb-3">
<div class="d-flex gap-2">
<a asp-controller="Help" asp-action="Settings" class="btn btn-outline-secondary btn-sm" target="_blank" title="Settings help">
<i class="bi bi-question-circle me-1"></i>Help
</a>
<a asp-controller="SetupWizard" asp-action="Step" asp-route-step="1" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-list-check me-1"></i>Setup Wizard
</a>
</div>
</div>
<!-- Mobile Tab Selector (Dropdown) -->
<div class="d-md-none mb-3">
<select class="form-select" id="mobileTabSelector">
<option value="company-info" selected>Company Info</option>
<option value="operating-costs">Operating Costs</option>
@if (Model.AiPhotoQuotesEnabled)
{
<option value="ai-profile">AI Profile</option>
}
<option value="quoting-calibration">Shop Equipment Profile</option>
<option value="app-defaults">App Defaults</option>
<option value="job-defaults">Job &amp; Workflow</option>
<option value="notifications">Notifications</option>
<option value="data-retention">Data Retention</option>
<option value="data-lookups">Data Lookups</option>
<option value="pdf-templates">PDF Templates</option>
@if (Model.AllowOnlinePayments)
{
<option value="online-payments">Online Payments</option>
}
<option value="kiosk">Kiosk</option>
<option value="timeclock">Timeclock</option>
@if (ViewBag.AllowCustomFormulas == true)
{
<option value="custom-formulas">Custom Formulas</option>
}
</select>
</div>
<!-- Desktop Tabs Navigation -->
<ul class="nav nav-tabs d-none d-md-flex" id="settingsTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="company-info-tab" data-bs-toggle="tab" data-bs-target="#company-info" type="button" role="tab">
<i class="bi bi-info-circle"></i> Company Info
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="operating-costs-tab" data-bs-toggle="tab" data-bs-target="#operating-costs" type="button" role="tab">
<i class="bi bi-calculator"></i> Operating Costs
</button>
</li>
@if (Model.AiPhotoQuotesEnabled)
{
<li class="nav-item" role="presentation">
<button class="nav-link" id="ai-profile-tab" data-bs-toggle="tab" data-bs-target="#ai-profile" type="button" role="tab">
<i class="bi bi-robot"></i> AI Profile
</button>
</li>
}
<li class="nav-item" role="presentation">
<button class="nav-link" id="quoting-calibration-tab" data-bs-toggle="tab" data-bs-target="#quoting-calibration" type="button" role="tab">
<i class="bi bi-speedometer2"></i> Shop Equipment Profile
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="app-defaults-tab" data-bs-toggle="tab" data-bs-target="#app-defaults" type="button" role="tab">
<i class="bi bi-sliders"></i> App Defaults
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="job-defaults-tab" data-bs-toggle="tab" data-bs-target="#job-defaults" type="button" role="tab">
<i class="bi bi-gear"></i> Job &amp; Workflow
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="notifications-tab" data-bs-toggle="tab" data-bs-target="#notifications" type="button" role="tab">
<i class="bi bi-bell"></i> Notifications
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="data-retention-tab" data-bs-toggle="tab" data-bs-target="#data-retention" type="button" role="tab">
<i class="bi bi-archive"></i> Data Retention
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="data-lookups-tab" data-bs-toggle="tab" data-bs-target="#data-lookups" type="button" role="tab">
<i class="bi bi-list-ul"></i> Data Lookups
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="pdf-templates-tab" data-bs-toggle="tab" data-bs-target="#pdf-templates" type="button" role="tab">
<i class="bi bi-file-earmark-pdf"></i> PDF Templates
</button>
</li>
@if (Model.AllowOnlinePayments)
{
<li class="nav-item" role="presentation">
<button class="nav-link" id="online-payments-tab" data-bs-toggle="tab" data-bs-target="#online-payments" type="button" role="tab">
<i class="bi bi-credit-card"></i> Online Payments
</button>
</li>
}
<li class="nav-item" role="presentation">
<button class="nav-link" id="kiosk-tab" data-bs-toggle="tab" data-bs-target="#kiosk" type="button" role="tab">
<i class="bi bi-tablet"></i> Kiosk
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="timeclock-tab" data-bs-toggle="tab" data-bs-target="#timeclock" type="button" role="tab">
<i class="bi bi-clock-history"></i> Timeclock
</button>
</li>
@if (ViewBag.AllowCustomFormulas == true)
{
<li class="nav-item" role="presentation">
<button class="nav-link" id="custom-formulas-tab" data-bs-toggle="tab" data-bs-target="#custom-formulas" type="button" role="tab">
<i class="bi bi-calculator"></i> Custom Formulas
</button>
</li>
}
</ul>
<!-- Tabs Content -->
<div class="tab-content" id="settingsTabContent">
<!-- Company Info Tab -->
<div class="tab-pane fade show active" id="company-info" role="tabpanel">
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title">Company Information
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Company Information"
data-bs-content="This information appears on every customer-facing document &mdash; quotes, invoices, and PDFs. Keep the company name, address, and email accurate so customers see the right details. The &lt;strong&gt;Primary Contact Email&lt;/strong&gt; is used as the reply-to address on all outgoing notifications.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Settings#company-information' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
<form id="companyInfoForm">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="companyName" class="form-label">Company Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="companyName" name="CompanyName" value="@Model.CompanyName" required maxlength="200">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="companyCode" class="form-label">Company Code</label>
<input type="text" class="form-control" id="companyCode" name="CompanyCode" value="@Model.CompanyCode" maxlength="10">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="primaryContactName" class="form-label">Primary Contact Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="primaryContactName" name="PrimaryContactName" value="@Model.PrimaryContactName" required maxlength="100">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="primaryContactEmail" class="form-label">Primary Contact Email <span class="text-danger">*</span></label>
<input type="email" class="form-control" id="primaryContactEmail" name="PrimaryContactEmail" value="@Model.PrimaryContactEmail" required maxlength="100">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="phone" class="form-label">Phone</label>
<input type="tel" class="form-control" id="phone" name="Phone" value="@Model.Phone" maxlength="20">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="timeZone" class="form-label">Time Zone</label>
<select class="form-select" id="timeZone" name="TimeZone">
<optgroup label="United States">
<option value="America/New_York" selected="@(Model.TimeZone == "America/New_York" ? "selected" : null)">Eastern (ET) &mdash; New York</option>
<option value="America/Chicago" selected="@(Model.TimeZone == "America/Chicago" ? "selected" : null)">Central (CT) &mdash; Chicago</option>
<option value="America/Denver" selected="@(Model.TimeZone == "America/Denver" ? "selected" : null)">Mountain (MT) &mdash; Denver</option>
<option value="America/Phoenix" selected="@(Model.TimeZone == "America/Phoenix" ? "selected" : null)">Mountain no-DST &mdash; Phoenix</option>
<option value="America/Los_Angeles" selected="@(Model.TimeZone == "America/Los_Angeles" ? "selected" : null)">Pacific (PT) &mdash; Los Angeles</option>
<option value="America/Anchorage" selected="@(Model.TimeZone == "America/Anchorage" ? "selected" : null)">Alaska (AKT) &mdash; Anchorage</option>
<option value="Pacific/Honolulu" selected="@(Model.TimeZone == "Pacific/Honolulu" ? "selected" : null)">Hawaii (HT) &mdash; Honolulu</option>
</optgroup>
<optgroup label="Canada">
<option value="America/Halifax" selected="@(Model.TimeZone == "America/Halifax" ? "selected" : null)">Atlantic (AT) &mdash; Halifax</option>
<option value="America/Toronto" selected="@(Model.TimeZone == "America/Toronto" ? "selected" : null)">Eastern &mdash; Toronto</option>
<option value="America/Winnipeg" selected="@(Model.TimeZone == "America/Winnipeg" ? "selected" : null)">Central &mdash; Winnipeg</option>
<option value="America/Edmonton" selected="@(Model.TimeZone == "America/Edmonton" ? "selected" : null)">Mountain &mdash; Edmonton</option>
<option value="America/Vancouver" selected="@(Model.TimeZone == "America/Vancouver" ? "selected" : null)">Pacific &mdash; Vancouver</option>
</optgroup>
<optgroup label="Europe">
<option value="Europe/London" selected="@(Model.TimeZone == "Europe/London" ? "selected" : null)">GMT/BST &mdash; London</option>
<option value="Europe/Paris" selected="@(Model.TimeZone == "Europe/Paris" ? "selected" : null)">CET &mdash; Paris / Berlin</option>
<option value="Europe/Helsinki" selected="@(Model.TimeZone == "Europe/Helsinki" ? "selected" : null)">EET &mdash; Helsinki</option>
<option value="Europe/Moscow" selected="@(Model.TimeZone == "Europe/Moscow" ? "selected" : null)">MSK &mdash; Moscow</option>
</optgroup>
<optgroup label="Asia / Pacific">
<option value="Asia/Dubai" selected="@(Model.TimeZone == "Asia/Dubai" ? "selected" : null)">GST &mdash; Dubai</option>
<option value="Asia/Kolkata" selected="@(Model.TimeZone == "Asia/Kolkata" ? "selected" : null)">IST &mdash; India</option>
<option value="Asia/Bangkok" selected="@(Model.TimeZone == "Asia/Bangkok" ? "selected" : null)">ICT &mdash; Bangkok</option>
<option value="Asia/Shanghai" selected="@(Model.TimeZone == "Asia/Shanghai" ? "selected" : null)">CST &mdash; Beijing / Shanghai</option>
<option value="Asia/Tokyo" selected="@(Model.TimeZone == "Asia/Tokyo" ? "selected" : null)">JST &mdash; Tokyo</option>
<option value="Australia/Sydney" selected="@(Model.TimeZone == "Australia/Sydney" ? "selected" : null)">AEST &mdash; Sydney</option>
<option value="Pacific/Auckland" selected="@(Model.TimeZone == "Pacific/Auckland" ? "selected" : null)">NZST &mdash; Auckland</option>
</optgroup>
<optgroup label="South America">
<option value="America/Sao_Paulo" selected="@(Model.TimeZone == "America/Sao_Paulo" ? "selected" : null)">BRT &mdash; São Paulo</option>
<option value="America/Buenos_Aires" selected="@(Model.TimeZone == "America/Buenos_Aires" ? "selected" : null)">ART &mdash; Buenos Aires</option>
</optgroup>
<optgroup label="UTC">
<option value="UTC" selected="@(Model.TimeZone == "UTC" ? "selected" : null)">UTC</option>
</optgroup>
</select>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="accountingMethod" class="form-label">Accounting Method</label>
<select class="form-select" id="accountingMethod" name="AccountingMethod">
<option value="1" selected="@(Model.AccountingMethod == PowderCoating.Core.Enums.AccountingMethod.Accrual ? "selected" : null)">Accrual (default)</option>
<option value="0" selected="@(Model.AccountingMethod == PowderCoating.Core.Enums.AccountingMethod.Cash ? "selected" : null)">Cash Basis</option>
</select>
<div class="form-text">Affects how financial reports (P&amp;L, Balance Sheet, Cash Flow) present data. Switching does not re-post historical transactions.</div>
</div>
</div>
</div>
<!-- Period Lock -->
<div class="row mb-4">
<div class="col-12">
<h6 class="border-bottom pb-2 mb-3 text-muted">Period Locking</h6>
</div>
<div class="col-md-4">
@{
var lockThrough = ViewBag.BookLockedThrough as DateTime?;
}
<label class="form-label">Books Locked Through</label>
@if (lockThrough.HasValue)
{
<div class="input-group">
<span class="input-group-text"><i class="bi bi-lock-fill text-warning"></i></span>
<input type="text" class="form-control" value="@lockThrough.Value.ToString("MMMM d, yyyy")" readonly />
<form asp-action="SetPeriodLock" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-secondary" title="Clear lock">
<i class="bi bi-unlock"></i> Clear
</button>
</form>
</div>
<div class="form-text text-warning">Entries dated on or before this date are blocked.</div>
}
else
{
<div class="text-muted small mb-2">No period lock set &mdash; all dates are open.</div>
}
</div>
<div class="col-md-4">
<label class="form-label">Set New Lock Date</label>
<form asp-action="SetPeriodLock" method="post" class="d-flex gap-2">
@Html.AntiForgeryToken()
<input type="date" name="lockThrough" class="form-control" />
<button type="submit" class="btn btn-warning" onclick="return confirm('Lock all accounting periods on or before this date? Users will not be able to post or edit entries in those periods.')">
<i class="bi bi-lock me-1"></i>Lock
</button>
</form>
<div class="form-text">Prevents backdating any GL entry (bills, JEs) into closed periods.</div>
</div>
</div>
<div class="mb-3">
<label for="address" class="form-label">Address</label>
<input type="text" class="form-control" id="address" name="Address" value="@Model.Address" maxlength="200">
</div>
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label for="city" class="form-label">City</label>
<input type="text" class="form-control" id="city" name="City" value="@Model.City" maxlength="100">
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="state" class="form-label">State</label>
<input type="text" class="form-control" id="state" name="State" value="@Model.State" maxlength="2">
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="zipCode" class="form-label">Zip Code</label>
<input type="text" class="form-control" id="zipCode" name="ZipCode" value="@Model.ZipCode" maxlength="10">
</div>
</div>
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary" id="btnSaveCompanyInfo">
<i class="bi bi-save"></i> Save Changes
</button>
</div>
</form>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title">Company Logo</h5>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Current Logo</label>
<div id="logoPreview" class="border rounded p-3 text-center bg-light" style="min-height: 200px;">
@if (Model.HasLogo)
{
<img src="@Url.Action("Logo", "CompanySettings")" alt="Company Logo" class="img-fluid" style="max-height: 200px;" />
}
else
{
<p class="text-muted mt-5">No logo uploaded</p>
}
</div>
</div>
@if (Model.HasLogo)
{
<button type="button" class="btn btn-danger" id="btnDeleteLogo">
<i class="bi bi-trash"></i> Delete Logo
</button>
}
</div>
<div class="col-md-6">
<form id="logoUploadForm" enctype="multipart/form-data">
<div class="mb-3">
<label for="logoFile" class="form-label">Upload New Logo</label>
<input type="file" class="form-control" id="logoFile" name="logoFile" accept=".jpg,.jpeg,.png,.gif,.webp">
<div class="form-text">
Allowed formats: JPG, PNG, GIF, WEBP<br>
Maximum size: 10 MB
</div>
</div>
<button type="submit" class="btn btn-primary" id="btnUploadLogo">
<i class="bi bi-upload"></i> Upload Logo
</button>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Operating Costs Tab -->
<div class="tab-pane fade" id="operating-costs" role="tabpanel">
<form id="operatingCostsForm">
<!-- Header -->
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title mb-1">Operating Costs Configuration
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Operating Costs"
data-bs-content="These are the rates the quoting engine uses to price every job automatically. Set them to your real shop costs and the system will produce accurate quotes without manual calculation. &lt;strong&gt;New quotes use the current rates&lt;/strong&gt; &mdash; changing a rate here does not retroactively reprice existing quotes.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Settings#pricing-configuration' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
<p class="text-muted mb-0">Configure your operating costs for accurate job quoting calculations.</p>
</div>
</div>
<!-- Rates & Costs -->
<div class="card mt-3 border-0 shadow-sm">
<div class="card-header bg-transparent fw-semibold">
<i class="bi bi-currency-dollar text-primary me-1"></i> Rates &amp; Costs
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Rates &amp; Costs"
data-bs-content="&lt;strong&gt;Standard Labor Rate&lt;/strong&gt; is the baseline $/hr for all coating work &mdash; sandblasting and masking are multiplied from this. &lt;strong&gt;Powder Coating Cost/sq ft&lt;/strong&gt; is the fallback material rate used when you don't select a specific powder inventory item on a quote item. &lt;strong&gt;Additional Coat Labor&lt;/strong&gt; is the percentage of the base labor cost charged for each coat after the first (e.g. 30% means a 2nd coat adds 30% more labor).">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="mb-3">
<label for="standardLaborRate" class="form-label">Standard Labor Rate <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" class="form-control" id="standardLaborRate" name="StandardLaborRate" value="@(Model.OperatingCosts?.StandardLaborRate ?? 0)" min="0" max="10000" required>
<span class="input-group-text">/hr</span>
</div>
<small class="text-muted">Billing rate used in quotes and pricing</small>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="laborCostPerHour" class="form-label">Shop Labor Cost Rate</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" class="form-control" id="laborCostPerHour" name="LaborCostPerHour" value="@(Model.OperatingCosts?.LaborCostPerHour?.ToString() ?? "")" min="0" max="10000" placeholder="@(((Model.OperatingCosts?.StandardLaborRate ?? 0) * 0.20m).ToString("0.00"))">
<span class="input-group-text">/hr</span>
</div>
<small class="text-muted">Actual wage cost for job costing &amp; profit display only &mdash; never shown to customers. Leave blank to default to 20% of billing rate.</small>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="additionalCoatLaborPercent" class="form-label">Additional Coat Labor</label>
<div class="input-group">
<input type="number" step="1" class="form-control" id="additionalCoatLaborPercent" name="AdditionalCoatLaborPercent" value="@(Model.OperatingCosts?.AdditionalCoatLaborPercent ?? 30)" min="0" max="100">
<span class="input-group-text">%</span>
</div>
<small class="text-muted">Labor % for each coat after the first</small>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="powderCoatingCostPerSqFt" class="form-label">Powder Coating Cost</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.0001" class="form-control" id="powderCoatingCostPerSqFt" name="PowderCoatingCostPerSqFt" value="@(Model.OperatingCosts?.PowderCoatingCostPerSqFt ?? 0)" min="0" max="1000">
<span class="input-group-text">/sq ft</span>
</div>
<small class="text-muted">Default when no inventory item selected</small>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="taxPercent" class="form-label">Default Tax Rate</label>
<div class="input-group">
<input type="number" step="0.01" class="form-control" id="taxPercent" name="TaxPercent" value="@(Model.OperatingCosts?.TaxPercent ?? 0)" min="0" max="100">
<span class="input-group-text">%</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="shopSuppliesRate" class="form-label">Shop Supplies Rate</label>
<div class="input-group">
<input type="number" step="0.01" class="form-control" id="shopSuppliesRate" name="ShopSuppliesRate" value="@(Model.OperatingCosts?.ShopSuppliesRate ?? 0)" min="0" max="100">
<span class="input-group-text">%</span>
</div>
<small class="text-muted">Applied to materials/labor</small>
</div>
</div>
</div>
</div>
</div>
<!-- Facility Overhead -->
<div class="card mt-3 border-0 shadow-sm">
<div class="card-header bg-transparent fw-semibold">
<i class="bi bi-building text-primary me-1"></i> Facility Overhead
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Facility Overhead"
data-bs-content="Enter your monthly shop rent and combined utility costs. The system divides these by your estimated billable hours to derive a per-hour overhead rate, which is then added to every quote proportionally to the estimated job time. This ensures fixed facility costs are recovered across all jobs rather than absorbed into your markup.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<div class="row align-items-start">
<div class="col-md-3">
<div class="mb-3">
<label for="monthlyRent" class="form-label">Monthly Rent</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" class="form-control facility-overhead-input" id="monthlyRent" name="MonthlyRent" value="@(Model.OperatingCosts?.MonthlyRent ?? 0)" min="0" max="1000000">
<span class="input-group-text">/mo</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="monthlyUtilities" class="form-label">Monthly Utilities</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" class="form-control facility-overhead-input" id="monthlyUtilities" name="MonthlyUtilities" value="@(Model.OperatingCosts?.MonthlyUtilities ?? 0)" min="0" max="1000000">
<span class="input-group-text">/mo</span>
</div>
<small class="text-muted">Electricity, gas, water, internet</small>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="monthlyBillableHours" class="form-label">Billable Hours/Month</label>
<div class="input-group">
<input type="number" step="1" class="form-control facility-overhead-input" id="monthlyBillableHours" name="MonthlyBillableHours" value="@(Model.OperatingCosts?.MonthlyBillableHours ?? 160)" min="1" max="10000">
<span class="input-group-text">hrs</span>
</div>
<small class="text-muted">Typical: 160 hrs (4 wks &times; 40 hrs)</small>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label class="form-label text-muted">Calculated Rate</label>
<div class="input-group">
<span class="input-group-text bg-light"><i class="bi bi-calculator"></i></span>
<input type="text" class="form-control bg-light" id="facilityOverheadRateDisplay" readonly
value="@((Model.OperatingCosts?.FacilityOverheadRatePerHour ?? 0).ToString("C2")) / hr">
</div>
<small class="text-muted">Added to quotes per estimated labor hour</small>
</div>
</div>
</div>
</div>
</div>
<!-- Equipment Operating Costs -->
<div class="card mt-3 border-0 shadow-sm">
<div class="card-header bg-transparent fw-semibold">
<i class="bi bi-tools text-primary me-1"></i> Equipment Operating Costs
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Equipment Operating Costs"
data-bs-content="The hourly cost of running each piece of equipment, including energy and depreciation. These are added to quote items based on the prep services selected. The &lt;strong&gt;Default Oven Rate&lt;/strong&gt; is used on quotes where no named oven is chosen &mdash; add individual shop ovens below if you have multiple ovens with different capacities and costs.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label for="ovenOperatingCostPerHour" class="form-label">Default Oven Rate <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" class="form-control" id="ovenOperatingCostPerHour" name="OvenOperatingCostPerHour" value="@(Model.OperatingCosts?.OvenOperatingCostPerHour ?? 0)" min="0" max="10000" required>
<span class="input-group-text">/hr</span>
</div>
<small class="text-muted">Used when no specific oven is selected on a quote.</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="sandblasterCostPerHour" class="form-label">Sandblaster</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" class="form-control" id="sandblasterCostPerHour" name="SandblasterCostPerHour" value="@(Model.OperatingCosts?.SandblasterCostPerHour ?? 0)" min="0" max="10000">
<span class="input-group-text">/hr</span>
</div>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="coatingBoothCostPerHour" class="form-label">Coating Booth</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" class="form-control" id="coatingBoothCostPerHour" name="CoatingBoothCostPerHour" value="@(Model.OperatingCosts?.CoatingBoothCostPerHour ?? 0)" min="0" max="10000">
<span class="input-group-text">/hr</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Pricing & Profit -->
<div class="card mt-3 border-0 shadow-sm">
<div class="card-header bg-transparent fw-semibold">
<i class="bi bi-graph-up-arrow text-primary me-1"></i> Pricing &amp; Profit
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Pricing &amp; Profit"
data-bs-content="&lt;strong&gt;Markup mode&lt;/strong&gt; adds a % on top of material costs only (labor and equipment pass through at cost). &lt;strong&gt;Margin mode&lt;/strong&gt; targets a gross margin % of the total selling price &mdash; e.g. 30% margin on a $100 cost base gives a $142.86 price. Note: margin % and markup % are not the same number. &lt;strong&gt;Shop Minimum&lt;/strong&gt; sets a floor price for any job.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
@{
var currentPricingMode = (int)(Model.OperatingCosts?.PricingMode ?? PowderCoating.Core.Enums.PricingMode.MarkupOnMaterial);
}
<div class="mb-3">
<label class="form-label fw-semibold">Pricing Mode</label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="pricingModeRadio" id="pricingModeMarkup" value="0"
@(currentPricingMode == 0 ? "checked" : "") onchange="onPricingModeChange()">
<label class="form-check-label" for="pricingModeMarkup">
<strong>Markup</strong> &mdash; add % to material costs
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="pricingModeRadio" id="pricingModeMargin" value="1"
@(currentPricingMode == 1 ? "checked" : "") onchange="onPricingModeChange()">
<label class="form-check-label" for="pricingModeMargin">
<strong>Margin</strong> &mdash; target gross margin % of selling price
</label>
</div>
</div>
<input type="hidden" id="pricingModeValue" name="PricingMode" value="@currentPricingMode">
</div>
<div class="row">
<div class="col-md-4" id="markupSection" style="display:@(currentPricingMode == 0 ? "block" : "none")">
<div class="mb-3">
<label for="generalMarkupPercentage" class="form-label">Markup % <span class="text-danger">*</span></label>
<div class="input-group">
<input type="number" step="0.01" class="form-control" id="generalMarkupPercentage" name="GeneralMarkupPercentage" value="@(Model.OperatingCosts?.GeneralMarkupPercentage ?? 0)" min="0" max="100">
<span class="input-group-text">%</span>
</div>
<small class="text-muted">Added on top of material costs</small>
</div>
</div>
<div class="col-md-4" id="marginSection" style="display:@(currentPricingMode == 1 ? "block" : "none")">
<div class="mb-3">
<label for="targetMarginPercent" class="form-label">Target Margin % <span class="text-danger">*</span></label>
<div class="input-group">
<input type="number" step="0.01" class="form-control" id="targetMarginPercent" name="TargetMarginPercent" value="@(Model.OperatingCosts?.TargetMarginPercent ?? 0)" min="0" max="99">
<span class="input-group-text">%</span>
</div>
<small class="text-muted">% of selling price kept as profit</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="shopMinimumCharge" class="form-label">Shop Minimum</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" class="form-control" id="shopMinimumCharge" name="ShopMinimumCharge" value="@(Model.OperatingCosts?.ShopMinimumCharge ?? 0)" min="0" max="100000">
</div>
<small class="text-muted">Minimum charge for any job/quote</small>
</div>
</div>
</div>
</div>
</div>
<!-- Rush Charges -->
<div class="card mt-3 border-0 shadow-sm">
<div class="card-header bg-transparent fw-semibold">
<i class="bi bi-lightning-charge text-primary me-1"></i> Rush Charges
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Rush Charges"
data-bs-content="When a quote is marked as a &lt;strong&gt;Rush Job&lt;/strong&gt;, this charge is automatically added to the total. Choose &lt;strong&gt;Percentage&lt;/strong&gt; to add a % of the subtotal (e.g. 25% rush surcharge) or &lt;strong&gt;Fixed Amount&lt;/strong&gt; to add a flat fee (e.g. $75 rush fee). The rush charge appears as its own line on the quote.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Rush Charge Type</label>
<div class="btn-group w-100" role="group">
<input type="radio" class="btn-check" name="rushChargeTypeRadio" id="rushChargeTypePercentage" value="Percentage" checked="@((Model.OperatingCosts?.RushChargeType ?? "Percentage") == "Percentage")">
<label class="btn btn-outline-primary" for="rushChargeTypePercentage">
<i class="bi bi-percent"></i> Percentage
</label>
<input type="radio" class="btn-check" name="rushChargeTypeRadio" id="rushChargeTypeFixed" value="FixedAmount" checked="@((Model.OperatingCosts?.RushChargeType) == "FixedAmount")">
<label class="btn btn-outline-primary" for="rushChargeTypeFixed">
<i class="bi bi-currency-dollar"></i> Fixed Amount
</label>
</div>
</div>
</div>
<div class="col-md-6">
<div id="rushChargePercentageInput" style="display: @((Model.OperatingCosts?.RushChargeType ?? "Percentage") == "Percentage" ? "block" : "none")">
<div class="mb-3">
<label for="rushChargePercentage" class="form-label">Rush Charge Percentage</label>
<div class="input-group">
<input type="number" step="0.01" class="form-control" id="rushChargePercentage" name="RushChargePercentage" value="@(Model.OperatingCosts?.RushChargePercentage ?? 0)" min="0" max="100">
<span class="input-group-text">%</span>
</div>
<small class="text-muted">Percentage of subtotal added for rush jobs</small>
</div>
</div>
<div id="rushChargeFixedInput" style="display: @((Model.OperatingCosts?.RushChargeType) == "FixedAmount" ? "block" : "none")">
<div class="mb-3">
<label for="rushChargeFixedAmount" class="form-label">Rush Charge Amount</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" class="form-control" id="rushChargeFixedAmount" name="RushChargeFixedAmount" value="@(Model.OperatingCosts?.RushChargeFixedAmount ?? 0)" min="0" max="100000">
</div>
<small class="text-muted">Fixed dollar amount added for rush jobs</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Part Complexity Multipliers -->
<div class="card mt-3 border-0 shadow-sm">
<div class="card-header bg-transparent fw-semibold">
<i class="bi bi-layers text-primary me-1"></i> Part Complexity Multipliers
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Part Complexity Multipliers"
data-bs-content="A percentage added to the price of &lt;strong&gt;calculated items&lt;/strong&gt; based on how intricate the part is. When adding an item in a quote, staff select a complexity level &mdash; the system then applies this multiplier to account for the extra time and care needed. &lt;em&gt;Simple&lt;/em&gt; = 0% (flat panels, basic shapes). &lt;em&gt;Extreme&lt;/em&gt; = highly detailed, tight recesses, masking-intensive parts.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<p class="text-muted small mb-3">Percentage added to the calculated item price based on part intricacy. Applied to calculated items only (not catalog, generic, or labor items).</p>
<div class="row g-3">
<div class="col-sm-6 col-md-3">
<label for="complexitySimplePercent" class="form-label">Simple (%)</label>
<div class="input-group">
<input type="number" step="0.1" class="form-control" id="complexitySimplePercent" name="ComplexitySimplePercent" value="@(Model.OperatingCosts?.ComplexitySimplePercent ?? 0)" min="0" max="500">
<span class="input-group-text">%</span>
</div>
<small class="text-muted">No added complexity</small>
</div>
<div class="col-sm-6 col-md-3">
<label for="complexityModeratePercent" class="form-label">Moderate (%)</label>
<div class="input-group">
<input type="number" step="0.1" class="form-control" id="complexityModeratePercent" name="ComplexityModeratePercent" value="@(Model.OperatingCosts?.ComplexityModeratePercent ?? 5)" min="0" max="500">
<span class="input-group-text">%</span>
</div>
<small class="text-muted">Some detail work</small>
</div>
<div class="col-sm-6 col-md-3">
<label for="complexityComplexPercent" class="form-label">Complex (%)</label>
<div class="input-group">
<input type="number" step="0.1" class="form-control" id="complexityComplexPercent" name="ComplexityComplexPercent" value="@(Model.OperatingCosts?.ComplexityComplexPercent ?? 15)" min="0" max="500">
<span class="input-group-text">%</span>
</div>
<small class="text-muted">Intricate parts</small>
</div>
<div class="col-sm-6 col-md-3">
<label for="complexityExtremePercent" class="form-label">Extreme (%)</label>
<div class="input-group">
<input type="number" step="0.1" class="form-control" id="complexityExtremePercent" name="ComplexityExtremePercent" value="@(Model.OperatingCosts?.ComplexityExtremePercent ?? 25)" min="0" max="500">
<span class="input-group-text">%</span>
</div>
<small class="text-muted">Highly detailed/difficult</small>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-end mt-4 mb-2">
<button type="submit" class="btn btn-primary" id="btnSaveOperatingCosts">
<i class="bi bi-save"></i> Save Operating Costs
</button>
</div>
</form>
</div>
<!-- Oven Cost Add/Edit Modal (outside all forms to avoid form interaction issues) -->
<div class="modal fade" id="ovenModal" tabindex="-1" aria-labelledby="ovenModalTitle" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ovenModalTitle">Add Oven</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="ovenModalId" value="">
<div class="mb-3">
<label for="ovenLabelInput" class="form-label">Label <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="ovenLabelInput" maxlength="100" placeholder="e.g. Large Oven #1">
</div>
<div class="mb-3">
<label for="ovenCostInput" class="form-label">Cost Per Hour <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="ovenCostInput" step="0.01" min="0" max="10000" placeholder="0.00">
<span class="input-group-text">/hr</span>
</div>
</div>
<div class="mb-3">
<label for="ovenMaxSqFtInput" class="form-label">
Max Capacity (sq ft)
<a href="#" class="ms-1 small text-decoration-none" id="ovenCalcToggle" title="Calculate from oven dimensions">
<i class="bi bi-calculator"></i>
</a>
</label>
<input type="number" class="form-control" id="ovenMaxSqFtInput" step="0.1" min="0" max="100000" placeholder="e.g. 120">
<small class="text-muted">Used by Oven Scheduler</small>
</div>
<!-- Dimension calculator (hidden by default) -->
<div id="ovenDimCalc" class="d-none mb-3 p-2 border rounded bg-light">
<div class="small fw-semibold mb-2 text-muted"><i class="bi bi-rulers me-1"></i>Calculate from dimensions (ft)</div>
<div class="d-flex align-items-center gap-1 flex-wrap">
<div class="d-flex align-items-center gap-1">
<input type="number" class="form-control form-control-sm" id="ovenDimW" placeholder="W" step="0.1" min="0" style="width:55px;" title="Width (ft)">
<span class="text-muted">×</span>
<input type="number" class="form-control form-control-sm" id="ovenDimD" placeholder="D" step="0.1" min="0" style="width:55px;" title="Depth (ft)">
<span class="text-muted">×</span>
<input type="number" class="form-control form-control-sm" id="ovenDimH" placeholder="H" step="0.1" min="0" style="width:55px;" title="Height (ft)">
<span class="text-muted small">ft</span>
</div>
<div class="d-flex align-items-center gap-1 ms-auto">
<span class="text-muted">=</span>
<span id="ovenDimResult" class="fw-semibold small text-primary" style="min-width:65px;">&mdash;</span>
<button type="button" class="btn btn-sm btn-outline-primary" id="ovenDimApply" disabled>Use</button>
</div>
</div>
<div class="text-muted mt-1" style="font-size:.72rem;">W × D × H of oven interior &mdash; 20% deducted for rack &amp; wall depth</div>
</div>
<div class="mb-3">
<label for="ovenOrderInput" class="form-label">Display Order</label>
<input type="number" class="form-control" id="ovenOrderInput" min="0" max="9999" value="0">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="ovenActiveInput" checked>
<label class="form-check-label" for="ovenActiveInput">Active</label>
</div>
<div id="ovenErrorMsg" class="alert alert-danger d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveOven()">Save</button>
</div>
</div>
</div>
</div>
<!-- AI Profile Tab (only shown when AI Photo Quotes are enabled) -->
@if (Model.AiPhotoQuotesEnabled)
{
<div class="tab-pane fade" id="ai-profile" role="tabpanel">
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-robot text-primary me-1"></i> AI Photo Quote Profile
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="AI Photo Quote Profile"
data-bs-content="Describe your shop's specialties, typical items, and pricing style in plain language. This text is injected into the AI's system prompt every time a photo quote is analysed &mdash; the more specific you are, the better calibrated the estimates will be for your business. You can also describe anything the AI tends to get wrong. &lt;br&gt;&lt;br&gt;&lt;strong&gt;Additionally&lt;/strong&gt;, the AI automatically learns from quotes your team accepted without overriding &mdash; those become calibration examples that improve accuracy over time.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
<p class="text-muted">Tell the AI about your shop so it produces better estimates for your specific work and pricing style.</p>
<div class="row g-4">
<div class="col-lg-8">
<div class="mb-3">
<label for="aiContextProfile" class="form-label fw-semibold">Shop Description</label>
<textarea id="aiContextProfile" class="form-control" rows="8" maxlength="2000"
placeholder="Examples:&#10;• We specialise in automotive restoration &mdash; wheels, frames, suspension brackets, and roll cages are our bread and butter.&#10;• Our customers expect premium pricing. We rarely work on items over 20 sqft.&#10;• Most items come to us already stripped; sandblasting adds roughly 15 min per item on average.&#10;• We use a 2-stage cure cycle &mdash; pre-heat 10 min, coat, cure 20 min at 400°F.">@(Model.OperatingCosts?.AiContextProfile)</textarea>
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Plain language &mdash; write it as if briefing a new estimator on your shop.</small>
<small class="text-muted"><span id="aiProfileCharCount">@(Model.OperatingCosts?.AiContextProfile?.Length ?? 0)</span>/2000</small>
</div>
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<button type="button" class="btn btn-primary" id="btnSaveAiProfile">
<i class="bi bi-floppy me-1"></i> Save AI Profile
</button>
<button type="button" class="btn btn-outline-secondary" id="btnGenerateAiDraft"
title="Build a suggested profile from your existing settings &mdash; ovens, workers, inventory categories, and rates">
<i class="bi bi-stars me-1"></i> Generate from my settings
</button>
<span id="aiProfileStatus" class="small"></span>
</div>
</div>
<div class="col-lg-4">
<div class="card bg-light border-0">
<div class="card-body">
<h6 class="card-title"><i class="bi bi-lightbulb text-warning me-1"></i> How AI Learning Works</h6>
<p class="small mb-2"><strong>Layer 1 &mdash; Pricing config:</strong> Your operating costs (labor, equipment, markup) are always injected automatically.</p>
<p class="small mb-2"><strong>Layer 2 &mdash; Your shop profile:</strong> The description you write here is added to every AI analysis, guiding estimates toward your typical work.</p>
<p class="small mb-0"><strong>Layer 3 &mdash; Automatic learning:</strong> Each time your team accepts an AI estimate without changing it, that item is silently added as a calibration example. The AI improves on its own the more you use it.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
}
<!-- Shop Equipment Profile Tab -->
<div class="tab-pane fade" id="quoting-calibration" role="tabpanel">
@{
var costs = Model.OperatingCosts;
var tierVal = (int)(costs?.ShopCapabilityTier ?? PowderCoating.Core.Enums.ShopCapabilityTier.Small);
}
<div class="card shadow-sm mt-3">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-speedometer2 me-2"></i>Shop Profile</h5>
</div>
<div class="card-body">
<p class="text-muted small mb-4">
Tell the quoting engine what size shop you're running. This gives the AI context about your capacity
when generating estimates and is used as a fallback when specific equipment rates aren't configured.
</p>
<div class="mb-3">
<label class="form-label fw-medium">Shop Size</label>
<select class="form-select" id="shopCapabilityTier" style="max-width:320px">
<option value="0" selected="@(tierVal == 0 ? "selected" : null)">Garage &mdash; Home setup, part-time</option>
<option value="1" selected="@(tierVal == 1 ? "selected" : null)">Small &mdash; 1&ndash;5 person shop</option>
<option value="2" selected="@(tierVal == 2 ? "selected" : null)">Medium &mdash; Established shop, 5&ndash;10 people</option>
<option value="3" selected="@(tierVal == 3 ? "selected" : null)">Large &mdash; High-volume, 10+ people</option>
</select>
<div class="form-text">Used by the AI when estimating job complexity and throughput.</div>
</div>
<div class="mt-3">
<button type="button" class="btn btn-primary" id="saveBlastProfile">
<i class="bi bi-check-circle me-1"></i>Save
</button>
</div>
<div id="blastProfileStatus" class="mt-2"></div>
</div>
</div>
<!-- ── Named Blast Setups ──────────────────────────────────────── -->
<div class="card shadow-sm mt-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-fan me-2"></i>Named Blast Setups</h5>
<button type="button" class="btn btn-primary btn-sm" id="btnAddBlastSetup">
<i class="bi bi-plus-circle"></i> Add Setup
</button>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Define each blast rig your shop uses (cabinet, pressure pot, blast room, etc.).
When quoting, workers pick the rig they'll actually use so time estimates are accurate.
Mark one as the <strong>Default</strong> to pre-select it in AI Photo Quotes.
</p>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>CFM</th>
<th>Derived Rate</th>
<th>Status</th>
<th style="width:100px;">Actions</th>
</tr>
</thead>
<tbody id="blastSetupsTableBody">
</tbody>
</table>
</div>
<div id="blastSetupError" class="alert alert-danger d-none"></div>
</div>
</div>
<!-- ── Named Ovens ──────────────────────────────────────────────── -->
<div class="card shadow-sm mt-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-thermometer-high me-2"></i>Named Ovens</h5>
<button type="button" class="btn btn-primary btn-sm" onclick="showAddOvenModal()">
<i class="bi bi-plus-circle me-1"></i>Add Oven
</button>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Add each oven with its rate and capacity. The Oven Scheduler uses capacity and cycle time to plan batches; quotes use the per-hour rate.
If no specific oven is selected on a quote, the Default Oven Rate from Operating Costs is used.
</p>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle" id="ovenCostsTable">
<thead class="table-light">
<tr>
<th>Label</th>
<th>Rate/hr</th>
<th>Capacity (sqft)</th>
<th>Order</th>
<th>Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody id="ovenCostsBody">
</tbody>
</table>
</div>
<span class="text-muted small" id="ovenCountText"></span>
</div>
</div>
</div>
<!-- App Defaults Tab -->
<div class="tab-pane fade" id="app-defaults" role="tabpanel">
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title">Application Defaults
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Application Defaults"
data-bs-content="These values are pre-filled when creating new records so staff don't have to set them every time. They can always be overridden on individual records. Set them to match your most common scenario.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Settings#default-settings' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
<p class="text-muted">Default values used when creating new records.</p>
<form id="appDefaultsForm">
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Default Currency</label>
<select class="form-select" id="defaultCurrency" name="DefaultCurrency">
<option value="USD" selected="@(Model.Preferences?.DefaultCurrency == "USD" ? "selected" : null)">USD &mdash; US Dollar</option>
<option value="CAD" selected="@(Model.Preferences?.DefaultCurrency == "CAD" ? "selected" : null)">CAD &mdash; Canadian Dollar</option>
<option value="EUR" selected="@(Model.Preferences?.DefaultCurrency == "EUR" ? "selected" : null)">EUR &mdash; Euro</option>
<option value="GBP" selected="@(Model.Preferences?.DefaultCurrency == "GBP" ? "selected" : null)">GBP &mdash; British Pound</option>
<option value="AUD" selected="@(Model.Preferences?.DefaultCurrency == "AUD" ? "selected" : null)">AUD &mdash; Australian Dollar</option>
</select>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Date Format</label>
<select class="form-select" id="defaultDateFormat" name="DefaultDateFormat">
<option value="MM/dd/yyyy" selected="@(Model.Preferences?.DefaultDateFormat == "MM/dd/yyyy" ? "selected" : null)">MM/DD/YYYY</option>
<option value="dd/MM/yyyy" selected="@(Model.Preferences?.DefaultDateFormat == "dd/MM/yyyy" ? "selected" : null)">DD/MM/YYYY</option>
<option value="yyyy-MM-dd" selected="@(Model.Preferences?.DefaultDateFormat == "yyyy-MM-dd" ? "selected" : null)">YYYY-MM-DD</option>
</select>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Time Format</label>
<select class="form-select" id="defaultTimeFormat" name="DefaultTimeFormat">
<option value="12h" selected="@(Model.Preferences?.DefaultTimeFormat == "12h" ? "selected" : null)">12-hour (AM/PM)</option>
<option value="24h" selected="@(Model.Preferences?.DefaultTimeFormat == "24h" ? "selected" : null)">24-hour</option>
</select>
</div>
</div>
</div>
<h6 class="border-bottom pb-2 mb-3 mt-2">Measurement System</h6>
<div class="row">
<div class="col-md-12">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="useMetricSystem" name="UseMetricSystem" @(Model.Preferences?.UseMetricSystem == true ? "checked" : "")>
<label class="form-check-label" for="useMetricSystem">
Use Metric System (meters, kilograms)
</label>
<div class="form-text">
When enabled, measurements throughout the application will use the metric system (square meters, kilograms) instead of imperial (square feet, pounds).
This affects surface area calculations, inventory coverage, and all measurement displays.
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Default Payment Terms</label>
<select class="form-select" id="defaultPaymentTerms" name="DefaultPaymentTerms">
<option value="Due on Receipt" selected="@(Model.Preferences?.DefaultPaymentTerms == "Due on Receipt" ? "selected" : null)">Due on Receipt</option>
<option value="Net 15" selected="@(Model.Preferences?.DefaultPaymentTerms == "Net 15" ? "selected" : null)">Net 15</option>
<option value="Net 30" selected="@(Model.Preferences?.DefaultPaymentTerms == "Net 30" ? "selected" : null)">Net 30</option>
<option value="Net 60" selected="@(Model.Preferences?.DefaultPaymentTerms == "Net 60" ? "selected" : null)">Net 60</option>
</select>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Quote Validity (days)</label>
<input type="number" class="form-control" id="defaultQuoteValidityDays" name="DefaultQuoteValidityDays" value="@(Model.Preferences?.DefaultQuoteValidityDays ?? 30)" min="1" max="365">
</div>
</div>
</div>
<h6 class="border-bottom pb-2 mb-3 mt-2">Number Prefixes
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Number Prefixes"
data-bs-content="The prefix is combined with a date stamp and sequence number to form record IDs &mdash; for example prefix &lt;strong&gt;QT&lt;/strong&gt; produces &lt;em&gt;QT-2603-0042&lt;/em&gt;. Change the prefix to match your preferred numbering convention. Changing it only affects &lt;strong&gt;new&lt;/strong&gt; records; existing numbers are not renamed.">
<i class="bi bi-question-circle"></i>
</a>
</h6>
<div class="row">
<div class="col-md-3">
<div class="mb-3">
<label class="form-label">Quote Prefix</label>
<input type="text" class="form-control" id="quoteNumberPrefix" name="QuoteNumberPrefix" value="@(Model.Preferences?.QuoteNumberPrefix ?? "QT")" maxlength="10">
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label class="form-label">Job Prefix</label>
<input type="text" class="form-control" id="jobNumberPrefix" name="JobNumberPrefix" value="@(Model.Preferences?.JobNumberPrefix ?? "JOB")" maxlength="10">
</div>
</div>
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary" id="btnSaveAppDefaults">
<i class="bi bi-save"></i> Save Defaults
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Job & Workflow Defaults Tab -->
<div class="tab-pane fade" id="job-defaults" role="tabpanel">
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title">Job &amp; Workflow Defaults
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Job &amp; Workflow Defaults"
data-bs-content="Controls how jobs are created and flow through your shop. &lt;strong&gt;Require Customer PO&lt;/strong&gt; enforces that a PO number is entered before a job can be saved &mdash; useful for commercial accounts. &lt;strong&gt;Allow Customer Approval&lt;/strong&gt; enables the approval step in the job workflow &mdash; when a quote is approved, the job moves to an Approved status before work begins.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
<p class="text-muted">Configure default behavior for jobs and the production workflow.</p>
<form id="jobDefaultsForm">
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Default Job Priority</label>
<select class="form-select" id="defaultJobPriority" name="DefaultJobPriority">
<option value="Low" selected="@(Model.Preferences?.DefaultJobPriority == "Low" ? "selected" : null)">Low</option>
<option value="Normal" selected="@(Model.Preferences?.DefaultJobPriority == "Normal" ? "selected" : null)">Normal</option>
<option value="High" selected="@(Model.Preferences?.DefaultJobPriority == "High" ? "selected" : null)">High</option>
<option value="Urgent" selected="@(Model.Preferences?.DefaultJobPriority == "Urgent" ? "selected" : null)">Urgent</option>
<option value="Rush" selected="@(Model.Preferences?.DefaultJobPriority == "Rush" ? "selected" : null)">Rush</option>
</select>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Default Turnaround (days)</label>
<input type="number" class="form-control" id="defaultTurnaroundDays" name="DefaultTurnaroundDays" value="@(Model.Preferences?.DefaultTurnaroundDays ?? 7)" min="1" max="365">
</div>
</div>
</div>
<h6 class="border-bottom pb-2 mb-3 mt-2">Customer Options</h6>
<div class="row">
<div class="col-md-4">
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="requireCustomerPO" name="RequireCustomerPO" checked="@(Model.Preferences?.RequireCustomerPO == true ? "checked" : null)">
<label class="form-check-label" for="requireCustomerPO">Require Customer PO Number</label>
</div>
</div>
<div class="col-md-4">
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="allowCustomerApproval" name="AllowCustomerApproval" checked="@(Model.Preferences?.AllowCustomerApproval != false ? "checked" : null)">
<label class="form-check-label" for="allowCustomerApproval">Allow Customer Job Approval</label>
</div>
</div>
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary" id="btnSaveJobDefaults">
<i class="bi bi-save"></i> Save Defaults
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Notifications Tab -->
<div class="tab-pane fade" id="notifications" role="tabpanel">
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title">Notifications &amp; Alerts
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Notifications &amp; Alerts"
data-bs-content="Controls which events send emails to your team and customers. Set the &lt;strong&gt;From Email Address&lt;/strong&gt; to a domain you control &mdash; using a domain-verified address prevents emails landing in spam. If left blank, the system default address is used. Turn off notification types you don't need to avoid inbox noise.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
<p class="text-muted">Control which events trigger email notifications and alert thresholds.</p>
@if (ViewBag.SmsEnabled == true)
{
<div class="card border mb-4">
<div class="card-body">
<h6 class="card-title fw-semibold"><i class="bi bi-phone me-1"></i> SMS Notifications</h6>
@if (Model.SmsDisabledByAdmin)
{
<div class="alert alert-danger alert-permanent mb-3 py-2">
<i class="bi bi-slash-circle me-1"></i>
<strong>SMS has been disabled by an administrator.</strong> Contact support to re-enable.
</div>
}
else if (!Model.AllowSms)
{
<div class="alert alert-info alert-permanent mb-3 py-2">
<i class="bi bi-info-circle me-1"></i>
SMS notifications are not included in your current plan. Upgrade to Pro or Enterprise to enable customer SMS alerts.
</div>
}
else
{
<p class="text-muted small mb-3">When enabled, customers who have given SMS consent will receive text alerts for job status changes (e.g. ready for pickup).</p>
<div class="form-check form-switch" id="smsToggleWrap">
<input class="form-check-input" type="checkbox" id="smsEnabledToggle" @(Model.SmsEnabled ? "checked" : "")
data-has-agreement="@(Model.HasCurrentSmsAgreement ? "true" : "false")"
data-terms-version="@Model.SmsTermsVersion">
<label class="form-check-label fw-medium" for="smsEnabledToggle">Enable SMS Notifications</label>
</div>
@if (!Model.HasCurrentSmsAgreement && !Model.SmsEnabled)
{
<div class="form-text text-warning mt-1">
<i class="bi bi-info-circle me-1"></i>You'll need to accept the SMS terms of service the first time you enable this.
</div>
}
}
</div>
</div>
}
<form id="notificationsForm">
<h6 class="border-bottom pb-2 mb-3">Email Sender</h6>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label" for="emailFromAddress">From Email Address</label>
<input type="email" class="form-control" id="emailFromAddress" name="EmailFromAddress"
value="@(Model.Preferences?.EmailFromAddress ?? string.Empty)"
placeholder="e.g. quotes@yourcompany.com" maxlength="100">
<div class="form-text">Leave blank to use the default configured in system settings.</div>
</div>
<div class="col-md-6">
<label class="form-label" for="emailFromName">From Display Name</label>
<input type="text" class="form-control" id="emailFromName" name="EmailFromName"
value="@(Model.Preferences?.EmailFromName ?? string.Empty)"
placeholder="e.g. SCP Powder Coating" maxlength="100">
<div class="form-text">The name customers see in their email client.</div>
</div>
</div>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="emailNotificationsEnabled" name="EmailNotificationsEnabled" checked="@(Model.Preferences?.EmailNotificationsEnabled != false ? "checked" : null)">
<label class="form-check-label fw-semibold" for="emailNotificationsEnabled">Enable Email Notifications</label>
</div>
<h6 class="border-bottom pb-2 mb-3 mt-3">Notify On</h6>
<div class="row">
<div class="col-md-3">
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="notifyOnNewJob" name="NotifyOnNewJob" checked="@(Model.Preferences?.NotifyOnNewJob != false ? "checked" : null)">
<label class="form-check-label" for="notifyOnNewJob">New Job</label>
</div>
</div>
<div class="col-md-3">
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="notifyOnNewQuote" name="NotifyOnNewQuote" checked="@(Model.Preferences?.NotifyOnNewQuote != false ? "checked" : null)">
<label class="form-check-label" for="notifyOnNewQuote">New Quote</label>
</div>
</div>
<div class="col-md-3">
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="notifyOnJobStatusChange" name="NotifyOnJobStatusChange" checked="@(Model.Preferences?.NotifyOnJobStatusChange != false ? "checked" : null)">
<label class="form-check-label" for="notifyOnJobStatusChange">Job Status Change</label>
</div>
</div>
<div class="col-md-3">
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="notifyOnQuoteApproval" name="NotifyOnQuoteApproval" checked="@(Model.Preferences?.NotifyOnQuoteApproval != false ? "checked" : null)">
<label class="form-check-label" for="notifyOnQuoteApproval">Quote Approval</label>
</div>
</div>
<div class="col-md-3">
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="notifyOnPaymentReceived" name="NotifyOnPaymentReceived" checked="@(Model.Preferences?.NotifyOnPaymentReceived != false ? "checked" : null)">
<label class="form-check-label" for="notifyOnPaymentReceived">Payment Received</label>
</div>
</div>
</div>
<h6 class="border-bottom pb-2 mb-3 mt-2">Alert Thresholds
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Alert Thresholds"
data-bs-content="Sets how many days &lt;em&gt;before&lt;/em&gt; an event the system highlights it as upcoming. For example, a &lt;strong&gt;Quote Expiry Warning&lt;/strong&gt; of 3 days means quotes expiring within 3 days are flagged in the quotes list. Set to 0 to disable a warning entirely.">
<i class="bi bi-question-circle"></i>
</a>
</h6>
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Quote Expiry Warning (days before)</label>
<input type="number" class="form-control" id="quoteExpiryWarningDays" name="QuoteExpiryWarningDays" value="@(Model.Preferences?.QuoteExpiryWarningDays ?? 3)" min="0" max="30">
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Due Date Warning (days before)</label>
<input type="number" class="form-control" id="dueDateWarningDays" name="DueDateWarningDays" value="@(Model.Preferences?.DueDateWarningDays ?? 2)" min="0" max="30">
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Maintenance Alert (days ahead)</label>
<input type="number" class="form-control" id="maintenanceAlertDays" name="MaintenanceAlertDays" value="@(Model.Preferences?.MaintenanceAlertDays ?? 7)" min="0" max="90">
</div>
</div>
</div>
<h6 class="border-bottom pb-2 mb-3 mt-2">Automated Payment Reminders
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Automated Payment Reminders"
data-bs-content="When enabled, the system automatically emails customers when invoices become overdue. Reminders are sent at the day milestones you specify (e.g. 7, 14, 30 days past due). Only one reminder per milestone per invoice is sent. Customers who have opted out of email notifications are never contacted.">
<i class="bi bi-question-circle"></i>
</a>
</h6>
<div class="row">
<div class="col-md-4">
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="paymentRemindersEnabled" name="PaymentRemindersEnabled"
@(Model.Preferences?.PaymentRemindersEnabled == true ? "checked" : "")>
<label class="form-check-label fw-semibold" for="paymentRemindersEnabled">Enable Payment Reminders</label>
<div class="form-text">Sends reminder emails to customers with overdue invoices.</div>
</div>
</div>
<div class="col-md-8">
<div class="mb-3">
<label class="form-label" for="paymentReminderDays">Reminder Days (comma-separated, days past due)</label>
<input type="text" class="form-control" id="paymentReminderDays" name="PaymentReminderDays"
value="@(Model.Preferences?.PaymentReminderDays ?? "7,14,30")"
placeholder="e.g. 7,14,30" maxlength="50">
<div class="form-text">Enter the number of days past the due date to send each reminder (e.g. <code>7,14,30</code>).</div>
</div>
</div>
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary" id="btnSaveNotifications">
<i class="bi bi-save"></i> Save Notifications
</button>
</div>
</form>
</div>
</div>
<!-- Template List -->
<div id="ntpl-list" class="card mt-3">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-envelope-check"></i> Notification Templates
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Notification Templates"
data-bs-content="Customise the subject and body of every automated email sent by the system &mdash; job status updates, quote approvals, invoice reminders, and more. Templates use &lt;strong&gt;&#123;&#123;placeholder&#125;&#125;&lt;/strong&gt; tokens that are replaced with live data when the email is sent. Click &lt;strong&gt;Edit&lt;/strong&gt; on any row to modify it; use &lt;strong&gt;Reset to Default&lt;/strong&gt; to restore the original wording at any time.&lt;br&gt;&lt;br&gt;Changes take effect immediately &mdash; the next triggered notification will use the updated template.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-3">Template</th>
<th>Channel</th>
<th>Last Modified</th>
<th class="text-end pe-3" style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody id="ntpl-tbody">
@foreach (var tmpl in Model.NotificationTemplates.Where(t => t.IsEmail || ViewBag.SmsEnabled == true))
{
<tr id="ntpl-row-@tmpl.Id">
<td class="ps-3 fw-semibold">@tmpl.DisplayName</td>
<td>
@if (tmpl.IsEmail)
{
<span class="badge bg-primary"><i class="bi bi-envelope"></i> Email</span>
}
else
{
<span class="badge bg-success"><i class="bi bi-phone"></i> SMS</span>
}
</td>
<td class="text-muted small ntpl-modified">
@(tmpl.UpdatedAt?.ToString("MMM d, yyyy h:mm tt") ?? "Using defaults")
</td>
<td class="text-end pe-3">
<button type="button" class="btn btn-sm btn-outline-primary"
onclick="ntplEdit(@tmpl.Id)">
<i class="bi bi-pencil"></i> Edit
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="card-footer text-muted small">
<i class="bi bi-info-circle"></i>
Templates support <strong>&#123;&#123;placeholder&#125;&#125;</strong> tokens replaced with live data when notifications are sent. Click <strong>Edit</strong> to customise any template.
</div>
</div>
</div>
<!-- Data Retention Tab -->
<div class="tab-pane fade" id="data-retention" role="tabpanel">
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title">Data Retention
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Data Retention"
data-bs-content="Controls how long records are kept. Most businesses set quote and job retention to &lt;strong&gt;7 years&lt;/strong&gt; to satisfy tax and audit requirements. &lt;strong&gt;Deleted record retention&lt;/strong&gt; is the grace period after a soft-delete before the record is permanently purged &mdash; useful if someone accidentally deletes something.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
<p class="text-muted">Define how long records are kept before archiving or deletion.</p>
<form id="dataRetentionForm">
<h6 class="border-bottom pb-2 mb-3">Record Retention</h6>
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Quote Retention (years)</label>
<input type="number" class="form-control" id="quoteRetentionYears" name="QuoteRetentionYears" value="@(Model.Preferences?.QuoteRetentionYears ?? 7)" min="1" max="99">
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Job Retention (years)</label>
<input type="number" class="form-control" id="jobRetentionYears" name="JobRetentionYears" value="@(Model.Preferences?.JobRetentionYears ?? 7)" min="1" max="99">
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Log Retention (days)</label>
<input type="number" class="form-control" id="logRetentionDays" name="LogRetentionDays" value="@(Model.Preferences?.LogRetentionDays ?? 90)" min="30" max="3650">
</div>
</div>
</div>
<h6 class="border-bottom pb-2 mb-3 mt-2">Auto-Maintenance</h6>
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Auto-Archive Completed Jobs (days)</label>
<input type="number" class="form-control" id="autoArchiveJobsDays" name="AutoArchiveJobsDays" value="@(Model.Preferences?.AutoArchiveJobsDays ?? 365)" min="30" max="3650">
<small class="text-muted">Archive completed jobs after this many days</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Deleted Record Retention (days)</label>
<input type="number" class="form-control" id="deletedRecordRetentionDays" name="DeletedRecordRetentionDays" value="@(Model.Preferences?.DeletedRecordRetentionDays ?? 30)" min="7" max="365">
<small class="text-muted">Keep soft-deleted records for this many days</small>
</div>
</div>
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary" id="btnSaveDataRetention">
<i class="bi bi-save"></i> Save Retention Policy
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Data Lookups Tab -->
<div class="tab-pane fade" id="data-lookups" role="tabpanel">
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title">Manage Data Lookups
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Data Lookups"
data-bs-content="Lookups are the dropdown options that appear throughout the app &mdash; job statuses, priorities, quote statuses, and more. You can rename labels, change colours, and reorder them to match your shop's terminology. &lt;strong&gt;Status codes&lt;/strong&gt; drive workflow logic and should not be changed unless you understand the impact.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
<p class="text-muted">Customize dropdown values and workflow statuses for your company</p>
<!-- Sub-tabs for different lookup types -->
<ul class="nav nav-pills mb-3" id="lookupSubTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="job-statuses-subtab" data-bs-toggle="pill" data-bs-target="#job-statuses-lookup" type="button" role="tab">
Job Statuses
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="job-priorities-subtab" data-bs-toggle="pill" data-bs-target="#job-priorities-lookup" type="button" role="tab">
Job Priorities
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="quote-statuses-subtab" data-bs-toggle="pill" data-bs-target="#quote-statuses-lookup" type="button" role="tab">
Quote Statuses
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="appointment-types-subtab" data-bs-toggle="pill" data-bs-target="#appointment-types-lookup" type="button" role="tab">
Appointment Types
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="inventory-categories-subtab" data-bs-toggle="pill" data-bs-target="#inventory-categories-lookup" type="button" role="tab">
Inventory Categories
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="prep-services-subtab" data-bs-toggle="pill" data-bs-target="#prep-services-lookup" type="button" role="tab">
Prep Services
</button>
</li>
</ul>
<div class="tab-content" id="lookupSubTabContent">
<!-- Job Statuses Lookup Table -->
<div class="tab-pane fade show active" id="job-statuses-lookup" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0"><span id="jobStatusCount">0</span> Job Statuses</h6>
<button type="button" class="btn btn-primary btn-sm" id="btnAddJobStatus">
<i class="bi bi-plus-circle"></i> Add Job Status
</button>
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 30px;"><i class="bi bi-grip-vertical"></i></th>
<th>Display Name</th>
<th>Status Code</th>
<th>Color</th>
<th>Category</th>
<th>Flags</th>
<th>Usage</th>
<th style="width: 150px;">Actions</th>
</tr>
</thead>
<tbody id="jobStatusesTableBody">
<tr>
<td colspan="8" class="text-center text-muted">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
Loading...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Job Priorities Lookup Table -->
<div class="tab-pane fade" id="job-priorities-lookup" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0"><span id="jobPriorityCount">0</span> Job Priorities</h6>
<button type="button" class="btn btn-primary btn-sm" id="btnAddJobPriority">
<i class="bi bi-plus-circle"></i> Add Job Priority
</button>
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 30px;"><i class="bi bi-grip-vertical"></i></th>
<th>Display Name</th>
<th>Priority Code</th>
<th>Color</th>
<th>Usage</th>
<th style="width: 150px;">Actions</th>
</tr>
</thead>
<tbody id="jobPrioritiesTableBody">
<tr>
<td colspan="6" class="text-center text-muted">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
Loading...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Quote Statuses Lookup Table -->
<div class="tab-pane fade" id="quote-statuses-lookup" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0"><span id="quoteStatusCount">0</span> Quote Statuses</h6>
<button type="button" class="btn btn-primary btn-sm" id="btnAddQuoteStatus">
<i class="bi bi-plus-circle"></i> Add Quote Status
</button>
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 30px;"><i class="bi bi-grip-vertical"></i></th>
<th>Display Name</th>
<th>Status Code</th>
<th>Color</th>
<th>Business Flags</th>
<th>Usage</th>
<th style="width: 150px;">Actions</th>
</tr>
</thead>
<tbody id="quoteStatusesTableBody">
<tr>
<td colspan="6" class="text-center text-muted">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
Loading...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Appointment Types Lookup Table -->
<div class="tab-pane fade" id="appointment-types-lookup" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0"><span id="appointmentTypeCount">0</span> Appointment Types</h6>
<button type="button" class="btn btn-primary btn-sm" id="btnAddAppointmentType">
<i class="bi bi-plus-circle"></i> Add Type
</button>
</div>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Display Name</th>
<th>Type Code</th>
<th>Color</th>
<th>Requires Job</th>
<th>Active</th>
<th>Usage</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="appointmentTypesTableBody">
<tr>
<td colspan="6" class="text-center text-muted">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
Loading appointment types...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Inventory Categories Lookup Table -->
<div class="tab-pane fade" id="inventory-categories-lookup" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0"><span id="inventoryCategoryCount">0</span> Inventory Categories</h6>
<button type="button" class="btn btn-primary btn-sm" id="btnAddInventoryCategory">
<i class="bi bi-plus-circle"></i> Add Category
</button>
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 30px;"><i class="bi bi-grip-vertical"></i></th>
<th>Display Name</th>
<th>Category Code</th>
<th>Description</th>
<th>Usage</th>
<th style="width: 150px;">Actions</th>
</tr>
</thead>
<tbody id="inventoryCategoriesTableBody">
<tr>
<td colspan="6" class="text-center text-muted">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
Loading...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Prep Services Lookup Table -->
<div class="tab-pane fade" id="prep-services-lookup" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0"><span id="prepServiceCount">0</span> Prep Services</h6>
<button type="button" class="btn btn-primary btn-sm" id="btnAddPrepService">
<i class="bi bi-plus-circle"></i> Add Service
</button>
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 30px;"><i class="bi bi-grip-vertical"></i></th>
<th>Service Name</th>
<th>Description</th>
<th>Status</th>
<th style="width: 150px;">Actions</th>
</tr>
</thead>
<tbody id="prepServicesTableBody">
<tr>
<td colspan="5" class="text-center text-muted">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
Loading...
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- PDF Templates Tab -->
<div class="tab-pane fade" id="pdf-templates" role="tabpanel">
<div class="card mt-3">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" id="pdfTemplateTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="pdf-quote-tab" data-bs-toggle="tab" data-bs-target="#pdf-quote" type="button" role="tab">
<i class="bi bi-file-earmark-pdf me-1"></i>Quote PDF
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="pdf-invoice-tab" data-bs-toggle="tab" data-bs-target="#pdf-invoice" type="button" role="tab">
<i class="bi bi-receipt me-1"></i>Invoice PDF
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="pdf-workorder-tab" data-bs-toggle="tab" data-bs-target="#pdf-workorder" type="button" role="tab">
<i class="bi bi-clipboard me-1"></i>Work Order
</button>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content" id="pdfTemplateTabContent">
<!-- Quote PDF -->
<div class="tab-pane fade show active" id="pdf-quote" role="tabpanel">
<p class="text-muted mb-4">
Customise how your printable and emailed quote PDFs look. After saving, open any quote and click
<strong>Download PDF</strong> to preview the result.
</p>
<!-- BRANDING -->
<h6 class="fw-semibold text-uppercase text-muted small mb-3">Branding</h6>
<div class="mb-4">
<label class="form-label fw-semibold">Accent Color</label>
@{ var qtColor = string.IsNullOrWhiteSpace(Model.Preferences?.QtAccentColor) ? "#374151" : Model.Preferences!.QtAccentColor; }
<div class="d-flex align-items-center gap-2 mb-2">
<input type="color" id="qtAccentColorPicker" value="@qtColor"
class="form-control form-control-color" style="width:48px;height:38px;padding:2px;"
oninput="document.getElementById('qtAccentColorHex').value = this.value">
<input type="text" id="qtAccentColorHex" class="form-control font-monospace" style="max-width:110px;"
value="@qtColor" maxlength="7" placeholder="#374151"
oninput="syncColorPicker(this.value)">
</div>
<!-- Quick presets -->
<div class="d-flex gap-2 flex-wrap mb-2">
@foreach (var preset in new[] {
("#374151","Dark Slate"), ("#1e3a5f","Navy"), ("#14532d","Forest Green"),
("#7f1d1d","Burgundy"), ("#134e4a","Dark Teal"), ("#1f2937","Charcoal") })
{
<button type="button" class="btn btn-sm border px-2 py-1"
style="background:@preset.Item1;min-width:32px;"
title="@preset.Item2"
onclick="setAccentColor('@preset.Item1')">
&nbsp;
</button>
}
</div>
<div class="form-text">Used for section headings and company name text when no logo is uploaded.</div>
</div>
<hr>
<!-- DEFAULT TERMS -->
<h6 class="fw-semibold text-uppercase text-muted small mb-3">Default Terms &amp; Conditions</h6>
<div class="mb-4">
<textarea id="qtDefaultTerms" class="form-control" rows="6" maxlength="3000"
placeholder="E.g. Payment due within 30 days. All sales final...">@(Model.Preferences?.QtDefaultTerms ?? "")</textarea>
<div class="form-text">Automatically filled into the Terms field whenever you create a new quote. You can still edit it per quote before sending.</div>
</div>
<hr>
<!-- FOOTER NOTE -->
<h6 class="fw-semibold text-uppercase text-muted small mb-3">Page Footer Note</h6>
<div class="mb-4">
<input type="text" id="qtFooterNote" class="form-control" maxlength="200"
value="@(Model.Preferences?.QtFooterNote ?? "")"
placeholder="E.g. Thank you for your business!">
<div class="form-text">Appears at the bottom of every PDF page alongside the page number.</div>
</div>
<button type="button" class="btn btn-primary" onclick="saveQuoteTemplate()">
<i class="bi bi-floppy me-1"></i> Save Quote PDF Settings
</button>
</div>
<!-- Invoice PDF -->
<div class="tab-pane fade" id="pdf-invoice" role="tabpanel">
<p class="text-muted mb-4">
Customise how your printable and emailed invoice PDFs look. After saving, open any invoice and click
<strong>Download PDF</strong> to preview the result.
</p>
<!-- BRANDING -->
<h6 class="fw-semibold text-uppercase text-muted small mb-3">Branding</h6>
<div class="mb-4">
<label class="form-label fw-semibold">Accent Color</label>
@{ var inColor = string.IsNullOrWhiteSpace(Model.Preferences?.InAccentColor) ? "#374151" : Model.Preferences!.InAccentColor; }
<div class="d-flex align-items-center gap-2 mb-2">
<input type="color" id="inAccentColorPicker" value="@inColor"
class="form-control form-control-color" style="width:48px;height:38px;padding:2px;"
oninput="document.getElementById('inAccentColorHex').value = this.value">
<input type="text" id="inAccentColorHex" class="form-control font-monospace" style="max-width:110px;"
value="@inColor" maxlength="7" placeholder="#374151"
oninput="syncInvoiceColorPicker(this.value)">
</div>
<!-- Quick presets -->
<div class="d-flex gap-2 flex-wrap mb-2">
@foreach (var preset in new[] {
("#374151","Dark Slate"), ("#1e3a5f","Navy"), ("#14532d","Forest Green"),
("#7f1d1d","Burgundy"), ("#134e4a","Dark Teal"), ("#1f2937","Charcoal") })
{
<button type="button" class="btn btn-sm border px-2 py-1"
style="background:@preset.Item1;min-width:32px;"
title="@preset.Item2"
onclick="setInvoiceAccentColor('@preset.Item1')">
&nbsp;
</button>
}
</div>
<div class="form-text">Used for section headings and company name text when no logo is uploaded.</div>
</div>
<hr>
<!-- DEFAULT PAYMENT TERMS -->
<h6 class="fw-semibold text-uppercase text-muted small mb-3">Default Payment Terms</h6>
<div class="mb-4">
<textarea id="inDefaultTerms" class="form-control" rows="6" maxlength="3000"
placeholder="E.g. Payment due within 30 days. Checks payable to...">@(Model.Preferences?.InDefaultTerms ?? "")</textarea>
<div class="form-text">Appears in the Payment Terms section of the invoice PDF when no per-invoice terms are set.</div>
</div>
<hr>
<!-- FOOTER NOTE -->
<h6 class="fw-semibold text-uppercase text-muted small mb-3">Page Footer Note</h6>
<div class="mb-4">
<input type="text" id="inFooterNote" class="form-control" maxlength="200"
value="@(Model.Preferences?.InFooterNote ?? "")"
placeholder="E.g. Thank you for your business!">
<div class="form-text">Appears at the bottom of every PDF page alongside the page number.</div>
</div>
<button type="button" class="btn btn-primary" onclick="saveInvoiceTemplate()">
<i class="bi bi-floppy me-1"></i> Save Invoice PDF Settings
</button>
</div>
<!-- Work Order PDF -->
<div class="tab-pane fade" id="pdf-workorder" role="tabpanel">
<p class="text-muted mb-4">
Customise the blank work order form your shop prints for walk-in or drop-off customers.
After saving, click <strong>Print Blank Work Order</strong> from the Jobs page to preview.
</p>
<h6 class="fw-semibold text-uppercase text-muted small mb-3">Branding</h6>
<div class="mb-4">
<label class="form-label fw-semibold">Accent Color</label>
@{ var woColor = string.IsNullOrWhiteSpace(Model.Preferences?.WoAccentColor) ? "#374151" : Model.Preferences!.WoAccentColor; }
<div class="d-flex align-items-center gap-2 mb-2">
<input type="color" id="woAccentColorPicker" value="@woColor"
class="form-control form-control-color" style="width:48px;height:38px;padding:2px;"
oninput="document.getElementById('woAccentColorHex').value = this.value">
<input type="text" id="woAccentColorHex" class="form-control font-monospace" style="max-width:110px;"
value="@woColor" maxlength="7" placeholder="#374151"
oninput="document.getElementById('woAccentColorPicker').value = this.value">
</div>
<div class="d-flex gap-2 flex-wrap mb-2">
@foreach (var preset in new[] {
("#374151","Dark Slate"), ("#1e3a5f","Navy"), ("#14532d","Forest Green"),
("#7f1d1d","Burgundy"), ("#134e4a","Dark Teal"), ("#1f2937","Charcoal") })
{
<button type="button" class="btn btn-sm border px-2 py-1"
style="background:@preset.Item1;min-width:32px;" title="@preset.Item2"
onclick="document.getElementById('woAccentColorPicker').value='@preset.Item1'; document.getElementById('woAccentColorHex').value='@preset.Item1';">
&nbsp;
</button>
}
</div>
<div class="form-text">Used for header rows on the work order table.</div>
</div>
<hr>
<h6 class="fw-semibold text-uppercase text-muted small mb-3">Terms &amp; Conditions</h6>
<div class="mb-3">
<label class="form-label fw-semibold">Shop Terms</label>
<textarea id="woTerms" class="form-control" rows="5" maxlength="2000"
placeholder="e.g. *Products must be picked up within 5 days of notification of completion or a storage fee may apply."
>@(Model.Preferences?.WoTerms ?? "")</textarea>
<div class="form-text">Printed in italic at the bottom of every blank work order. Supports plain text &mdash; use * or ** for visual emphasis.</div>
</div>
<div class="d-flex gap-2 align-items-center">
<button type="button" class="btn btn-primary" onclick="saveWorkOrderTemplate()">
<i class="bi bi-floppy me-1"></i> Save Work Order Settings
</button>
<a href="@Url.Action("Blank", "WorkOrder")" target="_blank" class="btn btn-outline-secondary">
<i class="bi bi-printer me-1"></i> Preview Work Order
</a>
</div>
</div>
</div>
</div>
</div>
</div>
@if (Model.AllowOnlinePayments)
{
<!-- Online Payments Tab -->
<div class="tab-pane fade" id="online-payments" role="tabpanel">
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-credit-card me-2"></i>Online Payments (Stripe)</h5>
</div>
<div class="card-body">
@* ── Stripe Connect status ── *@
@if (!(bool)ViewBag.StripeConnectConfigured)
{
<div class="alert alert-danger alert-permanent d-flex align-items-start gap-3 mb-4">
<i class="bi bi-exclamation-octagon-fill fs-4 flex-shrink-0 mt-1"></i>
<div>
<strong>Stripe Connect is not configured.</strong>
<p class="mb-0 small mt-1">
A platform administrator must set <code>Stripe:ConnectClientId</code> in
<code>appsettings.json</code> before companies can connect their Stripe accounts.
The value starts with <code>ca_</code> and can be found in your
<strong>Stripe Dashboard → Connect → Settings</strong>.
</p>
</div>
</div>
}
else if (Model.StripeConnectStatus == PowderCoating.Core.Enums.StripeConnectStatus.Active)
{
<div class="alert alert-success alert-permanent d-flex align-items-center gap-3 mb-4">
<i class="bi bi-check-circle-fill fs-4"></i>
<div>
<strong>Stripe account connected.</strong>
<br />
<span class="small text-muted">Account ID: @Model.StripeAccountId</span>
</div>
<button type="button" class="btn btn-outline-danger btn-sm ms-auto"
onclick="disconnectStripe()">
<i class="bi bi-x-circle me-1"></i>Disconnect
</button>
</div>
}
else
{
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-3 mb-4">
<i class="bi bi-exclamation-triangle-fill fs-4"></i>
<div>
<strong>No Stripe account connected.</strong>
<p class="mb-0 small">Connect your Stripe account to start accepting online payments from customers.</p>
</div>
<a href="@Url.Action("ConnectStripe", "CompanySettings")"
class="btn btn-primary btn-sm ms-auto">
<i class="bi bi-stripe me-1"></i>Connect with Stripe
</a>
</div>
}
@* ── Surcharge settings (only editable when connected) ── *@
<fieldset @(Model.StripeConnectStatus != PowderCoating.Core.Enums.StripeConnectStatus.Active ? "disabled" : "")>
<h6 class="fw-semibold mb-3">Online Payment Fee (Surcharge)</h6>
<p class="text-muted small">
You may pass the Stripe transaction cost to customers as a convenience fee.
Surcharges are <strong>capped at 3%</strong> per Visa/Mastercard network rules
and may not apply to debit card transactions in all states.
</p>
<div class="row g-3 mb-3">
<div class="col-sm-4">
<label class="form-label fw-semibold">Fee Type</label>
<select id="surchargeType" class="form-select"
data-selected="@((int)Model.OnlinePaymentSurchargeType)">
<option value="0">No fee (absorb cost)</option>
<option value="1">Percentage (%)</option>
<option value="2">Flat amount ($)</option>
</select>
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold">Fee Value</label>
<div class="input-group">
<span class="input-group-text" id="surchargePrefix">%</span>
<input type="number" id="surchargeValue" class="form-control"
min="0" max="3" step="0.01"
value="@Model.OnlinePaymentSurchargeValue.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)" />
</div>
<div class="form-text">Max 3% for percentage type.</div>
</div>
</div>
<div class="form-check mb-3" id="surchargeAckRow"
style="@(Model.OnlinePaymentSurchargeType == PowderCoating.Core.Enums.OnlinePaymentSurchargeType.None ? "display:none" : "")">
<input class="form-check-input" type="checkbox" id="surchargeAck"
@(Model.OnlineSurchargeAcknowledged ? "checked" : "") />
<label class="form-check-label small" for="surchargeAck">
I understand that I am responsible for compliance with card network surcharge rules,
including displaying the required disclosure to customers and not applying surcharges
to debit transactions where prohibited.
</label>
</div>
<button type="button" class="btn btn-primary" onclick="saveOnlinePaymentSettings()">
<i class="bi bi-floppy me-1"></i>Save Payment Settings
</button>
</fieldset>
</div>
</div>
</div>
}
<!-- Kiosk Tab -->
<div class="tab-pane fade" id="kiosk" role="tabpanel">
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-tablet me-2"></i>Customer Intake Kiosk</h5>
</div>
<div class="card-body">
<h6 class="fw-semibold mb-1">Intake Output</h6>
<p class="text-muted small mb-3">
When a customer completes the intake form, what should be created in the system?
</p>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card h-100 border @(Model.Preferences?.KioskIntakeOutput == "Job" ? "" : "border-primary bg-primary-subtle")"
id="kioskOutputQuoteCard" style="cursor:pointer;" onclick="selectKioskOutput('Quote')">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-2">
<div class="form-check mb-0">
<input class="form-check-input" type="radio" name="kioskOutput" id="kioskOutputQuote"
value="Quote" @(Model.Preferences?.KioskIntakeOutput != "Job" ? "checked" : "") />
</div>
<h6 class="mb-0 fw-semibold"><i class="bi bi-file-earmark-text me-1 text-primary"></i>Create a Quote</h6>
</div>
<p class="text-muted small mb-0">
A draft quote is created and reviewed by staff before work begins.
Best for shops that price after seeing the parts.
</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100 border @(Model.Preferences?.KioskIntakeOutput == "Job" ? "border-success bg-success-subtle" : "")"
id="kioskOutputJobCard" style="cursor:pointer;" onclick="selectKioskOutput('Job')">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-2">
<div class="form-check mb-0">
<input class="form-check-input" type="radio" name="kioskOutput" id="kioskOutputJob"
value="Job" @(Model.Preferences?.KioskIntakeOutput == "Job" ? "checked" : "") />
</div>
<h6 class="mb-0 fw-semibold"><i class="bi bi-briefcase me-1 text-success"></i>Create a Job</h6>
</div>
<p class="text-muted small mb-0">
A job is created immediately on submission.
Best for shops that price on the spot and want the work order ready right away.
</p>
</div>
</div>
</div>
</div>
<button type="button" class="btn btn-primary" onclick="saveKioskSettings()">
<i class="bi bi-floppy me-1"></i> Save Kiosk Settings
</button>
</div>
</div>
</div>
<!-- ── Timeclock ─────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="timeclock" role="tabpanel">
<div class="card shadow-sm mt-3">
<div class="card-header fw-semibold"><i class="bi bi-clock-history me-2"></i>Timeclock Settings</div>
<div class="card-body">
@{
var kioskDevices = ViewBag.TimeclockKioskDevices as List<PowderCoating.Core.Entities.TimeclockKioskDevice> ?? new();
}
<div class="mb-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="timeclockEnabled" @(Model.TimeclockEnabled ? "checked" : "") />
<label class="form-check-label fw-semibold" for="timeclockEnabled">Enable Timeclock</label>
</div>
<div class="form-text">When disabled, the Timeclock link is hidden from the navigation and the Attendance report is not accessible.</div>
</div>
<div class="mb-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="timeclockMultiplePunches" @(Model.TimeclockAllowMultiplePunchesPerDay ? "checked" : "") />
<label class="form-check-label fw-semibold" for="timeclockMultiplePunches">Allow multiple clock-ins per day</label>
</div>
<div class="form-text">When enabled, employees can clock in and out multiple times per day (e.g. for lunch breaks). When disabled, each employee is limited to one in/out pair per day.</div>
</div>
<div class="mb-4">
<label class="form-label fw-semibold" for="timeclockAutoClockOut">Auto clock-out after</label>
<div class="input-group" style="max-width:220px;">
<input type="number" id="timeclockAutoClockOut" class="form-control" min="1" max="24"
value="@(Model.TimeclockAutoClockOutHours?.ToString() ?? "")"
placeholder="Disabled" />
<span class="input-group-text">hours</span>
</div>
<div class="form-text">If an employee forgets to clock out, the system will automatically clock them out after this many hours. Leave blank to disable. Maximum 24 hours.</div>
</div>
<button type="button" class="btn btn-primary" id="btn-save-timeclock">
<i class="bi bi-save me-1"></i>Save Timeclock Settings
</button>
<span id="timeclock-status" class="ms-2 small"></span>
<hr class="my-4" />
<h6 class="fw-semibold mb-1">Kiosk Tablets</h6>
<p class="text-muted small mb-3">
Activate the timeclock kiosk on any shop-floor tablet. Employees tap their name and enter their PIN to clock in or out &mdash; no login required.
Multiple tablets are supported; each device is listed below.
</p>
<div class="alert alert-info alert-permanent small mb-3">
<i class="bi bi-tablet me-2"></i>
<strong>To activate a new kiosk:</strong> on the tablet's browser, navigate to
<code>/Timeclock/Kiosk/Activate</code>, optionally enter a device name, and click
&ldquo;Activate This Device.&rdquo; The tablet will then show the employee clock-in grid at
<code>/Timeclock/Kiosk</code>.
</div>
@if (!kioskDevices.Any())
{
<p class="text-muted small fst-italic">No kiosk devices activated yet.</p>
}
else
{
<table class="table table-sm table-hover">
<thead class="table-light">
<tr>
<th>Device Name</th>
<th>Activated</th>
<th>Last Seen</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var dev in kioskDevices)
{
<tr data-device-id="@dev.Id">
<td class="fw-semibold">@(dev.DeviceName ?? "Unnamed Device")</td>
<td class="text-muted small">@dev.ActivatedAt.ToLocalTime().ToString("M/d/yy h:mm tt")</td>
<td class="text-muted small">
@if (dev.LastSeenAt.HasValue)
{ @dev.LastSeenAt.Value.ToLocalTime().ToString("M/d/yy h:mm tt") }
else
{ <span class="fst-italic">Never used</span> }
</td>
<td class="text-end">
<button class="btn btn-xs btn-outline-danger btn-deactivate-kiosk"
data-id="@dev.Id"
data-name="@(dev.DeviceName ?? "Unnamed Device")">
<i class="bi bi-x-circle me-1"></i>Deactivate
</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</div>
<!-- ── Custom Formula Item Templates ──────────────────────────────── -->
@if (ViewBag.AllowCustomFormulas == true)
{
<div class="tab-pane fade" id="custom-formulas" role="tabpanel">
<div class="card shadow-sm mt-3">
<div class="card-header d-flex justify-content-between align-items-start align-items-sm-center flex-column flex-sm-row gap-2">
<h5 class="mb-0"><i class="bi bi-calculator me-2"></i>Custom Formula Item Templates</h5>
<div class="d-flex flex-wrap gap-2">
<a asp-controller="FormulaLibrary" asp-action="Index"
class="btn btn-outline-info btn-sm">
<i class="bi bi-collection me-1"></i>Community Library
</a>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="cfShowWalkthrough()">
<i class="bi bi-question-circle me-1"></i>How it works
</button>
<button type="button" class="btn btn-primary btn-sm" onclick="cfShowCreate()">
<i class="bi bi-plus-circle me-1"></i>New Template
</button>
</div>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Define reusable pricing formulas for complex fabricated items (roof curbs, enclosures, frames).
When a user adds a formula item to a quote or job, they fill in the measurements and the formula
calculates the price automatically.
Browse the <a asp-controller="FormulaLibrary" asp-action="Index">Community Library</a> to import
formulas shared by other shops.
</p>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle" id="cfTemplatesTable">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Output Mode</th>
<th>Fields</th>
<th>Active</th>
<th>Library</th>
<th></th>
</tr>
</thead>
<tbody id="cfTemplatesBody">
<tr><td colspan="6" class="text-muted text-center py-3">Loading&hellip;</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
}
@* Share modal lives inside the AllowCustomFormulas block so it is always in the DOM
when the Share button can appear — prevents stale-cache mismatches. *@
@if (ViewBag.AllowCustomFormulas == true)
{
<div class="modal fade" id="cfShareModal" tabindex="-1" aria-labelledby="cfShareModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="cfShareModalLabel">
<i class="bi bi-collection me-2 text-info"></i>Share to Community Library
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="cfShareTemplateId" value="0" />
<p class="text-muted small mb-3">
Your formula will be visible to all Powder Coating Logix users and can be imported
into their local library. You can remove it from the community library at any time &mdash;
anyone who has already imported it will keep their copy.
</p>
<div class="mb-3">
<label class="form-label fw-semibold">Tags <small class="text-muted">(optional, comma-separated)</small></label>
<input type="text" class="form-control" id="cfShareTags"
placeholder="e.g. HVAC, Sheet Metal, Enclosures" />
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Industry Hint <small class="text-muted">(optional)</small></label>
<input type="text" class="form-control" id="cfShareIndustryHint"
placeholder="e.g. HVAC, Automotive, Structural" />
</div>
<div id="cfShareInspiredBy" class="alert alert-light border fst-italic small py-2" style="display:none">
<i class="bi bi-diagram-2 me-1"></i>
This formula will be listed as &ldquo;Inspired by&rdquo; the original community entry.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-info text-white" id="cfShareConfirmBtn" onclick="cfConfirmShare()">
<i class="bi bi-collection me-1"></i>Share to Library
</button>
</div>
</div>
</div>
</div>
}
</div>
</div>
<!-- Custom Formula Walkthrough Modal -->
<div class="modal fade" id="cfWalkthroughModal" tabindex="-1" aria-labelledby="cfWalkthroughLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title" id="cfWalkthroughLabel">
<i class="bi bi-calculator text-info me-2"></i>Custom Formula Templates &mdash; How It Works
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body pt-2">
<!-- Step progress dots -->
<div class="d-flex justify-content-center gap-2 mb-4" id="cfWalkthroughDots"></div>
<!-- Step content -->
<div id="cfWalkthroughContent"></div>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-outline-secondary" id="cfWtPrevBtn" onclick="cfWalkthroughNav(-1)">
<i class="bi bi-arrow-left me-1"></i>Back
</button>
<button type="button" class="btn btn-primary" id="cfWtNextBtn" onclick="cfWalkthroughNav(1)">
Next<i class="bi bi-arrow-right ms-1"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Custom Formula Template Modal -->
<div class="modal fade" id="cfModal" tabindex="-1" aria-labelledby="cfModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="cfModalLabel">New Formula Template</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="cfId" value="0" />
<div class="row g-3">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Name <span class="text-danger">*</span></label>
<input type="text" id="cfName" class="form-control" placeholder="e.g. Roof Curb" maxlength="100" />
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" id="cfDescription" class="form-control" maxlength="500" />
</div>
<div class="mb-3">
<label class="form-label">Output Mode <span class="text-danger">*</span></label>
<select id="cfOutputMode" class="form-select" onchange="cfToggleRateFields()">
<option value="FixedRate">Fixed Rate &mdash; formula &rarr; $ amount</option>
<option value="SurfaceAreaSqFt">Surface Area &mdash; formula &rarr; sq ft (standard pricing engine prices it)</option>
</select>
</div>
<div id="cfRateFields">
<div class="mb-3">
<label class="form-label">Default Rate</label>
<input type="number" id="cfDefaultRate" class="form-control" step="0.01" placeholder="e.g. 0.85" />
<div class="form-text">Used as the <code>rate</code> variable if not overridden per-quote.</div>
</div>
<div class="mb-3">
<label class="form-label">Rate Label</label>
<input type="text" id="cfRateLabel" class="form-control" maxlength="50" placeholder="e.g. $/sq ft" />
</div>
</div>
<div class="mb-3">
<label class="form-label">Formula <span class="text-danger">*</span></label>
<textarea id="cfFormula" class="form-control font-monospace" rows="3"
style="resize:vertical;min-height:4rem"
placeholder="e.g. 2*(L*W + L*H + W*H)/144 * rate"></textarea>
<div class="form-text mt-1">
<span class="me-1">Variables (click to insert):</span>
<span id="cfVariablePills"></span>
</div>
<div class="mt-2">
<a class="small text-decoration-none" data-bs-toggle="collapse" href="#cfFormulaRef" role="button">
<i class="bi bi-question-circle me-1"></i>Formula reference
</a>
<div class="collapse" id="cfFormulaRef">
<div class="card card-body py-2 px-3 mt-1 small border-secondary-subtle" style="font-size:.8rem">
<div class="row g-2">
<div class="col-md-6">
<strong class="d-block mb-1 text-muted text-uppercase" style="font-size:.65rem;letter-spacing:.05em">Operators</strong>
<code>+ &nbsp;- &nbsp;* &nbsp;/ &nbsp;%</code><br>
<code>&lt; &nbsp;&gt; &nbsp;&lt;= &nbsp;&gt;= &nbsp;== &nbsp;!=</code><br>
<code>&amp;&amp; &nbsp;|| &nbsp;!</code>
<strong class="d-block mt-2 mb-1 text-muted text-uppercase" style="font-size:.65rem;letter-spacing:.05em">Built-in variables (auto-injected)</strong>
<code>rate</code> &mdash; template&rsquo;s default rate<br>
<code>standard_labor_rate</code><br>
<code>markup_pct</code><br>
<code>additional_coat_labor_pct</code>
</div>
<div class="col-md-6">
<strong class="d-block mb-1 text-muted text-uppercase" style="font-size:.65rem;letter-spacing:.05em">Functions (must be lowercase)</strong>
<code>if(cond, a, b)</code> &mdash; conditional<br>
<code>abs(x)</code><br>
<code>round(x, digits)</code><br>
<code>max(a, b)</code> / <code>min(a, b)</code><br>
<code>pow(base, exp)</code><br>
<code>sqrt(x)</code>
<strong class="d-block mt-2 mb-1 text-muted text-uppercase" style="font-size:.65rem;letter-spacing:.05em">Example</strong>
<code class="d-block text-break">if(qty &gt; 10, qty * rate * 0.9, qty * rate)</code>
<span class="text-muted">10% discount over 10 units</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea id="cfNotes" class="form-control" rows="2" maxlength="1000"></textarea>
</div>
<div class="form-check mb-3">
<input type="checkbox" id="cfIsActive" class="form-check-input" checked />
<label class="form-check-label" for="cfIsActive">Active (show in quote/job wizard)</label>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Fields</label>
<div class="form-text mb-2">Define the measurement inputs users will fill in.</div>
<div id="cfFieldsList"></div>
<button type="button" class="btn btn-outline-secondary btn-sm mt-2" onclick="cfAddField()">
<i class="bi bi-plus"></i> Add Field
</button>
</div>
<div class="mb-3">
<label class="form-label">Formula Test</label>
<div class="d-flex gap-2 align-items-center">
<button type="button" class="btn btn-outline-primary btn-sm" onclick="cfTestFormula()">
<i class="bi bi-play-circle"></i> Run
</button>
<span id="cfTestResult" class="fw-bold"></span>
</div>
<div class="form-text">Uses the default values from your field list.</div>
</div>
<div class="mb-3">
<label class="form-label">Diagram / Shop Drawing</label>
<div id="cfDiagramPreview" class="mb-2" style="display:none;">
<img id="cfDiagramImg" src="" alt="Diagram" class="img-fluid rounded border" style="max-height:180px;" />
</div>
<input type="file" id="cfDiagramFile" class="form-control form-control-sm" accept="image/*" onchange="cfPreviewDiagram(event)" />
<div class="form-text">Optional. Upload a shop drawing or photo to help users recognize this item.</div>
</div>
<div class="mb-3">
<label class="form-label">AI Formula Generator</label>
<div class="input-group">
<input type="text" id="cfAiPrompt" class="form-control" placeholder="Describe the item, e.g. 'Rectangular roof curb with flanged base'" />
<button type="button" class="btn btn-outline-secondary" onclick="cfGenerateFromAi()" id="cfAiBtn">
<i class="bi bi-stars"></i> Generate
</button>
</div>
<div class="form-text">Claude will suggest a formula, fields, and mode. You can edit before saving.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="cfSave()">
<i class="bi bi-floppy me-1"></i>Save Template
</button>
</div>
</div>
</div>
</div>
<!-- Notification Template Edit Modal -->
<div class="modal fade" id="ntplModal" tabindex="-1" aria-labelledby="ntplModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div class="d-flex align-items-center gap-2">
<span id="ntpl-channel-badge"></span>
<h5 class="modal-title mb-0" id="ntplModalLabel">
<span id="ntpl-edit-title"></span>
<small id="ntpl-type-label" class="text-muted fw-normal ms-2 fs-6"></small>
</h5>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="ntpl-id" value="" />
<input type="hidden" id="ntpl-is-email" value="" />
<div class="row g-4">
<!-- Left: form -->
<div class="col-lg-8">
<div class="mb-3" id="ntpl-subject-row">
<label class="form-label fw-semibold">Subject</label>
<input type="text" id="ntpl-subject" class="form-control"
placeholder="Email subject line" />
</div>
<div class="mb-3">
<label class="form-label fw-semibold d-flex justify-content-between">
<span>Body</span>
<span id="ntpl-sms-counter" class="text-muted fw-normal small" style="display:none;">
<span id="ntpl-char-count">0</span> chars
(<span id="ntpl-seg-count">1</span> segment)
</span>
</label>
<textarea id="ntpl-body" class="form-control" rows="16"
placeholder="Enter template body..."></textarea>
<div id="ntpl-email-hint" class="form-text" style="display:none;">
<i class="bi bi-code-slash me-1"></i>HTML is supported. Use <code>{{placeholder}}</code> tokens anywhere in the body.
</div>
<div id="ntpl-sms-hint" class="form-text" style="display:none;">
<i class="bi bi-info-circle"></i>
Standard SMS segments are 160 characters. Always include <code>Reply STOP to opt out</code> for CTIA compliance.
</div>
</div>
</div>
<!-- Right: placeholders + tips -->
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-braces"></i> Available Placeholders</h6>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Click any placeholder to copy it, then paste into the template body.
</p>
<div id="ntpl-placeholders" class="d-flex flex-column gap-2"></div>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h6 class="card-title"><i class="bi bi-lightbulb"></i> Tips</h6>
<ul class="small text-muted mb-0 ps-3" id="ntpl-tips"></ul>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer d-flex justify-content-between">
<button type="button" class="btn btn-outline-danger btn-sm" onclick="ntplReset()">
<i class="bi bi-arrow-counterclockwise"></i> Reset to Default
</button>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="ntplSave()">
<i class="bi bi-floppy"></i> Save Template
</button>
</div>
</div>
</div>
</div>
</div>
@await Html.PartialAsync("_LookupModals")
<!-- Toast Container -->
<div class="position-fixed top-0 end-0 p-3" style="z-index: 9999">
<div id="successToast" class="toast align-items-center text-white bg-success border-0 shadow-lg" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="true" data-bs-delay="4000">
<div class="d-flex">
<div class="toast-body fs-6">
<i class="bi bi-check-circle-fill me-2"></i>
<span id="successToastMessage"></span>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
<div id="errorToast" class="toast align-items-center text-white bg-danger border-0 shadow-lg" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="true" data-bs-delay="5000">
<div class="d-flex">
<div class="toast-body fs-6">
<i class="bi bi-exclamation-circle-fill me-2"></i>
<span id="errorToastMessage"></span>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>
<!-- SMS Terms of Service Agreement Modal -->
<div class="modal fade" id="smsTermsModal" tabindex="-1" aria-labelledby="smsTermsModalLabel" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="smsTermsModalLabel">
<i class="bi bi-phone me-2"></i>SMS Notifications &mdash; Terms of Service
</h5>
</div>
<div class="modal-body">
<div class="alert alert-warning alert-permanent mb-3">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>Read carefully before enabling.</strong> Sending unsolicited text messages carries significant legal risk. By enabling SMS you are personally accepting responsibility for your company's compliance.
</div>
<h6 class="fw-bold">1. Prior Express Written Consent Required</h6>
<p class="text-muted small">You <strong>must obtain clear, documented consent</strong> from each customer before sending them any SMS message. This means each customer must have explicitly agreed &mdash; in writing or through a recorded digital interaction &mdash; that they wish to receive text messages from your business. Enabling this feature is not consent on their behalf. You must collect and record their authorization individually, before enabling SMS for their account in this system.</p>
<h6 class="fw-bold">2. Federal Law Governs SMS &mdash; Fines Are Real</h6>
<p class="text-muted small">The <strong>Telephone Consumer Protection Act (TCPA)</strong>, enforced by the Federal Communications Commission (FCC), imposes fines of <strong>$500 to $1,500 per individual message</strong> sent without proper authorization. These fines apply per text, not per customer. A single campaign to 100 unconsented recipients could result in exposure of $50,000 to $150,000. The FCC and private plaintiffs both actively pursue TCPA violations.</p>
<h6 class="fw-bold">3. Opt-Out Requests Must Be Honored Immediately</h6>
<p class="text-muted small">Any customer who replies <strong>STOP, UNSUBSCRIBE, CANCEL, END, or QUIT</strong> must be removed from all future SMS immediately. This system will process inbound opt-out replies automatically, but you must also honor any opt-out communicated by phone, email, or in person. Continuing to text a customer after an opt-out is a TCPA violation.</p>
<h6 class="fw-bold">4. Message Rates &amp; Content Restrictions</h6>
<p class="text-muted small">Every message sent must include your business name and an opt-out reminder (e.g., "Reply STOP to opt out"). Messages must be directly relevant to the service the customer consented to receive and must not contain solicitations, promotions, or third-party offers unless the customer has separately consented to those.</p>
<h6 class="fw-bold">5. Your Responsibility &mdash; Not Ours</h6>
<p class="text-muted small">Powder Coating Logix provides this feature as a communication tool only. <strong>We are not responsible for how you use it.</strong> You agree that your company is solely responsible for obtaining proper consent, maintaining records of that consent, honoring opt-outs, and ensuring all outbound messages comply with the TCPA, FCC regulations, and any applicable state laws. You agree to indemnify and hold Powder Coating Logix harmless from any claims, fines, or damages arising from your company's use of SMS.</p>
<hr />
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="smsTermsAgreementCheck">
<label class="form-check-label fw-semibold" for="smsTermsAgreementCheck">
I have read and understood the above terms. I confirm that my company will obtain proper customer consent before enabling SMS for any individual customer, and I accept full responsibility for our compliance with all applicable laws.
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="smsTermsDeclineBtn">Cancel &mdash; Keep SMS Disabled</button>
<button type="button" class="btn btn-primary" id="smsTermsAcceptBtn" disabled>
<i class="bi bi-check-circle me-1"></i>I Agree &amp; Enable SMS
</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
$(document).ready(function () {
// Mobile tab selector handler
$('#mobileTabSelector').on('change', function() {
var selectedTab = $(this).val();
if (selectedTab.startsWith('url:')) {
window.location.href = selectedTab.substring(4);
return;
}
var tabButton = $('button[data-bs-target="#' + selectedTab + '"]');
if (tabButton.length) {
var tab = new bootstrap.Tab(tabButton[0]);
tab.show();
} else {
$('.tab-pane').removeClass('show active');
$('#' + selectedTab).addClass('show active');
}
});
// Activate tab (and optional sub-tab) based on URL hash
var hash = window.location.hash;
if (hash) {
var tabId = hash.substring(1);
// Update mobile selector if present
$('#mobileTabSelector').val(tabId);
// Find a top-level tab or a pill sub-tab with this target
var tabButton = $('button[data-bs-target="#' + tabId + '"]');
if (tabButton.length) {
var isPill = tabButton.attr('data-bs-toggle') === 'pill';
if (isPill) {
// Activate the parent tab-pane's top-level tab first
var parentPaneId = tabButton.closest('.tab-pane').attr('id');
if (parentPaneId) {
var parentTabBtn = $('button[data-bs-target="#' + parentPaneId + '"]');
if (parentTabBtn.length) new bootstrap.Tab(parentTabBtn[0]).show();
$('#mobileTabSelector').val(parentPaneId);
}
// Then activate the pill sub-tab
new bootstrap.Tab(tabButton[0]).show();
} else {
new bootstrap.Tab(tabButton[0]).show();
}
} else {
// Mobile fallback: directly show the pane
$('.tab-pane').removeClass('show active');
$('#' + tabId).addClass('show active');
}
}
// Update mobile selector when desktop tab is clicked
$('button[data-bs-toggle="tab"]').on('shown.bs.tab', function (e) {
var targetId = $(e.target).data('bs-target').substring(1);
$('#mobileTabSelector').val(targetId);
});
// Rush Charge Type Toggle
$('input[name="rushChargeTypeRadio"]').on('change', function() {
var selectedType = $(this).val();
if (selectedType === 'Percentage') {
$('#rushChargePercentageInput').show();
$('#rushChargeFixedInput').hide();
} else {
$('#rushChargePercentageInput').hide();
$('#rushChargeFixedInput').show();
}
});
// Company Info Form Submit
$('#companyInfoForm').on('submit', function (e) {
e.preventDefault();
const formData = {
CompanyName: $('#companyName').val(),
CompanyCode: $('#companyCode').val(),
PrimaryContactName: $('#primaryContactName').val(),
PrimaryContactEmail: $('#primaryContactEmail').val(),
Phone: $('#phone').val(),
Address: $('#address').val(),
City: $('#city').val(),
State: $('#state').val(),
ZipCode: $('#zipCode').val(),
TimeZone: $('#timeZone').val(),
AccountingMethod: parseInt($('#accountingMethod').val())
};
const btn = $('#btnSaveCompanyInfo');
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span> Saving...');
$.ajax({
url: '@Url.Action("UpdateCompanyInfo", "CompanySettings")',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(formData),
success: function (response) {
if (response.success) {
showToast('success', response.message);
showButtonSuccess(btn, '<i class="bi bi-save"></i> Save Changes');
} else {
showToast('error', response.message);
btn.prop('disabled', false).html('<i class="bi bi-save"></i> Save Changes');
}
},
error: function () {
showToast('error', 'An error occurred while saving company information.');
btn.prop('disabled', false).html('<i class="bi bi-save"></i> Save Changes');
}
});
});
// Logo Upload Form Submit
$('#logoUploadForm').on('submit', function (e) {
e.preventDefault();
const fileInput = $('#logoFile')[0];
if (!fileInput.files || !fileInput.files[0]) {
showToast('error', 'Please select a file to upload.');
return;
}
const formData = new FormData();
formData.append('logoFile', fileInput.files[0]);
// Get antiforgery token from the page
const token = $('input[name="__RequestVerificationToken"]').val();
if (token) {
formData.append('__RequestVerificationToken', token);
}
const btn = $('#btnUploadLogo');
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span> Uploading...');
$.ajax({
url: '@Url.Action("UploadLogo", "CompanySettings")',
type: 'POST',
data: formData,
processData: false,
contentType: false,
success: function (response) {
if (response.success) {
showToast('success', response.message);
showButtonSuccess(btn, '<i class="bi bi-upload"></i> Upload Logo');
// Update logo preview with cache-busting
const logoSrc = response.logoUrl + '?v=' + Date.now();
$('#logoPreview').html('<img src="' + logoSrc + '" alt="Company Logo" class="img-fluid" style="max-height: 200px;" />');
// Show delete button
if ($('#btnDeleteLogo').length === 0) {
$('#logoPreview').after('<button type="button" class="btn btn-danger mt-3" id="btnDeleteLogo"><i class="bi bi-trash"></i> Delete Logo</button>');
}
// Clear file input
$('#logoFile').val('');
} else {
showToast('error', response.message);
btn.prop('disabled', false).html('<i class="bi bi-upload"></i> Upload Logo');
}
},
error: function () {
showToast('error', 'An error occurred while uploading the logo.');
btn.prop('disabled', false).html('<i class="bi bi-upload"></i> Upload Logo');
}
});
});
// Delete Logo (delegated event handler)
$(document).on('click', '#btnDeleteLogo', function () {
if (!confirm('Are you sure you want to delete the company logo?')) {
return;
}
const btn = $(this);
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span> Deleting...');
$.ajax({
url: '@Url.Action("DeleteLogo", "CompanySettings")',
type: 'POST',
success: function (response) {
if (response.success) {
showToast('success', response.message);
// Update logo preview
$('#logoPreview').html('<p class="text-muted mt-5">No logo uploaded</p>');
// Remove delete button
btn.remove();
} else {
showToast('error', response.message);
btn.prop('disabled', false).html('<i class="bi bi-trash"></i> Delete Logo');
}
},
error: function () {
showToast('error', 'An error occurred while deleting the logo.');
btn.prop('disabled', false).html('<i class="bi bi-trash"></i> Delete Logo');
}
});
});
// Operating Costs Form Submit
// Live facility overhead rate preview
function updateFacilityOverheadRate() {
var rent = parseFloat($('#monthlyRent').val()) || 0;
var utilities = parseFloat($('#monthlyUtilities').val()) || 0;
var hours = parseInt($('#monthlyBillableHours').val()) || 1;
var rate = hours > 0 ? (rent + utilities) / hours : 0;
$('#facilityOverheadRateDisplay').val('$' + rate.toFixed(2) + ' / hr');
}
$('.facility-overhead-input').on('input', updateFacilityOverheadRate);
$('#operatingCostsForm').on('submit', function (e) {
e.preventDefault();
const formData = {
StandardLaborRate: parseFloat($('#standardLaborRate').val()) || 0,
AdditionalCoatLaborPercent: parseFloat($('#additionalCoatLaborPercent').val()) || 30,
OvenOperatingCostPerHour: parseFloat($('#ovenOperatingCostPerHour').val()) || 0,
SandblasterCostPerHour: parseFloat($('#sandblasterCostPerHour').val()) || 0,
CoatingBoothCostPerHour: parseFloat($('#coatingBoothCostPerHour').val()) || 0,
PowderCoatingCostPerSqFt: parseFloat($('#powderCoatingCostPerSqFt').val()) || 0,
TaxPercent: parseFloat($('#taxPercent').val()) || 0,
ShopSuppliesRate: parseFloat($('#shopSuppliesRate').val()) || 0,
ShopMinimumCharge: parseFloat($('#shopMinimumCharge').val()) || 0,
PricingMode: parseInt($('#pricingModeValue').val()) || 0,
GeneralMarkupPercentage: parseFloat($('#generalMarkupPercentage').val()) || 0,
TargetMarginPercent: parseFloat($('#targetMarginPercent').val()) || 0,
RushChargeType: $('input[name="rushChargeTypeRadio"]:checked').val(),
RushChargePercentage: parseFloat($('#rushChargePercentage').val()) || 0,
RushChargeFixedAmount: parseFloat($('#rushChargeFixedAmount').val()) || 0,
ComplexitySimplePercent: parseFloat($('#complexitySimplePercent').val()) || 0,
ComplexityModeratePercent: parseFloat($('#complexityModeratePercent').val()) || 5,
ComplexityComplexPercent: parseFloat($('#complexityComplexPercent').val()) || 15,
ComplexityExtremePercent: parseFloat($('#complexityExtremePercent').val()) || 25,
MonthlyRent: parseFloat($('#monthlyRent').val()) || 0,
MonthlyUtilities: parseFloat($('#monthlyUtilities').val()) || 0,
MonthlyBillableHours: parseInt($('#monthlyBillableHours').val()) || 160
};
const btn = $('#btnSaveOperatingCosts');
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span> Saving...');
$.ajax({
url: '@Url.Action("UpdateOperatingCosts", "CompanySettings")',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(formData),
success: function (response) {
if (response.success) {
showToast('success', response.message);
showButtonSuccess(btn, '<i class="bi bi-save"></i> Save Operating Costs');
} else {
showToast('error', response.message);
btn.prop('disabled', false).html('<i class="bi bi-save"></i> Save Operating Costs');
}
},
error: function () {
showToast('error', 'An error occurred while saving operating costs.');
btn.prop('disabled', false).html('<i class="bi bi-save"></i> Save Operating Costs');
}
});
});
// AI Profile &mdash; char counter and save (elements only exist when AiPhotoQuotesEnabled)
$('#aiContextProfile').on('input', function () {
$('#aiProfileCharCount').text($(this).val().length);
});
$('#btnSaveAiProfile').on('click', function () {
const btn = $(this);
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span> Saving...');
$.ajax({
url: '@Url.Action("UpdateAiProfile", "CompanySettings")',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ aiContextProfile: $('#aiContextProfile').val() }),
headers: { 'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val() },
success: function (response) {
if (response.success) {
showToast('success', response.message);
showButtonSuccess(btn, '<i class="bi bi-floppy me-1"></i> Save AI Profile');
} else {
showToast('error', response.message);
btn.prop('disabled', false).html('<i class="bi bi-floppy me-1"></i> Save AI Profile');
}
},
error: function () {
showToast('error', 'An error occurred while saving the AI profile.');
btn.prop('disabled', false).html('<i class="bi bi-floppy me-1"></i> Save AI Profile');
}
});
});
$('#btnGenerateAiDraft').on('click', function () {
const btn = $(this);
const existing = $('#aiContextProfile').val().trim();
if (existing && !confirm('This will replace your current profile text with a generated draft. Continue?')) return;
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span> Generating...');
$.ajax({
url: '@Url.Action("GenerateAiProfileDraft", "CompanySettings")',
type: 'GET',
success: function (response) {
if (response.success) {
$('#aiContextProfile').val(response.draft);
$('#aiProfileCharCount').text(response.draft.length);
showToast('info', 'Draft generated &mdash; review and edit it, then click Save AI Profile.');
} else {
showToast('error', response.message);
}
},
error: function () {
showToast('error', 'An error occurred while generating the draft.');
},
complete: function () {
btn.prop('disabled', false).html('<i class="bi bi-stars me-1"></i> Generate from my settings');
}
});
});
// Quoting Calibration &mdash; save
$('#saveBlastProfile').on('click', function () {
var btn = $(this);
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span> Saving...');
$.ajax({
url: '@Url.Action("UpdateBlastProfile", "CompanySettings")',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
shopCapabilityTier: parseInt($('#shopCapabilityTier').val())
}),
headers: { 'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val() },
success: function (response) {
if (response.success) {
showToast('success', response.message);
showButtonSuccess(btn, '<i class="bi bi-check-circle me-1"></i>Save Calibration');
} else {
showToast('error', response.message);
btn.prop('disabled', false).html('<i class="bi bi-check-circle me-1"></i>Save Calibration');
}
},
error: function () {
showToast('error', 'An error occurred while saving the quoting calibration.');
btn.prop('disabled', false).html('<i class="bi bi-check-circle me-1"></i>Save Calibration');
}
});
});
// Generic preferences form helper
function bindPreferencesForm(formId, btnId, url, buildData, btnLabel) {
$(formId).on('submit', function (e) {
e.preventDefault();
const btn = $(btnId);
const originalHtml = '<i class="bi bi-save"></i> ' + btnLabel;
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span> Saving...');
$.ajax({
url: url,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(buildData()),
success: function (r) {
showToast(r.success ? 'success' : 'error', r.message);
if (r.success) {
showButtonSuccess(btn, originalHtml);
} else {
btn.prop('disabled', false).html(originalHtml);
}
},
error: function () {
showToast('error', 'An error occurred while saving.');
btn.prop('disabled', false).html(originalHtml);
}
});
});
}
bindPreferencesForm('#appDefaultsForm', '#btnSaveAppDefaults', '@Url.Action("UpdateAppDefaults","CompanySettings")', function () {
return {
DefaultCurrency: $('#defaultCurrency').val(),
DefaultDateFormat: $('#defaultDateFormat').val(),
DefaultTimeFormat: $('#defaultTimeFormat').val(),
DefaultPaymentTerms: $('#defaultPaymentTerms').val(),
DefaultQuoteValidityDays: parseInt($('#defaultQuoteValidityDays').val()) || 30,
QuoteNumberPrefix: $('#quoteNumberPrefix').val(),
JobNumberPrefix: $('#jobNumberPrefix').val(),
UseMetricSystem: $('#useMetricSystem').is(':checked')
};
}, 'Save Defaults');
bindPreferencesForm('#jobDefaultsForm', '#btnSaveJobDefaults', '@Url.Action("UpdateJobDefaults","CompanySettings")', function () {
return {
DefaultJobPriority: $('#defaultJobPriority').val(),
RequireCustomerPO: $('#requireCustomerPO').is(':checked'),
AllowCustomerApproval: $('#allowCustomerApproval').is(':checked'),
DefaultTurnaroundDays: parseInt($('#defaultTurnaroundDays').val()) || 7
};
}, 'Save Defaults');
bindPreferencesForm('#notificationsForm', '#btnSaveNotifications', '@Url.Action("UpdateNotifications","CompanySettings")', function () {
return {
EmailFromAddress: $('#emailFromAddress').val() || null,
EmailFromName: $('#emailFromName').val() || null,
EmailNotificationsEnabled: $('#emailNotificationsEnabled').is(':checked'),
NotifyOnNewJob: $('#notifyOnNewJob').is(':checked'),
NotifyOnNewQuote: $('#notifyOnNewQuote').is(':checked'),
NotifyOnJobStatusChange: $('#notifyOnJobStatusChange').is(':checked'),
NotifyOnQuoteApproval: $('#notifyOnQuoteApproval').is(':checked'),
NotifyOnPaymentReceived: $('#notifyOnPaymentReceived').is(':checked'),
QuoteExpiryWarningDays: parseInt($('#quoteExpiryWarningDays').val()) || 3,
DueDateWarningDays: parseInt($('#dueDateWarningDays').val()) || 2,
MaintenanceAlertDays: parseInt($('#maintenanceAlertDays').val()) || 7,
PaymentRemindersEnabled: $('#paymentRemindersEnabled').is(':checked'),
PaymentReminderDays: $('#paymentReminderDays').val() || '7,14,30'
};
}, 'Save Notifications');
bindPreferencesForm('#dataRetentionForm', '#btnSaveDataRetention', '@Url.Action("UpdateDataRetention","CompanySettings")', function () {
return {
QuoteRetentionYears: parseInt($('#quoteRetentionYears').val()) || 7,
JobRetentionYears: parseInt($('#jobRetentionYears').val()) || 7,
LogRetentionDays: parseInt($('#logRetentionDays').val()) || 90,
AutoArchiveJobsDays: parseInt($('#autoArchiveJobsDays').val()) || 365,
DeletedRecordRetentionDays: parseInt($('#deletedRecordRetentionDays').val()) || 30
};
}, 'Save Retention Policy');
// SMS toggle &mdash; shows terms modal on first enable (or after terms version change)
(function () {
const toggle = document.getElementById('smsEnabledToggle');
if (!toggle) return;
const smsTermsModal = new bootstrap.Modal(document.getElementById('smsTermsModal'));
const acceptBtn = document.getElementById('smsTermsAcceptBtn');
const declineBtn = document.getElementById('smsTermsDeclineBtn');
const agreeCheck = document.getElementById('smsTermsAgreementCheck');
// Unlock the accept button only when checkbox is ticked
agreeCheck.addEventListener('change', function () {
acceptBtn.disabled = !this.checked;
});
function postSmsPreference(enabled, agreedToTerms, termsVersion) {
$.ajax({
url: '@Url.Action("UpdateSmsPreferences", "CompanySettings")',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ SmsEnabled: enabled, AgreedToTerms: agreedToTerms, TermsVersion: termsVersion }),
headers: { 'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').first().val() },
success: function (res) {
if (res.success) {
toggle.dataset.hasAgreement = 'true';
showToast('success', res.message);
} else {
// Revert toggle on failure
toggle.checked = !enabled;
showToast('error', res.message || 'Failed to save SMS preference.');
}
},
error: function () {
toggle.checked = !enabled;
showToast('error', 'Failed to save SMS preference.');
}
});
}
toggle.addEventListener('change', function () {
const enabled = this.checked;
const hasAgreement = this.dataset.hasAgreement === 'true';
const termsVersion = this.dataset.termsVersion;
if (!enabled) {
// Disabling: no agreement needed
postSmsPreference(false, false, null);
return;
}
if (hasAgreement) {
// Re-enabling: already agreed to this version
postSmsPreference(true, false, null);
return;
}
// First enable (or terms version changed): show the modal
agreeCheck.checked = false;
acceptBtn.disabled = true;
smsTermsModal.show();
// Revert toggle until they explicitly agree
toggle.checked = false;
acceptBtn.onclick = function () {
smsTermsModal.hide();
toggle.checked = true;
postSmsPreference(true, true, termsVersion);
};
declineBtn.onclick = function () {
smsTermsModal.hide();
toggle.checked = false;
};
});
})();
// Toast helper function (exposed globally for lookup management)
window.showToast = function(type, message) {
if (type === 'success') {
$('#successToastMessage').text(message);
const toast = new bootstrap.Toast($('#successToast')[0]);
toast.show();
} else {
$('#errorToastMessage').text(message);
const toast = new bootstrap.Toast($('#errorToast')[0]);
toast.show();
}
};
// Button success animation helper
function showButtonSuccess(btn, originalHtml, duration = 2000) {
btn.removeClass('btn-primary').addClass('btn-success');
btn.html('<i class="bi bi-check-circle-fill"></i> Saved!');
setTimeout(function() {
btn.removeClass('btn-success').addClass('btn-primary');
btn.html(originalHtml);
}, duration);
}
});
</script>
<script src="~/lib/sortablejs/Sortable.min.js"></script>
<script src="~/js/company-settings-lookups.js" asp-append-version="true"></script>
<script src="~/js/company-settings-lookups-modals.js" asp-append-version="true"></script>
<script>
// ── Oven Costs Management ──────────────────────────────────────────────
// Cache element references once &mdash; modal is now outside all forms
const _ovenModal = new bootstrap.Modal(document.getElementById('ovenModal'));
const _ovenTitle = document.getElementById('ovenModalTitle');
const _ovenId = document.getElementById('ovenModalId');
const _ovenLabel = document.getElementById('ovenLabelInput');
const _ovenCost = document.getElementById('ovenCostInput');
const _ovenMaxSqFt = document.getElementById('ovenMaxSqFtInput');
const _ovenOrder = document.getElementById('ovenOrderInput');
const _ovenActive = document.getElementById('ovenActiveInput');
const _ovenError = document.getElementById('ovenErrorMsg');
const _ovenBody = document.getElementById('ovenCostsBody');
const _ovenCount = document.getElementById('ovenCountText');
function onPricingModeChange() {
const mode = parseInt($('input[name="pricingModeRadio"]:checked').val());
$('#pricingModeValue').val(mode);
$('#markupSection').toggle(mode === 0);
$('#marginSection').toggle(mode === 1);
}
async function loadOvenCosts() {
try {
const resp = await fetch('/CompanySettings/GetOvenCosts');
const data = await resp.json();
if (!data.success) throw new Error(data.message);
renderOvenTable(data.data);
_ovenCount.textContent = data.data.length === 0
? 'No shop ovens &mdash; default rate will be used on all quotes.'
: `${data.data.length} oven(s) configured`;
} catch (e) {
_ovenBody.innerHTML = `<tr><td colspan="6" class="text-center text-danger">Failed to load ovens: ${escHtml(e.message)}</td></tr>`;
}
}
function renderOvenTable(ovens) {
if (!ovens.length) {
_ovenBody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-3">No shop ovens configured.</td></tr>';
return;
}
_ovenBody.innerHTML = ovens.map(o => {
const d = escHtml(JSON.stringify({ id: o.id, label: o.label, costPerHour: o.costPerHour, maxLoadSqFt: o.maxLoadSqFt, defaultCycleMinutes: o.defaultCycleMinutes, displayOrder: o.displayOrder, isActive: o.isActive }));
return `
<tr>
<td><strong>${escHtml(o.label)}</strong></td>
<td>$${parseFloat(o.costPerHour).toFixed(2)}/hr</td>
<td class="text-muted small">${o.maxLoadSqFt != null ? parseFloat(o.maxLoadSqFt).toFixed(0) + ' sqft' : '&mdash;'}</td>
<td>${o.displayOrder}</td>
<td>${o.isActive
? '<span class="badge bg-success">Active</span>'
: '<span class="badge bg-secondary">Inactive</span>'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary me-1 btn-oven-edit" data-oven="${d}" title="Edit">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger btn-oven-delete" data-oven="${d}" title="Delete">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>`;
}).join('');
}
// Event delegation for oven table buttons (avoids inline onclick escaping issues)
_ovenBody.addEventListener('click', function (e) {
const editBtn = e.target.closest('.btn-oven-edit');
const deleteBtn = e.target.closest('.btn-oven-delete');
if (editBtn) {
const o = JSON.parse(editBtn.getAttribute('data-oven'));
showEditOvenModal(o.id, o.label, o.costPerHour, o.maxLoadSqFt, o.defaultCycleMinutes, o.displayOrder, o.isActive);
} else if (deleteBtn) {
const o = JSON.parse(deleteBtn.getAttribute('data-oven'));
deleteOven(o.id, o.label);
}
});
function showAddOvenModal() {
_ovenTitle.textContent = 'Add Oven';
_ovenId.value = '';
_ovenLabel.value = '';
_ovenCost.value = '';
_ovenMaxSqFt.value = '';
_ovenOrder.value = '0';
_ovenActive.checked = true;
_ovenError.classList.add('d-none');
_ovenModal.show();
}
function showEditOvenModal(id, label, cost, maxSqFt, cycleMin, order, isActive) {
_ovenTitle.textContent = 'Edit Oven';
_ovenId.value = id;
_ovenLabel.value = label;
_ovenCost.value = cost;
_ovenMaxSqFt.value = maxSqFt != null ? maxSqFt : '';
_ovenOrder.value = order;
_ovenActive.checked = isActive;
_ovenError.classList.add('d-none');
_ovenModal.show();
}
async function saveOven() {
const id = _ovenId.value;
const label = _ovenLabel.value.trim();
const cost = parseFloat(_ovenCost.value);
const maxSqFt = _ovenMaxSqFt.value !== '' ? parseFloat(_ovenMaxSqFt.value) : null;
const order = parseInt(_ovenOrder.value) || 0;
const isActive = _ovenActive.checked;
if (!label) { showOvenError('Label is required.'); return; }
if (isNaN(cost) || cost < 0) { showOvenError('A valid cost per hour is required.'); return; }
const url = id ? '/CompanySettings/UpdateOvenCost' : '/CompanySettings/CreateOvenCost';
const basePayload = { label, costPerHour: cost, maxLoadSqFt: maxSqFt, displayOrder: order, isActive };
const payload = id ? { id: parseInt(id), ...basePayload } : basePayload;
try {
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? ''
},
body: JSON.stringify(payload)
});
const data = await resp.json();
if (!data.success) throw new Error(data.message);
_ovenModal.hide();
await loadOvenCosts();
showToast('success', id ? 'Oven updated.' : 'Oven added.');
} catch (e) {
showOvenError(e.message);
}
}
function showOvenError(msg) {
_ovenError.textContent = msg;
_ovenError.classList.remove('d-none');
}
// ── Dimension calculator ──────────────────────────────────────────────
const _calcPanel = document.getElementById('ovenDimCalc');
const _calcW = document.getElementById('ovenDimW');
const _calcD = document.getElementById('ovenDimD');
const _calcH = document.getElementById('ovenDimH');
const _calcResult = document.getElementById('ovenDimResult');
const _calcApply = document.getElementById('ovenDimApply');
document.getElementById('ovenCalcToggle').addEventListener('click', function (e) {
e.preventDefault();
const hidden = _calcPanel.classList.toggle('d-none');
if (!hidden) { _calcW.value = ''; _calcD.value = ''; _calcH.value = ''; _calcResult.textContent = '&mdash;'; _calcApply.disabled = true; _calcW.focus(); }
});
function _updateCalc() {
const w = parseFloat(_calcW.value);
const d = parseFloat(_calcD.value);
const h = parseFloat(_calcH.value);
if (w > 0 && d > 0 && h > 0) {
const raw = w * d * h;
const val = Math.round(raw * 0.8 * 10) / 10;
_calcResult.textContent = val + ' cu ft';
_calcApply.disabled = false;
_calcApply.dataset.val = val;
} else if (w > 0 && d > 0) {
const raw = w * d;
const val = Math.round(raw * 0.8 * 10) / 10;
_calcResult.textContent = val + ' sq ft';
_calcApply.disabled = false;
_calcApply.dataset.val = val;
} else {
_calcResult.textContent = '&mdash;';
_calcApply.disabled = true;
}
}
_calcW.addEventListener('input', _updateCalc);
_calcD.addEventListener('input', _updateCalc);
_calcH.addEventListener('input', _updateCalc);
_calcApply.addEventListener('click', function () {
_ovenMaxSqFt.value = this.dataset.val;
_calcPanel.classList.add('d-none');
});
// Reset calculator when modal is hidden
document.getElementById('ovenModal').addEventListener('hidden.bs.modal', function () {
_calcPanel.classList.add('d-none');
_calcW.value = ''; _calcD.value = ''; _calcH.value = '';
_calcResult.textContent = '&mdash;'; _calcApply.disabled = true;
});
// ─────────────────────────────────────────────────────────────────────
async function deleteOven(id, label) {
if (!confirm(`Delete oven "${label}"? This cannot be undone.`)) return;
try {
const resp = await fetch('/CompanySettings/DeleteOvenCost', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? ''
},
body: JSON.stringify(id)
});
const data = await resp.json();
if (!data.success) throw new Error(data.message);
await loadOvenCosts();
showToast('success', 'Oven deleted.');
} catch (e) {
showToast('error', e.message);
}
}
function escHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Reload oven list whenever the Shop Equipment Profile tab is shown
document.getElementById('quoting-calibration-tab')?.addEventListener('shown.bs.tab', () => {
loadOvenCosts();
});
// ── Quote PDF Template ──────────────────────────────────────────────
function syncColorPicker(hex) {
if (/^#[0-9A-Fa-f]{6}$/.test(hex)) {
document.getElementById('qtAccentColorPicker').value = hex;
}
}
function setAccentColor(hex) {
document.getElementById('qtAccentColorPicker').value = hex;
document.getElementById('qtAccentColorHex').value = hex;
}
async function saveQuoteTemplate() {
const hex = document.getElementById('qtAccentColorHex').value.trim();
if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) {
showToast('error', 'Accent color must be a valid 6-digit hex color (e.g. #374151).');
return;
}
const payload = {
QtAccentColor: hex,
QtDefaultTerms: document.getElementById('qtDefaultTerms').value || null,
QtFooterNote: document.getElementById('qtFooterNote').value || null
};
try {
const resp = await fetch('/CompanySettings/UpdateQuoteTemplate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? ''
},
body: JSON.stringify(payload)
});
const data = await resp.json();
if (!data.success) throw new Error(data.message);
showToast('success', 'Quote PDF settings saved successfully.');
} catch (e) {
showToast('error', e.message);
}
}
// ── Invoice PDF Template ─────────────────────────────────────────────
function syncInvoiceColorPicker(hex) {
if (/^#[0-9A-Fa-f]{6}$/.test(hex)) {
document.getElementById('inAccentColorPicker').value = hex;
}
}
function setInvoiceAccentColor(hex) {
document.getElementById('inAccentColorPicker').value = hex;
document.getElementById('inAccentColorHex').value = hex;
}
async function saveInvoiceTemplate() {
const hex = document.getElementById('inAccentColorHex').value.trim();
if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) {
showToast('error', 'Accent color must be a valid 6-digit hex color (e.g. #374151).');
return;
}
const payload = {
InAccentColor: hex,
InDefaultTerms: document.getElementById('inDefaultTerms').value || null,
InFooterNote: document.getElementById('inFooterNote').value || null
};
try {
const resp = await fetch('/CompanySettings/UpdateInvoiceTemplate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? ''
},
body: JSON.stringify(payload)
});
const data = await resp.json();
if (!data.success) throw new Error(data.message);
showToast('success', 'Invoice PDF settings saved successfully.');
} catch (e) {
showToast('error', e.message);
}
}
async function saveWorkOrderTemplate() {
const hex = document.getElementById('woAccentColorHex').value.trim();
if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) {
showToast('error', 'Accent color must be a valid 6-digit hex color (e.g. #374151).');
return;
}
const payload = {
WoAccentColor: hex,
WoTerms: document.getElementById('woTerms').value || null
};
try {
const resp = await fetch('/CompanySettings/UpdateWorkOrderTemplate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? ''
},
body: JSON.stringify(payload)
});
const data = await resp.json();
if (!data.success) throw new Error(data.message);
showToast('success', 'Work order settings saved successfully.');
} catch (e) {
showToast('error', e.message);
}
}
// ── Notification Template Inline Editor ──────────────────────────────
const ntplCsrf = () => document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
async function ntplEdit(id) {
try {
const resp = await fetch(`/CompanySettings/GetTemplateJson/${id}`);
if (!resp.ok) throw new Error('Failed to load template.');
const t = await resp.json();
document.getElementById('ntpl-id').value = t.id;
document.getElementById('ntpl-is-email').value = t.isEmail ? '1' : '';
document.getElementById('ntpl-edit-title').textContent = t.displayName;
document.getElementById('ntpl-type-label').textContent = t.notificationType.replace(/([A-Z])/g, ' $1').trim();
document.getElementById('ntpl-subject').value = t.subject;
const bodyEl = document.getElementById('ntpl-body');
bodyEl.value = t.body;
bodyEl.rows = t.isEmail ? 16 : 5;
// Channel badge
document.getElementById('ntpl-channel-badge').innerHTML = t.isEmail
? '<span class="badge bg-primary"><i class="bi bi-envelope"></i> Email</span>'
: '<span class="badge bg-success"><i class="bi bi-phone"></i> SMS</span>';
// Show/hide email vs SMS fields
document.getElementById('ntpl-subject-row').style.display = t.isEmail ? '' : 'none';
document.getElementById('ntpl-email-hint').style.display = t.isEmail ? '' : 'none';
document.getElementById('ntpl-sms-hint').style.display = t.isEmail ? 'none' : '';
document.getElementById('ntpl-sms-counter').style.display = t.isEmail ? 'none' : '';
if (!t.isEmail) ntplUpdateSmsCounter(t.body.length);
// Placeholders
const ph = document.getElementById('ntpl-placeholders');
ph.innerHTML = t.placeholders.map(p => `
<div>
<span class="badge bg-light text-dark border px-2 py-1"
style="cursor:pointer;font-family:monospace;font-size:.85rem;"
onmouseenter="this.classList.replace('bg-light','bg-primary');this.classList.replace('text-dark','text-white')"
onmouseleave="this.classList.replace('bg-primary','bg-light');this.classList.replace('text-white','text-dark')"
onclick="ntplCopyPlaceholder('${p.placeholder}', this)"
title="${p.description} &mdash; click to copy">
${p.placeholder}
</span>
<span class="ms-1 text-success small" style="display:none;">Copied!</span>
<div class="text-muted" style="font-size:.78rem;">${p.description}</div>
</div>`).join('');
// Tips
document.getElementById('ntpl-tips').innerHTML = t.isEmail ? `
<li>Placeholders are case-insensitive.</li>
<li>Edit raw HTML directly. A plain-text version is generated automatically.</li>
<li>An unsubscribe link is always appended to comply with CAN-SPAM.</li>` : `
<li>Placeholders are case-insensitive.</li>
<li>Keep messages under 160 characters to avoid splitting.</li>
<li>Always include opt-out instructions (e.g. "Reply STOP") to comply with CTIA guidelines.</li>`;
bootstrap.Modal.getOrCreateInstance(document.getElementById('ntplModal')).show();
} catch (e) {
showToast('error', e.message);
}
}
function ntplCancelEdit() {
bootstrap.Modal.getInstance(document.getElementById('ntplModal'))?.hide();
}
function ntplUpdateSmsCounter(len) {
document.getElementById('ntpl-char-count').textContent = len;
const segs = Math.ceil(len / 160) || 1;
document.getElementById('ntpl-seg-count').textContent = segs;
}
document.getElementById('ntpl-body')?.addEventListener('input', function () {
if (!document.getElementById('ntpl-is-email').value)
ntplUpdateSmsCounter(this.value.length);
});
async function ntplSave() {
const id = parseInt(document.getElementById('ntpl-id').value);
const payload = {
id,
subject: document.getElementById('ntpl-subject').value || null,
body: document.getElementById('ntpl-body').value
};
try {
const resp = await fetch('/CompanySettings/SaveTemplateJson', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': ntplCsrf()
},
body: JSON.stringify(payload)
});
const data = await resp.json();
if (!data.success) throw new Error(data.message);
// Update "last modified" in the list row
const now = new Date().toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
const row = document.getElementById(`ntpl-row-${id}`);
if (row) row.querySelector('.ntpl-modified').textContent = now;
showToast('success', data.message);
ntplCancelEdit();
} catch (e) {
showToast('error', e.message);
}
}
async function ntplReset() {
if (!confirm('Reset this template to the built-in default? Your customisations will be lost.')) return;
const id = parseInt(document.getElementById('ntpl-id').value);
try {
const resp = await fetch(`/CompanySettings/ResetTemplateJson/${id}`, {
method: 'POST',
headers: { 'RequestVerificationToken': ntplCsrf() }
});
const data = await resp.json();
if (!data.success) throw new Error(data.message);
document.getElementById('ntpl-subject').value = data.newSubject ?? '';
document.getElementById('ntpl-body').value = data.newBody ?? '';
showToast('success', data.message);
} catch (e) {
showToast('error', e.message);
}
}
function ntplCopyPlaceholder(text, el) {
navigator.clipboard.writeText(text).catch(() => {
const ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta); ta.select();
document.execCommand('copy'); document.body.removeChild(ta);
}).then(() => {
const feedback = el.nextElementSibling;
if (feedback) { feedback.style.display = 'inline'; setTimeout(() => { feedback.style.display = 'none'; }, 1800); }
});
}
// ── Online Payments ──────────────────────────────────────────────────
const surchargeTypeEl = document.getElementById('surchargeType');
const surchargePrefixEl = document.getElementById('surchargePrefix');
const surchargeAckRow = document.getElementById('surchargeAckRow');
if (surchargeTypeEl) {
// Restore selected value from data attribute
surchargeTypeEl.value = surchargeTypeEl.dataset.selected || '0';
surchargeTypeEl.addEventListener('change', function () {
const val = parseInt(this.value);
if (surchargePrefixEl) surchargePrefixEl.textContent = val === 2 ? '$' : '%';
if (surchargeAckRow) surchargeAckRow.style.display = val === 0 ? 'none' : '';
document.getElementById('surchargeValue').max = val === 1 ? '3' : '9999';
});
}
async function saveOnlinePaymentSettings() {
const type = parseInt(document.getElementById('surchargeType').value);
const value = parseFloat(document.getElementById('surchargeValue').value) || 0;
const ack = document.getElementById('surchargeAck')?.checked ?? false;
if (type !== 0 && !ack) {
showError('Please acknowledge the surcharge compliance notice before saving.');
return;
}
const resp = await fetch('/CompanySettings/SaveOnlinePaymentSettings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
},
body: JSON.stringify({ surchargeType: type, surchargeValue: value, surchargeAcknowledged: ack })
});
const data = await resp.json();
if (data.success) showSuccess(data.message);
else showError(data.message);
}
async function disconnectStripe() {
if (!confirm('Are you sure you want to disconnect your Stripe account? Existing payment links will stop working.')) return;
const resp = await fetch('/CompanySettings/DisconnectStripe', {
method: 'POST',
headers: { 'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val() }
});
const data = await resp.json();
if (data.success) { showSuccess(data.message); setTimeout(() => location.reload(), 1500); }
else showError(data.message);
}
function selectKioskOutput(value) {
document.getElementById('kioskOutputQuote').checked = value === 'Quote';
document.getElementById('kioskOutputJob').checked = value === 'Job';
document.getElementById('kioskOutputQuoteCard').classList.toggle('border-primary', value === 'Quote');
document.getElementById('kioskOutputQuoteCard').classList.toggle('bg-primary-subtle', value === 'Quote');
document.getElementById('kioskOutputJobCard').classList.toggle('border-success', value === 'Job');
document.getElementById('kioskOutputJobCard').classList.toggle('bg-success-subtle', value === 'Job');
}
async function saveKioskSettings() {
const value = document.querySelector('input[name="kioskOutput"]:checked')?.value ?? 'Quote';
const resp = await fetch('/CompanySettings/UpdateKioskSettings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
},
body: JSON.stringify({ kioskIntakeOutput: value })
});
const data = await resp.json();
if (data.success) showSuccess(data.message);
else showError(data.message);
}
// Auto-open online-payments tab if redirected with ?tab=online-payments
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('tab') === 'online-payments') {
const btn = document.querySelector('[data-bs-target="#online-payments"]');
if (btn) new bootstrap.Tab(btn).show();
}
if (urlParams.get('tab') === 'kiosk') {
const btn = document.querySelector('[data-bs-target="#kiosk"]');
if (btn) new bootstrap.Tab(btn).show();
}
if (urlParams.get('tab') === 'custom-formulas') {
const btn = document.querySelector('[data-bs-target="#custom-formulas"]');
if (btn) new bootstrap.Tab(btn).show();
}
</script>
<script>
// Timeclock settings
(function () {
var antiForgery = document.querySelector('input[name="__RequestVerificationToken"]');
var token = antiForgery ? antiForgery.value : '';
function setStatus(msg, ok) {
var el = document.getElementById('timeclock-status');
el.textContent = msg;
el.className = 'ms-2 small text-' + (ok ? 'success' : 'danger');
setTimeout(function () { el.textContent = ''; }, 4000);
}
document.getElementById('btn-save-timeclock').addEventListener('click', function () {
var hours = document.getElementById('timeclockAutoClockOut').value.trim();
fetch('/CompanySettings/UpdateTimeclockSettings', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': token },
body: JSON.stringify({
TimeclockEnabled: document.getElementById('timeclockEnabled').checked,
TimeclockAllowMultiplePunchesPerDay: document.getElementById('timeclockMultiplePunches').checked,
TimeclockAutoClockOutHours: hours !== '' ? parseInt(hours, 10) : null
})
}).then(function (r) { return r.json(); }).then(function (res) {
setStatus(res.message, res.success);
}).catch(function () { setStatus('Error saving settings.', false); });
});
document.querySelectorAll('.btn-deactivate-kiosk').forEach(function (btn) {
btn.addEventListener('click', function () {
var id = btn.dataset.id;
var name = btn.dataset.name;
if (!confirm('Deactivate "' + name + '"? The tablet will lose kiosk access immediately.')) return;
fetch('/Timeclock/KioskDeactivate/' + id, {
method: 'POST',
headers: { 'RequestVerificationToken': token }
}).then(function (r) { return r.json(); }).then(function (res) {
if (res.success) {
var row = document.querySelector('tr[data-device-id="' + id + '"]');
if (row) row.remove();
} else {
alert(res.message || 'Error deactivating device.');
}
});
});
});
// Auto-show tab if URL fragment matches
if (window.location.hash === '#timeclock' || new URLSearchParams(window.location.search).get('tab') === 'timeclock') {
var btn = document.querySelector('[data-bs-target="#timeclock"]');
if (btn) new bootstrap.Tab(btn).show();
}
})();
</script>
<script src="~/js/company-settings-custom-formulas.js" asp-append-version="true"></script>
<script>
// Load formula templates when the tab is first shown; auto-show walkthrough if no templates yet
document.querySelector('[data-bs-target="#custom-formulas"]').addEventListener('shown.bs.tab', async () => {
if (!window._cfLoaded) {
await cfLoadTemplates();
window._cfLoaded = true;
if (!localStorage.getItem('cfWalkthroughSeen')) {
const hasTemplates = document.querySelectorAll('#cfTemplatesBody tr[data-id]').length > 0;
if (!hasTemplates) cfShowWalkthrough();
}
}
});
</script>
}