Compare commits

...

5 Commits

Author SHA1 Message Date
5cc088ea6b feat: Add Stock Items UI and update Supplier offerings
- Add Stock Items index page listing all stock items
- Add Stock Items edit page with supplier offerings management
- Update Suppliers edit page to manage offerings (select from stock
  items instead of material+length)
- Add Stock Items navigation link to sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:32:32 -05:00
6797d1e4fd feat: Update service layer for new stock model
- Add StockItemService for CRUD operations on stock items
- Update SupplierService to manage SupplierOfferings instead of
  SupplierStock (GetOfferingsForSupplierAsync, AddOfferingAsync, etc.)
- Update CutListPackingService to use StockItems for available lengths
- Register StockItemService in Program.cs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:32:25 -05:00
c4fc88f7d2 chore: Add migration for StockItem and SupplierOffering
Migration drops SupplierStocks table and creates StockItems and
SupplierOfferings tables with appropriate indexes and foreign keys.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:32:20 -05:00
9929d82768 refactor: Replace SupplierStock with StockItem/SupplierOffering model
- Remove SupplierStock entity
- Update Material navigation from SupplierStocks to StockItems
- Update Supplier navigation from Stocks to Offerings
- Update ApplicationDbContext with new DbSets and configurations
- Add unique constraints: StockItem(MaterialId, LengthInches) and
  SupplierOffering(SupplierId, StockItemId)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:32:14 -05:00
0ded77ce8b feat: Add StockItem and SupplierOffering entities
Introduce new data model that separates stock catalog (StockItem) from
supplier-specific pricing/catalog info (SupplierOffering). StockItem
represents a Material+Length combination, while SupplierOffering links
suppliers to stock items with part numbers, descriptions, and pricing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:32:07 -05:00
16 changed files with 1430 additions and 178 deletions

View File

@@ -23,6 +23,11 @@
<span class="bi bi-box-nav-menu" aria-hidden="true"></span> Materials <span class="bi bi-box-nav-menu" aria-hidden="true"></span> Materials
</NavLink> </NavLink>
</div> </div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="stock">
<span class="bi bi-boxes-nav-menu" aria-hidden="true"></span> Stock Items
</NavLink>
</div>
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="suppliers"> <NavLink class="nav-link" href="suppliers">
<span class="bi bi-building-nav-menu" aria-hidden="true"></span> Suppliers <span class="bi bi-building-nav-menu" aria-hidden="true"></span> Suppliers

View File

