Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,370 @@
@model PowderCoating.Application.DTOs.Inventory.CreateInventoryItemDto
@{
ViewData["Title"] = "Add Inventory Item";
ViewData["PageIcon"] = "bi-box-seam";
ViewData["PageHelpTitle"] = "Add Inventory Item";
ViewData["PageHelpContent"] = "Add a new material to inventory — powder coatings, consumables, or other shop supplies. Select a category first to auto-generate a SKU. Use AI Lookup to fill in manufacturer details from a part number. Set Reorder Point and Reorder Quantity so the system can alert you when stock runs low.";
}
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-end align-items-center mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to List
</a>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form asp-action="Create" method="post">
<partial name="_ValidationSummary" />
<!-- Basic Information -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0">
<i class="bi bi-info-circle me-2 text-primary"></i>Basic Information
</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Basic Information"
data-bs-content="Name and SKU are required. Category drives how the item is filtered and used in quotes — choosing a Powder Coating category shows the Coating Specifications section. SKU is auto-generated from the category prefix but you can edit it. Description is optional free text for internal notes.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="InventoryCategoryId" class="form-label">Category <span class="text-danger">*</span></label>
<select asp-for="InventoryCategoryId" class="form-select" id="field-category"
asp-items="@ViewBag.Categories"
data-coating-map="@ViewBag.CategoryIsCoatingJson">
<option value="">Select category</option>
</select>
<span asp-validation-for="InventoryCategoryId" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="SKU" class="form-label">SKU <span class="text-danger">*</span></label>
<div class="input-group">
<input asp-for="SKU" class="form-control" id="field-sku" placeholder="Select a category to auto-generate" />
<button type="button" class="btn btn-outline-secondary" id="btn-regen-sku" title="Regenerate SKU" style="display:none;">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<span asp-validation-for="SKU" class="text-danger"></span>
<div class="form-text">Auto-generated when you pick a category. You can edit it.</div>
</div>
<div class="col-12" id="wrap-name">
<label asp-for="Name" class="form-label">Name <span class="text-danger">*</span></label>
<input asp-for="Name" class="form-control" id="field-name" placeholder="Enter product name" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="col-12" id="wrap-description">
<label asp-for="Description" class="form-label">Description</label>
<textarea asp-for="Description" class="form-control" id="field-description" rows="2"></textarea>
<span asp-validation-for="Description" class="text-danger"></span>
</div>
</div>
</div>
<!-- Product Details -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0 d-flex align-items-center">
<i class="bi bi-palette me-2 text-primary"></i>Product Details
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? true))
{
<button type="button" class="btn btn-sm btn-outline-primary ms-2" id="ai-lookup-btn">
<i class="bi bi-stars me-1"></i>AI Lookup
</button>
}
</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Product Details"
data-bs-content="Manufacturer, part number, color name, color code, and finish describe the physical product. AI Lookup can auto-fill these fields from a manufacturer name or part number. Coverage is how many sq ft one pound coats at 1 mil thickness (typical: 30). Transfer Efficiency is what percentage of the powder actually sticks (typical: 6070%). Both values are used to calculate Powder Needed on quotes and jobs.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div id="ai-lookup-status" class="alert alert-info d-none py-2 small mb-3"></div>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="Manufacturer" class="form-label">Manufacturer</label>
<input asp-for="Manufacturer" class="form-control" id="field-manufacturer" placeholder="e.g., Tiger Drylac, Sherwin-Williams" />
<span asp-validation-for="Manufacturer" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="ManufacturerPartNumber" class="form-label">Manufacturer Part Number</label>
<input asp-for="ManufacturerPartNumber" class="form-control" id="field-partnumber" />
<span asp-validation-for="ManufacturerPartNumber" class="text-danger"></span>
</div>
<div class="col-md-4" id="wrap-colorname">
<label asp-for="ColorName" class="form-label">Color Name</label>
<input asp-for="ColorName" class="form-control" id="field-colorname" placeholder="e.g., Illusion Malbec" />
<span asp-validation-for="ColorName" class="text-danger"></span>
</div>
<div class="col-md-4" id="wrap-colorcode">
<label asp-for="ColorCode" class="form-label">Color Code</label>
<input asp-for="ColorCode" class="form-control" id="field-colorcode" placeholder="e.g., RAL9005" />
<span asp-validation-for="ColorCode" class="text-danger"></span>
</div>
<div class="col-md-4" id="wrap-finish">
<label asp-for="Finish" class="form-label">Finish</label>
<input asp-for="Finish" class="form-control" id="field-finish" placeholder="e.g., Gloss, Matte" />
<span asp-validation-for="Finish" class="text-danger"></span>
</div>
<div class="col-12" id="wrap-specpageurl">
<label asp-for="SpecPageUrl" class="form-label">Product URL</label>
<div class="input-group">
<input asp-for="SpecPageUrl" class="form-control" id="field-specpageurl" placeholder="https://..." />
<a id="field-specpageurl-link" href="#" target="_blank" class="btn btn-outline-secondary d-none" title="Open spec page">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
<span asp-validation-for="SpecPageUrl" class="text-danger"></span>
</div>
<div class="col-md-6" id="wrap-coverage">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="CoverageSqFtPerLb" class="form-label mb-0">Coverage (@ViewBag.CoverageUnit)</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Coverage"
data-bs-content="How many square feet one pound of this powder covers at a standard film thickness. Industry average is about 30 sq ft/lb. Check the manufacturer's technical data sheet for the exact value. This is used together with Transfer Efficiency to calculate how much powder to order for a job.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="CoverageSqFtPerLb" type="number" step="0.01" min="0" value="30" class="form-control" id="field-coverage" placeholder="30" />
<span asp-validation-for="CoverageSqFtPerLb" class="text-danger"></span>
<small class="form-text text-muted">Surface area coverage per unit of weight (default: 30)</small>
</div>
<div class="col-md-6" id="wrap-transfer">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="TransferEfficiency" class="form-label mb-0">Transfer Efficiency (%)</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Transfer Efficiency"
data-bs-content="The percentage of powder that actually adheres to the part rather than being lost as overspray. Electrostatic spray guns typically achieve 6070%. A lower efficiency means you need to order more powder per job. The system uses this value in the Powder Needed calculation on quotes.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="TransferEfficiency" type="number" step="0.01" min="0" max="100" value="65" class="form-control" id="field-transfer" placeholder="65" />
<span asp-validation-for="TransferEfficiency" class="text-danger"></span>
<small class="form-text text-muted">Percentage of coating that sticks to the part (default: 65%)</small>
</div>
</div>
</div>
<!-- Coating Specs (shown for coating-type items) -->
<div class="mb-4" id="coating-specs-section">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0">
<i class="bi bi-thermometer-half text-primary"></i>Coating Specifications
</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Coating Specifications"
data-bs-content="Cure Temperature and Cure Time come from the manufacturer's tech data sheet — they tell the oven operator the correct bake profile. Requires Clear Coat flags powders that need a clear top coat for durability or finish. Color Families tag this powder for filtering and matching in the quote wizard (e.g., a teal powder would get both Green and Blue).">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-4">
<label asp-for="CureTemperatureF" class="form-label">Cure Temperature (°F)</label>
<input asp-for="CureTemperatureF" type="number" step="1" min="200" max="500" class="form-control" id="field-curetemp" placeholder="e.g., 375" />
<span asp-validation-for="CureTemperatureF" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="CureTimeMinutes" class="form-label">Cure Time (minutes)</label>
<input asp-for="CureTimeMinutes" type="number" step="1" min="1" max="120" class="form-control" id="field-curetime" placeholder="e.g., 15" />
<span asp-validation-for="CureTimeMinutes" class="text-danger"></span>
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check mb-2">
<input asp-for="RequiresClearCoat" class="form-check-input" type="checkbox" id="field-clearcoat" />
<label asp-for="RequiresClearCoat" class="form-check-label fw-medium">Requires Clear Coat</label>
<div class="form-text">Check if this powder needs a clear top coat</div>
</div>
</div>
<div class="col-12">
<div class="d-flex align-items-center gap-1 mb-1">
<label class="form-label mb-0">Color Families</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Color Families"
data-bs-content="Click chips to tag which base color families this powder belongs to. A metallic teal would be tagged Green and Blue; a bronze would be tagged Brown and Gold. These tags drive color-match filtering when customers request a specific color in a quote.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="ColorFamilies" type="hidden" id="field-colorfamilies" />
<div class="d-flex flex-wrap gap-2 mt-1" id="color-family-chips">
@foreach (var fam in new[] { "Red","Orange","Yellow","Green","Blue","Purple","Pink","Brown","Black","White","Gray","Silver","Gold","Bronze","Copper","Clear" })
{
<span class="badge color-family-chip" data-family="@fam"
style="cursor:pointer;font-size:.8rem;padding:.35em .7em;">@fam</span>
}
</div>
<div class="form-text">Click to toggle which primary color families this powder belongs to (e.g., Teal = Green + Blue)</div>
</div>
</div>
</div>
<!-- Stock Information -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0">
<i class="bi bi-boxes me-2 text-primary"></i>Stock Information
</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Stock Information"
data-bs-content="Quantity on Hand is your current starting stock. Reorder Point is the threshold at which the system shows a Low Stock alert — when quantity drops to this level it's time to reorder. Reorder Quantity is how much to order in one batch. Location is the shelf or bin label so staff can find the material quickly.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-4">
<label asp-for="QuantityOnHand" class="form-label">Quantity on Hand</label>
<input asp-for="QuantityOnHand" type="number" step="0.01" min="0" value="0" class="form-control" />
<span asp-validation-for="QuantityOnHand" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="UnitOfMeasure" class="form-label">Unit of Measure</label>
<select asp-for="UnitOfMeasure" class="form-select" asp-items="@ViewBag.UnitsOfMeasure"></select>
<span asp-validation-for="UnitOfMeasure" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="Location" class="form-label">Location</label>
<input asp-for="Location" class="form-control" placeholder="e.g., Shelf A3" />
<span asp-validation-for="Location" class="text-danger"></span>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="ReorderPoint" class="form-label mb-0">Reorder Point</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Reorder Point"
data-bs-content="When Quantity on Hand falls at or below this number a Low Stock warning appears on the item and in the Inventory summary. Set it high enough to cover your lead time — for example if delivery takes a week and you use 2 lb/day, set the reorder point to at least 14 lbs.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="ReorderPoint" type="number" step="0.01" min="0" value="0" class="form-control" />
<span asp-validation-for="ReorderPoint" class="text-danger"></span>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="ReorderQuantity" class="form-label mb-0">Reorder Quantity</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Reorder Quantity"
data-bs-content="The standard quantity to order when restocking — typically a full case or pallet quantity from your supplier. This is informational and appears as a suggested order amount when the item is flagged as low stock.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="ReorderQuantity" type="number" step="0.01" min="0" value="0" class="form-control" />
<span asp-validation-for="ReorderQuantity" class="text-danger"></span>
</div>
</div>
</div>
<!-- Pricing & Vendor -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0">
<i class="bi bi-currency-dollar me-2 text-primary"></i>Pricing &amp; Vendor
</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Pricing &amp; Vendor"
data-bs-content="Unit Cost is what you pay per unit (lb, each, etc.) — this is used to calculate total stock value and feeds into job cost calculations. Primary Vendor links to your supplier record for quick reference and purchase ordering.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="UnitCost" class="form-label">Unit Cost</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="UnitCost" type="number" step="0.01" min="0" value="0" class="form-control" id="field-unitcost" />
</div>
<span asp-validation-for="UnitCost" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="PrimaryVendorId" class="form-label">Primary Vendor</label>
<select asp-for="PrimaryVendorId" class="form-select" id="field-vendor" asp-items="@ViewBag.Vendors"
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
<option value="">Select vendor</option>
<option value="__new__">+ Add New Vendor…</option>
</select>
<span asp-validation-for="PrimaryVendorId" class="text-danger"></span>
</div>
</div>
</div>
<!-- Financial Accounts -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0">
<i class="bi bi-journal-bookmark me-2 text-primary"></i>Financial Accounts
</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Financial Accounts"
data-bs-content="Inventory Account is the asset account where the value of this stock sits on the balance sheet (e.g., 1200 Inventory — Powder). COGS Account is debited when this material is consumed on a job (e.g., 5000 Cost of Goods Sold). Leave blank to use the company defaults set in your Chart of Accounts.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<p class="text-muted small mb-3">Map this item to chart-of-account entries for proper balance sheet and cost tracking. Leave blank to use defaults.</p>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="InventoryAccountId" class="form-label"></label>
<select asp-for="InventoryAccountId" class="form-select" asp-items="ViewBag.InventoryAccounts"
data-quick-add-url="/Accounts/Create?preSubType=4" data-quick-add-title="Add Inventory Account">
<option value="">(Default inventory account)</option>
<option value="__new__">+ Add New Account…</option>
</select>
<small class="form-text text-muted">Asset account where inventory value is tracked (e.g., 1200 Inventory - Powder).</small>
</div>
<div class="col-md-6">
<label asp-for="CogsAccountId" class="form-label"></label>
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
<option value="">(Default COGS account)</option>
<option value="__new__">+ Add New Account…</option>
</select>
<small class="form-text text-muted">Expense account debited when this material is consumed on a job.</small>
</div>
</div>
</div>
<!-- Notes -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-journal-text me-2 text-primary"></i>Notes
</h5>
<div class="row g-3">
<div class="col-12">
<label asp-for="Notes" class="form-label">Additional Notes</label>
<textarea asp-for="Notes" class="form-control" rows="3"></textarea>
<span asp-validation-for="Notes" class="text-danger"></span>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="d-flex gap-2 justify-content-end pt-3 border-top">
<a asp-action="Index" class="btn btn-outline-secondary px-4">Cancel</a>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-2"></i>Create Item
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>const inventoryFormIsCreate = true;</script>
<partial name="_InventoryColorFamilyScripts" />
}
@@ -0,0 +1,185 @@
@model PowderCoating.Application.DTOs.Inventory.InventoryItemDto
@{
ViewData["Title"] = "Delete Inventory Item";
ViewData["PageIcon"] = "bi-box-seam";
}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="d-flex justify-content-end align-items-center mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to List
</a>
</div>
<!-- Warning Banner -->
<div class="alert alert-danger d-flex align-items-start mb-4">
<i class="bi bi-exclamation-triangle-fill me-3" style="font-size: 1.5rem;"></i>
<div>
<h5 class="alert-heading mb-2">Are you sure you want to delete this inventory item?</h5>
<p class="mb-0">This action will mark the item as deleted. All related records will be preserved but the item will no longer appear in active listings.</p>
</div>
</div>
<div class="card border-danger shadow-sm">
<div class="card-header bg-danger bg-opacity-10 border-danger">
<h5 class="mb-0 text-danger">
<i class="bi bi-box-seam me-2"></i>Item to be Deleted
</h5>
</div>
<div class="card-body">
<!-- Basic Information -->
<div class="mb-4">
<h6 class="text-muted small text-uppercase mb-2">Basic Information</h6>
<div class="row g-3">
<div class="col-md-6">
<label class="text-muted small mb-1">SKU</label>
<p class="fw-semibold mb-0">@Model.SKU</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Category</label>
<p class="mb-0">
<span class="badge bg-secondary">@Model.Category</span>
</p>
</div>
<div class="col-12">
<label class="text-muted small mb-1">Name</label>
<p class="fw-semibold mb-0">@Model.Name</p>
</div>
@if (!string.IsNullOrEmpty(Model.Description))
{
<div class="col-12">
<label class="text-muted small mb-1">Description</label>
<p class="mb-0">@Model.Description</p>
</div>
}
</div>
</div>
<hr />
<!-- Product Details -->
@if (!string.IsNullOrEmpty(Model.ColorName) || !string.IsNullOrEmpty(Model.Manufacturer))
{
<div class="mb-4">
<h6 class="text-muted small text-uppercase mb-2">Product Details</h6>
<div class="row g-3">
@if (!string.IsNullOrEmpty(Model.ColorName))
{
<div class="col-md-4">
<label class="text-muted small mb-1">Color</label>
<p class="mb-0">@Model.ColorName @(!string.IsNullOrEmpty(Model.ColorCode) ? $"({Model.ColorCode})" : "")</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.Finish))
{
<div class="col-md-4">
<label class="text-muted small mb-1">Finish</label>
<p class="mb-0">@Model.Finish</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.Manufacturer))
{
<div class="col-md-4">
<label class="text-muted small mb-1">Manufacturer</label>
<p class="mb-0">@Model.Manufacturer</p>
</div>
}
</div>
</div>
<hr />
}
<!-- Stock Information -->
<div class="mb-4">
<h6 class="text-muted small text-uppercase mb-2">Stock Information</h6>
<div class="row g-3">
<div class="col-md-4">
<label class="text-muted small mb-1">Quantity on Hand</label>
<p class="mb-0 fw-semibold @(Model.IsOutOfStock ? "text-dark" : Model.IsLowStock ? "text-danger" : "")">
@Model.QuantityOnHand.ToString("N2") @Model.UnitOfMeasure
@if (Model.IsOutOfStock)
{
<i class="bi bi-x-circle"></i>
}
else if (Model.IsLowStock)
{
<i class="bi bi-exclamation-triangle"></i>
}
</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Reorder Point</label>
<p class="mb-0">@Model.ReorderPoint.ToString("N2") @Model.UnitOfMeasure</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Location</label>
<p class="mb-0">@(Model.Location ?? "Not specified")</p>
</div>
</div>
</div>
<hr />
<!-- Pricing Information -->
<div class="mb-4">
<h6 class="text-muted small text-uppercase mb-2">Pricing Information</h6>
<div class="row g-3">
<div class="col-md-4">
<label class="text-muted small mb-1">Unit Cost</label>
<p class="mb-0 fw-semibold">@Model.UnitCost.ToString("C")</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Stock Value</label>
<p class="mb-0 fw-semibold text-primary">@((Model.QuantityOnHand * Model.UnitCost).ToString("C"))</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Average Cost</label>
<p class="mb-0">@Model.AverageCost.ToString("C")</p>
</div>
</div>
</div>
@if (Model.QuantityOnHand > 0)
{
<div class="alert alert-warning d-flex align-items-center">
<i class="bi bi-exclamation-circle me-2"></i>
<div>
<strong>Warning:</strong> This item still has @Model.QuantityOnHand.ToString("N2") @Model.UnitOfMeasure in stock (value: @((Model.QuantityOnHand * Model.UnitCost).ToString("C"))). Consider transferring or adjusting inventory before deletion.
</div>
</div>
}
<!-- Delete Form -->
<div class="d-flex gap-2 justify-content-end pt-3 border-top mt-4">
<a asp-action="Index" class="btn btn-outline-secondary px-4">
<i class="bi bi-x-circle me-2"></i>Cancel
</a>
<form asp-action="Delete" method="post" class="d-inline">
<input type="hidden" asp-for="Id" />
<button type="submit" class="btn btn-danger px-4">
<i class="bi bi-trash me-2"></i>Delete Item
</button>
</form>
</div>
</div>
</div>
<!-- Additional Information -->
<div class="card border-0 shadow-sm mt-4">
<div class="card-body">
<h6 class="mb-3">
<i class="bi bi-info-circle me-2 text-info"></i>What happens when you delete an inventory item?
</h6>
<ul class="mb-0">
<li>The item will be marked as deleted (soft delete)</li>
<li>It will no longer appear in active inventory listings</li>
<li>All related transactions and job items will be preserved</li>
<li>Historical records and reports will still include this item</li>
<li>Administrators can restore deleted items if needed</li>
</ul>
</div>
</div>
</div>
</div>
@@ -0,0 +1,875 @@
@model PowderCoating.Application.DTOs.Inventory.InventoryItemDto
@{
ViewData["Title"] = $"{Model.Name}";
ViewData["PageIcon"] = "bi-box-seam";
ViewData["PageHelpTitle"] = "Inventory Item";
ViewData["PageHelpContent"] = "Full detail for this inventory item. Stock Information shows current quantity and reorder thresholds — a Low Stock banner appears when quantity is at or below the Reorder Point. Pricing shows Unit Cost (what you paid), Average Cost (weighted average across purchases), and Total Stock Value. Use the Actions panel to edit, view jobs using this powder, or delete the item.";
}
@section Styles {
<style>
.color-family-chip {
background: #e9ecef;
color: #495057;
border: 1.5px solid #ced4da;
font-size: .8rem;
padding: .35em .7em;
}
.color-family-chip[data-family="Red"] { border-left: 4px solid #dc3545; }
.color-family-chip[data-family="Orange"] { border-left: 4px solid #fd7e14; }
.color-family-chip[data-family="Yellow"] { border-left: 4px solid #ffc107; }
.color-family-chip[data-family="Green"] { border-left: 4px solid #198754; }
.color-family-chip[data-family="Blue"] { border-left: 4px solid #0d6efd; }
.color-family-chip[data-family="Purple"] { border-left: 4px solid #6f42c1; }
.color-family-chip[data-family="Pink"] { border-left: 4px solid #d63384; }
.color-family-chip[data-family="Brown"] { border-left: 4px solid #795548; }
.color-family-chip[data-family="Black"] { border-left: 4px solid #212529; }
.color-family-chip[data-family="White"] { border-left: 4px solid #adb5bd; }
.color-family-chip[data-family="Gray"] { border-left: 4px solid #6c757d; }
.color-family-chip[data-family="Silver"] { border-left: 4px solid #b0bec5; }
.color-family-chip[data-family="Gold"] { border-left: 4px solid #ffd700; }
.color-family-chip[data-family="Bronze"] { border-left: 4px solid #cd7f32; }
.color-family-chip[data-family="Copper"] { border-left: 4px solid #b87333; }
.color-family-chip[data-family="Clear"] { border-left: 4px solid #adb5bd; }
</style>
}
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<div class="d-flex align-items-center gap-2">
@if (Model.IsOutOfStock)
{
<span class="badge bg-dark"><i class="bi bi-x-circle me-1"></i>Out of Stock</span>
}
else if (Model.IsLowStock)
{
<span class="badge bg-danger"><i class="bi bi-exclamation-triangle me-1"></i>Low Stock</span>
}
else
{
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>In Stock</span>
}
</div>
<p class="text-muted mb-1">SKU: @Model.SKU</p>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary bg-opacity-10 text-secondary">@Model.Category</span>
@if (Model.IsActive)
{
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Active</span>
}
else
{
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Inactive</span>
}
</div>
</div>
<div class="d-flex gap-2">
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
<i class="bi bi-pencil me-2"></i>Edit
</a>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to List
</a>
</div>
</div>
<!-- Status Banners -->
@if (Model.IsOutOfStock)
{
<div class="alert alert-dark alert-permanent d-flex align-items-center mb-3">
<i class="bi bi-x-circle me-2"></i>
<div><strong>Out of Stock:</strong> No quantity on hand. Use Stock Adjustment to add inventory.</div>
</div>
}
else if (Model.IsLowStock)
{
<div class="alert alert-warning alert-permanent d-flex align-items-center mb-3">
<i class="bi bi-exclamation-triangle me-2"></i>
<div><strong>Low Stock:</strong> Current quantity (@Model.QuantityOnHand @Model.UnitOfMeasure) is at or below the reorder point (@Model.ReorderPoint @Model.UnitOfMeasure)</div>
</div>
}
@if (!Model.IsActive)
{
<div class="alert alert-danger alert-permanent d-flex align-items-center mb-3">
<i class="bi bi-x-circle me-2"></i>
<div><strong>Status:</strong> This item is inactive</div>
</div>
}
<div class="row g-4">
<!-- Left Column -->
<div class="col-lg-8">
<!-- Item Details (Basic + Product merged) -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-2 d-flex align-items-center justify-content-between">
<h6 class="mb-0 fw-semibold"><i class="bi bi-info-circle me-2 text-primary"></i>Item Details</h6>
@if ((bool)(ViewBag.IsCoating ?? false))
{
<div class="form-check form-switch mb-0" id="samplePanelCard">
<input class="form-check-input" type="checkbox" role="switch"
id="samplePanelToggle" @(Model.HasSamplePanel ? "checked" : "")
onchange="toggleSamplePanel(@Model.Id, this.checked)" />
<label class="form-check-label small" for="samplePanelToggle">
<i class="bi bi-palette me-1 text-primary"></i>I have a swatch/sample of this color
</label>
</div>
}
</div>
<div class="card-body py-3">
<div class="row g-2">
@if (!string.IsNullOrEmpty(Model.Description))
{
<div class="col-12">
<p class="mb-0">@Model.Description</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.ColorName) || !string.IsNullOrEmpty(Model.Manufacturer))
{
<div class="col-12"><hr class="my-2" /></div>
@if (!string.IsNullOrEmpty(Model.Manufacturer))
{
<div class="col-md-4">
<label class="text-muted small mb-1">Manufacturer</label>
<p class="mb-0">@Model.Manufacturer</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.ColorName))
{
<div class="col-md-4">
<label class="text-muted small mb-1">Color Name</label>
<p class="mb-0">@Model.ColorName</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.Finish))
{
<div class="col-md-4">
<label class="text-muted small mb-1">Finish</label>
<p class="mb-0">@Model.Finish</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.ColorCode))
{
<div class="col-md-4">
<label class="text-muted small mb-1">Color Code</label>
<p class="mb-0">@Model.ColorCode</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.ManufacturerPartNumber))
{
<div class="col-md-4">
<label class="text-muted small mb-1">Mfr Part #</label>
<p class="mb-0">@Model.ManufacturerPartNumber</p>
</div>
}
@if (Model.CoverageSqFtPerLb.HasValue)
{
<div class="col-md-4">
<label class="text-muted small mb-1">Coverage / lb</label>
<p class="mb-0">@Model.CoverageSqFtPerLb.Value @ViewBag.CoverageUnit</p>
</div>
}
@if (Model.TransferEfficiency.HasValue)
{
<div class="col-md-4">
<label class="text-muted small mb-1">Transfer Efficiency</label>
<p class="mb-0">@Model.TransferEfficiency.Value%</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.SpecPageUrl))
{
<div class="col-12">
<label class="text-muted small mb-1">Product URL</label>
<p class="mb-0">
<a href="@Model.SpecPageUrl" target="_blank" class="text-decoration-none">
<i class="bi bi-box-arrow-up-right me-1"></i>View on Manufacturer's Web Site
</a>
</p>
</div>
}
}
@if (!string.IsNullOrEmpty(Model.Notes))
{
<div class="col-12"><hr class="my-2" /></div>
<div class="col-12">
<label class="text-muted small mb-1">Notes</label>
<p class="mb-0" style="white-space: pre-wrap;">@Model.Notes</p>
</div>
}
</div>
</div>
</div>
<!-- Coating Specifications -->
@{
var hasCoatingSpecs = Model.CureTemperatureF.HasValue || Model.CureTimeMinutes.HasValue || Model.RequiresClearCoat || !string.IsNullOrEmpty(Model.ColorFamilies);
}
@if (hasCoatingSpecs)
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-2">
<h6 class="mb-0 fw-semibold"><i class="bi bi-thermometer-half me-2 text-primary"></i>Coating Specifications</h6>
</div>
<div class="card-body py-3">
<div class="row g-2">
@if (Model.CureTemperatureF.HasValue)
{
<div class="col-md-4">
<label class="text-muted small mb-1">Cure Temperature</label>
<p class="mb-0">@((int)Model.CureTemperatureF.Value)°F</p>
</div>
}
@if (Model.CureTimeMinutes.HasValue)
{
<div class="col-md-4">
<label class="text-muted small mb-1">Cure Time</label>
<p class="mb-0">@Model.CureTimeMinutes.Value min</p>
</div>
}
<div class="col-md-4">
<label class="text-muted small mb-1">Requires Clear Coat</label>
<p class="mb-0">
@if (Model.RequiresClearCoat)
{
<span class="badge bg-warning text-dark"><i class="bi bi-check me-1"></i>Yes</span>
}
else
{
<span class="text-muted">No</span>
}
</p>
</div>
@if (!string.IsNullOrEmpty(Model.ColorFamilies))
{
<div class="col-12">
<label class="text-muted small mb-1">Color Families</label>
<div class="d-flex flex-wrap gap-1 mt-1">
@foreach (var fam in Model.ColorFamilies.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(f => f.Trim()).Where(f => !string.IsNullOrWhiteSpace(f)))
{
<span class="badge color-family-chip" data-family="@fam">@fam</span>
}
</div>
</div>
}
</div>
</div>
</div>
}
<!-- Financial Accounts -->
@if (Model.InventoryAccountId.HasValue || Model.CogsAccountId.HasValue)
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-2">
<h6 class="mb-0 fw-semibold"><i class="bi bi-journal-bookmark me-2 text-primary"></i>Financial Accounts</h6>
</div>
<div class="card-body py-3">
<div class="row g-2">
@if (Model.InventoryAccountId.HasValue)
{
<div class="col-md-6">
<label class="text-muted small mb-1">Inventory Account</label>
<p class="mb-0">@(Model.InventoryAccountName ?? "—")</p>
</div>
}
@if (Model.CogsAccountId.HasValue)
{
<div class="col-md-6">
<label class="text-muted small mb-1">COGS Account</label>
<p class="mb-0">@(Model.CogsAccountName ?? "—")</p>
</div>
}
</div>
</div>
</div>
}
<!-- Sample Photos -->
<div id="samplePhotosSection" style="display:none;">
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-semibold">
<i class="bi bi-images me-2 text-primary"></i>Sample Photos
<span class="badge bg-secondary rounded-pill ms-1 fw-normal" id="samplePhotoTotalBadge" style="font-size:.7rem;"></span>
</h6>
<button class="btn btn-sm btn-outline-primary" id="btnViewAllPhotos">View All</button>
</div>
<div class="card-body py-3">
<div id="samplePhotoStrip" style="display:flex; gap:10px; overflow-x:auto; padding-bottom:4px;"></div>
</div>
</div>
</div>
</div>
<!-- Right Column -->
<div class="col-lg-4">
<!-- Stock, Pricing & Status -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-2 d-flex align-items-center gap-2">
<h6 class="mb-0 fw-semibold"><i class="bi bi-boxes me-2 text-primary"></i>Stock &amp; Pricing</h6>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
data-bs-title="Stock &amp; Pricing"
data-bs-content="Quantity on Hand is what's currently in the shop. Reorder Point triggers a Low Stock alert when stock falls to or below that value. Unit Cost is what you paid per unit. Average Cost is the weighted average across all purchases. Total Stock Value is Quantity × Unit Cost.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body py-3">
<div class="row g-2">
<div class="col-6">
<label class="text-muted small mb-1">Qty on Hand</label>
<p class="fw-semibold mb-0 @(Model.IsOutOfStock ? "text-dark" : Model.IsLowStock ? "text-danger" : "text-success")">
@Model.QuantityOnHand.ToString("N2") @Model.UnitOfMeasure
</p>
</div>
<div class="col-6">
<label class="text-muted small mb-1">Location</label>
<p class="mb-0">@(Model.Location ?? "—")</p>
</div>
<div class="col-6">
<label class="text-muted small mb-1">Reorder Point</label>
<p class="mb-0">@Model.ReorderPoint.ToString("N2") @Model.UnitOfMeasure</p>
</div>
<div class="col-6">
<label class="text-muted small mb-1">Reorder Qty</label>
<p class="mb-0">@Model.ReorderQuantity.ToString("N2") @Model.UnitOfMeasure</p>
</div>
<div class="col-6">
<label class="text-muted small mb-1">Min Stock</label>
<p class="mb-0">@Model.MinimumStock.ToString("N2") @Model.UnitOfMeasure</p>
</div>
<div class="col-6">
<label class="text-muted small mb-1">Max Stock</label>
<p class="mb-0">@Model.MaximumStock.ToString("N2") @Model.UnitOfMeasure</p>
</div>
<div class="col-12"><hr class="my-2" /></div>
<div class="col-6">
<label class="text-muted small mb-1">Unit Cost</label>
<p class="fw-semibold mb-0">@Model.UnitCost.ToString("C")</p>
</div>
<div class="col-6">
<label class="text-muted small mb-1">Average Cost</label>
<p class="mb-0">@Model.AverageCost.ToString("C")</p>
</div>
<div class="col-12">
<label class="text-muted small mb-1">Total Stock Value</label>
<p class="fw-semibold text-primary mb-0 fs-5">@((Model.QuantityOnHand * Model.UnitCost).ToString("C"))</p>
</div>
@if (Model.LastPurchaseDate.HasValue)
{
<div class="col-6">
<label class="text-muted small mb-1">Last Purchase</label>
<p class="mb-0">@Model.LastPurchasePrice.ToString("C")</p>
</div>
<div class="col-6">
<label class="text-muted small mb-1">Purchase Date</label>
<p class="mb-0 small">@Model.LastPurchaseDate.Value.ToString("MMM dd, yyyy")</p>
</div>
}
@if (Model.PrimaryVendorId.HasValue || !string.IsNullOrEmpty(Model.VendorPartNumber))
{
<div class="col-12"><hr class="my-2" /></div>
@if (Model.PrimaryVendorId.HasValue)
{
<div class="col-12">
<label class="text-muted small mb-1">Primary Vendor</label>
<p class="mb-0">@Model.PrimaryVendorName</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.VendorPartNumber))
{
<div class="col-12">
<label class="text-muted small mb-1">Vendor Part #</label>
<p class="mb-0">@Model.VendorPartNumber</p>
</div>
}
}
</div>
</div>
</div>
<!-- Actions -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-2">
<h6 class="mb-0 fw-semibold"><i class="bi bi-lightning me-2 text-primary"></i>Actions</h6>
</div>
<div class="card-body py-3">
<div class="d-grid gap-2">
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-primary">
<i class="bi bi-pencil me-2"></i>Edit Item
</a>
<button type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#stockAdjustmentModal">
<i class="bi bi-plus-slash-minus me-2"></i>Stock Adjustment
</button>
<a asp-action="Label" asp-route-id="@Model.Id" target="_blank" class="btn btn-outline-secondary">
<i class="bi bi-qr-code me-2"></i>Print QR Label
</a>
<a asp-action="Ledger" asp-route-inventoryItemId="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-journal-text me-2"></i>View Activity History
</a>
<button type="button" class="btn btn-outline-info" data-bs-toggle="modal" data-bs-target="#jobsUsingModal" id="btnJobsUsing">
<i class="bi bi-briefcase me-2"></i>Jobs Using This Powder
</button>
<a asp-action="Delete" asp-route-id="@Model.Id" class="btn btn-outline-danger">
<i class="bi bi-trash me-2"></i>Delete Item
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Stock Adjustment Modal -->
<div class="modal fade" id="stockAdjustmentModal" tabindex="-1" aria-labelledby="stockAdjustmentModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="stockAdjustmentModalLabel">
<i class="bi bi-plus-slash-minus me-2 text-success"></i>Stock Adjustment
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="stockAdjustmentForm" method="post" asp-action="StockAdjustment" asp-route-id="@Model.Id">
@Html.AntiForgeryToken()
<div class="modal-body">
<div class="alert alert-light alert-permanent border mb-3 p-2">
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">Current Stock</span>
<span class="fw-bold fs-5" id="adjCurrentQty">@Model.QuantityOnHand.ToString("N2") @Model.UnitOfMeasure</span>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Adjustment Type <span class="text-danger">*</span></label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="adjustmentType" id="adjTypeAdd" value="Add" checked />
<label class="form-check-label" for="adjTypeAdd">
<i class="bi bi-plus-circle text-success me-1"></i>Add Stock
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="adjustmentType" id="adjTypeRemove" value="Remove" />
<label class="form-check-label" for="adjTypeRemove">
<i class="bi bi-dash-circle text-danger me-1"></i>Remove Stock
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="adjustmentType" id="adjTypeSet" value="Set" />
<label class="form-check-label" for="adjTypeSet">
<i class="bi bi-pencil-square text-primary me-1"></i>Set Exact
</label>
</div>
</div>
</div>
<div class="mb-3">
<label for="adjQuantity" class="form-label fw-semibold" id="adjQtyLabel">Quantity to Add (@Model.UnitOfMeasure) <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="adjQuantity" name="quantity"
min="0" step="any" required placeholder="0" />
<div class="form-text" id="adjNewBalanceHint"></div>
</div>
<div class="mb-3">
<label for="adjReason" class="form-label fw-semibold">Reason <span class="text-danger">*</span></label>
<select class="form-select" id="adjReason" name="reason" required>
<option value="">— Select a reason —</option>
<optgroup label="Adding Stock">
<option value="Received from purchase order">Received from purchase order</option>
<option value="Physical count — found extra">Physical count — found extra</option>
<option value="Returned unused product">Returned unused product</option>
<option value="Transfer in from another location">Transfer in from another location</option>
</optgroup>
<optgroup label="Removing Stock">
<option value="Physical count — shortage found">Physical count — shortage found</option>
<option value="Damaged / unusable product">Damaged / unusable product</option>
<option value="Waste or spillage">Waste or spillage</option>
<option value="Transfer out to another location">Transfer out to another location</option>
<option value="Expired product disposed">Expired product disposed</option>
</optgroup>
<optgroup label="Set Exact">
<option value="Physical inventory count correction">Physical inventory count correction</option>
<option value="System correction">System correction</option>
</optgroup>
<option value="Other">Other (see notes)</option>
</select>
</div>
<div class="mb-1">
<label for="adjNotes" class="form-label fw-semibold">Notes <span class="text-muted fw-normal">(optional)</span></label>
<textarea class="form-control" id="adjNotes" name="notes" rows="2"
placeholder="Any additional details about this adjustment…" maxlength="500"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success" id="adjSubmitBtn">
<i class="bi bi-check-lg me-1"></i>Save Adjustment
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Photo Gallery Modal -->
<div class="modal fade" id="photoGalleryModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<div>
<h5 class="modal-title fw-semibold">
<i class="bi bi-images me-2 text-primary"></i>
<span id="galleryTitle">Sample Photos</span>
</h5>
<p class="text-muted small mb-0" id="gallerySubtitle">@Model.Name &mdash; @Model.ColorName @Model.ColorCode</p>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<button class="btn btn-sm btn-outline-secondary mb-3 d-none" id="btnBackToGrid">
<i class="bi bi-chevron-left me-1"></i>Back to Gallery
</button>
<div id="galleryGridView">
<div class="row g-2" id="galleryGrid"></div>
<div class="d-flex justify-content-between align-items-center mt-3" id="galleryPaginationRow" style="display:none!important;">
<small class="text-muted" id="galleryRangeLabel"></small>
<div id="galleryPagination" class="d-flex gap-1"></div>
</div>
</div>
<div id="galleryDetailView" class="d-none text-center">
<img id="galleryLargeImg" class="img-fluid rounded mb-3" style="max-height:65vh;" src="" alt="">
<div class="d-flex justify-content-between align-items-center mb-2">
<button class="btn btn-outline-primary btn-sm" id="btnDetailPrev">
<i class="bi bi-chevron-left"></i> Prev
</button>
<span class="text-muted small" id="detailPosition"></span>
<button class="btn btn-outline-primary btn-sm" id="btnDetailNext">
Next <i class="bi bi-chevron-right"></i>
</button>
</div>
<p class="mb-1 text-muted small" id="detailCaption"></p>
<p class="mb-0 text-muted small" id="detailJobInfo"></p>
</div>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Jobs Using This Powder Modal -->
<div class="modal fade" id="jobsUsingModal" tabindex="-1" aria-labelledby="jobsUsingModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<div>
<h5 class="modal-title fw-semibold" id="jobsUsingModalLabel">
<i class="bi bi-briefcase me-2 text-primary"></i>Jobs Using This Powder
</h5>
<p class="text-muted small mb-0">@Model.Name &mdash; @Model.ColorName @Model.ColorCode</p>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="jobsUsingModalBody">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
/* ── Stock Adjustment Modal ───────────────────────────────── */
(function () {
var currentQty = @Model.QuantityOnHand;
var uom = '@Model.UnitOfMeasure';
var radios = document.querySelectorAll('input[name="adjustmentType"]');
var qtyInput = document.getElementById('adjQuantity');
var qtyLabel = document.getElementById('adjQtyLabel');
var hint = document.getElementById('adjNewBalanceHint');
var reasonSel = document.getElementById('adjReason');
function updateLabel() {
var type = document.querySelector('input[name="adjustmentType"]:checked')?.value;
if (type === 'Add') { qtyLabel.textContent = 'Quantity to Add (' + uom + ') *'; qtyInput.min = '0.001'; }
if (type === 'Remove') { qtyLabel.textContent = 'Quantity to Remove (' + uom + ') *'; qtyInput.min = '0.001'; }
if (type === 'Set') { qtyLabel.textContent = 'New Quantity on Hand (' + uom + ') *'; qtyInput.min = '0'; }
updateHint();
}
function updateHint() {
var type = document.querySelector('input[name="adjustmentType"]:checked')?.value;
var qty = parseFloat(qtyInput.value) || 0;
var newQty;
if (type === 'Add') newQty = currentQty + qty;
else if (type === 'Remove') newQty = currentQty - qty;
else newQty = qty;
if (qtyInput.value === '') { hint.textContent = ''; return; }
var cls = newQty < 0 ? 'text-danger' : newQty === 0 ? 'text-warning' : 'text-success';
hint.innerHTML = 'New balance: <strong class="' + cls + '">' + newQty.toFixed(2) + ' ' + uom + '</strong>';
}
radios.forEach(function (r) { r.addEventListener('change', updateLabel); });
qtyInput.addEventListener('input', updateHint);
// Reset modal when reopened
document.getElementById('stockAdjustmentModal').addEventListener('show.bs.modal', function () {
document.getElementById('stockAdjustmentForm').reset();
document.getElementById('adjTypeAdd').checked = true;
updateLabel();
hint.textContent = '';
});
updateLabel();
// Client-side validation before submit
document.getElementById('stockAdjustmentForm').addEventListener('submit', function (e) {
var qty = parseFloat(qtyInput.value);
var type = document.querySelector('input[name="adjustmentType"]:checked')?.value;
var reason = reasonSel.value;
if (!qty || qty <= 0) { e.preventDefault(); qtyInput.classList.add('is-invalid'); return; }
if (!reason) { e.preventDefault(); reasonSel.classList.add('is-invalid'); return; }
if (type === 'Remove' && qty > currentQty) {
if (!confirm('This will reduce stock below zero. Continue?')) { e.preventDefault(); return; }
}
document.getElementById('adjSubmitBtn').disabled = true;
document.getElementById('adjSubmitBtn').innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…';
});
qtyInput.addEventListener('input', function () { qtyInput.classList.remove('is-invalid'); });
reasonSel.addEventListener('change', function () { reasonSel.classList.remove('is-invalid'); });
}());
(function () {
/* ── Tagged Photo Gallery ─────────────────────────────────── */
var inventoryId = @Model.Id;
var galleryPhotos = []; // full current page
var allPhotos = []; // accumulated across all pages (for prev/next)
var totalCount = 0;
var currentPage = 1;
var pageSize = 12;
var detailIndex = 0; // index within allPhotos
// Load strip photos on page load via direct FK match (not tag-based)
fetch('/Inventory/PhotosByPowder/' + inventoryId + '?page=1&pageSize=20')
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success || data.totalCount === 0) return;
document.getElementById('samplePhotosSection').style.display = '';
var badge = document.getElementById('samplePhotoTotalBadge');
badge.textContent = data.totalCount + ' photo' + (data.totalCount === 1 ? '' : 's');
totalCount = data.totalCount;
var strip = document.getElementById('samplePhotoStrip');
data.photos.forEach(function (p) {
var wrapper = document.createElement('div');
wrapper.style.cssText = 'flex:0 0 auto;position:relative;border-radius:8px;overflow:hidden;cursor:pointer;';
wrapper.title = (p.jobNumber ? p.jobNumber + ' · ' : '') + (p.customerName || '');
var img = document.createElement('img');
img.src = '/Jobs/GetPhoto/' + p.id;
img.alt = p.caption || 'Job photo';
img.style.cssText = 'width:140px;height:110px;object-fit:cover;display:block;transition:opacity .15s;';
var label = document.createElement('div');
label.style.cssText = 'position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.45);padding:3px 6px;';
label.innerHTML = '<small style="color:#fff;font-size:.68rem;">' + escHtml(p.jobNumber || '') + '</small>';
wrapper.appendChild(img);
wrapper.appendChild(label);
wrapper.addEventListener('mouseenter', function () { img.style.opacity = '.8'; });
wrapper.addEventListener('mouseleave', function () { img.style.opacity = '1'; });
wrapper.addEventListener('click', function () {
allPhotos = data.photos;
openGalleryModal(1, function () { openDetailView(data.photos.indexOf(p)); });
});
strip.appendChild(wrapper);
});
});
// View All button
document.getElementById('btnViewAllPhotos').addEventListener('click', function () {
allPhotos = [];
openGalleryModal(1, null);
});
function openGalleryModal(page, callback) {
currentPage = page;
loadGalleryPage(page, function () {
var modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('photoGalleryModal'));
modal.show();
showGridView();
if (callback) callback();
});
}
function loadGalleryPage(page, callback) {
currentPage = page;
fetch('/Inventory/PhotosByPowder/' + inventoryId + '?page=' + page + '&pageSize=' + pageSize)
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) return;
totalCount = data.totalCount;
galleryPhotos = data.photos;
// merge into allPhotos for prev/next navigation
allPhotos = data.photos;
renderGrid(data.photos, data.totalCount, page);
if (callback) callback();
});
}
function renderGrid(photos, total, page) {
var grid = document.getElementById('galleryGrid');
grid.innerHTML = '';
photos.forEach(function (p, i) {
var col = document.createElement('div');
col.className = 'col-6 col-sm-4 col-md-3';
col.innerHTML =
'<div style="cursor:pointer;border-radius:8px;overflow:hidden;position:relative;" onclick="invPhotoDetail(' + i + ')">' +
'<img src="/Jobs/GetPhoto/' + p.id + '" style="width:100%;height:120px;object-fit:cover;" alt="' + escHtml(p.caption) + '">' +
'<div style="position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.45);padding:4px 6px;">' +
'<small class="text-white" style="font-size:.7rem;">' + escHtml(p.jobNumber) + '</small>' +
'</div></div>';
grid.appendChild(col);
});
// Pagination
var totalPages = Math.ceil(total / pageSize);
var paginationRow = document.getElementById('galleryPaginationRow');
var paginationEl = document.getElementById('galleryPagination');
var rangeEl = document.getElementById('galleryRangeLabel');
if (totalPages <= 1) {
paginationRow.style.removeProperty('display');
paginationRow.style.display = 'none';
return;
}
paginationRow.style.removeProperty('display');
var from = (page - 1) * pageSize + 1;
var to = Math.min(page * pageSize, total);
rangeEl.textContent = 'Showing ' + from + '' + to + ' of ' + total + ' photos';
paginationEl.innerHTML = '';
if (page > 1) addPageBtn(paginationEl, '', page - 1);
for (var pg = 1; pg <= totalPages; pg++) {
addPageBtn(paginationEl, pg, pg, pg === page);
}
if (page < totalPages) addPageBtn(paginationEl, '', page + 1);
}
function addPageBtn(container, label, page, active) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm ' + (active ? 'btn-primary' : 'btn-outline-secondary');
btn.textContent = label;
btn.addEventListener('click', function () { loadGalleryPage(page, null); });
container.appendChild(btn);
}
// Called from onclick in rendered grid HTML
window.invPhotoDetail = function (index) {
openDetailView(index);
};
function openDetailView(index) {
detailIndex = index;
showDetailView();
renderDetailPhoto();
}
function renderDetailPhoto() {
var p = galleryPhotos[detailIndex];
if (!p) return;
document.getElementById('galleryLargeImg').src = '/Jobs/GetPhoto/' + p.id;
document.getElementById('detailCaption').textContent = p.caption || '';
document.getElementById('detailJobInfo').textContent =
(p.jobNumber ? 'Job: ' + p.jobNumber : '') +
(p.customerName ? ' · ' + p.customerName : '') +
' · ' + p.uploadedDate;
document.getElementById('detailPosition').textContent =
(detailIndex + 1) + ' of ' + galleryPhotos.length +
(totalCount > pageSize ? ' on this page' : '');
}
function showGridView() {
document.getElementById('galleryGridView').classList.remove('d-none');
document.getElementById('galleryDetailView').classList.add('d-none');
document.getElementById('btnBackToGrid').classList.add('d-none');
}
function showDetailView() {
document.getElementById('galleryGridView').classList.add('d-none');
document.getElementById('galleryDetailView').classList.remove('d-none');
document.getElementById('btnBackToGrid').classList.remove('d-none');
}
document.getElementById('btnBackToGrid').addEventListener('click', showGridView);
document.getElementById('btnDetailPrev').addEventListener('click', function () {
detailIndex = (detailIndex - 1 + galleryPhotos.length) % galleryPhotos.length;
renderDetailPhoto();
});
document.getElementById('btnDetailNext').addEventListener('click', function () {
detailIndex = (detailIndex + 1) % galleryPhotos.length;
renderDetailPhoto();
});
// Reset to grid when modal is hidden
document.getElementById('photoGalleryModal').addEventListener('hidden.bs.modal', showGridView);
function escHtml(str) {
return (str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
})();
/* ── Sample Panel Toggle ─────────────────────────────────────── */
window.toggleSamplePanel = function(itemId, hasPanel) {
var token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
fetch('/Inventory/ToggleSamplePanel', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': token },
body: 'id=' + itemId + '&hasPanel=' + hasPanel + '&__RequestVerificationToken=' + encodeURIComponent(token)
});
};
(function () {
var loaded = false;
document.getElementById('btnJobsUsing').addEventListener('click', function () {
if (loaded) return;
fetch('@Url.Action("JobsUsing", "Inventory", new { id = Model.Id })')
.then(function (r) { return r.text(); })
.then(function (html) {
document.getElementById('jobsUsingModalBody').innerHTML = html;
loaded = true;
})
.catch(function () {
document.getElementById('jobsUsingModalBody').innerHTML =
'<p class="text-danger text-center py-3">Failed to load jobs. Please try again.</p>';
});
});
})();
</script>
}
@@ -0,0 +1,389 @@
@model PowderCoating.Application.DTOs.Inventory.UpdateInventoryItemDto
@{
ViewData["Title"] = "Edit Inventory Item";
ViewData["PageIcon"] = "bi-box-seam";
ViewData["PageHelpTitle"] = "Edit Inventory Item";
ViewData["PageHelpContent"] = "Update any field on this item. Changes to Coverage or Transfer Efficiency will affect the Powder Needed calculation on future quotes and jobs. Changing Unit Cost does not retroactively update historical job costs — it applies going forward. Use AI Lookup to refresh manufacturer details from a part number.";
}
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-end align-items-center mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to List
</a>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form asp-action="Edit" method="post">
<input type="hidden" asp-for="Id" />
<partial name="_ValidationSummary" />
<!-- Basic Information -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0">
<i class="bi bi-info-circle me-2 text-primary"></i>Basic Information
</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Basic Information"
data-bs-content="Name and SKU are required. Category determines how the item is used in quotes — Powder Coating items show the Coating Specifications section. Inactive items are hidden from pickers but their historical data is preserved.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-4">
<label asp-for="SKU" class="form-label">SKU <span class="text-danger">*</span></label>
<input asp-for="SKU" class="form-control" placeholder="Enter SKU" />
<span asp-validation-for="SKU" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="InventoryCategoryId" class="form-label">Category <span class="text-danger">*</span></label>
<select asp-for="InventoryCategoryId" class="form-select" id="field-category"
asp-items="@ViewBag.Categories"
data-coating-map="@ViewBag.CategoryIsCoatingJson">
<option value="">Select category</option>
</select>
<span asp-validation-for="InventoryCategoryId" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="IsActive" class="form-label">Status</label>
<select asp-for="IsActive" class="form-select">
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
<div class="col-12" id="wrap-name">
<label asp-for="Name" class="form-label">Name <span class="text-danger">*</span></label>
<input asp-for="Name" class="form-control" id="field-name" placeholder="Enter product name" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="col-12" id="wrap-description">
<label asp-for="Description" class="form-label">Description</label>
<textarea asp-for="Description" class="form-control" id="field-description" rows="2"></textarea>
<span asp-validation-for="Description" class="text-danger"></span>
</div>
</div>
</div>
<!-- Product Details -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0 d-flex align-items-center">
<i class="bi bi-palette me-2 text-primary"></i>Product Details
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? true))
{
<button type="button" class="btn btn-sm btn-outline-primary ms-2" id="ai-lookup-btn">
<i class="bi bi-stars me-1"></i>AI Lookup
</button>
}
</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Product Details"
data-bs-content="Manufacturer, part number, color name, and finish describe the physical product. Coverage is how many sq ft one pound coats (typical: 30). Transfer Efficiency is what percentage sticks to the part (typical: 6070%). Both values affect the Powder Needed calculation on quotes and jobs. Use AI Lookup to auto-fill fields from a part number.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div id="ai-lookup-status" class="alert alert-info d-none py-2 small mb-3"></div>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="Manufacturer" class="form-label">Manufacturer</label>
<input asp-for="Manufacturer" class="form-control" id="field-manufacturer" />
<span asp-validation-for="Manufacturer" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="ManufacturerPartNumber" class="form-label">Manufacturer Part Number</label>
<input asp-for="ManufacturerPartNumber" class="form-control" id="field-partnumber" />
<span asp-validation-for="ManufacturerPartNumber" class="text-danger"></span>
</div>
<div class="col-md-4" id="wrap-colorname">
<label asp-for="ColorName" class="form-label">Color Name</label>
<input asp-for="ColorName" class="form-control" id="field-colorname" placeholder="e.g., Illusion Malbec" />
<span asp-validation-for="ColorName" class="text-danger"></span>
</div>
<div class="col-md-4" id="wrap-colorcode">
<label asp-for="ColorCode" class="form-label">Color Code</label>
<input asp-for="ColorCode" class="form-control" id="field-colorcode" placeholder="e.g., RAL9005" />
<span asp-validation-for="ColorCode" class="text-danger"></span>
</div>
<div class="col-md-4" id="wrap-finish">
<label asp-for="Finish" class="form-label">Finish</label>
<input asp-for="Finish" class="form-control" id="field-finish" placeholder="e.g., Gloss, Matte" />
<span asp-validation-for="Finish" class="text-danger"></span>
</div>
<div class="col-12" id="wrap-specpageurl">
<label asp-for="SpecPageUrl" class="form-label">Product URL</label>
<div class="input-group">
<input asp-for="SpecPageUrl" class="form-control" id="field-specpageurl" placeholder="https://..." />
<a id="field-specpageurl-link" href="#" target="_blank" class="btn btn-outline-secondary @(string.IsNullOrWhiteSpace(Model.SpecPageUrl) ? "d-none" : "")" title="Open spec page">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
<span asp-validation-for="SpecPageUrl" class="text-danger"></span>
</div>
<div class="col-md-6" id="wrap-coverage">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="CoverageSqFtPerLb" class="form-label mb-0">Coverage (@ViewBag.CoverageUnit)</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Coverage"
data-bs-content="How many square feet one pound covers at a standard film thickness. Industry average is about 30 sq ft/lb — check the manufacturer's tech data sheet for the exact figure. Used together with Transfer Efficiency to calculate powder to order for each job.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="CoverageSqFtPerLb" type="number" step="0.01" min="0" class="form-control" id="field-coverage" placeholder="30" />
<span asp-validation-for="CoverageSqFtPerLb" class="text-danger"></span>
<small class="form-text text-muted">Surface area coverage per unit of weight (default: 30)</small>
</div>
<div class="col-md-6" id="wrap-transfer">
<div class="d-flex align-items-center gap-1 mb-1">
<label asp-for="TransferEfficiency" class="form-label mb-0">Transfer Efficiency (%)</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Transfer Efficiency"
data-bs-content="The percentage of powder that adheres to the part vs. lost as overspray. Electrostatic guns typically achieve 6070%. A lower value means more powder is needed per job. The system uses this in the Powder Needed calculation on quotes.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="TransferEfficiency" type="number" step="0.01" min="0" max="100" class="form-control" id="field-transfer" placeholder="65" />
<span asp-validation-for="TransferEfficiency" class="text-danger"></span>
<small class="form-text text-muted">Percentage of coating that sticks to the part (default: 65%)</small>
</div>
</div>
</div>
<!-- Coating Specs -->
<div class="mb-4" id="coating-specs-section">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0">
<i class="bi bi-thermometer-half text-primary"></i>Coating Specifications
</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Coating Specifications"
data-bs-content="Cure Temperature and Cure Time come from the manufacturer's tech data sheet and guide the oven operator. Requires Clear Coat flags powders that need a clear top coat for durability or gloss. Color Families tag this powder for filtering — click chips to add or remove families (e.g., teal = Green + Blue).">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-4">
<label asp-for="CureTemperatureF" class="form-label">Cure Temperature (°F)</label>
<input asp-for="CureTemperatureF" type="number" step="1" min="200" max="500" class="form-control" id="field-curetemp" placeholder="e.g., 375" />
<span asp-validation-for="CureTemperatureF" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="CureTimeMinutes" class="form-label">Cure Time (minutes)</label>
<input asp-for="CureTimeMinutes" type="number" step="1" min="1" max="120" class="form-control" id="field-curetime" placeholder="e.g., 15" />
<span asp-validation-for="CureTimeMinutes" class="text-danger"></span>
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check mb-2">
<input asp-for="RequiresClearCoat" class="form-check-input" type="checkbox" id="field-clearcoat" />
<label asp-for="RequiresClearCoat" class="form-check-label fw-medium">Requires Clear Coat</label>
<div class="form-text">Check if this powder needs a clear top coat</div>
</div>
</div>
<div class="col-12">
<div class="d-flex align-items-center gap-1 mb-1">
<label class="form-label mb-0">Color Families</label>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Color Families"
data-bs-content="Click chips to tag which base color families this powder belongs to. A metallic teal would be tagged Green and Blue; a bronze would be tagged Brown and Gold. These tags drive color-match filtering in the quote wizard.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<input asp-for="ColorFamilies" type="hidden" id="field-colorfamilies" />
<div class="d-flex flex-wrap gap-2 mt-1" id="color-family-chips">
@foreach (var fam in new[] { "Red","Orange","Yellow","Green","Blue","Purple","Pink","Brown","Black","White","Gray","Silver","Gold","Bronze","Copper","Clear" })
{
<span class="badge color-family-chip" data-family="@fam"
style="cursor:pointer;font-size:.8rem;padding:.35em .7em;">@fam</span>
}
</div>
<div class="form-text">Click to toggle which primary color families this powder belongs to (e.g., Teal = Green + Blue)</div>
</div>
</div>
</div>
<!-- Stock Information -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0">
<i class="bi bi-boxes me-2 text-primary"></i>Stock Information
</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Stock Information"
data-bs-content="Quantity on Hand is your current count. Reorder Point triggers a Low Stock alert when quantity falls to or below this value. Reorder Quantity is the standard batch size to order. Minimum and Maximum Stock are optional planning bounds. Location is the shelf or bin label to help staff find the material.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-4">
<label asp-for="QuantityOnHand" class="form-label">Quantity on Hand</label>
<input asp-for="QuantityOnHand" type="number" step="0.01" min="0" class="form-control" />
<span asp-validation-for="QuantityOnHand" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="UnitOfMeasure" class="form-label">Unit of Measure</label>
<select asp-for="UnitOfMeasure" class="form-select" asp-items="@ViewBag.UnitsOfMeasure"></select>
<span asp-validation-for="UnitOfMeasure" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="Location" class="form-label">Location</label>
<input asp-for="Location" class="form-control" placeholder="e.g., Shelf A3" />
<span asp-validation-for="Location" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="ReorderPoint" class="form-label">Reorder Point</label>
<input asp-for="ReorderPoint" type="number" step="0.01" min="0" class="form-control" />
<span asp-validation-for="ReorderPoint" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="ReorderQuantity" class="form-label">Reorder Quantity</label>
<input asp-for="ReorderQuantity" type="number" step="0.01" min="0" class="form-control" />
<span asp-validation-for="ReorderQuantity" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="MinimumStock" class="form-label">Minimum Stock</label>
<input asp-for="MinimumStock" type="number" step="0.01" min="0" class="form-control" />
<span asp-validation-for="MinimumStock" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="MaximumStock" class="form-label">Maximum Stock</label>
<input asp-for="MaximumStock" type="number" step="0.01" min="0" class="form-control" />
<span asp-validation-for="MaximumStock" class="text-danger"></span>
</div>
</div>
</div>
<!-- Pricing & Vendor -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0">
<i class="bi bi-currency-dollar me-2 text-primary"></i>Pricing &amp; Vendor
</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Pricing &amp; Vendor"
data-bs-content="Unit Cost is what you pay per unit — used to calculate total stock value and feeds into job cost calculations. Changing this updates the displayed value going forward but does not change historical job costs. Primary Vendor links to your supplier for quick reference.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="UnitCost" class="form-label">Unit Cost</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="UnitCost" type="number" step="0.01" min="0" class="form-control" id="field-unitcost" />
</div>
<span asp-validation-for="UnitCost" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="PrimaryVendorId" class="form-label">Primary Vendor</label>
<select asp-for="PrimaryVendorId" class="form-select" id="field-vendor" asp-items="@ViewBag.Vendors"
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
<option value="">Select vendor</option>
<option value="__new__">+ Add New Vendor…</option>
</select>
<span asp-validation-for="PrimaryVendorId" class="text-danger"></span>
</div>
<div class="col-12">
<label asp-for="VendorPartNumber" class="form-label">Vendor Part Number</label>
<input asp-for="VendorPartNumber" class="form-control" />
<span asp-validation-for="VendorPartNumber" class="text-danger"></span>
</div>
</div>
</div>
<!-- Financial Accounts -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0">
<i class="bi bi-journal-bookmark me-2 text-primary"></i>Financial Accounts
</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Financial Accounts"
data-bs-content="Inventory Account is the asset account where stock value sits on the balance sheet (e.g., 1200 Inventory — Powder). COGS Account is debited when this material is consumed on a job (e.g., 5000 Cost of Goods Sold). Leave blank to use the company-wide defaults from your Chart of Accounts.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<p class="text-muted small mb-3">Map this item to chart-of-account entries for proper balance sheet and cost tracking. Leave blank to use defaults.</p>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="InventoryAccountId" class="form-label"></label>
<select asp-for="InventoryAccountId" class="form-select" asp-items="ViewBag.InventoryAccounts"
data-quick-add-url="/Accounts/Create?preSubType=4" data-quick-add-title="Add Inventory Account">
<option value="">(Default inventory account)</option>
<option value="__new__">+ Add New Account…</option>
</select>
<small class="form-text text-muted">Asset account where inventory value is tracked (e.g., 1200 Inventory - Powder).</small>
</div>
<div class="col-md-6">
<label asp-for="CogsAccountId" class="form-label"></label>
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
<option value="">(Default COGS account)</option>
<option value="__new__">+ Add New Account…</option>
</select>
<small class="form-text text-muted">Expense account debited when this material is consumed on a job.</small>
</div>
</div>
</div>
<!-- Notes -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-journal-text me-2 text-primary"></i>Notes
</h5>
<div class="row g-3">
<div class="col-12">
<label asp-for="Notes" class="form-label">Additional Notes</label>
<textarea asp-for="Notes" class="form-control" rows="3"></textarea>
<span asp-validation-for="Notes" class="text-danger"></span>
</div>
</div>
</div>
<!-- Sample Panel (coating items only) -->
<div class="mb-4" id="sample-panel-section" style="display:none;">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-palette me-2 text-primary"></i>Sample Panel
</h5>
<div class="row g-3">
<div class="col-md-6">
<div class="form-check form-switch mt-1">
<input class="form-check-input" type="checkbox" role="switch"
asp-for="HasSamplePanel" id="HasSamplePanel" />
<label class="form-check-label" asp-for="HasSamplePanel">
Sample panel is on the wall
</label>
</div>
<div class="form-text">Check this once you have a physical sample panel for this color hanging in your shop.</div>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="d-flex gap-2 justify-content-end pt-3 border-top">
<a asp-action="Index" class="btn btn-outline-secondary px-4">Cancel</a>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-save me-2"></i>Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<partial name="_InventoryColorFamilyScripts" />
}
@@ -0,0 +1,445 @@
@model PagedResult<PowderCoating.Application.DTOs.Inventory.InventoryListDto>
@{
ViewData["Title"] = "Inventory";
ViewData["PageIcon"] = "bi-box-seam";
ViewData["PageHelpTitle"] = "Inventory";
ViewData["PageHelpContent"] = "Track powder coatings, consumables, and other shop materials. Items show as Low Stock when quantity falls at or below the Reorder Point — the Low Stock count at the top is your reorder alert. Click any row to view full details or edit. Use the search box and category filter to narrow the list. Low Stock filter shows only items needing attention.";
var lowStockCount = (int)(ViewBag.StatsLowStockCount ?? 0);
var activeCount = (int)(ViewBag.StatsActiveCount ?? 0);
var totalValue = (decimal)(ViewBag.StatsTotalValue ?? 0m);
}
<div class="d-flex justify-content-end align-items-center mb-4">
<div class="d-flex gap-2">
<a asp-action="Ledger" class="btn btn-outline-secondary">
<i class="bi bi-journal-text me-2"></i>Inventory Activity
</a>
<a asp-action="SamplePanels" class="btn btn-outline-primary">
<i class="bi bi-palette me-2"></i>Manage Sample Panels
</a>
</div>
</div>
<!-- Stats Cards - Desktop -->
<div class="stats-cards-desktop">
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1" style="font-size: 0.875rem;">Total Items</p>
<h3 class="mb-0 fw-bold">@Model.TotalCount</h3>
</div>
<div class="rounded-circle p-3" style="background: #dbeafe;">
<i class="bi bi-box-seam text-primary" style="font-size: 1.5rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1" style="font-size: 0.875rem;">Low Stock Items</p>
<h3 class="mb-0 fw-bold @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</h3>
</div>
<div class="rounded-circle p-3" style="background: #fee2e2;">
<i class="bi bi-exclamation-triangle text-danger" style="font-size: 1.5rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1" style="font-size: 0.875rem;">Active Items</p>
<h3 class="mb-0 fw-bold">@activeCount</h3>
</div>
<div class="rounded-circle p-3" style="background: #d1fae5;">
<i class="bi bi-check-circle text-success" style="font-size: 1.5rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1" style="font-size: 0.875rem;">Total Value</p>
<h3 class="mb-0 fw-bold">@totalValue.ToString("C")</h3>
</div>
<div class="rounded-circle p-3" style="background: #fef3c7;">
<i class="bi bi-currency-dollar text-warning" style="font-size: 1.5rem;"></i>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Compact Stats - Mobile -->
<div class="mobile-stats-compact">
<div class="card">
<div class="stats-grid">
<div class="stat-item">
<div class="stat-icon"><i class="bi bi-box-seam text-primary"></i></div>
<div class="stat-value">@Model.TotalCount</div>
<div class="stat-label">Total</div>
</div>
<div class="stat-item">
<div class="stat-icon"><i class="bi bi-exclamation-triangle text-danger"></i></div>
<div class="stat-value @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</div>
<div class="stat-label">Low Stock</div>
</div>
<div class="stat-item">
<div class="stat-icon"><i class="bi bi-check-circle text-success"></i></div>
<div class="stat-value">@activeCount</div>
<div class="stat-label">Active</div>
</div>
<div class="stat-item">
<div class="stat-icon"><i class="bi bi-currency-dollar text-warning"></i></div>
<div class="stat-value">@totalValue.ToString("C0")</div>
<div class="stat-label">Value</div>
</div>
</div>
</div>
</div>
@{
var lowStockOnly = (bool)(ViewBag.LowStockOnly ?? false);
}
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm) || !string.IsNullOrEmpty(ViewBag.Category) || lowStockOnly)
{
<div class="alert @(lowStockOnly ? "alert-warning" : "alert-info") alert-permanent d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-funnel-fill me-2"></i>
@if (lowStockOnly)
{
<span>Showing <strong>@Model.TotalCount</strong> low stock item@(Model.TotalCount == 1 ? "" : "s") — at or below reorder point</span>
}
else
{
<span>Showing <strong>@Model.TotalCount</strong> item(s)</span>
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm))
{
<span> matching "<strong>@ViewBag.SearchTerm</strong>"</span>
}
@if (!string.IsNullOrEmpty(ViewBag.Category))
{
<span> in category "<strong>@ViewBag.Category</strong>"</span>
}
}
</div>
<a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x me-1"></i>Clear Filters
</a>
</div>
}
<!-- Inventory Table Card -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3">
<div class="d-flex flex-column flex-sm-row gap-2 w-100 w-lg-auto">
<form asp-action="Index" method="get" class="d-flex flex-column flex-sm-row gap-2 flex-grow-1 flex-lg-grow-0">
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
<input type="hidden" name="pageSize" value="@Model.PageSize" />
<select name="category" class="form-select" style="max-width: 250px; min-width: 150px;" onchange="this.form.submit()">
<option value="">All Categories</option>
@foreach (var cat in ViewBag.Categories)
{
<option value="@cat" selected="@(cat == ViewBag.Category)">@cat</option>
}
</select>
<div class="input-group" style="max-width: 480px; min-width: 300px;">
<span class="input-group-text bg-white border-end-0">
<i class="bi bi-search text-muted"></i>
</span>
<input type="text" name="searchTerm" class="form-control border-start-0"
placeholder="Search inventory..."
value="@ViewBag.SearchTerm">
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i>
</button>
</div>
</form>
<a asp-action="Create" class="btn btn-primary text-nowrap">
<i class="bi bi-plus-circle me-2"></i>
<span class="d-none d-sm-inline">Add Item</span>
<span class="d-inline d-sm-none">Add</span>
</a>
</div>
</div>
</div>
<div class="card-body p-0">
@if (!Model.Items.Any())
{
<div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No inventory items found</h5>
<p class="text-muted mb-4">Get started by adding your first inventory item</p>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Add Your First Item
</a>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th sortable="Name" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="ps-4">Item Name</th>
<th sortable="Category" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Category</th>
<th sortable="ColorName" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Color</th>
<th>Vendor</th>
<th sortable="QuantityOnHand" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Quantity</th>
<th>Reorder Point</th>
<th sortable="UnitCost" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Unit Cost</th>
<th>Stock Value</th>
<th sortable="IsActive" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Status</th>
<th class="text-end pe-4">Actions</th>
</tr>
</thead>
<tbody id="inventoryTable">
@foreach (var item in Model.Items)
{
<tr class="inventory-row" data-item-id="@item.Id" style="cursor: pointer;">
<td class="ps-4">
<div class="d-flex align-items-center gap-2">
<div class="rounded-circle d-flex align-items-center justify-content-center"
style="width: 40px; height: 40px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; font-weight: 600;">
<i class="bi bi-box"></i>
</div>
<div>
<div class="fw-semibold">@item.Name</div>
<small class="text-muted">@item.SKU</small>
</div>
</div>
</td>
<td>
<span class="badge bg-secondary bg-opacity-10 text-secondary">
@item.Category
</span>
</td>
<td>
@if (!string.IsNullOrEmpty(item.ColorName))
{
<span>@item.ColorName</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
@if (!string.IsNullOrEmpty(item.PrimaryVendorName))
{
<span class="text-muted">@item.PrimaryVendorName</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
<span class="@(item.IsOutOfStock ? "text-dark fw-semibold" : item.IsLowStock ? "text-danger fw-semibold" : "")">
@item.QuantityOnHand.ToString("N2") @item.UnitOfMeasure
@if (item.IsOutOfStock)
{
<i class="bi bi-x-circle ms-1"></i>
}
else if (item.IsLowStock)
{
<i class="bi bi-exclamation-triangle ms-1"></i>
}
</span>
</td>
<td>@item.ReorderPoint.ToString("N2") @item.UnitOfMeasure</td>
<td>@item.UnitCost.ToString("C")</td>
<td>
<span class="fw-semibold">@((item.QuantityOnHand * item.UnitCost).ToString("C"))</span>
</td>
<td>
@if (item.IsActive)
{
<span class="badge bg-success bg-opacity-10 text-success">
<i class="bi bi-check-circle me-1"></i>Active
</span>
}
else
{
<span class="badge bg-danger bg-opacity-10 text-danger">
<i class="bi bi-x-circle me-1"></i>Inactive
</span>
}
</td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm">
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-primary" title="View Details">
<i class="bi bi-eye"></i>
</a>
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-warning" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</a>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var item in Model.Items)
{
<div class="mobile-data-card"
data-id="@item.Id"
onclick="window.location.href='@Url.Action("Details", new { id = item.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<i class="bi bi-box"></i>
</div>
<div class="mobile-card-title">
<h6>@item.Name</h6>
<small>@item.SKU</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Category</span>
<span class="mobile-card-value">
<span class="badge bg-secondary bg-opacity-10 text-secondary">
@item.Category
</span>
</span>
</div>
@if (!string.IsNullOrEmpty(item.ColorName))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Color</span>
<span class="mobile-card-value">@item.ColorName</span>
</div>
}
@if (!string.IsNullOrEmpty(item.PrimaryVendorName))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Vendor</span>
<span class="mobile-card-value">@item.PrimaryVendorName</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Quantity</span>
<span class="mobile-card-value @(item.IsOutOfStock ? "text-dark fw-semibold" : item.IsLowStock ? "text-danger fw-semibold" : "")">
@item.QuantityOnHand.ToString("N2") @item.UnitOfMeasure
@if (item.IsOutOfStock)
{
<i class="bi bi-x-circle ms-1"></i>
}
else if (item.IsLowStock)
{
<i class="bi bi-exclamation-triangle ms-1"></i>
}
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Reorder Point</span>
<span class="mobile-card-value">@item.ReorderPoint.ToString("N2") @item.UnitOfMeasure</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Unit Cost</span>
<span class="mobile-card-value">@item.UnitCost.ToString("C")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Stock Value</span>
<span class="mobile-card-value fw-semibold text-primary">@((item.QuantityOnHand * item.UnitCost).ToString("C"))</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@if (item.IsActive)
{
<span class="badge bg-success bg-opacity-10 text-success">
<i class="bi bi-check-circle me-1"></i>Active
</span>
}
else
{
<span class="badge bg-danger bg-opacity-10 text-danger">
<i class="bi bi-x-circle me-1"></i>Inactive
</span>
}
</span>
</div>
</div>
<div class="mobile-card-footer">
<a href="@Url.Action("Details", new { id = item.Id })"
class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation();">
<i class="bi bi-eye me-1"></i>View
</a>
<a href="@Url.Action("Edit", new { id = item.Id })"
class="btn btn-sm btn-outline-secondary"
onclick="event.stopPropagation();">
<i class="bi bi-pencil me-1"></i>Edit
</a>
</div>
</div>
}
</div>
</div>
}
</div>
@if (Model.TotalCount > 0)
{
@await Html.PartialAsync("_Pagination", Model)
}
</div>
@section Scripts {
<script>
// Make table rows clickable
document.querySelectorAll('.inventory-row').forEach(row => {
row.addEventListener('click', function(e) {
// Don't navigate if clicking on action buttons or links
if (e.target.closest('.btn-group') || e.target.closest('a') || e.target.closest('button')) {
return;
}
const itemId = this.getAttribute('data-item-id');
window.location.href = '@Url.Action("Details", "Inventory")/' + itemId;
});
// Add hover effect
row.addEventListener('mouseenter', function() {
this.style.backgroundColor = '#f8f9fa';
});
row.addEventListener('mouseleave', function() {
this.style.backgroundColor = '';
});
});
</script>
}
@@ -0,0 +1,159 @@
@model PowderCoating.Application.DTOs.Inventory.InventoryItemDto
@{
ViewData["Title"] = $"Label — {Model.Name}";
Layout = null; // standalone print page
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Inventory Label — @Model.Name</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Arial, Helvetica, sans-serif;
background: #f0f0f0;
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 16px;
min-height: 100vh;
}
.screen-controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.btn {
padding: 8px 20px;
border-radius: 6px;
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 600;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-primary { background: #6f42c1; color: #fff; }
.btn-secondary { background: #6c757d; color: #fff; }
/* ── Label card ─────────────────────────────────────── */
.label-card {
background: #fff;
border: 2px solid #333;
border-radius: 8px;
width: 3.5in;
padding: 14px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,.15);
}
.label-logo {
font-size: 9px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: #6f42c1;
}
.label-qr img {
display: block;
width: 1.6in;
height: 1.6in;
}
.label-name {
font-size: 13px;
font-weight: 700;
text-align: center;
line-height: 1.3;
max-width: 3in;
}
.label-sku {
font-size: 11px;
color: #555;
letter-spacing: .04em;
}
.label-color {
font-size: 11px;
color: #333;
}
.label-scan-hint {
font-size: 9px;
color: #888;
text-align: center;
border-top: 1px dashed #ccc;
padding-top: 6px;
width: 100%;
}
/* ── Print styles ───────────────────────────────────── */
@@media print {
body { background: #fff; padding: 0; }
.screen-controls { display: none; }
.label-card {
border: 2px solid #333;
box-shadow: none;
width: 3.5in;
page-break-inside: avoid;
}
}
</style>
</head>
<body>
<div class="screen-controls">
<button class="btn btn-primary" onclick="window.print()">
&#128438; Print Label
</button>
<a class="btn btn-secondary" href="/Inventory/Details/@Model.Id">
&#8592; Back to Item
</a>
</div>
<div class="label-card">
<div class="label-logo">Powder Coating Logix</div>
<div class="label-qr">
<img src="/Inventory/QrCode/@Model.Id?size=8" alt="QR Code for @Model.Name" />
</div>
<div class="label-name">@Model.Name</div>
<div class="label-sku">SKU: @Model.SKU</div>
@if (!string.IsNullOrEmpty(Model.ColorName))
{
<div class="label-color">
@Model.ColorName
@if (!string.IsNullOrEmpty(Model.Finish))
{
<span> — @Model.Finish</span>
}
</div>
}
@if (!string.IsNullOrEmpty(Model.Manufacturer))
{
<div class="label-sku" style="color:#777">@Model.Manufacturer</div>
}
<div class="label-scan-hint">
Scan to log usage &bull; Powder Coating Logix
</div>
</div>
</body>
</html>
@@ -0,0 +1,316 @@
@model PowderCoating.Application.DTOs.Inventory.InventoryLedgerViewModel
@using PowderCoating.Application.DTOs.Inventory
@{
ViewData["Title"] = "Inventory Activity";
ViewData["PageIcon"] = "bi-clock-history";
var activeTab = Context.Request.Query["tab"].ToString();
if (string.IsNullOrEmpty(activeTab)) activeTab = "transactions";
}
@section Styles {
<style>
.badge-txn-Purchase { background: #198754; color: #fff; }
.badge-txn-Initial { background: #0d6efd; color: #fff; }
.badge-txn-Adjustment { background: #6f42c1; color: #fff; }
.badge-txn-JobUsage { background: #dc3545; color: #fff; }
.badge-txn-Sale { background: #fd7e14; color: #fff; }
.badge-txn-Waste { background: #6c757d; color: #fff; }
.badge-txn-Return { background: #20c997; color: #fff; }
.badge-txn-Transfer { background: #0dcaf0; color: #000; }
.qty-positive { color: #198754; font-weight: 600; }
.qty-negative { color: #dc3545; font-weight: 600; }
.variance-over { color: #dc3545; }
.variance-under { color: #198754; }
.filter-bar { background: var(--bs-tertiary-bg); border: 1px solid var(--bs-border-color); border-radius: .5rem; padding: 1rem 1.25rem; margin-bottom: 1.5rem; }
.stat-pill { background: var(--bs-tertiary-bg); border: 1px solid var(--bs-border-color); border-radius: .5rem; padding: .5rem 1rem; text-align: center; min-width: 130px; }
.stat-pill .stat-val { font-size: 1.25rem; font-weight: 700; }
.stat-pill .stat-lbl { font-size: .75rem; color: var(--bs-secondary-color); }
</style>
}
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
@if (!string.IsNullOrEmpty(Model.SelectedItemName))
{
<div class="text-muted small">
<i class="bi bi-box-seam me-1"></i>@Model.SelectedItemSku — @Model.SelectedItemName
</div>
}
</div>
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back to Inventory
</a>
</div>
@* ── Filter Bar ─────────────────────────────────────────────── *@
<form method="get" class="filter-bar">
<input type="hidden" name="tab" value="@activeTab" />
<div class="d-flex flex-wrap gap-2 align-items-end">
<div>
<label class="form-label mb-1 small fw-semibold">Item</label>
<select name="inventoryItemId" class="form-select form-select-sm" style="min-width:220px">
<option value="">All Items</option>
@foreach (var item in Model.AllItems)
{
<option value="@item.Id" selected="@(Model.InventoryItemId == item.Id)">
@item.SKU — @item.Name
</option>
}
</select>
</div>
<div>
<label class="form-label mb-1 small fw-semibold">From</label>
<input type="date" name="dateFrom" class="form-control form-control-sm"
value="@Model.DateFrom?.ToString("yyyy-MM-dd")" style="width:140px" />
</div>
<div>
<label class="form-label mb-1 small fw-semibold">To</label>
<input type="date" name="dateTo" class="form-control form-control-sm"
value="@Model.DateTo?.ToString("yyyy-MM-dd")" style="width:140px" />
</div>
<div>
<label class="form-label mb-1 small fw-semibold">Type</label>
<select name="typeFilter" class="form-select form-select-sm" style="min-width:140px">
<option value="">All Types</option>
@foreach (var t in new[] { "Purchase","Initial","Adjustment","JobUsage","Sale","Return","Waste","Transfer" })
{
<option value="@t" selected="@(Model.TypeFilter == t)">@t</option>
}
</select>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-search"></i></button>
<a asp-action="Ledger" class="btn btn-outline-secondary btn-sm">Clear</a>
</div>
</div>
</form>
@* ── Summary Pills ───────────────────────────────────────────── *@
<div class="d-flex flex-wrap gap-3 mb-3">
<div class="stat-pill">
<div class="stat-val text-success">@Model.TotalPurchased.ToString("N2")</div>
<div class="stat-lbl">lbs Received</div>
</div>
<div class="stat-pill">
<div class="stat-val text-danger">@Model.TotalUsed.ToString("N2")</div>
<div class="stat-lbl">lbs Used / Sold</div>
</div>
<div class="stat-pill">
<div class="stat-val @(Model.TotalAdjusted >= 0 ? "text-primary" : "text-warning")">
@(Model.TotalAdjusted >= 0 ? "+" : "")@Model.TotalAdjusted.ToString("N2")
</div>
<div class="stat-lbl">lbs Adjusted</div>
</div>
<div class="stat-pill">
<div class="stat-val">@Model.Transactions.Count</div>
<div class="stat-lbl">Transactions</div>
</div>
<div class="stat-pill">
<div class="stat-val">@Model.PowderUsageLogs.Count</div>
<div class="stat-lbl">Usage Records</div>
</div>
</div>
@* ── Tabs ─────────────────────────────────────────────────────── *@
<ul class="nav nav-tabs mb-3" id="ledgerTabs">
<li class="nav-item">
<button class="nav-link @(activeTab == "transactions" ? "active" : "")"
onclick="switchTab('transactions')">
<i class="bi bi-list-ul me-1"></i>Stock Transactions
<span class="badge bg-secondary ms-1">@Model.Transactions.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "usage" ? "active" : "")"
onclick="switchTab('usage')">
<i class="bi bi-fire me-1"></i>Powder Usage by Job
<span class="badge bg-secondary ms-1">@Model.PowderUsageLogs.Count</span>
</button>
</li>
</ul>
@* ── Transactions Tab ─────────────────────────────────────────── *@
<div id="tab-transactions" class="@(activeTab != "usage" ? "" : "d-none")">
@if (!Model.Transactions.Any())
{
<div class="alert alert-info alert-permanent">
<i class="bi bi-info-circle me-2"></i>No transactions found for the selected filters.
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Date</th>
@if (!Model.InventoryItemId.HasValue)
{
<th>Item</th>
}
<th>Type</th>
<th class="text-end">Qty</th>
<th class="text-end">Unit Cost</th>
<th class="text-end">Total</th>
<th class="text-end">Balance After</th>
<th>Reference</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
@foreach (var t in Model.Transactions)
{
<tr>
<td class="text-nowrap">@t.TransactionDate.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yyyy h:mm tt")</td>
@if (!Model.InventoryItemId.HasValue)
{
<td>
<a asp-action="Ledger" asp-route-inventoryItemId="@t.InventoryItemId" class="text-decoration-none">
<span class="fw-semibold">@t.ItemName</span>
<br /><small class="text-muted">@t.SKU</small>
</a>
</td>
}
<td>
<span class="badge badge-txn-@t.TransactionType">@t.TransactionType</span>
</td>
<td class="text-end @(t.Quantity >= 0 ? "qty-positive" : "qty-negative")">
@(t.Quantity >= 0 ? "+" : "")@t.Quantity.ToString("N2")
</td>
<td class="text-end">@t.UnitCost.ToString("C")</td>
<td class="text-end">@t.TotalCost.ToString("C")</td>
<td class="text-end fw-semibold">@t.BalanceAfter.ToString("N2")</td>
<td class="text-nowrap">
@if (t.PurchaseOrderId.HasValue)
{
<a asp-controller="PurchaseOrders" asp-action="Details" asp-route-id="@t.PurchaseOrderId">
@(t.PurchaseOrderNumber ?? $"PO #{t.PurchaseOrderId}")
</a>
}
else if (t.JobId.HasValue)
{
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@t.JobId" class="text-decoration-none fw-semibold">
@(t.JobNumber ?? t.Reference ?? $"Job #{t.JobId}")
</a>
}
else if (!string.IsNullOrEmpty(t.Reference))
{
@t.Reference
}
else
{
<span class="text-muted">—</span>
}
</td>
<td><small class="text-muted">@t.Notes</small></td>
</tr>
}
</tbody>
</table>
</div>
@if (Model.Transactions.Count == 500)
{
<p class="text-muted small">Showing the 500 most recent transactions. Use filters to narrow results.</p>
}
}
</div>
@* ── Usage Tab ────────────────────────────────────────────────── *@
<div id="tab-usage" class="@(activeTab == "usage" ? "" : "d-none")">
@if (!Model.PowderUsageLogs.Any())
{
<div class="alert alert-info alert-permanent">
<i class="bi bi-info-circle me-2"></i>No powder usage records found for the selected filters.
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Job</th>
<th>Customer</th>
@if (!Model.InventoryItemId.HasValue)
{
<th>Powder</th>
}
<th>Color / Coat</th>
<th class="text-end">Estimated (lbs)</th>
<th class="text-end">Actual (lbs)</th>
<th class="text-end">Variance</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
@foreach (var u in Model.PowderUsageLogs)
{
var variance = u.VarianceLbs;
<tr>
<td class="text-nowrap">@u.RecordedAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yyyy")</td>
<td class="text-nowrap">
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@u.JobId"
class="text-decoration-none fw-semibold">
@u.JobNumber
</a>
</td>
<td>@u.CustomerName</td>
@if (!Model.InventoryItemId.HasValue)
{
<td>
@if (u.InventoryItemId.HasValue)
{
<a asp-action="Ledger" asp-route-inventoryItemId="@u.InventoryItemId" class="text-decoration-none">
<span class="fw-semibold">@u.ItemName</span>
<br /><small class="text-muted">@u.SKU</small>
</a>
}
else
{
<span class="text-muted fst-italic">Custom/External</span>
}
</td>
}
<td>@(u.CoatColor ?? "—")</td>
<td class="text-end">@u.EstimatedLbs.ToString("N3")</td>
<td class="text-end fw-semibold">@u.ActualLbsUsed.ToString("N3")</td>
<td class="text-end @(variance > 0 ? "variance-over" : variance < 0 ? "variance-under" : "")">
@(variance > 0 ? "+" : "")@variance.ToString("N3")
</td>
<td><small class="text-muted">@u.Notes</small></td>
</tr>
}
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td colspan="@(Model.InventoryItemId.HasValue ? 4 : 5)">Totals</td>
<td class="text-end">@Model.PowderUsageLogs.Sum(u => u.EstimatedLbs).ToString("N3")</td>
<td class="text-end">@Model.PowderUsageLogs.Sum(u => u.ActualLbsUsed).ToString("N3")</td>
<td class="text-end @(Model.PowderUsageLogs.Sum(u => u.VarianceLbs) > 0 ? "variance-over" : "variance-under")">
@(Model.PowderUsageLogs.Sum(u => u.VarianceLbs) > 0 ? "+" : "")@Model.PowderUsageLogs.Sum(u => u.VarianceLbs).ToString("N3")
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
@if (Model.PowderUsageLogs.Count == 500)
{
<p class="text-muted small">Showing the 500 most recent usage records. Use filters to narrow results.</p>
}
}
</div>
@section Scripts {
<script>
function switchTab(tab) {
document.getElementById('tab-transactions').classList.toggle('d-none', tab !== 'transactions');
document.getElementById('tab-usage').classList.toggle('d-none', tab !== 'usage');
document.querySelectorAll('#ledgerTabs .nav-link').forEach(el => el.classList.remove('active'));
event.currentTarget.classList.add('active');
// Update hidden tab field in filter form
document.querySelector('input[name="tab"]').value = tab;
}
</script>
}
@@ -0,0 +1,341 @@
@{
ViewData["Title"] = "Sample Panels";
ViewData["PageIcon"] = "bi-palette";
var manufacturers = ViewBag.Manufacturers as List<string> ?? new List<string>();
var selectedMfr = ViewBag.SelectedManufacturer as string;
var activeTab = ViewBag.ActiveTab as string ?? "need";
var onHand = ViewBag.OnHandItems as List<PowderCoating.Core.Entities.InventoryItem> ?? new();
var needOrder = ViewBag.NeedToOrderItems as List<PowderCoating.Core.Entities.InventoryItem> ?? new();
var totalCoatings = (int)(ViewBag.TotalCoatings ?? 0);
var totalOnHand = (int)(ViewBag.TotalOnHand ?? 0);
var totalNeedOrder = (int)(ViewBag.TotalNeedOrder ?? 0);
string? lastMfr = null;
}
@section Styles {
<style>
.panel-row { cursor: pointer; transition: background .12s; }
.panel-row:hover { background: var(--bs-tertiary-bg); }
.panel-row td { vertical-align: middle; }
.panel-badge-on { color: #198754; }
.panel-badge-need { color: #6c757d; }
.color-swatch {
width: 24px; height: 24px;
border-radius: 4px;
border: 1px solid var(--bs-border-color);
display: inline-block;
flex-shrink: 0;
}
</style>
}
<div class="d-flex justify-content-end align-items-center gap-2 mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-box-seam me-1"></i>Back to Inventory
</a>
<button class="btn btn-outline-primary" id="btnPrintList">
<i class="bi bi-printer me-1"></i>Print Need-to-Order
</button>
</div>
<!-- Stats -->
<div class="row g-3 mb-4">
<div class="col-sm-4">
<div class="card border-0 shadow-sm text-center py-3">
<div class="fs-2 fw-bold text-primary">@totalCoatings</div>
<div class="text-muted small">Total Coating Colors</div>
</div>
</div>
<div class="col-sm-4">
<div class="card border-0 shadow-sm text-center py-3">
<div class="fs-2 fw-bold text-success">@totalOnHand</div>
<div class="text-muted small">Panels on Wall</div>
</div>
</div>
<div class="col-sm-4">
<div class="card border-0 shadow-sm text-center py-3">
<div class="fs-2 fw-bold text-secondary">@totalNeedOrder</div>
<div class="text-muted small">Need to Order</div>
</div>
</div>
</div>
<!-- Manufacturer Filter -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-3">
<form method="get" class="d-flex gap-2 align-items-center flex-wrap">
<input type="hidden" name="tab" value="@activeTab" id="filterTabInput" />
<label class="form-label mb-0 fw-semibold me-1">Filter by Manufacturer:</label>
<select name="manufacturer" class="form-select form-select-sm w-auto" onchange="this.form.submit()">
<option value="">— All Manufacturers —</option>
@foreach (var mfr in manufacturers)
{
<option value="@mfr" selected="@(selectedMfr == mfr ? "selected" : null)">@mfr</option>
}
</select>
@if (!string.IsNullOrWhiteSpace(selectedMfr))
{
<a asp-action="SamplePanels" asp-route-tab="@activeTab" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x me-1"></i>Clear Filter
</a>
}
</form>
</div>
</div>
<!-- Tabs -->
<ul class="nav nav-tabs mb-0" id="samplePanelTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link @(activeTab == "need" ? "active" : "")" id="tab-need"
data-bs-toggle="tab" data-bs-target="#pane-need" type="button" role="tab"
onclick="document.getElementById('filterTabInput').value='need'">
<i class="bi bi-bag me-1 text-secondary"></i>
Need to Order
<span class="badge bg-secondary rounded-pill ms-1">@needOrder.Count</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(activeTab == "onhand" ? "active" : "")" id="tab-onhand"
data-bs-toggle="tab" data-bs-target="#pane-onhand" type="button" role="tab"
onclick="document.getElementById('filterTabInput').value='onhand'">
<i class="bi bi-check-circle me-1 text-success"></i>
On Wall
<span class="badge bg-success rounded-pill ms-1">@onHand.Count</span>
</button>
</li>
</ul>
<div class="tab-content">
<!-- Need to Order Tab -->
<div class="tab-pane fade @(activeTab == "need" ? "show active" : "")" id="pane-need" role="tabpanel">
<div class="card border-0 shadow-sm border-top-0" style="border-radius: 0 0 .5rem .5rem;">
<div class="card-body p-0">
@if (!needOrder.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-check2-all display-4 text-success d-block mb-2"></i>
@if (string.IsNullOrWhiteSpace(selectedMfr))
{
<p>All coating colors have a sample panel on the wall!</p>
}
else
{
<p>All <strong>@selectedMfr</strong> colors have a sample panel on the wall.</p>
}
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0" id="needTable">
<thead class="table-group-divider">
<tr>
<th style="width:36px;"></th>
<th>Color / Item</th>
<th>Manufacturer</th>
<th>Part #</th>
<th>Finish</th>
<th>In Stock</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody id="needTableBody">
@{ lastMfr = null; }
@foreach (var item in needOrder)
{
if (string.IsNullOrWhiteSpace(selectedMfr) && item.Manufacturer != lastMfr)
{
lastMfr = item.Manufacturer;
<tr class="table-secondary">
<td colspan="6" class="py-1 px-3">
<small class="fw-semibold text-uppercase text-muted">
@(string.IsNullOrWhiteSpace(item.Manufacturer) ? "No Manufacturer" : item.Manufacturer)
</small>
</td>
</tr>
}
<tr class="panel-row" data-id="@item.Id">
<td class="ps-3">
@if (!string.IsNullOrWhiteSpace(item.ColorCode))
{
<span class="color-swatch" style="background: @(item.ColorCode.StartsWith("#") ? item.ColorCode : "#" + item.ColorCode);"
title="@item.ColorCode"></span>
}
else
{
<span class="color-swatch bg-body-secondary"></span>
}
</td>
<td>
<div class="fw-semibold">@(item.ColorName ?? item.Name)</div>
@if (!string.IsNullOrWhiteSpace(item.ColorName) && item.ColorName != item.Name)
{
<div class="text-muted small">@item.Name</div>
}
</td>
<td>@(item.Manufacturer ?? "—")</td>
<td class="text-muted small">@(item.ManufacturerPartNumber ?? "—")</td>
<td>@(item.Finish ?? "—")</td>
<td>
@if (item.QuantityOnHand > 0)
{
<span class="badge bg-success bg-opacity-10 text-success">@item.QuantityOnHand.ToString("N2") @item.UnitOfMeasure</span>
}
else
{
<span class="text-muted small">None</span>
}
</td>
<td class="text-end pe-3">
<button class="btn btn-sm btn-outline-success me-1 btn-toggle-panel"
data-item-id="@item.Id" data-has-panel="true"
title="Mark as received — panel is on wall">
<i class="bi bi-check-lg me-1"></i>Got It
</button>
<a asp-action="Details" asp-route-id="@item.Id"
class="btn btn-sm btn-outline-secondary" title="View item">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
</div>
<!-- On Wall Tab -->
<div class="tab-pane fade @(activeTab == "onhand" ? "show active" : "")" id="pane-onhand" role="tabpanel">
<div class="card border-0 shadow-sm border-top-0" style="border-radius: 0 0 .5rem .5rem;">
<div class="card-body p-0">
@if (!onHand.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-palette display-4 d-block mb-2"></i>
<p>No sample panels recorded yet. Use the <strong>Need to Order</strong> tab to mark colors as received.</p>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-group-divider">
<tr>
<th style="width:36px;"></th>
<th>Color / Item</th>
<th>Manufacturer</th>
<th>Part #</th>
<th>Finish</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@{ lastMfr = null; }
@foreach (var item in onHand)
{
if (string.IsNullOrWhiteSpace(selectedMfr) && item.Manufacturer != lastMfr)
{
lastMfr = item.Manufacturer;
<tr class="table-secondary">
<td colspan="6" class="py-1 px-3">
<small class="fw-semibold text-uppercase text-muted">
@(string.IsNullOrWhiteSpace(item.Manufacturer) ? "No Manufacturer" : item.Manufacturer)
</small>
</td>
</tr>
}
<tr class="panel-row" data-id="@item.Id">
<td class="ps-3">
@if (!string.IsNullOrWhiteSpace(item.ColorCode))
{
<span class="color-swatch" style="background: @(item.ColorCode.StartsWith("#") ? item.ColorCode : "#" + item.ColorCode);"
title="@item.ColorCode"></span>
}
else
{
<span class="color-swatch bg-body-secondary"></span>
}
</td>
<td>
<div class="fw-semibold">@(item.ColorName ?? item.Name)</div>
@if (!string.IsNullOrWhiteSpace(item.ColorName) && item.ColorName != item.Name)
{
<div class="text-muted small">@item.Name</div>
}
</td>
<td>@(item.Manufacturer ?? "—")</td>
<td class="text-muted small">@(item.ManufacturerPartNumber ?? "—")</td>
<td>@(item.Finish ?? "—")</td>
<td class="text-end pe-3">
<button class="btn btn-sm btn-outline-danger me-1 btn-toggle-panel"
data-item-id="@item.Id" data-has-panel="false"
title="Remove — panel no longer on wall">
<i class="bi bi-x-lg me-1"></i>Remove
</button>
<a asp-action="Details" asp-route-id="@item.Id"
class="btn btn-sm btn-outline-secondary" title="View item">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
</div>
</div>
<!-- Print-only need-to-order output -->
<div id="printArea" style="display:none;">
<h3 style="font-family:sans-serif;">Sample Panels — Need to Order</h3>
@if (!string.IsNullOrWhiteSpace(selectedMfr))
{
<p style="font-family:sans-serif;font-size:.9rem;color:#666;">Manufacturer: @selectedMfr</p>
}
<p style="font-family:sans-serif;font-size:.85rem;color:#666;">Printed @DateTime.Now.ToString("MMMM dd, yyyy")</p>
<table style="width:100%;border-collapse:collapse;font-family:sans-serif;font-size:.85rem;">
<thead>
<tr style="background:#f0f0f0;">
<th style="border:1px solid #ccc;padding:6px 10px;text-align:left;">Color</th>
<th style="border:1px solid #ccc;padding:6px 10px;text-align:left;">Manufacturer</th>
<th style="border:1px solid #ccc;padding:6px 10px;text-align:left;">Part #</th>
<th style="border:1px solid #ccc;padding:6px 10px;text-align:left;">Finish</th>
<th style="border:1px solid #ccc;padding:6px 10px;text-align:left;">In Stock</th>
<th style="border:1px solid #ccc;padding:6px 10px;text-align:left;">Ordered ✓</th>
</tr>
</thead>
<tbody>
@{ lastMfr = null; }
@foreach (var item in needOrder)
{
if (string.IsNullOrWhiteSpace(selectedMfr) && item.Manufacturer != lastMfr)
{
lastMfr = item.Manufacturer;
<tr>
<td colspan="6" style="border:1px solid #ccc;padding:4px 10px;background:#f7f7f7;">
<strong>@(string.IsNullOrWhiteSpace(item.Manufacturer) ? "No Manufacturer" : item.Manufacturer)</strong>
</td>
</tr>
}
<tr>
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.ColorName ?? item.Name)</td>
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.Manufacturer ?? "")</td>
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.ManufacturerPartNumber ?? "")</td>
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.Finish ?? "")</td>
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.QuantityOnHand > 0 ? item.QuantityOnHand.ToString("N2") + " " + item.UnitOfMeasure : "—")</td>
<td style="border:1px solid #ccc;padding:6px 10px;">&nbsp;</td>
</tr>
}
</tbody>
</table>
</div>
@section Scripts {
<script src="~/js/sample-panels.js" asp-append-version="true"></script>
}
@@ -0,0 +1,400 @@
@using PowderCoating.Application.DTOs.Inventory
@using PowderCoating.Web.Controllers
@{
var item = ViewBag.ItemDto as InventoryItemDto;
var myJobs = ViewBag.MyJobs as List<ScanJobOption> ?? new();
var otherJobs = ViewBag.OtherJobs as List<ScanJobOption> ?? new();
var preselectedJobId = ViewBag.PreselectedJobId as int?;
var scanError = ViewBag.ScanError as string;
ViewData["Title"] = $"Log Usage — {item?.Name}";
Layout = null; // mobile-first standalone page
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>Log Usage — @item?.Name</title>
<style>
:root {
--purple: #6f42c1;
--purple-dark: #5a32a3;
--danger: #dc3545;
--success: #198754;
--muted: #6c757d;
--border: #dee2e6;
--bg: #f8f9fa;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
background: var(--bg);
min-height: 100vh;
padding-bottom: 32px;
}
/* ── Header ──────────────────────────────────── */
.page-header {
background: var(--purple);
color: #fff;
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
}
.page-header .logo { font-weight: 700; font-size: 13px; letter-spacing: .05em; opacity: .85; }
.page-header h1 { font-size: 18px; font-weight: 700; line-height: 1.2; }
.page-header .sub { font-size: 13px; opacity: .85; }
/* ── Item card ───────────────────────────────── */
.item-card {
background: #fff;
margin: 16px;
border-radius: 12px;
padding: 16px;
box-shadow: 0 1px 4px rgba(0,0,0,.1);
display: flex;
align-items: center;
gap: 14px;
}
.item-qr img { width: 70px; height: 70px; border-radius: 6px; border: 1px solid var(--border); }
.item-info .item-name { font-size: 16px; font-weight: 700; }
.item-info .item-sku { font-size: 13px; color: var(--muted); margin-top: 2px; }
.item-info .item-stock {
margin-top: 6px;
font-size: 13px;
font-weight: 600;
}
.stock-ok { color: var(--success); }
.stock-low { color: var(--danger); }
.stock-zero { color: #343a40; }
/* ── Form card ───────────────────────────────── */
.form-card {
background: #fff;
margin: 0 16px 16px;
border-radius: 12px;
padding: 20px 16px;
box-shadow: 0 1px 4px rgba(0,0,0,.1);
}
.form-card h2 { font-size: 15px; font-weight: 700; margin-bottom: 16px; color: #333; }
.field { margin-bottom: 18px; }
.field label {
display: block;
font-size: 13px;
font-weight: 600;
color: #444;
margin-bottom: 6px;
}
.field label .req { color: var(--danger); }
.field input[type=number],
.field select,
.field textarea {
width: 100%;
padding: 12px 14px;
border: 1.5px solid var(--border);
border-radius: 8px;
font-size: 16px; /* prevents iOS zoom */
background: #fff;
appearance: none;
-webkit-appearance: none;
}
.field input[type=number]:focus,
.field select:focus,
.field textarea:focus {
outline: none;
border-color: var(--purple);
box-shadow: 0 0 0 3px rgba(111,66,193,.15);
}
/* ── Job picker ──────────────────────────────── */
.job-tabs { display: flex; gap: 8px; margin-bottom: 12px; }
.job-tab {
flex: 1;
padding: 8px;
border: 1.5px solid var(--border);
border-radius: 8px;
background: #fff;
font-size: 13px;
font-weight: 600;
color: var(--muted);
cursor: pointer;
text-align: center;
}
.job-tab.active { border-color: var(--purple); color: var(--purple); background: #f3effe; }
.job-list { display: flex; flex-direction: column; gap: 8px; max-height: 220px; overflow-y: auto; }
.job-option {
padding: 10px 12px;
border: 1.5px solid var(--border);
border-radius: 8px;
cursor: pointer;
background: #fff;
display: flex;
flex-direction: column;
gap: 2px;
}
.job-option:hover, .job-option.selected { border-color: var(--purple); background: #f3effe; }
.job-option .jn { font-size: 14px; font-weight: 700; }
.job-option .cn { font-size: 12px; color: var(--muted); }
.no-job-opt {
padding: 10px 12px;
border: 1.5px dashed var(--border);
border-radius: 8px;
cursor: pointer;
background: #fff;
font-size: 13px;
color: var(--muted);
text-align: center;
}
.no-job-opt.selected { border-color: var(--muted); background: #f8f9fa; color: #333; }
#jobIdInput { display: none; }
/* ── Reason pills ────────────────────────────── */
.reason-pills { display: flex; flex-wrap: wrap; gap: 8px; }
.reason-pill {
padding: 8px 14px;
border: 1.5px solid var(--border);
border-radius: 20px;
font-size: 13px;
cursor: pointer;
background: #fff;
color: #444;
white-space: nowrap;
}
.reason-pill.selected { border-color: var(--purple); background: #f3effe; color: var(--purple); font-weight: 600; }
/* ── Submit ──────────────────────────────────── */
.btn-submit {
width: 100%;
padding: 16px;
background: var(--purple);
color: #fff;
border: none;
border-radius: 10px;
font-size: 17px;
font-weight: 700;
cursor: pointer;
margin-top: 4px;
}
.btn-submit:disabled { opacity: .6; }
.btn-submit:active { background: var(--purple-dark); }
.error-banner {
margin: 0 16px 16px;
background: #f8d7da;
color: #842029;
border: 1px solid #f5c2c7;
border-radius: 8px;
padding: 12px 14px;
font-size: 14px;
}
.hint { font-size: 12px; color: var(--muted); margin-top: 4px; }
</style>
</head>
<body>
<div class="page-header">
<div>
<div class="logo">Powder Coating Logix</div>
<h1>Log Usage</h1>
<div class="sub">Record powder used from inventory</div>
</div>
</div>
@if (!string.IsNullOrEmpty(scanError))
{
<div class="error-banner">⚠ @scanError</div>
}
<!-- Item Info -->
<div class="item-card">
<div class="item-qr">
<img src="/Inventory/QrCode/@item!.Id?size=4" alt="QR" />
</div>
<div class="item-info">
<div class="item-name">@item.Name</div>
<div class="item-sku">@item.SKU</div>
@if (!string.IsNullOrEmpty(item.ColorName))
{
<div class="item-sku">@item.ColorName@(item.Finish != null ? " · " + item.Finish : "")</div>
}
<div class="item-stock @(item.IsOutOfStock ? "stock-zero" : item.IsLowStock ? "stock-low" : "stock-ok")">
@item.QuantityOnHand.ToString("N2") @item.UnitOfMeasure on hand
@if (item.IsOutOfStock) { <span>· Out of Stock</span> }
else if (item.IsLowStock) { <span>· Low Stock</span> }
</div>
</div>
</div>
<!-- Usage Form -->
<form method="post" action="/Inventory/LogUsage" id="usageForm">
@Html.AntiForgeryToken()
<input type="hidden" name="inventoryItemId" value="@item.Id" />
<input type="hidden" name="jobId" id="jobIdInput" />
<input type="hidden" name="transactionType" id="transactionTypeInput" value="Adjustment" />
<div class="form-card">
<h2>1. Select Job (optional)</h2>
@if (myJobs.Any() || otherJobs.Any())
{
<div class="job-tabs">
@if (myJobs.Any())
{
<div class="job-tab active" id="tabMine" onclick="showTab('mine')">My Jobs (@myJobs.Count)</div>
}
@if (otherJobs.Any())
{
<div class="job-tab @(!myJobs.Any() ? "active" : "")" id="tabOther" onclick="showTab('other')">Other Jobs</div>
}
<div class="job-tab" id="tabNone" onclick="showTab('none')">No Job</div>
</div>
<div id="listMine" class="job-list" style="@(!myJobs.Any() ? "display:none" : "")">
@foreach (var j in myJobs)
{
<div class="job-option @(preselectedJobId == j.Id ? "selected" : "")"
data-jobid="@j.Id" onclick="selectJob(this)">
<span class="jn">@j.JobNumber</span>
<span class="cn">@j.CustomerName</span>
</div>
}
</div>
<div id="listOther" class="job-list" style="display:none">
@foreach (var j in otherJobs)
{
<div class="job-option" data-jobid="@j.Id" onclick="selectJob(this)">
<span class="jn">@j.JobNumber</span>
<span class="cn">@j.CustomerName</span>
</div>
}
</div>
<div id="listNone" style="display:none">
<div class="no-job-opt selected" onclick="selectNoJob(this)">
No job — log as general usage
</div>
</div>
}
else
{
<p style="font-size:13px;color:var(--muted)">No active jobs found. Usage will be logged without a job reference.</p>
}
<div class="hint" id="jobHint" style="margin-top:8px"></div>
</div>
<div class="form-card">
<h2>2. Enter Quantity</h2>
<div class="field">
<label for="quantityInput">Amount Used (@item.UnitOfMeasure) <span class="req">*</span></label>
<input type="number" id="quantityInput" name="quantity"
min="0" step="any" required placeholder="0" inputmode="decimal" />
<div class="hint" id="balanceHint"></div>
</div>
</div>
<div class="form-card">
<h2>3. Reason</h2>
<div class="field">
<div class="reason-pills">
<div class="reason-pill selected" data-val="JobUsage" onclick="selectReason(this)">Job Usage</div>
<div class="reason-pill" data-val="Waste" onclick="selectReason(this)">Waste / Spillage</div>
<div class="reason-pill" data-val="Adjustment" onclick="selectReason(this)">Correction</div>
<div class="reason-pill" data-val="Transfer" onclick="selectReason(this)">Transfer Out</div>
</div>
</div>
<div class="field">
<label for="notesInput">Notes <span style="font-weight:400;color:var(--muted)">(optional)</span></label>
<textarea id="notesInput" name="notes" rows="2"
placeholder="Any additional details…" maxlength="500"
style="font-size:16px;resize:none"></textarea>
</div>
</div>
<div style="margin: 0 16px">
<button type="submit" class="btn-submit" id="submitBtn">
Save Usage Log
</button>
</div>
</form>
<script>
var currentQty = @item.QuantityOnHand;
var uom = '@item.UnitOfMeasure';
// ── Job selection ────────────────────────────────
function showTab(tab) {
['mine','other','none'].forEach(function(t) {
var list = document.getElementById('list' + t.charAt(0).toUpperCase() + t.slice(1));
var tabEl = document.getElementById('tab' + t.charAt(0).toUpperCase() + t.slice(1));
if (list) list.style.display = t === tab ? '' : 'none';
if (tabEl) tabEl.classList.toggle('active', t === tab);
});
if (tab === 'none') {
document.getElementById('jobIdInput').value = '';
document.getElementById('jobHint').textContent = 'Usage will be logged without a job reference.';
} else {
document.getElementById('jobIdInput').value = '';
document.getElementById('jobHint').textContent = '';
}
}
function selectJob(el) {
document.querySelectorAll('.job-option').forEach(function(e) { e.classList.remove('selected'); });
el.classList.add('selected');
document.getElementById('jobIdInput').value = el.dataset.jobid;
document.getElementById('jobHint').textContent = 'Job selected: ' + el.querySelector('.jn').textContent;
}
function selectNoJob(el) {
document.querySelectorAll('.job-option').forEach(function(e) { e.classList.remove('selected'); });
document.getElementById('jobIdInput').value = '';
document.getElementById('jobHint').textContent = 'Usage will be logged without a job reference.';
}
// ── Reason selection ─────────────────────────────
function selectReason(el) {
document.querySelectorAll('.reason-pill').forEach(function(e) { e.classList.remove('selected'); });
el.classList.add('selected');
document.getElementById('transactionTypeInput').value = el.dataset.val;
}
// ── Balance hint ─────────────────────────────────
document.getElementById('quantityInput').addEventListener('input', function() {
var qty = parseFloat(this.value) || 0;
if (!this.value) { document.getElementById('balanceHint').textContent = ''; return; }
var newBal = currentQty - qty;
var col = newBal < 0 ? 'var(--danger)' : newBal === 0 ? '#343a40' : 'var(--success)';
document.getElementById('balanceHint').innerHTML =
'New balance: <strong style="color:' + col + '">' + newBal.toFixed(2) + ' ' + uom + '</strong>';
});
// ── Preselect job if coming from success page ────
@if (preselectedJobId.HasValue)
{
<text>
(function() {
var preId = '@preselectedJobId';
var el = document.querySelector('[data-jobid="' + preId + '"]');
if (el) { selectJob(el); el.scrollIntoView({block:'nearest'}); }
}());
</text>
}
// ── Submit spinner ───────────────────────────────
document.getElementById('usageForm').addEventListener('submit', function() {
var btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = 'Saving…';
});
</script>
</body>
</html>
@@ -0,0 +1,117 @@
@{
var message = ViewBag.Message as string;
var itemId = ViewBag.ItemId as string;
var jobId = ViewBag.JobId as string;
var itemName = ViewBag.ItemName as string;
ViewData["Title"] = "Usage Logged";
Layout = null;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>Usage Logged</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
background: #f8f9fa;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 20px;
text-align: center;
}
.check-circle {
width: 80px; height: 80px;
background: #198754;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
margin: 0 auto 20px;
font-size: 40px;
color: #fff;
}
h1 { font-size: 24px; font-weight: 700; color: #198754; margin-bottom: 10px; }
.msg {
font-size: 15px;
color: #444;
margin-bottom: 32px;
line-height: 1.5;
max-width: 320px;
}
.actions { display: flex; flex-direction: column; gap: 12px; width: 100%; max-width: 320px; }
.btn {
padding: 15px;
border-radius: 10px;
border: none;
font-size: 16px;
font-weight: 700;
cursor: pointer;
text-decoration: none;
display: block;
text-align: center;
}
.btn-primary { background: #6f42c1; color: #fff; }
.btn-outline {
background: #fff;
color: #6f42c1;
border: 2px solid #6f42c1;
}
.btn-muted {
background: #fff;
color: #6c757d;
border: 2px solid #dee2e6;
font-size: 14px;
padding: 12px;
}
.logo { font-size: 11px; color: #aaa; margin-top: 32px; letter-spacing: .05em; }
</style>
</head>
<body>
<div class="check-circle">✓</div>
<h1>Usage Logged!</h1>
@if (!string.IsNullOrEmpty(message))
{
<p class="msg">@message</p>
}
<div class="actions">
@if (!string.IsNullOrEmpty(itemId))
{
@if (!string.IsNullOrEmpty(jobId))
{
<a class="btn btn-primary"
href="/Inventory/Scan/@itemId?jobId=@jobId">
Log Another Item for This Job
</a>
}
else
{
<a class="btn btn-primary" href="/Inventory/Scan/@itemId">
Log Another for This Item
</a>
}
}
<a class="btn btn-outline" href="/Inventory">
Back to Inventory
</a>
@if (!string.IsNullOrEmpty(itemId))
{
<a class="btn btn-muted" href="/Inventory/Details/@itemId">
View Item Details
</a>
}
</div>
<div class="logo">Powder Coating Logix</div>
</body>
</html>
@@ -0,0 +1,480 @@
<style>
.color-family-chip {
background: #e9ecef;
color: #495057;
border: 1.5px solid #ced4da;
transition: background .15s, color .15s, border-color .15s;
user-select: none;
}
.color-family-chip.active {
background: #0d6efd;
color: #fff;
border-color: #0a58ca;
}
.color-family-chip[data-family="Red"] { border-left: 4px solid #dc3545; }
.color-family-chip[data-family="Orange"] { border-left: 4px solid #fd7e14; }
.color-family-chip[data-family="Yellow"] { border-left: 4px solid #ffc107; }
.color-family-chip[data-family="Green"] { border-left: 4px solid #198754; }
.color-family-chip[data-family="Blue"] { border-left: 4px solid #0d6efd; }
.color-family-chip[data-family="Purple"] { border-left: 4px solid #6f42c1; }
.color-family-chip[data-family="Pink"] { border-left: 4px solid #d63384; }
.color-family-chip[data-family="Brown"] { border-left: 4px solid #795548; }
.color-family-chip[data-family="Black"] { border-left: 4px solid #212529; }
.color-family-chip[data-family="White"] { border-left: 4px solid #adb5bd; }
.color-family-chip[data-family="Gray"] { border-left: 4px solid #6c757d; }
.color-family-chip[data-family="Silver"] { border-left: 4px solid #b0bec5; }
.color-family-chip[data-family="Gold"] { border-left: 4px solid #ffd700; }
.color-family-chip[data-family="Bronze"] { border-left: 4px solid #cd7f32; }
.color-family-chip[data-family="Copper"] { border-left: 4px solid #b87333; }
.color-family-chip[data-family="Clear"] { border-left: 4px solid #adb5bd; }
</style>
<script>
(function () {
const isCreateForm = typeof inventoryFormIsCreate !== 'undefined' && inventoryFormIsCreate;
// ── Category → IsCoating map + show/hide coating section ─────────────
const categorySelect = document.getElementById('field-category');
const coatingSection = document.getElementById('coating-specs-section');
const aiBtn = document.getElementById('ai-lookup-btn');
let coatingMap = {};
if (categorySelect && categorySelect.dataset.coatingMap) {
try { coatingMap = JSON.parse(categorySelect.dataset.coatingMap); } catch {}
}
function isCoatingCategory(catId) {
return catId && coatingMap[String(catId)] === true;
}
const coatingOnlyFields = ['wrap-colorname', 'wrap-colorcode', 'wrap-finish', 'wrap-coverage', 'wrap-transfer'];
const colorNameLabel = document.querySelector('#wrap-colorname label');
function updateCoatingVisibility(catId) {
const show = isCoatingCategory(catId);
if (coatingSection) coatingSection.style.display = show ? '' : 'none';
if (aiBtn) aiBtn.style.display = show ? '' : 'none';
const samplePanelSection = document.getElementById('sample-panel-section');
if (samplePanelSection) samplePanelSection.style.display = show ? '' : 'none';
coatingOnlyFields.forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = show ? '' : 'none';
});
// In coating mode the Color Name field doubles as the item name
const wrapName = document.getElementById('wrap-name');
if (wrapName) wrapName.style.display = show ? 'none' : '';
// Move Description: after Product URL for coatings, after Name for everything else
const wrapDesc = document.getElementById('wrap-description');
const wrapSpecUrl = document.getElementById('wrap-specpageurl');
if (wrapDesc && wrapSpecUrl && wrapName) {
if (show) {
wrapSpecUrl.insertAdjacentElement('afterend', wrapDesc);
} else {
wrapName.insertAdjacentElement('afterend', wrapDesc);
}
}
if (colorNameLabel) {
colorNameLabel.innerHTML = show
? 'Color Name <span class="text-danger">*</span> <small class="text-muted fw-normal">(used as item name)</small>'
: 'Color Name';
}
}
// Initialise visibility on page load
updateCoatingVisibility(categorySelect?.value);
if (categorySelect) {
categorySelect.addEventListener('change', () => {
updateCoatingVisibility(categorySelect.value);
if (isCreateForm) autoGenerateSku(categorySelect.value);
});
}
// When submitting a coating item, always use the Color Name as the item Name
const form = categorySelect?.closest('form');
if (form) {
form.addEventListener('submit', () => {
if (isCoatingCategory(categorySelect?.value) && colorNameEl && nameEl) {
const color = colorNameEl.value.trim();
if (color) nameEl.value = color;
}
});
}
// ── Auto SKU generation (Create form only) ────────────────────────────
const skuInput = document.getElementById('field-sku');
const regenBtn = document.getElementById('btn-regen-sku');
let lastAutoSku = ''; // track the last auto-generated value so we don't overwrite user edits
async function autoGenerateSku(catId) {
if (!isCreateForm || !skuInput || !catId) return;
// Only auto-fill if the field is empty OR still holds the previous auto-generated value
const current = skuInput.value.trim();
if (current && current !== lastAutoSku) return;
try {
const resp = await fetch(`/Inventory/GenerateSku?categoryId=${catId}`);
const data = await resp.json();
if (data.sku) {
skuInput.value = data.sku;
lastAutoSku = data.sku;
if (regenBtn) regenBtn.style.display = '';
}
} catch {}
}
if (regenBtn) {
regenBtn.addEventListener('click', async () => {
const catId = categorySelect?.value;
if (!catId) return;
lastAutoSku = ''; // force re-fill even if user edited it
skuInput.value = '';
await autoGenerateSku(catId);
});
}
// If category is already selected on load (e.g. validation error re-render), generate SKU
if (isCreateForm && categorySelect?.value && !skuInput?.value?.trim()) {
autoGenerateSku(categorySelect.value);
}
// ── Coating auto-compose: Name = Manufacturer + Color Name ───────────
const nameEl = document.getElementById('field-name');
const manufacturerEl = document.getElementById('field-manufacturer');
const colorNameEl = document.getElementById('field-colorname');
const vendorSel = document.getElementById('field-vendor');
let lastAutoName = ''; // track last auto-composed name so user edits aren't overwritten
// ── AI bad-match / force-refill tracking ──────────────────────────────
let aiFilledFields = []; // element IDs filled by last AI run (inputs/textareas)
let aiFilledColorFamilies = false;
let aiFilledVendor = false;
let aiFilledClearCoat = false;
let forceRefill = false; // set true for bad-match retry
function autoComposeName() {
if (!isCoatingCategory(categorySelect?.value)) return;
const color = colorNameEl?.value?.trim() ?? '';
if (!color) return;
const current = nameEl?.value?.trim() ?? '';
if (current === '' || current === lastAutoName) {
if (nameEl) nameEl.value = color;
lastAutoName = color;
}
}
function autoMatchVendor() {
if (!isCoatingCategory(categorySelect?.value)) return;
if (!vendorSel || vendorSel.value) return; // don't overwrite an existing selection
const mfr = (manufacturerEl?.value?.trim() ?? '').toLowerCase();
if (!mfr) return;
const match = Array.from(vendorSel.options).find(o =>
o.text.toLowerCase().includes(mfr) || mfr.includes(o.text.toLowerCase().trim())
);
if (match) vendorSel.value = match.value;
}
if (manufacturerEl) {
manufacturerEl.addEventListener('input', autoMatchVendor);
}
if (colorNameEl) {
colorNameEl.addEventListener('input', autoComposeName);
}
// ── Spec page URL: keep open-link button in sync with input ──────────
const specUrlInput = document.getElementById('field-specpageurl');
const specUrlLink = document.getElementById('field-specpageurl-link');
if (specUrlInput && specUrlLink) {
specUrlInput.addEventListener('input', () => {
const v = specUrlInput.value.trim();
if (v) { specUrlLink.href = v; specUrlLink.classList.remove('d-none'); }
else { specUrlLink.classList.add('d-none'); }
});
// Initialise on load (Edit page with existing value)
if (specUrlInput.value.trim()) {
specUrlLink.href = specUrlInput.value.trim();
specUrlLink.classList.remove('d-none');
}
}
// ── Color family chip toggle ──────────────────────────────────────────
const hiddenInput = document.getElementById('field-colorfamilies');
const chips = document.querySelectorAll('.color-family-chip');
function getSelected() {
return (hiddenInput.value || '').split(',').map(s => s.trim()).filter(Boolean);
}
function setSelected(arr) {
hiddenInput.value = arr.join(',');
chips.forEach(chip => {
chip.classList.toggle('active', arr.includes(chip.dataset.family));
});
}
setSelected(getSelected()); // initialise from existing value (Edit page)
chips.forEach(chip => {
chip.addEventListener('click', () => {
const fam = chip.dataset.family;
const sel = getSelected();
const idx = sel.indexOf(fam);
if (idx >= 0) sel.splice(idx, 1); else sel.push(fam);
setSelected(sel);
});
});
// ── AI Lookup ─────────────────────────────────────────────────────────
const btn = document.getElementById('ai-lookup-btn');
const statusEl = document.getElementById('ai-lookup-status');
if (!btn) return;
function showBadMatchBtn() {
if (document.getElementById('ai-bad-match-btn')) return; // already shown
const b = document.createElement('button');
b.type = 'button';
b.id = 'ai-bad-match-btn';
b.className = 'btn btn-sm btn-outline-warning ms-2';
b.innerHTML = '<i class="bi bi-arrow-repeat me-1"></i>Wrong match?';
b.addEventListener('click', () => {
// Clear all AI-filled fields
aiFilledFields.forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
if (aiFilledColorFamilies) { setSelected([]); aiFilledColorFamilies = false; }
if (aiFilledVendor) {
const vs = document.getElementById('field-vendor');
if (vs) vs.value = '';
aiFilledVendor = false;
}
if (aiFilledClearCoat) {
const cc = document.getElementById('field-clearcoat');
if (cc) cc.checked = false;
aiFilledClearCoat = false;
}
aiFilledFields = [];
lastAutoName = '';
forceRefill = false;
b.remove();
// If manufacturer is blank, prompt the user to fill it in for a better result
const mfrEl = document.getElementById('field-manufacturer');
if (!mfrEl?.value?.trim()) {
showStatus('info', '<i class="bi bi-person-fill me-1"></i><strong>Fields cleared.</strong> Enter the <strong>Manufacturer</strong> name for a more accurate lookup, then click <em>AI Lookup</em> again.');
mfrEl?.focus();
} else {
showStatus('info', '<i class="bi bi-check-circle me-1"></i>Fields cleared. Update any details above and click <em>AI Lookup</em> again.');
}
});
btn.insertAdjacentElement('afterend', b);
}
function hideBadMatchBtn() {
document.getElementById('ai-bad-match-btn')?.remove();
}
btn.addEventListener('click', async () => {
const manufacturer = document.getElementById('field-manufacturer')?.value?.trim() || '';
const colorName = document.getElementById('field-colorname')?.value?.trim() || '';
const colorCode = document.getElementById('field-colorcode')?.value?.trim() || '';
const partNumber = document.getElementById('field-partnumber')?.value?.trim() || '';
// Fall back to the item Name field so the user can just type a name and hit AI Lookup
const itemName = document.getElementById('field-name')?.value?.trim() || '';
const hasInput = manufacturer || colorName || colorCode || partNumber || itemName;
if (!hasInput) {
showWarning(
'Fill in at least one of: Manufacturer, Color Name, Color Code, Part Number, or the item Name field — then try again.',
'AI Lookup needs more info'
);
return;
}
// Always fall back to the item Name field if the Color Name field is blank
const effectiveColorName = colorName || itemName;
hideBadMatchBtn();
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Looking up...';
showInfo('Searching for product specifications…', 'AI Lookup');
try {
const formData = new FormData();
formData.append('manufacturer', manufacturer);
formData.append('colorName', effectiveColorName);
formData.append('colorCode', colorCode);
formData.append('partNumber', partNumber);
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
if (token) formData.append('__RequestVerificationToken', token);
const resp = await fetch('/Inventory/AiLookup', { method: 'POST', body: formData });
const data = await resp.json();
console.log('[AI Lookup] Raw response:', data);
if (!data.success) {
showError('AI lookup failed: ' + (data.errorMessage || 'Unknown error'), 'AI Lookup Failed');
showStatus('danger', 'AI lookup failed: ' + (data.errorMessage || 'Unknown error'));
return;
}
const filled = [];
const fillIf = (id, value, label) => {
const el = document.getElementById(id);
if (!el) return;
const v = (value !== null && value !== undefined) ? String(value).trim() : '';
if (v && (forceRefill || !el.value.trim())) {
el.value = v;
filled.push(label);
if (!aiFilledFields.includes(id)) aiFilledFields.push(id);
}
};
const fillTextareaIf = (id, value, label) => {
const el = document.getElementById(id);
if (!el) return;
const v = (value !== null && value !== undefined) ? String(value).trim() : '';
if (v && (forceRefill || !el.value.trim())) {
el.value = v;
filled.push(label);
if (!aiFilledFields.includes(id)) aiFilledFields.push(id);
}
};
// Identity
fillIf('field-manufacturer', data.manufacturer, 'Manufacturer');
fillIf('field-partnumber', data.manufacturerPartNumber, 'Part Number');
fillIf('field-colorname', data.colorName, 'Color Name');
fillIf('field-colorcode', data.colorCode, 'Color Code');
fillTextareaIf('field-description', data.description, 'Description');
// Auto-compose item name if blank (or force-refill on bad-match retry)
const nameEl = document.getElementById('field-name');
if (nameEl && (forceRefill || !nameEl.value.trim()) && data.colorName) {
nameEl.value = data.colorName;
lastAutoName = nameEl.value;
filled.push('Name');
if (!aiFilledFields.includes('field-name')) aiFilledFields.push('field-name');
}
// Spec page URL — fill if blank, and update the open-link button
if (data.specPageUrl && (forceRefill || !document.getElementById('field-specpageurl')?.value?.trim())) {
const urlEl = document.getElementById('field-specpageurl');
const linkEl = document.getElementById('field-specpageurl-link');
if (urlEl) {
urlEl.value = data.specPageUrl;
filled.push('Product URL');
if (!aiFilledFields.includes('field-specpageurl')) aiFilledFields.push('field-specpageurl');
}
if (linkEl) { linkEl.href = data.specPageUrl; linkEl.classList.remove('d-none'); }
}
// Product details
fillIf('field-finish', data.finish, 'Finish');
fillIf('field-coverage', data.coverageSqFtPerLb, 'Coverage');
fillIf('field-transfer', data.transferEfficiency, 'Transfer Efficiency');
// Coating specs
fillIf('field-curetemp', data.cureTemperatureF, 'Cure Temp');
fillIf('field-curetime', data.cureTimeMinutes, 'Cure Time');
if (data.requiresClearCoat !== null && data.requiresClearCoat !== undefined) {
const cc = document.getElementById('field-clearcoat');
if (cc) { cc.checked = data.requiresClearCoat; filled.push('Clear Coat'); aiFilledClearCoat = true; }
}
if (data.colorFamilies && (forceRefill || getSelected().length === 0)) {
setSelected(data.colorFamilies.split(',').map(s => s.trim()).filter(Boolean));
filled.push('Color Families');
aiFilledColorFamilies = true;
}
// Vendor: match by name (case-insensitive) against dropdown options
if (data.vendorName) {
const vendorSel = document.getElementById('field-vendor');
if (vendorSel && (forceRefill || !vendorSel.value)) {
const needle = data.vendorName.toLowerCase();
const match = Array.from(vendorSel.options).find(o =>
o.text.toLowerCase().includes(needle) || needle.includes(o.text.toLowerCase().trim())
);
if (match) { vendorSel.value = match.value; filled.push('Vendor'); aiFilledVendor = true; }
}
}
// Unit cost: fill if currently blank or zero (or force-refill)
if (data.unitCostPerLb !== null && data.unitCostPerLb !== undefined) {
const costEl = document.getElementById('field-unitcost');
if (costEl) {
const cur = parseFloat(costEl.value) || 0;
if (forceRefill || cur === 0) {
costEl.value = data.unitCostPerLb;
filled.push('Unit Cost');
if (!aiFilledFields.includes('field-unitcost')) aiFilledFields.push('field-unitcost');
}
}
}
// Build a persistent "needs more info" tip if key identity fields are still unknown
const missingHints = [];
if (!data.manufacturer && !document.getElementById('field-manufacturer')?.value?.trim())
missingHints.push('a <strong>Manufacturer</strong>');
if (!data.cureTemperatureF && !data.cureTimeMinutes)
missingHints.push('a more specific <strong>Color Name</strong> or <strong>Part Number</strong>');
const tipHtml = missingHints.length > 0
? `<div class="mt-2 p-2 border border-warning rounded bg-warning bg-opacity-10 small">
<i class="bi bi-lightbulb me-1 text-warning"></i>
<strong>Tip:</strong> For better results, try adding ${missingHints.join(' and ')} then click <em>AI Lookup</em> again.
</div>`
: '';
// Show "Wrong match?" button next to AI Lookup on any successful response
showBadMatchBtn();
if (filled.length > 0) {
const msg = `Auto-filled: ${filled.join(', ')}.`;
showSuccess(msg, 'AI Lookup Complete');
const reasoning = data.reasoning
? ` <span class="text-muted fst-italic">${data.reasoning}</span>`
: '';
showStatus('success', msg + reasoning + tipHtml + debugPanel(data));
} else {
showWarning('No new fields were filled — they may already be populated, or the product wasn\'t found online.', 'AI Lookup');
showStatus('warning', 'No new fields to fill.' + tipHtml + debugPanel(data));
}
} catch (err) {
showError('Request failed: ' + err.message, 'AI Lookup Error');
showStatus('danger', 'Request failed: ' + err.message);
} finally {
forceRefill = false; // reset after each run
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-stars me-1"></i>AI Lookup';
}
});
function debugPanel(data) {
const json = JSON.stringify(data, null, 2);
return `
<div class="mt-2">
<a class="text-muted small" href="#" onclick="this.nextElementSibling.classList.toggle('d-none');return false;">
<i class="bi bi-code-slash me-1"></i>Show raw AI response
</a>
<pre class="d-none mt-1 p-2 bg-light border rounded small text-start" style="font-size:.72rem;max-height:200px;overflow:auto;">${json}</pre>
</div>`;
}
function showStatus(type, msg) {
statusEl.className = `alert alert-${type} py-2 small mb-3`;
statusEl.innerHTML = msg;
statusEl.classList.remove('d-none');
}
})();
</script>
@@ -0,0 +1,58 @@
@model List<PowderCoating.Core.Entities.Job>
@if (!Model.Any())
{
<div class="text-center py-4 text-muted">
<i class="bi bi-clipboard-x" style="font-size: 2rem;"></i>
<p class="mt-2 mb-0">No jobs have used this powder yet.</p>
</div>
}
else
{
<p class="text-muted small mb-3">@Model.Count job@(Model.Count == 1 ? "" : "s") found using this powder.</p>
<div class="list-group list-group-flush">
@foreach (var job in Model)
{
<div class="list-group-item px-0">
<div class="d-flex gap-3 align-items-start">
@if (job.Photos.Any())
{
<a href="@Url.Action("Details", "Jobs", new { id = job.Id })" class="flex-shrink-0">
<img src="@Url.Action("GetPhoto", "Jobs", new { id = job.Photos.OrderBy(p => p.CreatedAt).First().Id })"
alt="Job photo"
style="width:64px;height:64px;object-fit:cover;border-radius:6px;" />
</a>
}
else
{
<div class="flex-shrink-0 d-flex align-items-center justify-content-center bg-light rounded"
style="width:64px;height:64px;">
<i class="bi bi-image text-muted" style="font-size:1.5rem;"></i>
</div>
}
<div class="flex-grow-1 min-width-0">
<div class="d-flex justify-content-between align-items-start gap-2 flex-wrap">
<a href="@Url.Action("Details", "Jobs", new { id = job.Id })"
class="fw-semibold text-decoration-none">@job.JobNumber</a>
<span class="badge bg-secondary text-white small">
@job.JobStatus?.DisplayName
</span>
</div>
<div class="text-muted small mt-1">@job.Customer?.CompanyName</div>
<div class="d-flex gap-3 mt-1">
<span class="text-muted small">
<i class="bi bi-calendar3 me-1"></i>@job.CreatedAt.ToString("MMM dd, yyyy")
</span>
@if (job.Photos.Any())
{
<span class="text-muted small">
<i class="bi bi-camera me-1"></i>@job.Photos.Count photo@(job.Photos.Count == 1 ? "" : "s")
</span>
}
</div>
</div>
</div>
</div>
}
</div>
}