7e1676cfd7
- AP Aging report (GetApAgingAsync, controller actions, view, PDF export) mirrors AR Aging — groups open bills by vendor, buckets by days past due date - Trial Balance report (GetTrialBalanceAsync, view, PDF export) uses Account.CurrentBalance, groups by AccountType, validates debits == credits - Cash vs Accrual accounting method setting on Company entity switchable at any time — report-time only, no GL re-posting on change P&L cash: revenue = payments received; expenses = bills/expenses paid in period Balance Sheet cash: omits AR and AP lines (no receivables/payables concept) AccountingMethod badge shown on P&L and Balance Sheet views - Migration A (AddAccountingMethod) applied, default = Accrual for all existing companies - AP Aging and Trial Balance added to Reports Landing page Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3218 lines
205 KiB
Plaintext
3218 lines
205 KiB
Plaintext
@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>
|
||
<option value="quoting-calibration">Shop Equipment Profile</option>
|
||
<option value="app-defaults">App Defaults</option>
|
||
<option value="job-defaults">Job & 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>
|
||
</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 & 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>
|
||
}
|
||
</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 — quotes, invoices, and PDFs. Keep the company name, address, and email accurate so customers see the right details. The <strong>Primary Contact Email</strong> is used as the reply-to address on all outgoing notifications.<br><br><a href='/Help/Settings#company-information' target='_blank'>Learn more →</a>">
|
||
<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) — New York</option>
|
||
<option value="America/Chicago" selected="@(Model.TimeZone == "America/Chicago" ? "selected" : null)">Central (CT) — Chicago</option>
|
||
<option value="America/Denver" selected="@(Model.TimeZone == "America/Denver" ? "selected" : null)">Mountain (MT) — Denver</option>
|
||
<option value="America/Phoenix" selected="@(Model.TimeZone == "America/Phoenix" ? "selected" : null)">Mountain no-DST — Phoenix</option>
|
||
<option value="America/Los_Angeles" selected="@(Model.TimeZone == "America/Los_Angeles" ? "selected" : null)">Pacific (PT) — Los Angeles</option>
|
||
<option value="America/Anchorage" selected="@(Model.TimeZone == "America/Anchorage" ? "selected" : null)">Alaska (AKT) — Anchorage</option>
|
||
<option value="Pacific/Honolulu" selected="@(Model.TimeZone == "Pacific/Honolulu" ? "selected" : null)">Hawaii (HT) — Honolulu</option>
|
||
</optgroup>
|
||
<optgroup label="Canada">
|
||
<option value="America/Halifax" selected="@(Model.TimeZone == "America/Halifax" ? "selected" : null)">Atlantic (AT) — Halifax</option>
|
||
<option value="America/Toronto" selected="@(Model.TimeZone == "America/Toronto" ? "selected" : null)">Eastern — Toronto</option>
|
||
<option value="America/Winnipeg" selected="@(Model.TimeZone == "America/Winnipeg" ? "selected" : null)">Central — Winnipeg</option>
|
||
<option value="America/Edmonton" selected="@(Model.TimeZone == "America/Edmonton" ? "selected" : null)">Mountain — Edmonton</option>
|
||
<option value="America/Vancouver" selected="@(Model.TimeZone == "America/Vancouver" ? "selected" : null)">Pacific — Vancouver</option>
|
||
</optgroup>
|
||
<optgroup label="Europe">
|
||
<option value="Europe/London" selected="@(Model.TimeZone == "Europe/London" ? "selected" : null)">GMT/BST — London</option>
|
||
<option value="Europe/Paris" selected="@(Model.TimeZone == "Europe/Paris" ? "selected" : null)">CET — Paris / Berlin</option>
|
||
<option value="Europe/Helsinki" selected="@(Model.TimeZone == "Europe/Helsinki" ? "selected" : null)">EET — Helsinki</option>
|
||
<option value="Europe/Moscow" selected="@(Model.TimeZone == "Europe/Moscow" ? "selected" : null)">MSK — Moscow</option>
|
||
</optgroup>
|
||
<optgroup label="Asia / Pacific">
|
||
<option value="Asia/Dubai" selected="@(Model.TimeZone == "Asia/Dubai" ? "selected" : null)">GST — Dubai</option>
|
||
<option value="Asia/Kolkata" selected="@(Model.TimeZone == "Asia/Kolkata" ? "selected" : null)">IST — India</option>
|
||
<option value="Asia/Bangkok" selected="@(Model.TimeZone == "Asia/Bangkok" ? "selected" : null)">ICT — Bangkok</option>
|
||
<option value="Asia/Shanghai" selected="@(Model.TimeZone == "Asia/Shanghai" ? "selected" : null)">CST — Beijing / Shanghai</option>
|
||
<option value="Asia/Tokyo" selected="@(Model.TimeZone == "Asia/Tokyo" ? "selected" : null)">JST — Tokyo</option>
|
||
<option value="Australia/Sydney" selected="@(Model.TimeZone == "Australia/Sydney" ? "selected" : null)">AEST — Sydney</option>
|
||
<option value="Pacific/Auckland" selected="@(Model.TimeZone == "Pacific/Auckland" ? "selected" : null)">NZST — Auckland</option>
|
||
</optgroup>
|
||
<optgroup label="South America">
|
||
<option value="America/Sao_Paulo" selected="@(Model.TimeZone == "America/Sao_Paulo" ? "selected" : null)">BRT — São Paulo</option>
|
||
<option value="America/Buenos_Aires" selected="@(Model.TimeZone == "America/Buenos_Aires" ? "selected" : null)">ART — 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&L, Balance Sheet, Cash Flow) present data. Switching does not re-post historical transactions.</div>
|
||
</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">
|
||
<div class="card mt-3">
|
||
<div class="card-body">
|
||
<h5 class="card-title">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. <strong>New quotes use the current rates</strong> — changing a rate here does not retroactively reprice existing quotes.<br><br><a href='/Help/Settings#pricing-configuration' target='_blank'>Learn more →</a>">
|
||
<i class="bi bi-question-circle"></i>
|
||
</a>
|
||
</h5>
|
||
<p class="text-muted">Configure your operating costs for accurate job quoting calculations.</p>
|
||
|
||
<form id="operatingCostsForm">
|
||
<!-- Rates & Costs -->
|
||
<h6 class="border-bottom pb-2 mb-3">Rates & Costs
|
||
<a tabindex="0" class="help-icon" role="button"
|
||
data-bs-toggle="popover" data-bs-placement="right"
|
||
data-bs-title="Rates & Costs"
|
||
data-bs-content="<strong>Standard Labor Rate</strong> is the baseline $/hr for all coating work — sandblasting and masking are multiplied from this. <strong>Powder Coating Cost/sq ft</strong> is the fallback material rate used when you don't select a specific powder inventory item on a quote item. <strong>Additional Coat Labor</strong> 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>
|
||
</h6>
|
||
<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>
|
||
</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>
|
||
|
||
<!-- Facility Overhead -->
|
||
<h6 class="border-bottom pb-2 mb-3 mt-3">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>
|
||
</h6>
|
||
<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 × 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>
|
||
|
||
<!-- Equipment Operating Costs -->
|
||
<h6 class="border-bottom pb-2 mb-3 mt-3">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 <strong>Default Oven Rate</strong> is used on quotes where no named oven is chosen — add individual shop ovens below if you have multiple ovens with different capacities and costs.">
|
||
<i class="bi bi-question-circle"></i>
|
||
</a>
|
||
</h6>
|
||
<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>
|
||
|
||
<!-- Role-Based Labor Rates -->
|
||
<h6 class="border-bottom pb-2 mb-3 mt-4">Role-Based Labor Cost Rates
|
||
<a tabindex="0" class="help-icon" role="button"
|
||
data-bs-toggle="popover" data-bs-placement="right"
|
||
data-bs-title="Role-Based Labor Cost Rates"
|
||
data-bs-content="Set an optional cost rate per worker role for job profitability calculations. These are your <strong>internal cost rates</strong> (what you pay), not what you bill customers. If a rate is left blank, the <strong>Standard Labor Rate</strong> above is used as the fallback.">
|
||
<i class="bi bi-question-circle"></i>
|
||
</a>
|
||
</h6>
|
||
<p class="text-muted small">Used for job costing only — not shown to customers. Leave blank to use the Standard Labor Rate.</p>
|
||
<div class="table-responsive mb-3">
|
||
<table class="table table-sm align-middle" id="roleCostTable">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>Role</th>
|
||
<th style="width:180px;">Cost Rate / hr</th>
|
||
<th style="width:140px;" class="text-muted small">Fallback if blank</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="roleCostBody">
|
||
<tr><td colspan="3" class="text-center text-muted py-2"><div class="spinner-border spinner-border-sm me-2"></div>Loading...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<button type="button" class="btn btn-sm btn-primary" onclick="saveRoleCosts()">
|
||
<i class="bi bi-floppy me-1"></i>Save Labor Rates
|
||
</button>
|
||
<span id="roleCostSaveStatus" class="ms-2 small"></span>
|
||
|
||
<!-- Pricing & Overhead -->
|
||
<h6 class="border-bottom pb-2 mb-3 mt-4">Pricing & Profit
|
||
<a tabindex="0" class="help-icon" role="button"
|
||
data-bs-toggle="popover" data-bs-placement="right"
|
||
data-bs-title="Pricing & Profit"
|
||
data-bs-content="<strong>Markup mode</strong> adds a % on top of material costs only (labor and equipment pass through at cost). <strong>Margin mode</strong> targets a gross margin % of the total selling price — e.g. 30% margin on a $100 cost base gives a $142.86 price. Note: margin % and markup % are not the same number. <strong>Shop Minimum</strong> sets a floor price for any job.">
|
||
<i class="bi bi-question-circle"></i>
|
||
</a>
|
||
</h6>
|
||
@{
|
||
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> — 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> — 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>
|
||
|
||
<!-- Rush Charges -->
|
||
<h6 class="border-bottom pb-2 mb-3 mt-3">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 <strong>Rush Job</strong>, this charge is automatically added to the total. Choose <strong>Percentage</strong> to add a % of the subtotal (e.g. 25% rush surcharge) or <strong>Fixed Amount</strong> 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>
|
||
</h6>
|
||
<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>
|
||
|
||
<!-- Part Complexity Multipliers -->
|
||
<div class="card mb-4 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 <strong>calculated items</strong> based on how intricate the part is. When adding an item in a quote, staff select a complexity level — the system then applies this multiplier to account for the extra time and care needed. <em>Simple</em> = 0% (flat panels, basic shapes). <em>Extreme</em> = 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">
|
||
<button type="submit" class="btn btn-primary" id="btnSaveOperatingCosts">
|
||
<i class="bi bi-save"></i> Save Operating Costs
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</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;">—</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 — 20% deducted for rack & 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-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 — 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. <br><br><strong>Additionally</strong>, the AI automatically learns from quotes your team accepted without overriding — 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: • We specialise in automotive restoration — wheels, frames, suspension brackets, and roll cages are our bread and butter. • Our customers expect premium pricing. We rarely work on items over 20 sqft. • Most items come to us already stripped; sandblasting adds roughly 15 min per item on average. • We use a 2-stage cure cycle — 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 — 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 — 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 — Pricing config:</strong> Your operating costs (labor, equipment, markup) are always injected automatically.</p>
|
||
<p class="small mb-2"><strong>Layer 2 — 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 — 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 — Home setup, part-time</option>
|
||
<option value="1" selected="@(tierVal == 1 ? "selected" : null)">Small — 1–5 person shop</option>
|
||
<option value="2" selected="@(tierVal == 2 ? "selected" : null)">Medium — Established shop, 5–10 people</option>
|
||
<option value="3" selected="@(tierVal == 3 ? "selected" : null)">Large — 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.<br><br><a href='/Help/Settings#default-settings' target='_blank'>Learn more →</a>">
|
||
<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 — US Dollar</option>
|
||
<option value="CAD" selected="@(Model.Preferences?.DefaultCurrency == "CAD" ? "selected" : null)">CAD — Canadian Dollar</option>
|
||
<option value="EUR" selected="@(Model.Preferences?.DefaultCurrency == "EUR" ? "selected" : null)">EUR — Euro</option>
|
||
<option value="GBP" selected="@(Model.Preferences?.DefaultCurrency == "GBP" ? "selected" : null)">GBP — British Pound</option>
|
||
<option value="AUD" selected="@(Model.Preferences?.DefaultCurrency == "AUD" ? "selected" : null)">AUD — 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 — for example prefix <strong>QT</strong> produces <em>QT-2603-0042</em>. Change the prefix to match your preferred numbering convention. Changing it only affects <strong>new</strong> 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 & Workflow Defaults
|
||
<a tabindex="0" class="help-icon" role="button"
|
||
data-bs-toggle="popover" data-bs-placement="right"
|
||
data-bs-title="Job & Workflow Defaults"
|
||
data-bs-content="Controls how jobs are created and flow through your shop. <strong>Require Customer PO</strong> enforces that a PO number is entered before a job can be saved — useful for commercial accounts. <strong>Allow Customer Approval</strong> enables the approval step in the job workflow — 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 & Alerts
|
||
<a tabindex="0" class="help-icon" role="button"
|
||
data-bs-toggle="popover" data-bs-placement="right"
|
||
data-bs-title="Notifications & Alerts"
|
||
data-bs-content="Controls which events send emails to your team and customers. Set the <strong>From Email Address</strong> to a domain you control — 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 <em>before</em> an event the system highlights it as upcoming. For example, a <strong>Quote Expiry Warning</strong> 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 — job status updates, quote approvals, invoice reminders, and more. Templates use <strong>{{placeholder}}</strong> tokens that are replaced with live data when the email is sent. Click <strong>Edit</strong> on any row to modify it; use <strong>Reset to Default</strong> to restore the original wording at any time.<br><br>Changes take effect immediately — 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>{{placeholder}}</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 <strong>7 years</strong> to satisfy tax and audit requirements. <strong>Deleted record retention</strong> is the grace period after a soft-delete before the record is permanently purged — 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 — job statuses, priorities, quote statuses, and more. You can rename labels, change colours, and reorder them to match your shop's terminology. <strong>Status codes</strong> 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')">
|
||
|
||
</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 & 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')">
|
||
|
||
</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';">
|
||
|
||
</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 & 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 — 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>
|
||
}
|
||
</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 — 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 — in writing or through a recorded digital interaction — 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 — 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 & 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 — 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 — 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 & 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 — 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 — 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 — 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 — 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 — 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 — 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' : '—'}</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 = '—'; _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 = '—';
|
||
_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 = '—'; _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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// Reload oven list whenever the Shop Equipment Profile tab is shown
|
||
document.getElementById('quoting-calibration-tab')?.addEventListener('shown.bs.tab', () => {
|
||
loadOvenCosts();
|
||
});
|
||
|
||
// Reload role costs whenever the Operating Costs tab is shown
|
||
document.getElementById('operating-costs-tab')?.addEventListener('shown.bs.tab', () => {
|
||
loadRoleCosts();
|
||
});
|
||
|
||
// If Equipment Profile tab is already active on page load, load immediately
|
||
if (document.getElementById('quoting-calibration')?.classList.contains('show')) {
|
||
loadOvenCosts();
|
||
}
|
||
|
||
// If Operating Costs tab is already active on page load, load role costs immediately
|
||
if (document.getElementById('operating-costs')?.classList.contains('show')) {
|
||
loadRoleCosts();
|
||
}
|
||
|
||
// ── Role-Based Labor Cost Rates ────────────────────────────────────────
|
||
const ROLE_NAMES = ['General Labor','Sandblaster','Coater','Masker','Quality Control','Oven Operator','Supervisor','Maintenance'];
|
||
|
||
async function loadRoleCosts() {
|
||
const resp = await fetch('/CompanySettings/GetRoleCosts');
|
||
const saved = await resp.json(); // [{role, hourlyRate}]
|
||
const rateMap = {};
|
||
saved.forEach(r => rateMap[r.role] = r.hourlyRate);
|
||
|
||
const fallbackEl = document.getElementById('standardLaborRate');
|
||
const fallback = fallbackEl ? `$${parseFloat(fallbackEl.value || 0).toFixed(2)}/hr` : 'Standard Rate';
|
||
|
||
const tbody = document.getElementById('roleCostBody');
|
||
tbody.innerHTML = ROLE_NAMES.map((name, i) => `
|
||
<tr>
|
||
<td><span class="badge bg-secondary">${name}</span></td>
|
||
<td>
|
||
<div class="input-group input-group-sm">
|
||
<span class="input-group-text">$</span>
|
||
<input type="number" step="0.01" min="0" max="999"
|
||
class="form-control role-cost-input"
|
||
data-role="${i}"
|
||
value="${rateMap[i] > 0 ? rateMap[i] : ''}"
|
||
placeholder="(use default)">
|
||
</div>
|
||
</td>
|
||
<td class="text-muted small">${fallback}</td>
|
||
</tr>`).join('');
|
||
}
|
||
|
||
async function saveRoleCosts() {
|
||
const inputs = document.querySelectorAll('.role-cost-input');
|
||
const rates = Array.from(inputs).map(el => ({
|
||
role: parseInt(el.dataset.role),
|
||
hourlyRate: parseFloat(el.value) || 0
|
||
}));
|
||
const statusEl = document.getElementById('roleCostSaveStatus');
|
||
statusEl.textContent = 'Saving...';
|
||
statusEl.className = 'ms-2 small text-muted';
|
||
const resp = await fetch('/CompanySettings/SaveRoleCosts', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '' },
|
||
body: JSON.stringify(rates)
|
||
});
|
||
const result = await resp.json();
|
||
if (result.success) {
|
||
statusEl.textContent = '✓ Saved';
|
||
statusEl.className = 'ms-2 small text-success';
|
||
setTimeout(() => statusEl.textContent = '', 3000);
|
||
} else {
|
||
statusEl.textContent = result.message || 'Error saving';
|
||
statusEl.className = 'ms-2 small text-danger';
|
||
}
|
||
}
|
||
|
||
// ── 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} — 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);
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
</script>
|
||
|
||
}
|