@@ -0,0 +1,371 @@
@page "/stock/new"
@page "/stock/{Id:int}"
@inject StockItemService StockItemService
@inject MaterialService MaterialService
@inject SupplierService SupplierService
@inject NavigationManager Navigation
@using CutList.Core.Formatting
<PageTitle>@(IsNew ? "Add Stock Item" : "Edit Stock Item")</PageTitle>
<h1>@(IsNew ? "Add Stock Item" : $"{stockItem.Material?.DisplayName} - {ArchUnits.FormatFromInches((double)stockItem.LengthInches)}")</h1>
@if (loading)
{
<p><em>Loading...</em></p>
}
else
{
<div class="row">
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Stock Item Details</h5>
</div>
<div class="card-body">
<EditForm Model="stockItem" OnValidSubmit="SaveStockItemAsync">
<DataAnnotationsValidator />
<div class="mb-3">
<label class="form-label">Material</label>
<select class="form-select" @bind="stockItem.MaterialId" disabled="@(!IsNew)">
<option value="0">-- Select Material --</option>
@foreach (var material in materials)
{
<option value="@material.Id">@material.DisplayName</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Length</label>
@if (IsNew)
{
<LengthInput @bind-Value="stockItem.LengthInches" />
}
else
{
<input type="text" class="form-control" value="@ArchUnits.FormatFromInches((double)stockItem.LengthInches)" readonly />
}
</div>
<div class="mb-3">
<label class="form-label">Name (optional)</label>
<InputText class="form-control" @bind-Value="stockItem.Name" placeholder="Custom display name" />
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger">@errorMessage</div>
}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@saving">
@if (saving)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
@(IsNew ? "Create Stock Item" : "Save Changes")
</button>
<a href="stock" class="btn btn-outline-secondary">@(IsNew ? "Cancel" : "Back to List")</a>
</div>
</EditForm>
</div>
</div>
</div>
@if (!IsNew)
{
<div class="col-lg-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Supplier Offerings</h5>
<button class="btn btn-sm btn-primary" @onclick="ShowAddOfferingForm">Add Offering</button>
</div>
<div class="card-body">
@if (showOfferingForm)
{
<div class="border rounded p-3 mb-3 bg-light">
<h6>@(editingOffering == null ? "Add Offering" : "Edit Offering")</h6>
<div class="row g-2">
<div class="col-12">
<label class="form-label">Supplier</label>
<select class="form-select" @bind="newOffering.SupplierId">
<option value="0">-- Select Supplier --</option>
@foreach (var supplier in suppliers)
{
<option value="@supplier.Id">@supplier.Name</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Part Number</label>
<InputText class="form-control" @bind-Value="newOffering.PartNumber" />
</div>
<div class="col-md-6">
<label class="form-label">Price</label>
<InputNumber class="form-control" @bind-Value="newOffering.Price" placeholder="0.00" />
</div>
<div class="col-12">
<label class="form-label">Supplier Description</label>
<InputText class="form-control" @bind-Value="newOffering.SupplierDescription" />
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<InputText class="form-control" @bind-Value="newOffering.Notes" />
</div>
</div>
@if (!string.IsNullOrEmpty(offeringErrorMessage))
{
<div class="alert alert-danger mt-2 mb-0">@offeringErrorMessage</div>
}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary btn-sm" @onclick="SaveOfferingAsync" disabled="@savingOffering">
@if (savingOffering)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
@(editingOffering == null ? "Add" : "Save")
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelOfferingForm">Cancel</button>
</div>
</div>
}
@if (offerings.Count == 0)
{
<p class="text-muted">No supplier offerings configured yet.</p>
}
else
{
<table class="table table-sm">
<thead>
<tr>
<th>Supplier</th>
<th>Part #</th>
<th>Price</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var offering in offerings)
{
<tr>
<td>@offering.Supplier.Name</td>
<td>@(offering.PartNumber ?? "-")</td>
<td>@(offering.Price.HasValue ? offering.Price.Value.ToString("C") : "-")</td>
<td>
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditOffering(offering)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDeleteOffering(offering)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</div>
}
</div>
}
<ConfirmDialog @ref="deleteOfferingDialog"
Title="Delete Offering"
Message="@deleteOfferingMessage"
ConfirmText="Delete"
OnConfirm="DeleteOfferingConfirmed" />
@code {
[Parameter]
public int? Id { get; set; }
private StockItem stockItem = new();
private List<Material> materials = new();
private List<Supplier> suppliers = new();
private List<SupplierOffering> offerings = new();
private bool loading = true;
private bool saving;
private bool savingOffering;
private string? errorMessage;
private string? offeringErrorMessage;
private bool showOfferingForm;
private SupplierOffering newOffering = new();
private SupplierOffering? editingOffering;
private ConfirmDialog deleteOfferingDialog = null!;
private SupplierOffering? offeringToDelete;
private string deleteOfferingMessage = "";
private bool IsNew => !Id.HasValue;
protected override async Task OnInitializedAsync()
{
materials = await MaterialService.GetAllAsync();
suppliers = await SupplierService.GetAllAsync();
if (Id.HasValue)
{
var existing = await StockItemService.GetByIdAsync(Id.Value);
if (existing == null)
{
Navigation.NavigateTo("stock");
return;
}
stockItem = existing;
offerings = existing.SupplierOfferings.Where(o => o.IsActive).ToList();
}
loading = false;
}
private async Task SaveStockItemAsync()
{
errorMessage = null;
saving = true;
try
{
if (stockItem.MaterialId == 0)
{
errorMessage = "Please select a material";
return;
}
if (stockItem.LengthInches <= 0)
{
errorMessage = "Length must be greater than zero";
return;
}
var exists = await StockItemService.ExistsAsync(
stockItem.MaterialId,
stockItem.LengthInches,
IsNew ? null : stockItem.Id);
if (exists)
{
errorMessage = "A stock item with this material and length already exists";
return;
}
if (IsNew)
{
var created = await StockItemService.CreateAsync(stockItem);
Navigation.NavigateTo($"stock/{created.Id}");
}
else
{
await StockItemService.UpdateAsync(stockItem);
}
}
finally
{
saving = false;
}
}
private void ShowAddOfferingForm()
{
editingOffering = null;
newOffering = new SupplierOffering { StockItemId = Id!.Value };
showOfferingForm = true;
offeringErrorMessage = null;
}
private void EditOffering(SupplierOffering offering)
{
editingOffering = offering;
newOffering = new SupplierOffering
{
Id = offering.Id,
StockItemId = offering.StockItemId,
SupplierId = offering.SupplierId,
PartNumber = offering.PartNumber,
SupplierDescription = offering.SupplierDescription,
Price = offering.Price,
Notes = offering.Notes
};
showOfferingForm = true;
offeringErrorMessage = null;
}
private void CancelOfferingForm()
{
showOfferingForm = false;
editingOffering = null;
offeringErrorMessage = null;
}
private async Task SaveOfferingAsync()
{
offeringErrorMessage = null;
savingOffering = true;
try
{
if (newOffering.SupplierId == 0)
{
offeringErrorMessage = "Please select a supplier";
return;
}
var exists = await SupplierService.OfferingExistsAsync(
newOffering.SupplierId,
newOffering.StockItemId,
editingOffering?.Id);
if (exists)
{
offeringErrorMessage = "This supplier already has an offering for this stock item";
return;
}
if (editingOffering == null)
{
await SupplierService.AddOfferingAsync(newOffering);
}
else
{
await SupplierService.UpdateOfferingAsync(newOffering);
}
// Refresh the stock item to get updated offerings
var updated = await StockItemService.GetByIdAsync(Id!.Value);
if (updated != null)
{
stockItem = updated;
offerings = updated.SupplierOfferings.Where(o => o.IsActive).ToList();
}
showOfferingForm = false;
editingOffering = null;
}
finally
{
savingOffering = false;
}
}
private void ConfirmDeleteOffering(SupplierOffering offering)
{
offeringToDelete = offering;
deleteOfferingMessage = $"Are you sure you want to delete the offering from {offering.Supplier.Name}?";
deleteOfferingDialog.Show();
}
private async Task DeleteOfferingConfirmed()
{
if (offeringToDelete != null)
{
await SupplierService.DeleteOfferingAsync(offeringToDelete.Id);
// Refresh the stock item to get updated offerings
var updated = await StockItemService.GetByIdAsync(Id!.Value);
if (updated != null)
{
stockItem = updated;
offerings = updated.SupplierOfferings.Where(o => o.IsActive).ToList();
}
}
}
}

View File

@@ -0,0 +1,85 @@
@page "/stock"
@inject StockItemService StockItemService
@inject NavigationManager Navigation
@using CutList.Core.Formatting
<PageTitle>Stock Items</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>Stock Items</h1>
<a href="stock/new" class="btn btn-primary">Add Stock Item</a>
</div>
@if (loading)
{
<p><em>Loading...</em></p>
}
else if (stockItems.Count == 0)
{
<div class="alert alert-info">
No stock items found. <a href="stock/new">Add your first stock item</a>.
</div>
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Material</th>
<th>Length</th>
<th>Name</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in stockItems)
{
<tr>
<td>@item.Material.DisplayName</td>
<td>@ArchUnits.FormatFromInches((double)item.LengthInches)</td>
<td>@(item.Name ?? "-")</td>
<td>
<a href="stock/@item.Id" class="btn btn-sm btn-outline-primary">Edit</a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(item)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
<ConfirmDialog @ref="deleteDialog"
Title="Delete Stock Item"
Message="@deleteMessage"
ConfirmText="Delete"
OnConfirm="DeleteConfirmed" />
@code {
private List<StockItem> stockItems = new();
private bool loading = true;
private ConfirmDialog deleteDialog = null!;
private StockItem? itemToDelete;
private string deleteMessage = "";
protected override async Task OnInitializedAsync()
{
stockItems = await StockItemService.GetAllAsync();
loading = false;
}
private void ConfirmDelete(StockItem item)
{
itemToDelete = item;
deleteMessage = $"Are you sure you want to delete \"{item.Material.DisplayName} - {ArchUnits.FormatFromInches((double)item.LengthInches)}\"?";
deleteDialog.Show();
}
private async Task DeleteConfirmed()
{
if (itemToDelete != null)
{
await StockItemService.DeleteAsync(itemToDelete.Id);
stockItems = await StockItemService.GetAllAsync();
}
}
}

View File

@@ -1,9 +1,10 @@
@page "/suppliers/new" @page "/suppliers/new"
@page "/suppliers/{Id:int}" @page "/suppliers/{Id:int}"
@inject SupplierService SupplierService @inject SupplierService SupplierService
@inject MaterialService MaterialService
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject CutList.Web.Data.ApplicationDbContext DbContext
@using CutList.Core.Formatting @using CutList.Core.Formatting
@using Microsoft.EntityFrameworkCore
<PageTitle>@(IsNew ? "Add Supplier" : "Edit Supplier")</PageTitle> <PageTitle>@(IsNew ? "Add Supplier" : "Edit Supplier")</PageTitle>
@@ -66,80 +67,84 @@ else
<div class="col-lg-6"> <div class="col-lg-6">
<div class="card"> <div class="card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Stock Lengths</h5> <h5 class="mb-0">Supplier Offerings</h5>
<button class="btn btn-sm btn-primary" @onclick="ShowAddStockForm">Add Stock</button> <button class="btn btn-sm btn-primary" @onclick="ShowAddOfferingForm">Add Offering</button>
</div> </div>
<div class="card-body"> <div class="card-body">
@if (showStockForm) @if (showOfferingForm)
{ {
<div class="border rounded p-3 mb-3 bg-light"> <div class="border rounded p-3 mb-3 bg-light">
<h6>@(editingStock == null ? "Add Stock Length" : "Edit Stock Length")</h6> <h6>@(editingOffering == null ? "Add Offering" : "Edit Offering")</h6>
<div class="row g-2"> <div class="row g-2">
<div class="col-md-6"> <div class="col-12">
<label class="form-label">Material</label> <label class="form-label">Stock Item</label>
<select class="form-select" @bind="newStock.MaterialId"> <select class="form-select" @bind="newOffering.StockItemId">
<option value="0">-- Select Material --</option> <option value="0">-- Select Stock Item --</option>
@foreach (var material in materials) @foreach (var stockItem in stockItems)
{ {
<option value="@material.Id">@material.DisplayName</option> <option value="@stockItem.Id">@stockItem.Material.DisplayName - @ArchUnits.FormatFromInches((double)stockItem.LengthInches)</option>
} }
</select> </select>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Length</label> <label class="form-label">Part Number</label>
<LengthInput @bind-Value="newStock.LengthInches" /> <InputText class="form-control" @bind-Value="newOffering.PartNumber" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Price (optional)</label> <label class="form-label">Price</label>
<InputNumber class="form-control" @bind-Value="newStock.Price" placeholder="0.00" /> <InputNumber class="form-control" @bind-Value="newOffering.Price" placeholder="0.00" />
</div> </div>
<div class="col-md-6"> <div class="col-12">
<label class="form-label">Supplier Description</label>
<InputText class="form-control" @bind-Value="newOffering.SupplierDescription" />
</div>
<div class="col-12">
<label class="form-label">Notes</label> <label class="form-label">Notes</label>
<InputText class="form-control" @bind-Value="newStock.Notes" /> <InputText class="form-control" @bind-Value="newOffering.Notes" />
</div> </div>
</div> </div>
@if (!string.IsNullOrEmpty(stockErrorMessage)) @if (!string.IsNullOrEmpty(offeringErrorMessage))
{ {
<div class="alert alert-danger mt-2 mb-0">@stockErrorMessage</div> <div class="alert alert-danger mt-2 mb-0">@offeringErrorMessage</div>
} }
<div class="mt-3 d-flex gap-2"> <div class="mt-3 d-flex gap-2">
<button class="btn btn-primary btn-sm" @onclick="SaveStockAsync" disabled="@savingStock"> <button class="btn btn-primary btn-sm" @onclick="SaveOfferingAsync" disabled="@savingOffering">
@if (savingStock) @if (savingOffering)
{ {
<span class="spinner-border spinner-border-sm me-1"></span> <span class="spinner-border spinner-border-sm me-1"></span>
} }
@(editingStock == null ? "Add" : "Save") @(editingOffering == null ? "Add" : "Save")
</button> </button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelStockForm">Cancel</button> <button class="btn btn-outline-secondary btn-sm" @onclick="CancelOfferingForm">Cancel</button>
</div> </div>
</div> </div>
} }
@if (stocks.Count == 0) @if (offerings.Count == 0)
{ {
<p class="text-muted">No stock lengths configured yet.</p> <p class="text-muted">No offerings configured yet.</p>
} }
else else
{ {
<table class="table table-sm"> <table class="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Material</th> <th>Stock Item</th>
<th>Length</th> <th>Part #</th>
<th>Price</th> <th>Price</th>
<th style="width: 100px;">Actions</th> <th style="width: 100px;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var stock in stocks) @foreach (var offering in offerings)
{ {
<tr> <tr>
<td>@stock.Material.DisplayName</td> <td>@offering.StockItem.Material.DisplayName - @ArchUnits.FormatFromInches((double)offering.StockItem.LengthInches)</td>
<td>@ArchUnits.FormatFromInches((double)stock.LengthInches)</td> <td>@(offering.PartNumber ?? "-")</td>
<td>@(stock.Price.HasValue ? stock.Price.Value.ToString("C") : "-")</td> <td>@(offering.Price.HasValue ? offering.Price.Value.ToString("C") : "-")</td>
<td> <td>
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditStock(stock)">Edit</button> <button class="btn btn-sm btn-outline-primary" @onclick="() => EditOffering(offering)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDeleteStock(stock)">Delete</button> <button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDeleteOffering(offering)">Delete</button>
</td> </td>
</tr> </tr>
} }
@@ -153,38 +158,44 @@ else
</div> </div>
} }
<ConfirmDialog @ref="deleteStockDialog" <ConfirmDialog @ref="deleteOfferingDialog"
Title="Delete Stock Length" Title="Delete Offering"
Message="@deleteStockMessage" Message="@deleteOfferingMessage"
ConfirmText="Delete" ConfirmText="Delete"
OnConfirm="DeleteStockConfirmed" /> OnConfirm="DeleteOfferingConfirmed" />
@code { @code {
[Parameter] [Parameter]
public int? Id { get; set; } public int? Id { get; set; }
private Supplier supplier = new(); private Supplier supplier = new();
private List<SupplierStock> stocks = new(); private List<SupplierOffering> offerings = new();
private List<Material> materials = new(); private List<StockItem> stockItems = new();
private bool loading = true; private bool loading = true;
private bool savingSupplier; private bool savingSupplier;
private bool savingStock; private bool savingOffering;
private string? supplierErrorMessage; private string? supplierErrorMessage;
private string? stockErrorMessage; private string? offeringErrorMessage;
private bool showStockForm; private bool showOfferingForm;
private SupplierStock newStock = new(); private SupplierOffering newOffering = new();
private SupplierStock? editingStock; private SupplierOffering? editingOffering;
private ConfirmDialog deleteStockDialog = null!; private ConfirmDialog deleteOfferingDialog = null!;
private SupplierStock? stockToDelete; private SupplierOffering? offeringToDelete;
private string deleteStockMessage = ""; private string deleteOfferingMessage = "";
private bool IsNew => !Id.HasValue; private bool IsNew => !Id.HasValue;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
materials = await MaterialService.GetAllAsync(); stockItems = await DbContext.StockItems
.Include(si => si.Material)
.Where(si => si.IsActive)
.OrderBy(si => si.Material.Shape)
.ThenBy(si => si.Material.Size)
.ThenBy(si => si.LengthInches)
.ToListAsync();
if (Id.HasValue) if (Id.HasValue)
{ {
@@ -195,7 +206,7 @@ else
return; return;
} }
supplier = existing; supplier = existing;
stocks = await SupplierService.GetStocksForSupplierAsync(Id.Value); offerings = await SupplierService.GetOfferingsForSupplierAsync(Id.Value);
} }
loading = false; loading = false;
} }
@@ -229,100 +240,94 @@ else
} }
} }
private void ShowAddStockForm() private void ShowAddOfferingForm()
{ {
editingStock = null; editingOffering = null;
newStock = new SupplierStock { SupplierId = Id!.Value }; newOffering = new SupplierOffering { SupplierId = Id!.Value };
showStockForm = true; showOfferingForm = true;
stockErrorMessage = null; offeringErrorMessage = null;
} }
private void EditStock(SupplierStock stock) private void EditOffering(SupplierOffering offering)
{ {
editingStock = stock; editingOffering = offering;
newStock = new SupplierStock newOffering = new SupplierOffering
{ {
Id = stock.Id, Id = offering.Id,
SupplierId = stock.SupplierId, SupplierId = offering.SupplierId,
MaterialId = stock.MaterialId, StockItemId = offering.StockItemId,
LengthInches = stock.LengthInches, PartNumber = offering.PartNumber,
Price = stock.Price, SupplierDescription = offering.SupplierDescription,
Notes = stock.Notes Price = offering.Price,
Notes = offering.Notes
}; };
showStockForm = true; showOfferingForm = true;
stockErrorMessage = null; offeringErrorMessage = null;
} }
private void CancelStockForm() private void CancelOfferingForm()
{ {
showStockForm = false; showOfferingForm = false;
editingStock = null; editingOffering = null;
stockErrorMessage = null; offeringErrorMessage = null;
} }
private async Task SaveStockAsync() private async Task SaveOfferingAsync()
{ {
stockErrorMessage = null; offeringErrorMessage = null;
savingStock = true; savingOffering = true;
try try
{ {
if (newStock.MaterialId == 0) if (newOffering.StockItemId == 0)
{ {
stockErrorMessage = "Please select a material"; offeringErrorMessage = "Please select a stock item";
return; return;
} }
if (newStock.LengthInches <= 0) var exists = await SupplierService.OfferingExistsAsync(
{ newOffering.SupplierId,
stockErrorMessage = "Length must be greater than zero"; newOffering.StockItemId,
return; editingOffering?.Id);
}
var exists = await SupplierService.StockExistsAsync(
newStock.SupplierId,
newStock.MaterialId,
newStock.LengthInches,
editingStock?.Id);
if (exists) if (exists)
{ {
stockErrorMessage = "This stock length already exists for this material"; offeringErrorMessage = "This supplier already has an offering for this stock item";
return; return;
} }
if (editingStock == null) if (editingOffering == null)
{ {
await SupplierService.AddStockAsync(newStock); await SupplierService.AddOfferingAsync(newOffering);
} }
else else
{ {
await SupplierService.UpdateStockAsync(newStock); await SupplierService.UpdateOfferingAsync(newOffering);
} }
stocks = await SupplierService.GetStocksForSupplierAsync(Id!.Value); offerings = await SupplierService.GetOfferingsForSupplierAsync(Id!.Value);
showStockForm = false; showOfferingForm = false;
editingStock = null; editingOffering = null;
} }
finally finally
{ {
savingStock = false; savingOffering = false;
} }
} }
private void ConfirmDeleteStock(SupplierStock stock) private void ConfirmDeleteOffering(SupplierOffering offering)
{ {
stockToDelete = stock; offeringToDelete = offering;
deleteStockMessage = $"Are you sure you want to delete the {ArchUnits.FormatFromInches((double)stock.LengthInches)} stock length?"; deleteOfferingMessage = $"Are you sure you want to delete the offering for {offering.StockItem.Material.DisplayName} - {ArchUnits.FormatFromInches((double)offering.StockItem.LengthInches)}?";
deleteStockDialog.Show(); deleteOfferingDialog.Show();
} }
private async Task DeleteStockConfirmed() private async Task DeleteOfferingConfirmed()
{ {
if (stockToDelete != null) if (offeringToDelete != null)
{ {
await SupplierService.DeleteStockAsync(stockToDelete.Id); await SupplierService.DeleteOfferingAsync(offeringToDelete.Id);
stocks = await SupplierService.GetStocksForSupplierAsync(Id!.Value); offerings = await SupplierService.GetOfferingsForSupplierAsync(Id!.Value);
} }
} }
} }

