2bf8871892
- Appointment reminders: add AppointmentReminderBackgroundService (60s poll), ReminderSentAt dedup stamp, NotifyAppointmentReminderAsync sends both customer email and creator staff email; AppointmentReminderStaff notification type + default template added; DateTime.Now used instead of UtcNow to match locally-stored ScheduledStartTime; ToLocalTime() double-conversion removed - NoExtraLayerCharge not persisted: flag existed on CreateQuoteItemCoatDto and was used by pricing engine but never written to JobItemCoat/QuoteItemCoat entities — every edit reset it to false and re-applied the extra layer charge; added column to both entities (migration AddNoExtraLayerChargeToCoats), both read DTOs, all 3 JobItemAssemblyService overloads, JobItemCoatSeed inner class, and existingItemsData JSON in all 5 wizard views; fixed JS template path that hard-coded noExtraLayerCharge: false - Coat notes not visible: notes were rendered in desktop job details but missing from the wizard item card summary and the mobile card view; both fixed - Scroll position lost on item save: sessionStorage save/restore added to item-wizard.js owner form submit handler; path-keyed so cross-page navigation does not restore stale position; requestAnimationFrame used for reliable mobile scroll restoration - Invoice Send dead button: #sendChannelModal was gated inside @if (isDraft) but the button targeting it fires for Sent/Overdue invoices too when customer has both email and SMS; modal moved outside the Draft guard - InitialCreate migration added for fresh database installs; Baseline migration guarded with IF OBJECT_ID check so it no-ops on fresh DBs; Razor scoping bug fixed in Customers/Index.cshtml Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
190 lines
8.8 KiB
Plaintext
190 lines
8.8 KiB
Plaintext
@model PowderCoating.Application.DTOs.Job.JobEditItemsViewModel
|
||
@using PowderCoating.Core.Entities
|
||
|
||
@{
|
||
ViewData["Title"] = $"Edit Items — {Model.JobNumber}";
|
||
ViewData["PageIcon"] = "bi-list-check";
|
||
}
|
||
|
||
<div class="container-fluid mt-4">
|
||
<div class="d-flex justify-content-end mb-4">
|
||
<a asp-action="Details" asp-route-id="@Model.JobId" class="btn btn-outline-secondary">
|
||
<i class="bi bi-arrow-left me-1"></i>Back to Job
|
||
</a>
|
||
</div>
|
||
|
||
<form asp-action="UpdateItems" asp-controller="Jobs" method="post" id="jobItemsForm">
|
||
@Html.AntiForgeryToken()
|
||
<input type="hidden" name="JobId" value="@Model.JobId" />
|
||
<input type="hidden" name="JobNumber" value="@Model.JobNumber" />
|
||
<input type="hidden" name="CustomerId" value="@Model.CustomerId" />
|
||
<input type="hidden" name="TaxPercent" value="@Model.TaxPercent" />
|
||
<input type="hidden" name="OvenCostId" value="@Model.OvenCostId" />
|
||
<input type="hidden" name="OvenBatches" value="@Model.OvenBatches" />
|
||
<input type="hidden" name="OvenCycleMinutes" value="@Model.OvenCycleMinutes" />
|
||
|
||
@if (!ViewData.ModelState.IsValid)
|
||
{
|
||
<div class="alert alert-danger alert-permanent mb-4" role="alert">
|
||
<ul class="mb-0">
|
||
@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
|
||
{
|
||
<li>@error.ErrorMessage</li>
|
||
}
|
||
</ul>
|
||
</div>
|
||
}
|
||
|
||
<!-- Items Card -->
|
||
<div class="card mb-4">
|
||
<div class="card-header d-flex align-items-center justify-content-between">
|
||
<h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Job Items</h5>
|
||
<button type="button" class="btn btn-primary" onclick="openWizard()">
|
||
<i class="bi bi-plus-circle me-1"></i>Add Item
|
||
</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="itemsEmptyMessage" class="text-center py-5 text-muted" style="display: none;">
|
||
<i class="bi bi-box-seam display-4 d-block mb-2 opacity-25"></i>
|
||
<p class="mb-0">No items added yet.</p>
|
||
<p class="small">Click <strong>Add Item</strong> to get started.</p>
|
||
</div>
|
||
<div id="itemCardsContainer"></div>
|
||
|
||
<!-- Hidden fields written by wizard JS -->
|
||
<div id="hiddenFieldsContainer"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pricing Summary -->
|
||
<div class="card mb-4">
|
||
<div class="card-header bg-primary text-white d-flex align-items-center justify-content-between">
|
||
<h5 class="mb-0"><i class="bi bi-calculator me-2"></i>Pricing Summary</h5>
|
||
<span id="pricingSpinner" class="spinner-border spinner-border-sm text-white d-none" role="status"></span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="text-end" id="pricingSummary">
|
||
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
|
||
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
|
||
<p class="mb-1 d-none" id="ovenBatchCostRow">
|
||
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
|
||
<strong id="ovenBatchCostDisplay">$0.00</strong>
|
||
</p>
|
||
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
|
||
Tier Discount (<span id="pricingTierDiscountPercentDisplay">0</span>%):
|
||
<strong id="pricingTierDiscountDisplay">-$0.00</strong>
|
||
</p>
|
||
<p class="mb-1 d-none" id="shopSuppliesRow">Shop Supplies (<span id="shopSuppliesPercentDisplay">0</span>%): <strong id="shopSuppliesDisplay">$0.00</strong></p>
|
||
<p class="mb-1 d-none" id="subtotalRow">Subtotal: <strong id="subtotalDisplay">$0.00</strong></p>
|
||
<p class="mb-1 d-none" id="taxRow">Tax (<span id="taxPercentDisplay">0</span>%): <strong id="taxDisplay">$0.00</strong></p>
|
||
<hr class="my-2 d-none" id="pricingDivider" />
|
||
<h5 class="mb-0 d-none" id="totalRow">Total: <strong id="totalDisplay" class="text-primary">$0.00</strong></h5>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Form Actions -->
|
||
<div class="card mb-4">
|
||
<div class="card-body d-flex align-items-center justify-content-end gap-3">
|
||
<a asp-action="Details" asp-route-id="@Model.JobId" class="btn btn-outline-secondary btn-lg">
|
||
<i class="bi bi-x-circle me-1"></i>Cancel
|
||
</a>
|
||
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn">
|
||
<i class="bi bi-check-circle me-1"></i>Save Items
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
@await Html.PartialAsync("_SqFtCalculatorModal")
|
||
@await Html.PartialAsync("_ItemWizardModal")
|
||
|
||
<!-- Embedded data for JS -->
|
||
@if (ViewBag.InventoryCoatings != null)
|
||
{
|
||
<script id="inventoryPowdersData" type="application/json">
|
||
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.InventoryCoatings))
|
||
</script>
|
||
}
|
||
@if (ViewBag.CatalogItems != null)
|
||
{
|
||
<script id="catalogItemsData" type="application/json">
|
||
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CatalogItems))
|
||
</script>
|
||
}
|
||
<script id="vendorsData" type="application/json">
|
||
@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.Vendors ?? new List<object>()))
|
||
</script>
|
||
<script id="prepServicesData" type="application/json">
|
||
@Html.Raw(Json.Serialize(((List<PrepService>)(ViewBag.PrepServices ?? new List<PrepService>())).Select(p => new { id = p.Id, name = p.ServiceName, description = p.Description, requiresBlastSetup = p.RequiresBlastSetup })))
|
||
</script>
|
||
<script id="blastSetupsData" type="application/json">
|
||
@Html.Raw(Json.Serialize(ViewBag.BlastSetups ?? new List<object>()))
|
||
</script>
|
||
|
||
<!-- Existing items -->
|
||
<script id="existingItemsData" type="application/json">
|
||
@Html.Raw(System.Text.Json.JsonSerializer.Serialize((Model.JobItems ?? new List<PowderCoating.Application.DTOs.Quote.CreateQuoteItemDto>()).Select(item => new {
|
||
description = item.Description,
|
||
quantity = item.Quantity,
|
||
surfaceAreaSqFt = item.SurfaceAreaSqFt,
|
||
estimatedMinutes = item.EstimatedMinutes,
|
||
catalogItemId = item.CatalogItemId,
|
||
manualUnitPrice = item.ManualUnitPrice,
|
||
powderCostOverride = item.PowderCostOverride,
|
||
complexity = item.Complexity,
|
||
isGenericItem = item.IsGenericItem,
|
||
isLaborItem = item.IsLaborItem,
|
||
isAiItem = item.IsAiItem,
|
||
requiresSandblasting = item.RequiresSandblasting,
|
||
requiresMasking = item.RequiresMasking,
|
||
notes = item.Notes,
|
||
includePrepCost = item.IncludePrepCost,
|
||
coats = item.Coats.Select(c => new {
|
||
coatName = c.CoatName,
|
||
sequence = c.Sequence,
|
||
inventoryItemId = c.InventoryItemId,
|
||
colorName = c.ColorName,
|
||
vendorId = c.VendorId,
|
||
colorCode = c.ColorCode,
|
||
finish = c.Finish,
|
||
coverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||
transferEfficiency = c.TransferEfficiency,
|
||
powderCostPerLb = c.PowderCostPerLb,
|
||
powderToOrder = c.PowderToOrder,
|
||
notes = c.Notes,
|
||
noExtraLayerCharge = c.NoExtraLayerCharge
|
||
}),
|
||
prepServices = item.PrepServices.Select(ps => new {
|
||
prepServiceId = ps.PrepServiceId,
|
||
estimatedMinutes = ps.EstimatedMinutes,
|
||
blastSetupId = ps.BlastSetupId
|
||
})
|
||
})))
|
||
</script>
|
||
|
||
<script id="quoteMetaData" type="application/json">
|
||
{
|
||
"customerId": @Json.Serialize(Model.CustomerId),
|
||
"taxPercent": @Model.TaxPercent,
|
||
"discountType": "None",
|
||
"discountValue": 0,
|
||
"isRushJob": false,
|
||
"ovenCostId": @Json.Serialize(Model.OvenCostId),
|
||
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
|
||
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
|
||
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
|
||
"itemsFieldPrefix": "JobItems",
|
||
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
|
||
}
|
||
</script>
|
||
|
||
@section Styles {
|
||
<link rel="stylesheet" href="~/css/item-wizard.css">
|
||
}
|
||
|
||
@section Scripts {
|
||
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
|
||
}
|