Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Inventory/Details.cshtml
T
spouliot 14f220347b Add scheme failsafe to all inventory URL link buttons
If a stored URL is missing http:// or https:// the browser treats it as relative
and appends it to the app URL. Guard in three places:

- inventory-catalog-lookup.js syncLinkButton: ensureAbsoluteUrl() prepends https://
- inventory-label-scan.js syncLink: same guard for scan-filled URL fields
- Details.cshtml SafeUrl() Razor helper on SpecPageUrl, SdsUrl, TdsUrl links

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 17:38:09 -04:00

927 lines
52 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@model PowderCoating.Application.DTOs.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 &mdash; 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.";
string SafeUrl(string? url) =>
string.IsNullOrEmpty(url) ? "#"
: (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
? url : "https://" + url;
}
@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="@SafeUrl(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.SdsUrl) || !string.IsNullOrEmpty(Model.TdsUrl))
{
<div class="col-12">
<label class="text-muted small mb-1">Data Sheets</label>
<div class="d-flex gap-2 flex-wrap">
@if (!string.IsNullOrEmpty(Model.SdsUrl))
{
<a href="@SafeUrl(Model.SdsUrl)" target="_blank" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-earmark-pdf me-1"></i>Safety Data Sheet
</a>
}
@if (!string.IsNullOrEmpty(Model.TdsUrl))
{
<a href="@SafeUrl(Model.TdsUrl)" target="_blank" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-earmark-text me-1"></i>Technical Data Sheet
</a>
}
</div>
</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 ?? "&mdash;")</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 ?? "&mdash;")</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">
@if (!string.IsNullOrWhiteSpace(Model.ImageUrl))
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3 text-center">
<a href="#" data-bs-toggle="modal" data-bs-target="#imageModal" title="Click to enlarge" style="cursor:zoom-in;">
<img src="@Model.ImageUrl" alt="@Model.Name"
style="max-width:100%;max-height:200px;object-fit:contain;" />
</a>
<div class="text-muted small mt-1"><i class="bi bi-zoom-in me-1"></i>Click to enlarge</div>
</div>
</div>
<!-- Image Lightbox Modal -->
<div class="modal fade" id="imageModal" tabindex="-1" aria-label="Product image">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content border-0 bg-transparent shadow-none">
<div class="modal-body p-0 text-center position-relative">
<button type="button" class="btn-close btn-close-white position-absolute top-0 end-0 m-2" data-bs-dismiss="modal" aria-label="Close"></button>
<img src="@Model.ImageUrl" alt="@Model.Name"
style="max-width:100%;max-height:85vh;object-fit:contain;border-radius:6px;" />
</div>
</div>
</div>
</div>
}
<!-- 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 ?? "&mdash;")</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="">&mdash; Select a reason &mdash;</option>
<optgroup label="Adding Stock">
<option value="Received from purchase order">Received from purchase order</option>
<option value="Physical count &mdash; found extra">Physical count &mdash; 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 &mdash; shortage found">Physical count &mdash; 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&hellip;" 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-outline-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-outline-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&hellip;';
});
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 + '&ndash;' + 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>
}