View File

@@ -13,7 +13,8 @@ public class ApplicationDbContext : DbContext
public DbSet<Material> Materials => Set<Material>(); public DbSet<Material> Materials => Set<Material>();
public DbSet<MaterialStockLength> MaterialStockLengths => Set<MaterialStockLength>(); public DbSet<MaterialStockLength> MaterialStockLengths => Set<MaterialStockLength>();
public DbSet<Supplier> Suppliers => Set<Supplier>(); public DbSet<Supplier> Suppliers => Set<Supplier>();
public DbSet<SupplierStock> SupplierStocks => Set<SupplierStock>(); public DbSet<StockItem> StockItems => Set<StockItem>();
public DbSet<SupplierOffering> SupplierOfferings => Set<SupplierOffering>();
public DbSet<CuttingTool> CuttingTools => Set<CuttingTool>(); public DbSet<CuttingTool> CuttingTools => Set<CuttingTool>();
public DbSet<Project> Projects => Set<Project>(); public DbSet<Project> Projects => Set<Project>();
public DbSet<ProjectPart> ProjectParts => Set<ProjectPart>(); public DbSet<ProjectPart> ProjectParts => Set<ProjectPart>();
@@ -56,25 +57,42 @@ public class ApplicationDbContext : DbContext
entity.Property(e => e.CreatedAt).HasDefaultValueSql("GETUTCDATE()"); entity.Property(e => e.CreatedAt).HasDefaultValueSql("GETUTCDATE()");
}); });
// SupplierStock // StockItem
modelBuilder.Entity<SupplierStock>(entity => modelBuilder.Entity<StockItem>(entity =>
{ {
entity.HasKey(e => e.Id); entity.HasKey(e => e.Id);
entity.Property(e => e.LengthInches).HasPrecision(10, 4); entity.Property(e => e.LengthInches).HasPrecision(10, 4);
entity.Property(e => e.Price).HasPrecision(10, 2); entity.Property(e => e.Name).HasMaxLength(100);
entity.Property(e => e.Notes).HasMaxLength(255); entity.Property(e => e.CreatedAt).HasDefaultValueSql("GETUTCDATE()");
entity.HasOne(e => e.Supplier)
.WithMany(s => s.Stocks)
.HasForeignKey(e => e.SupplierId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Material) entity.HasOne(e => e.Material)
.WithMany(m => m.SupplierStocks) .WithMany(m => m.StockItems)
.HasForeignKey(e => e.MaterialId) .HasForeignKey(e => e.MaterialId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(e => new { e.SupplierId, e.MaterialId, e.LengthInches }).IsUnique(); entity.HasIndex(e => new { e.MaterialId, e.LengthInches }).IsUnique();
});
// SupplierOffering
modelBuilder.Entity<SupplierOffering>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.PartNumber).HasMaxLength(100);
entity.Property(e => e.SupplierDescription).HasMaxLength(255);
entity.Property(e => e.Price).HasPrecision(10, 2);
entity.Property(e => e.Notes).HasMaxLength(255);
entity.HasOne(e => e.StockItem)
.WithMany(s => s.SupplierOfferings)
.HasForeignKey(e => e.StockItemId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Supplier)
.WithMany(s => s.Offerings)
.HasForeignKey(e => e.SupplierId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(e => new { e.SupplierId, e.StockItemId }).IsUnique();
}); });
// CuttingTool // CuttingTool

View File

@@ -10,7 +10,7 @@ public class Material
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
public ICollection<SupplierStock> SupplierStocks { get; set; } = new List<SupplierStock>(); public ICollection<StockItem> StockItems { get; set; } = new List<StockItem>();
public ICollection<MaterialStockLength> StockLengths { get; set; } = new List<MaterialStockLength>(); public ICollection<MaterialStockLength> StockLengths { get; set; } = new List<MaterialStockLength>();
public ICollection<ProjectPart> ProjectParts { get; set; } = new List<ProjectPart>(); public ICollection<ProjectPart> ProjectParts { get; set; } = new List<ProjectPart>();

View File

@@ -0,0 +1,15 @@
namespace CutList.Web.Data.Entities;
public class StockItem
{
public int Id { get; set; }
public int MaterialId { get; set; }
public decimal LengthInches { get; set; }
public string? Name { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public Material Material { get; set; } = null!;
public ICollection<SupplierOffering> SupplierOfferings { get; set; } = new List<SupplierOffering>();
}

View File

@@ -9,5 +9,5 @@ public class Supplier
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<SupplierStock> Stocks { get; set; } = new List<SupplierStock>(); public ICollection<SupplierOffering> Offerings { get; set; } = new List<SupplierOffering>();
} }

View File

@@ -1,15 +1,16 @@
namespace CutList.Web.Data.Entities; namespace CutList.Web.Data.Entities;
public class SupplierStock public class SupplierOffering
{ {
public int Id { get; set; } public int Id { get; set; }
public int StockItemId { get; set; }
public int SupplierId { get; set; } public int SupplierId { get; set; }
public int MaterialId { get; set; } public string? PartNumber { get; set; }
public decimal LengthInches { get; set; } public string? SupplierDescription { get; set; }
public decimal? Price { get; set; } public decimal? Price { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public StockItem StockItem { get; set; } = null!;
public Supplier Supplier { get; set; } = null!; public Supplier Supplier { get; set; } = null!;
public Material Material { get; set; } = null!;
} }

View File

@@ -0,0 +1,452 @@
// <auto-generated />
using System;
using CutList.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CutList.Web.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260203032915_AddStockItemAndSupplierOffering")]
partial class AddStockItemAndSupplierOffering
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDefault")
.HasColumnType("bit");
b.Property<decimal>("KerfInches")
.HasPrecision(6, 4)
.HasColumnType("decimal(6,4)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.HasKey("Id");
b.ToTable("CuttingTools");
b.HasData(
new
{
Id = 1,
IsActive = true,
IsDefault = true,
KerfInches = 0.0625m,
Name = "Bandsaw"
},
new
{
Id = 2,
IsActive = true,
IsDefault = false,
KerfInches = 0.125m,
Name = "Chop Saw"
},
new
{
Id = 3,
IsActive = true,
IsDefault = false,
KerfInches = 0.0625m,
Name = "Cold Cut Saw"
},
new
{
Id = 4,
IsActive = true,
IsDefault = false,
KerfInches = 0.0625m,
Name = "Hacksaw"
});
});
modelBuilder.Entity("CutList.Web.Data.Entities.Material", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<string>("Description")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Shape")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Size")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.ToTable("Materials");
});
modelBuilder.Entity("CutList.Web.Data.Entities.MaterialStockLength", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<decimal>("LengthInches")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<int>("MaterialId")
.HasColumnType("int");
b.Property<string>("Notes")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MaterialId", "LengthInches")
.IsUnique();
b.ToTable("MaterialStockLengths");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Project", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<string>("Customer")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int?>("CuttingToolId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("CuttingToolId");
b.ToTable("Projects");
});
modelBuilder.Entity("CutList.Web.Data.Entities.ProjectPart", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("LengthInches")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<int>("MaterialId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("ProjectId")
.HasColumnType("int");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MaterialId");
b.HasIndex("ProjectId");
b.ToTable("ProjectParts");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<decimal>("LengthInches")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<int>("MaterialId")
.HasColumnType("int");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("MaterialId", "LengthInches")
.IsUnique();
b.ToTable("StockItems");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ContactInfo")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Suppliers");
});
modelBuilder.Entity("CutList.Web.Data.Entities.SupplierOffering", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Notes")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("PartNumber")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<decimal?>("Price")
.HasPrecision(10, 2)
.HasColumnType("decimal(10,2)");
b.Property<int>("StockItemId")
.HasColumnType("int");
b.Property<string>("SupplierDescription")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<int>("SupplierId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("StockItemId");
b.HasIndex("SupplierId", "StockItemId")
.IsUnique();
b.ToTable("SupplierOfferings");
});
modelBuilder.Entity("CutList.Web.Data.Entities.MaterialStockLength", b =>
{
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
.WithMany("StockLengths")
.HasForeignKey("MaterialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Material");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Project", b =>
{
b.HasOne("CutList.Web.Data.Entities.CuttingTool", "CuttingTool")
.WithMany("Projects")
.HasForeignKey("CuttingToolId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("CuttingTool");
});
modelBuilder.Entity("CutList.Web.Data.Entities.ProjectPart", b =>
{
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
.WithMany("ProjectParts")
.HasForeignKey("MaterialId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Project", "Project")
.WithMany("Parts")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Material");
b.Navigation("Project");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
.WithMany("StockItems")
.HasForeignKey("MaterialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Material");
});
modelBuilder.Entity("CutList.Web.Data.Entities.SupplierOffering", b =>
{
b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem")
.WithMany("SupplierOfferings")
.HasForeignKey("StockItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Supplier", "Supplier")
.WithMany("Offerings")
.HasForeignKey("SupplierId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("StockItem");
b.Navigation("Supplier");
});
modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
{
b.Navigation("Projects");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Material", b =>
{
b.Navigation("ProjectParts");
b.Navigation("StockItems");
b.Navigation("StockLengths");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Project", b =>
{
b.Navigation("Parts");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.Navigation("SupplierOfferings");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b =>
{
b.Navigation("Offerings");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,141 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CutList.Web.Migrations
{
/// <inheritdoc />
public partial class AddStockItemAndSupplierOffering : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SupplierStocks");
migrationBuilder.CreateTable(
name: "StockItems",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
MaterialId = table.Column<int>(type: "int", nullable: false),
LengthInches = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()"),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_StockItems", x => x.Id);
table.ForeignKey(
name: "FK_StockItems_Materials_MaterialId",
column: x => x.MaterialId,
principalTable: "Materials",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SupplierOfferings",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
StockItemId = table.Column<int>(type: "int", nullable: false),
SupplierId = table.Column<int>(type: "int", nullable: false),
PartNumber = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
SupplierDescription = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
Price = table.Column<decimal>(type: "decimal(10,2)", precision: 10, scale: 2, nullable: true),
Notes = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SupplierOfferings", x => x.Id);
table.ForeignKey(
name: "FK_SupplierOfferings_StockItems_StockItemId",
column: x => x.StockItemId,
principalTable: "StockItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SupplierOfferings_Suppliers_SupplierId",
column: x => x.SupplierId,
principalTable: "Suppliers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_StockItems_MaterialId_LengthInches",
table: "StockItems",
columns: new[] { "MaterialId", "LengthInches" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SupplierOfferings_StockItemId",
table: "SupplierOfferings",
column: "StockItemId");
migrationBuilder.CreateIndex(
name: "IX_SupplierOfferings_SupplierId_StockItemId",
table: "SupplierOfferings",
columns: new[] { "SupplierId", "StockItemId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SupplierOfferings");
migrationBuilder.DropTable(
name: "StockItems");
migrationBuilder.CreateTable(
name: "SupplierStocks",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
MaterialId = table.Column<int>(type: "int", nullable: false),
SupplierId = table.Column<int>(type: "int", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
LengthInches = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false),
Notes = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
Price = table.Column<decimal>(type: "decimal(10,2)", precision: 10, scale: 2, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SupplierStocks", x => x.Id);
table.ForeignKey(
name: "FK_SupplierStocks_Materials_MaterialId",
column: x => x.MaterialId,
principalTable: "Materials",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SupplierStocks_Suppliers_SupplierId",
column: x => x.SupplierId,
principalTable: "Suppliers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_SupplierStocks_MaterialId",
table: "SupplierStocks",
column: "MaterialId");
migrationBuilder.CreateIndex(
name: "IX_SupplierStocks_SupplierId_MaterialId_LengthInches",
table: "SupplierStocks",
columns: new[] { "SupplierId", "MaterialId", "LengthInches" },
unique: true);
}
}
}

View File

@@ -231,6 +231,44 @@ namespace CutList.Web.Migrations
b.ToTable("ProjectParts"); b.ToTable("ProjectParts");
}); });
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<decimal>("LengthInches")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<int>("MaterialId")
.HasColumnType("int");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("MaterialId", "LengthInches")
.IsUnique();
b.ToTable("StockItems");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b => modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -264,7 +302,7 @@ namespace CutList.Web.Migrations
b.ToTable("Suppliers"); b.ToTable("Suppliers");
}); });
modelBuilder.Entity("CutList.Web.Data.Entities.SupplierStock", b => modelBuilder.Entity("CutList.Web.Data.Entities.SupplierOffering", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -275,32 +313,36 @@ namespace CutList.Web.Migrations
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<decimal>("LengthInches")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<int>("MaterialId")
.HasColumnType("int");
b.Property<string>("Notes") b.Property<string>("Notes")
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("nvarchar(255)"); .HasColumnType("nvarchar(255)");
b.Property<string>("PartNumber")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<decimal?>("Price") b.Property<decimal?>("Price")
.HasPrecision(10, 2) .HasPrecision(10, 2)
.HasColumnType("decimal(10,2)"); .HasColumnType("decimal(10,2)");
b.Property<int>("StockItemId")
.HasColumnType("int");
b.Property<string>("SupplierDescription")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<int>("SupplierId") b.Property<int>("SupplierId")
.HasColumnType("int"); .HasColumnType("int");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("MaterialId"); b.HasIndex("StockItemId");
b.HasIndex("SupplierId", "MaterialId", "LengthInches") b.HasIndex("SupplierId", "StockItemId")
.IsUnique(); .IsUnique();
b.ToTable("SupplierStocks"); b.ToTable("SupplierOfferings");
}); });
modelBuilder.Entity("CutList.Web.Data.Entities.MaterialStockLength", b => modelBuilder.Entity("CutList.Web.Data.Entities.MaterialStockLength", b =>
@@ -343,21 +385,32 @@ namespace CutList.Web.Migrations
b.Navigation("Project"); b.Navigation("Project");
}); });
modelBuilder.Entity("CutList.Web.Data.Entities.SupplierStock", b => modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{ {
b.HasOne("CutList.Web.Data.Entities.Material", "Material") b.HasOne("CutList.Web.Data.Entities.Material", "Material")
.WithMany("SupplierStocks") .WithMany("StockItems")
.HasForeignKey("MaterialId") .HasForeignKey("MaterialId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Material");
});
modelBuilder.Entity("CutList.Web.Data.Entities.SupplierOffering", b =>
{
b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem")
.WithMany("SupplierOfferings")
.HasForeignKey("StockItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Supplier", "Supplier") b.HasOne("CutList.Web.Data.Entities.Supplier", "Supplier")
.WithMany("Stocks") .WithMany("Offerings")
.HasForeignKey("SupplierId") .HasForeignKey("SupplierId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Material"); b.Navigation("StockItem");
b.Navigation("Supplier"); b.Navigation("Supplier");
}); });
@@ -371,9 +424,9 @@ namespace CutList.Web.Migrations
{ {
b.Navigation("ProjectParts"); b.Navigation("ProjectParts");
b.Navigation("StockLengths"); b.Navigation("StockItems");
b.Navigation("SupplierStocks"); b.Navigation("StockLengths");
}); });
modelBuilder.Entity("CutList.Web.Data.Entities.Project", b => modelBuilder.Entity("CutList.Web.Data.Entities.Project", b =>
@@ -381,9 +434,14 @@ namespace CutList.Web.Migrations
b.Navigation("Parts"); b.Navigation("Parts");
}); });
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.Navigation("SupplierOfferings");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b => modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b =>
{ {
b.Navigation("Stocks"); b.Navigation("Offerings");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }

View File

@@ -17,6 +17,7 @@ builder.Services.AddDbContext<ApplicationDbContext>(options =>
// Add application services // Add application services
builder.Services.AddScoped<MaterialService>(); builder.Services.AddScoped<MaterialService>();
builder.Services.AddScoped<SupplierService>(); builder.Services.AddScoped<SupplierService>();
builder.Services.AddScoped<StockItemService>();
builder.Services.AddScoped<ProjectService>(); builder.Services.AddScoped<ProjectService>();
builder.Services.AddScoped<CutListPackingService>(); builder.Services.AddScoped<CutListPackingService>();
builder.Services.AddScoped<ReportService>(); builder.Services.AddScoped<ReportService>();

View File

@@ -38,8 +38,8 @@ public class CutListPackingService
.Where(s => s.MaterialId == materialId && s.IsActive && s.Quantity > 0) .Where(s => s.MaterialId == materialId && s.IsActive && s.Quantity > 0)
.ToListAsync(); .ToListAsync();
// Get supplier stock lengths for this material (for purchase) // Get stock item lengths for this material (for purchase)
var supplierLengths = await _context.SupplierStocks var stockItemLengths = await _context.StockItems
.Where(s => s.MaterialId == materialId && s.IsActive) .Where(s => s.MaterialId == materialId && s.IsActive)
.Select(s => s.LengthInches) .Select(s => s.LengthInches)
.Distinct() .Distinct()
@@ -60,8 +60,8 @@ public class CutListPackingService
}); });
} }
// Supplier stock bins with unlimited quantity // Stock item bins with unlimited quantity
foreach (var length in supplierLengths) foreach (var length in stockItemLengths)
{ {
// Only add if not already covered by in-stock // Only add if not already covered by in-stock
if (!stockBins.Any(b => b.LengthInches == length && b.IsInStock)) if (!stockBins.Any(b => b.LengthInches == length && b.IsInStock))

View File

@@ -0,0 +1,99 @@
using CutList.Web.Data;
using CutList.Web.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace CutList.Web.Services;
public class StockItemService
{
private readonly ApplicationDbContext _context;
public StockItemService(ApplicationDbContext context)
{
_context = context;
}
public async Task<List<StockItem>> GetAllAsync(bool includeInactive = false)
{
var query = _context.StockItems
.Include(s => s.Material)
.AsQueryable();
if (!includeInactive)
{
query = query.Where(s => s.IsActive);
}
return await query
.OrderBy(s => s.Material.Shape)
.ThenBy(s => s.Material.Size)
.ThenBy(s => s.LengthInches)
.ToListAsync();
}
public async Task<List<StockItem>> GetByMaterialAsync(int materialId, bool includeInactive = false)
{
var query = _context.StockItems
.Include(s => s.Material)
.Where(s => s.MaterialId == materialId);
if (!includeInactive)
{
query = query.Where(s => s.IsActive);
}
return await query
.OrderBy(s => s.LengthInches)
.ToListAsync();
}
public async Task<StockItem?> GetByIdAsync(int id)
{
return await _context.StockItems
.Include(s => s.Material)
.Include(s => s.SupplierOfferings)
.ThenInclude(o => o.Supplier)
.FirstOrDefaultAsync(s => s.Id == id);
}
public async Task<StockItem> CreateAsync(StockItem stockItem)
{
stockItem.CreatedAt = DateTime.UtcNow;
_context.StockItems.Add(stockItem);
await _context.SaveChangesAsync();
return stockItem;
}
public async Task UpdateAsync(StockItem stockItem)
{
stockItem.UpdatedAt = DateTime.UtcNow;
_context.StockItems.Update(stockItem);
await _context.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var stockItem = await _context.StockItems.FindAsync(id);
if (stockItem != null)
{
stockItem.IsActive = false;
stockItem.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
}
public async Task<bool> ExistsAsync(int materialId, decimal lengthInches, int? excludeId = null)
{
var query = _context.StockItems.Where(s =>
s.MaterialId == materialId &&
s.LengthInches == lengthInches &&
s.IsActive);
if (excludeId.HasValue)
{
query = query.Where(s => s.Id != excludeId.Value);
}
return await query.AnyAsync();
}
}

View File

@@ -26,8 +26,9 @@ public class SupplierService
public async Task<Supplier?> GetByIdAsync(int id) public async Task<Supplier?> GetByIdAsync(int id)
{ {
return await _context.Suppliers return await _context.Suppliers
.Include(s => s.Stocks) .Include(s => s.Offerings)
.ThenInclude(st => st.Material) .ThenInclude(o => o.StockItem)
.ThenInclude(si => si.Material)
.FirstOrDefaultAsync(s => s.Id == id); .FirstOrDefaultAsync(s => s.Id == id);
} }
@@ -55,70 +56,70 @@ public class SupplierService
} }
} }
// Stock management // Offering management
public async Task<List<SupplierStock>> GetStocksForSupplierAsync(int supplierId) public async Task<List<SupplierOffering>> GetOfferingsForSupplierAsync(int supplierId)
{ {
return await _context.SupplierStocks return await _context.SupplierOfferings
.Include(s => s.Material) .Include(o => o.StockItem)
.Where(s => s.SupplierId == supplierId && s.IsActive) .ThenInclude(si => si.Material)
.OrderBy(s => s.Material.Shape) .Where(o => o.SupplierId == supplierId && o.IsActive)
.ThenBy(s => s.Material.Size) .OrderBy(o => o.StockItem.Material.Shape)
.ThenBy(s => s.LengthInches) .ThenBy(o => o.StockItem.Material.Size)
.ThenBy(o => o.StockItem.LengthInches)
.ToListAsync(); .ToListAsync();
} }
public async Task<List<SupplierStock>> GetStocksForMaterialAsync(int materialId) public async Task<List<SupplierOffering>> GetOfferingsForStockItemAsync(int stockItemId)
{ {
return await _context.SupplierStocks return await _context.SupplierOfferings
.Include(s => s.Supplier) .Include(o => o.Supplier)
.Where(s => s.MaterialId == materialId && s.IsActive && s.Supplier.IsActive) .Where(o => o.StockItemId == stockItemId && o.IsActive && o.Supplier.IsActive)
.OrderBy(s => s.Supplier.Name) .OrderBy(o => o.Supplier.Name)
.ThenBy(s => s.LengthInches)
.ToListAsync(); .ToListAsync();
} }
public async Task<SupplierStock?> GetStockByIdAsync(int id) public async Task<SupplierOffering?> GetOfferingByIdAsync(int id)
{ {
return await _context.SupplierStocks return await _context.SupplierOfferings
.Include(s => s.Material) .Include(o => o.StockItem)
.Include(s => s.Supplier) .ThenInclude(si => si.Material)
.FirstOrDefaultAsync(s => s.Id == id); .Include(o => o.Supplier)
.FirstOrDefaultAsync(o => o.Id == id);
} }
public async Task<SupplierStock> AddStockAsync(SupplierStock stock) public async Task<SupplierOffering> AddOfferingAsync(SupplierOffering offering)
{ {
_context.SupplierStocks.Add(stock); _context.SupplierOfferings.Add(offering);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return stock; return offering;
} }
public async Task UpdateStockAsync(SupplierStock stock) public async Task UpdateOfferingAsync(SupplierOffering offering)
{ {
_context.SupplierStocks.Update(stock); _context.SupplierOfferings.Update(offering);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
public async Task DeleteStockAsync(int id) public async Task DeleteOfferingAsync(int id)
{ {
var stock = await _context.SupplierStocks.FindAsync(id); var offering = await _context.SupplierOfferings.FindAsync(id);
if (stock != null) if (offering != null)
{ {
stock.IsActive = false; offering.IsActive = false;
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
} }
public async Task<bool> StockExistsAsync(int supplierId, int materialId, decimal lengthInches, int? excludeId = null) public async Task<bool> OfferingExistsAsync(int supplierId, int stockItemId, int? excludeId = null)
{ {
var query = _context.SupplierStocks.Where(s => var query = _context.SupplierOfferings.Where(o =>
s.SupplierId == supplierId && o.SupplierId == supplierId &&
s.MaterialId == materialId && o.StockItemId == stockItemId &&
s.LengthInches == lengthInches && o.IsActive);
s.IsActive);
if (excludeId.HasValue) if (excludeId.HasValue)
{ {
query = query.Where(s => s.Id != excludeId.Value); query = query.Where(o => o.Id != excludeId.Value);
} }
return await query.AnyAsync(); return await query.AnyAsync();