From ce14dd50cb30b09e0a8abd0fb3068d2db22e46e3 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 4 Feb 2026 23:37:24 -0500 Subject: [PATCH] refactor: Rename Project to Job with enhanced model Rename the Project concept to Job for clarity: - Add Job, JobPart, JobStock entities - JobStock supports both inventory stock and custom lengths - Add JobNumber field for job identification - Add priority-based stock allocation for cut optimization - Include Jobs UI pages (Index, Edit, Results) Co-Authored-By: Claude Opus 4.5 --- CutList.Web/Components/Pages/Jobs/Edit.razor | 810 ++++++++++++++++++ CutList.Web/Components/Pages/Jobs/Index.razor | 120 +++ .../Components/Pages/Jobs/Results.razor | 258 ++++++ CutList.Web/Data/Entities/Job.cs | 19 + CutList.Web/Data/Entities/JobPart.cs | 15 + CutList.Web/Data/Entities/JobStock.cs | 46 + ...60204214947_RenameProjectToJob.Designer.cs | 452 ++++++++++ .../20260204214947_RenameProjectToJob.cs | 169 ++++ .../20260204222017_AddJobNumber.Designer.cs | 494 +++++++++++ .../Migrations/20260204222017_AddJobNumber.cs | 74 ++ .../20260204223202_AddJobStock.Designer.cs | 566 ++++++++++++ .../Migrations/20260204223202_AddJobStock.cs | 74 ++ CutList.Web/Services/JobService.cs | 320 +++++++ 13 files changed, 3417 insertions(+) create mode 100644 CutList.Web/Components/Pages/Jobs/Edit.razor create mode 100644 CutList.Web/Components/Pages/Jobs/Index.razor create mode 100644 CutList.Web/Components/Pages/Jobs/Results.razor create mode 100644 CutList.Web/Data/Entities/Job.cs create mode 100644 CutList.Web/Data/Entities/JobPart.cs create mode 100644 CutList.Web/Data/Entities/JobStock.cs create mode 100644 CutList.Web/Migrations/20260204214947_RenameProjectToJob.Designer.cs create mode 100644 CutList.Web/Migrations/20260204214947_RenameProjectToJob.cs create mode 100644 CutList.Web/Migrations/20260204222017_AddJobNumber.Designer.cs create mode 100644 CutList.Web/Migrations/20260204222017_AddJobNumber.cs create mode 100644 CutList.Web/Migrations/20260204223202_AddJobStock.Designer.cs create mode 100644 CutList.Web/Migrations/20260204223202_AddJobStock.cs create mode 100644 CutList.Web/Services/JobService.cs diff --git a/CutList.Web/Components/Pages/Jobs/Edit.razor b/CutList.Web/Components/Pages/Jobs/Edit.razor new file mode 100644 index 0000000..16b35a5 --- /dev/null +++ b/CutList.Web/Components/Pages/Jobs/Edit.razor @@ -0,0 +1,810 @@ +@page "/jobs/new" +@page "/jobs/{Id:int}" +@inject JobService JobService +@inject MaterialService MaterialService +@inject StockItemService StockItemService +@inject NavigationManager Navigation +@using CutList.Core.Formatting +@using CutList.Web.Data.Entities + +@(IsNew ? "New Job" : job.DisplayName) + +
+

@(IsNew ? "New Job" : job.DisplayName)

+ @if (!IsNew) + { + Run Optimization + } +
+ +@if (loading) +{ +

Loading...

+} +else if (IsNew) +{ + +
+
+ @RenderDetailsForm() +
+
+} +else +{ + + + +
+ @if (activeTab == Tab.Details) + { +
+
+ @RenderDetailsForm() +
+
+ } + else if (activeTab == Tab.Parts) + { + @RenderPartsTab() + } + else if (activeTab == Tab.Stock) + { + @RenderStockTab() + } +
+} + +@code { + private enum Tab { Details, Parts, Stock } + + [Parameter] + public int? Id { get; set; } + + private Job job = new(); + private List materials = new(); + private List cuttingTools = new(); + + private bool loading = true; + private bool savingJob; + private string? jobErrorMessage; + private Tab activeTab = Tab.Details; + + private void SetTab(Tab tab) => activeTab = tab; + + // Parts form + private bool showPartForm; + private JobPart newPart = new(); + private JobPart? editingPart; + private string? partErrorMessage; + private MaterialShape? selectedShape; + + // Stock form + private bool showStockForm; + private bool showCustomStockForm; + private JobStock newStock = new(); + private JobStock? editingStock; + private string? stockErrorMessage; + private MaterialShape? stockSelectedShape; + private int stockSelectedMaterialId; + private List availableStockItems = new(); + + private IEnumerable DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s); + private IEnumerable FilteredMaterials => !selectedShape.HasValue + ? Enumerable.Empty() + : materials.Where(m => m.Shape == selectedShape.Value).OrderBy(m => m.Size); + + private bool IsNew => !Id.HasValue; + + protected override async Task OnInitializedAsync() + { + materials = await MaterialService.GetAllAsync(); + cuttingTools = await JobService.GetCuttingToolsAsync(); + + if (Id.HasValue) + { + var existing = await JobService.GetByIdAsync(Id.Value); + if (existing == null) + { + Navigation.NavigateTo("jobs"); + return; + } + job = existing; + } + else + { + // Set default cutting tool for new jobs + var defaultTool = await JobService.GetDefaultCuttingToolAsync(); + if (defaultTool != null) + { + job.CuttingToolId = defaultTool.Id; + } + } + + loading = false; + } + + private RenderFragment RenderDetailsForm() => __builder => + { +
+
+
Job Details
+
+
+ + @if (!IsNew) + { +
+ + +
+ } + +
+ + +
+ +
+ + +
+ +
+ + + + @foreach (var tool in cuttingTools) + { + + } + +
+ +
+ + +
+ + @if (!string.IsNullOrEmpty(jobErrorMessage)) + { +
@jobErrorMessage
+ } + +
+ + Back +
+
+
+
+ }; + + private RenderFragment RenderPartsTab() => __builder => + { +
+
+
Parts to Cut
+ +
+
+ @if (showPartForm) + { +
+
@(editingPart == null ? "Add Part" : "Edit Part")
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(partErrorMessage)) + { +
@partErrorMessage
+ } +
+ + +
+
+ } + + @if (job.Parts.Count == 0) + { +
+

No parts added yet.

+

Add the parts you need to cut, selecting the material for each.

+
+ } + else + { +
+ + + + + + + + + + + + @foreach (var part in job.Parts) + { + + + + + + + + } + +
MaterialLengthQtyNameActions
@part.Material.DisplayName@ArchUnits.FormatFromInches((double)part.LengthInches)@part.Quantity@(string.IsNullOrWhiteSpace(part.Name) ? "-" : part.Name) + + +
+
+
+ Total: @job.Parts.Sum(p => p.Quantity) pieces +
+ } +
+
+ }; + + private async Task SaveJobAsync() + { + jobErrorMessage = null; + savingJob = true; + + try + { + if (IsNew) + { + var created = await JobService.CreateAsync(job); + Navigation.NavigateTo($"jobs/{created.Id}"); + } + else + { + await JobService.UpdateAsync(job); + } + } + finally + { + savingJob = false; + } + } + + // Parts methods + private void ShowAddPartForm() + { + editingPart = null; + newPart = new JobPart { JobId = Id!.Value, Quantity = 1 }; + selectedShape = null; + showPartForm = true; + partErrorMessage = null; + } + + private void OnShapeChanged() + { + newPart.MaterialId = 0; + } + + private void EditPart(JobPart part) + { + editingPart = part; + newPart = new JobPart + { + Id = part.Id, + JobId = part.JobId, + MaterialId = part.MaterialId, + Name = part.Name, + LengthInches = part.LengthInches, + Quantity = part.Quantity, + SortOrder = part.SortOrder + }; + selectedShape = part.Material?.Shape; + showPartForm = true; + partErrorMessage = null; + } + + private void CancelPartForm() + { + showPartForm = false; + editingPart = null; + } + + private async Task SavePartAsync() + { + partErrorMessage = null; + + if (!selectedShape.HasValue) + { + partErrorMessage = "Please select a shape"; + return; + } + + if (newPart.MaterialId == 0) + { + partErrorMessage = "Please select a size"; + return; + } + + if (newPart.LengthInches <= 0) + { + partErrorMessage = "Length must be greater than zero"; + return; + } + + if (newPart.Quantity < 1) + { + partErrorMessage = "Quantity must be at least 1"; + return; + } + + if (editingPart == null) + { + await JobService.AddPartAsync(newPart); + } + else + { + await JobService.UpdatePartAsync(newPart); + } + + job = (await JobService.GetByIdAsync(Id!.Value))!; + showPartForm = false; + editingPart = null; + } + + private async Task DeletePart(JobPart part) + { + await JobService.DeletePartAsync(part.Id); + job = (await JobService.GetByIdAsync(Id!.Value))!; + } + + // Stock tab + private RenderFragment RenderStockTab() => __builder => + { +
+
+
Stock for This Job
+
+ + +
+
+
+ @if (showStockForm) + { + @RenderStockFromInventoryForm() + } + else if (showCustomStockForm) + { + @RenderCustomStockForm() + } + + @if (job.Stock.Count == 0) + { +
+

No stock configured for this job.

+

Add stock from your inventory or define custom lengths.

+

If no stock is selected, the optimizer will use all available stock for the materials in your parts list.

+
+ } + else + { + @RenderStockTable() + } +
+
+ }; + + private RenderFragment RenderStockFromInventoryForm() => __builder => + { +
+
@(editingStock == null ? "Add Stock from Inventory" : "Edit Stock Selection")
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + Lower = used first +
+
+ @if (!string.IsNullOrEmpty(stockErrorMessage)) + { +
@stockErrorMessage
+ } +
+ + +
+
+ }; + + private RenderFragment RenderCustomStockForm() => __builder => + { +
+
@(editingStock == null ? "Add Custom Stock Length" : "Edit Custom Stock")
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + Use -1 for unlimited +
+
+
+
+ + + Lower = used first +
+
+ @if (!string.IsNullOrEmpty(stockErrorMessage)) + { +
@stockErrorMessage
+ } +
+ + +
+
+ }; + + private RenderFragment RenderStockTable() => __builder => + { +
+ + + + + + + + + + + + + @foreach (var stock in job.Stock.OrderBy(s => s.Material?.Shape).ThenBy(s => s.Material?.Size).ThenBy(s => s.Priority)) + { + + + + + + + + + } + +
MaterialLengthQtyPrioritySourceActions
@stock.Material?.DisplayName@ArchUnits.FormatFromInches((double)stock.LengthInches)@(stock.Quantity == -1 ? "Unlimited" : stock.Quantity.ToString())@stock.Priority + @if (stock.IsCustomLength) + { + Custom + } + else + { + Inventory + } + + + +
+
+ }; + + private void ShowAddStockFromInventory() + { + editingStock = null; + newStock = new JobStock { JobId = Id!.Value, Quantity = 1, Priority = 10 }; + stockSelectedShape = null; + stockSelectedMaterialId = 0; + availableStockItems.Clear(); + showStockForm = true; + showCustomStockForm = false; + stockErrorMessage = null; + } + + private void ShowAddCustomStock() + { + editingStock = null; + newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10, IsCustomLength = true }; + stockSelectedShape = null; + showStockForm = false; + showCustomStockForm = true; + stockErrorMessage = null; + } + + private void CancelStockForm() + { + showStockForm = false; + showCustomStockForm = false; + editingStock = null; + } + + private async Task OnStockShapeChanged() + { + stockSelectedMaterialId = 0; + newStock.MaterialId = 0; + newStock.StockItemId = null; + availableStockItems.Clear(); + } + + private async Task OnStockMaterialChanged() + { + newStock.MaterialId = stockSelectedMaterialId; + newStock.StockItemId = null; + if (stockSelectedMaterialId > 0) + { + availableStockItems = await JobService.GetAvailableStockForMaterialAsync(stockSelectedMaterialId); + } + else + { + availableStockItems.Clear(); + } + } + + private void EditStock(JobStock stock) + { + editingStock = stock; + newStock = new JobStock + { + Id = stock.Id, + JobId = stock.JobId, + MaterialId = stock.MaterialId, + StockItemId = stock.StockItemId, + LengthInches = stock.LengthInches, + Quantity = stock.Quantity, + IsCustomLength = stock.IsCustomLength, + Priority = stock.Priority, + SortOrder = stock.SortOrder + }; + stockSelectedShape = stock.Material?.Shape; + stockSelectedMaterialId = stock.MaterialId; + stockErrorMessage = null; + + if (stock.IsCustomLength) + { + showStockForm = false; + showCustomStockForm = true; + } + else + { + showStockForm = true; + showCustomStockForm = false; + _ = OnStockMaterialChanged(); + } + } + + private async Task SaveStockFromInventoryAsync() + { + stockErrorMessage = null; + + if (!stockSelectedShape.HasValue) + { + stockErrorMessage = "Please select a shape"; + return; + } + + if (stockSelectedMaterialId == 0) + { + stockErrorMessage = "Please select a size"; + return; + } + + if (!newStock.StockItemId.HasValue) + { + stockErrorMessage = "Please select a stock length"; + return; + } + + if (newStock.Quantity < 1) + { + stockErrorMessage = "Quantity must be at least 1"; + return; + } + + var selectedStock = availableStockItems.FirstOrDefault(s => s.Id == newStock.StockItemId); + if (selectedStock == null) + { + stockErrorMessage = "Selected stock not found"; + return; + } + + newStock.MaterialId = stockSelectedMaterialId; + newStock.LengthInches = selectedStock.LengthInches; + newStock.IsCustomLength = false; + + if (editingStock == null) + { + await JobService.AddStockAsync(newStock); + } + else + { + await JobService.UpdateStockAsync(newStock); + } + + job = (await JobService.GetByIdAsync(Id!.Value))!; + showStockForm = false; + editingStock = null; + } + + private async Task SaveCustomStockAsync() + { + stockErrorMessage = null; + + if (!stockSelectedShape.HasValue) + { + stockErrorMessage = "Please select a shape"; + return; + } + + if (newStock.MaterialId == 0) + { + stockErrorMessage = "Please select a size"; + return; + } + + if (newStock.LengthInches <= 0) + { + stockErrorMessage = "Length must be greater than zero"; + return; + } + + if (newStock.Quantity < -1 || newStock.Quantity == 0) + { + stockErrorMessage = "Quantity must be at least 1 (or -1 for unlimited)"; + return; + } + + newStock.StockItemId = null; + newStock.IsCustomLength = true; + + if (editingStock == null) + { + await JobService.AddStockAsync(newStock); + } + else + { + await JobService.UpdateStockAsync(newStock); + } + + job = (await JobService.GetByIdAsync(Id!.Value))!; + showCustomStockForm = false; + editingStock = null; + } + + private async Task DeleteStock(JobStock stock) + { + await JobService.DeleteStockAsync(stock.Id); + job = (await JobService.GetByIdAsync(Id!.Value))!; + } +} diff --git a/CutList.Web/Components/Pages/Jobs/Index.razor b/CutList.Web/Components/Pages/Jobs/Index.razor new file mode 100644 index 0000000..816186f --- /dev/null +++ b/CutList.Web/Components/Pages/Jobs/Index.razor @@ -0,0 +1,120 @@ +@page "/jobs" +@inject JobService JobService +@inject NavigationManager Navigation + +Jobs + +
+

Jobs

+
+ + New Job +
+
+ +@if (loading) +{ +

Loading...

+} +else if (jobs.Count == 0) +{ +
+ No jobs found. Create your first job. +
+} +else +{ + + + + + + + + + + + + + @foreach (var job in jobs) + { + + + + + + + + + } + +
Job #NameCustomerCutting ToolLast ModifiedActions
@job.JobNumber@(job.Name ?? "-")@(job.Customer ?? "-")@(job.CuttingTool?.Name ?? "-")@((job.UpdatedAt ?? job.CreatedAt).ToLocalTime().ToString("g")) + Edit + Optimize + + +
+} + + + +@code { + private List jobs = new(); + private bool loading = true; + private bool creating = false; + private ConfirmDialog deleteDialog = null!; + private Job? jobToDelete; + private string deleteMessage = ""; + + protected override async Task OnInitializedAsync() + { + jobs = await JobService.GetAllAsync(); + loading = false; + } + + private async Task QuickCreateJob() + { + creating = true; + try + { + var job = await JobService.QuickCreateAsync(); + Navigation.NavigateTo($"jobs/{job.Id}"); + } + finally + { + creating = false; + } + } + + private void ConfirmDelete(Job job) + { + jobToDelete = job; + deleteMessage = $"Are you sure you want to delete \"{job.DisplayName}\"? This will also delete all parts."; + deleteDialog.Show(); + } + + private async Task DeleteConfirmed() + { + if (jobToDelete != null) + { + await JobService.DeleteAsync(jobToDelete.Id); + jobs = await JobService.GetAllAsync(); + } + } + + private async Task DuplicateJob(Job job) + { + var duplicate = await JobService.DuplicateAsync(job.Id); + Navigation.NavigateTo($"jobs/{duplicate.Id}"); + } +} diff --git a/CutList.Web/Components/Pages/Jobs/Results.razor b/CutList.Web/Components/Pages/Jobs/Results.razor new file mode 100644 index 0000000..b5d64c1 --- /dev/null +++ b/CutList.Web/Components/Pages/Jobs/Results.razor @@ -0,0 +1,258 @@ +@page "/jobs/{Id:int}/results" +@inject JobService JobService +@inject CutListPackingService PackingService +@inject NavigationManager Navigation +@inject IJSRuntime JS +@using CutList.Core +@using CutList.Core.Nesting +@using CutList.Core.Formatting + +Results - @(job?.DisplayName ?? "Job") + +@if (loading) +{ +

Loading...

+} +else if (job == null) +{ +
Job not found.
+} +else +{ +
+
+

@job.DisplayName

+ @if (!string.IsNullOrWhiteSpace(job.Customer)) + { +

Customer: @job.Customer

+ } +
+
+ Edit Job + +
+
+ + @if (!CanOptimize) + { +
+

Cannot Optimize

+ +
+ } + else if (packResult != null) + { + @if (summary!.TotalItemsNotPlaced > 0) + { +
+
Items Not Placed
+

Some items could not be placed. This usually means no stock lengths are configured for the material, or parts are too long.

+
+ } + + +
+
+
+
+

@(summary.TotalInStockBins + summary.TotalToBePurchasedBins)

+

Total Stock Bars

+
+
+
+
+
+
+

@summary.TotalPieces

+

Total Pieces

+
+
+
+
+
+
+

@ArchUnits.FormatFromInches(summary.TotalWaste)

+

Total Waste

+
+
+
+
+
+
+

@summary.Efficiency.ToString("F1")%

+

Efficiency

+
+
+
+
+ + +
+
+
+
+
In Stock
+
+
+

@summary.TotalInStockBins bars

+

Ready to cut from existing inventory

+
+
+
+
+
+
+
To Be Purchased
+
+
+

@summary.TotalToBePurchasedBins bars

+

Need to order from supplier

+
+
+
+
+ + + @foreach (var materialResult in packResult.MaterialResults) + { + var materialSummary = summary.MaterialSummaries.First(s => s.Material.Id == materialResult.Material.Id); + +
+
+

@materialResult.Material.DisplayName

+
+
+ +
+
+ @(materialSummary.InStockBins + materialSummary.ToBePurchasedBins) bars +
+
+ @materialSummary.TotalPieces pieces +
+
+ @materialSummary.Efficiency.ToString("F1")% efficiency +
+
+ @materialSummary.InStockBins in stock +
+
+ @materialSummary.ToBePurchasedBins to purchase +
+
+ + @if (materialResult.PackResult.ItemsNotUsed.Count > 0) + { +
+ @materialResult.PackResult.ItemsNotUsed.Count items not placed - + No stock lengths available or parts too long. +
+ } + + @if (materialResult.InStockBins.Count > 0) + { +
In Stock (@materialResult.InStockBins.Count bars)
+ @RenderBinList(materialResult.InStockBins) + } + + @if (materialResult.ToBePurchasedBins.Count > 0) + { +
To Be Purchased (@materialResult.ToBePurchasedBins.Count bars)
+ @RenderBinList(materialResult.ToBePurchasedBins) + + +
+ Order Summary: +
    + @foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length).OrderByDescending(g => g.Key)) + { +
  • @group.Count() x @ArchUnits.FormatFromInches(group.Key)
  • + } +
+
+ } +
+
+ } + } +} + +@code { + [Parameter] + public int Id { get; set; } + + private Job? job; + private MultiMaterialPackResult? packResult; + private MultiMaterialPackingSummary? summary; + private bool loading = true; + + private bool CanOptimize => job != null && + job.Parts.Count > 0 && + job.CuttingToolId != null; + + protected override async Task OnInitializedAsync() + { + job = await JobService.GetByIdAsync(Id); + + if (job != null && CanOptimize) + { + var kerf = job.CuttingTool?.KerfInches ?? 0.125m; + // Pass job stock if configured, otherwise packing service uses all available stock + packResult = await PackingService.PackAsync(job.Parts, kerf, job.Stock.Count > 0 ? job.Stock : null); + summary = PackingService.GetSummary(packResult); + } + + loading = false; + } + + private RenderFragment RenderBinList(List bins) => __builder => + { +
+ + + + + + + + + + + @{ var binNumber = 1; } + @foreach (var bin in bins) + { + + + + + + + binNumber++; + } + +
#Stock LengthCutsWaste
@binNumber@ArchUnits.FormatFromInches(bin.Length) + @foreach (var item in bin.Items) + { + + @(string.IsNullOrWhiteSpace(item.Name) ? ArchUnits.FormatFromInches(item.Length) : $"{item.Name} ({ArchUnits.FormatFromInches(item.Length)})") + + } + @ArchUnits.FormatFromInches(bin.RemainingLength)
+
+ }; + + private async Task PrintReport() + { + var filename = $"CutList - {job!.Name} - {DateTime.Now:yyyy-MM-dd}"; + await JS.InvokeVoidAsync("printWithTitle", filename); + } +} diff --git a/CutList.Web/Data/Entities/Job.cs b/CutList.Web/Data/Entities/Job.cs new file mode 100644 index 0000000..6c63133 --- /dev/null +++ b/CutList.Web/Data/Entities/Job.cs @@ -0,0 +1,19 @@ +namespace CutList.Web.Data.Entities; + +public class Job +{ + public int Id { get; set; } + public string JobNumber { get; set; } = string.Empty; + public string? Name { get; set; } + public string? Customer { get; set; } + public int? CuttingToolId { get; set; } + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? UpdatedAt { get; set; } + + public CuttingTool? CuttingTool { get; set; } + public ICollection Parts { get; set; } = new List(); + public ICollection Stock { get; set; } = new List(); + + public string DisplayName => string.IsNullOrWhiteSpace(Name) ? JobNumber : $"{JobNumber} - {Name}"; +} diff --git a/CutList.Web/Data/Entities/JobPart.cs b/CutList.Web/Data/Entities/JobPart.cs new file mode 100644 index 0000000..365d1c3 --- /dev/null +++ b/CutList.Web/Data/Entities/JobPart.cs @@ -0,0 +1,15 @@ +namespace CutList.Web.Data.Entities; + +public class JobPart +{ + public int Id { get; set; } + public int JobId { get; set; } + public int MaterialId { get; set; } + public string Name { get; set; } = string.Empty; + public decimal LengthInches { get; set; } + public int Quantity { get; set; } = 1; + public int SortOrder { get; set; } + + public Job Job { get; set; } = null!; + public Material Material { get; set; } = null!; +} diff --git a/CutList.Web/Data/Entities/JobStock.cs b/CutList.Web/Data/Entities/JobStock.cs new file mode 100644 index 0000000..d8e30eb --- /dev/null +++ b/CutList.Web/Data/Entities/JobStock.cs @@ -0,0 +1,46 @@ +namespace CutList.Web.Data.Entities; + +/// +/// Represents stock allocated to a specific job. +/// Can reference an existing StockItem with quantity override, +/// or define a custom length just for this job. +/// +public class JobStock +{ + public int Id { get; set; } + public int JobId { get; set; } + public int MaterialId { get; set; } + + /// + /// If set, references an existing stock item. Null for custom job-specific lengths. + /// + public int? StockItemId { get; set; } + + /// + /// Length in inches. For stock items, copied from StockItem.LengthInches. + /// For custom lengths, user-specified. + /// + public decimal LengthInches { get; set; } + + /// + /// Quantity to use for this job. Can be less than or equal to available stock. + /// For custom lengths, represents unlimited available. + /// + public int Quantity { get; set; } = 1; + + /// + /// True if this is a custom length just for this job (not from inventory). + /// + public bool IsCustomLength { get; set; } + + /// + /// Priority for bin packing. Lower values are used first. + /// + public int Priority { get; set; } = 10; + + public int SortOrder { get; set; } + + public Job Job { get; set; } = null!; + public Material Material { get; set; } = null!; + public StockItem? StockItem { get; set; } +} diff --git a/CutList.Web/Migrations/20260204214947_RenameProjectToJob.Designer.cs b/CutList.Web/Migrations/20260204214947_RenameProjectToJob.Designer.cs new file mode 100644 index 0000000..2caef95 --- /dev/null +++ b/CutList.Web/Migrations/20260204214947_RenameProjectToJob.Designer.cs @@ -0,0 +1,452 @@ +// +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("20260204214947_RenameProjectToJob")] + partial class RenameProjectToJob + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDefault") + .HasColumnType("bit"); + + b.Property("KerfInches") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("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.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("Customer") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CuttingToolId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("CuttingToolId"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.JobPart", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("LengthInches") + .HasPrecision(10, 4) + .HasColumnType("decimal(10,4)"); + + b.Property("MaterialId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("MaterialId"); + + b.ToTable("JobParts"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.Material", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("Description") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Shape") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Size") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("Materials"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.MaterialStockLength", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LengthInches") + .HasPrecision(10, 4) + .HasColumnType("decimal(10,4)"); + + b.Property("MaterialId") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MaterialId", "LengthInches") + .IsUnique(); + + b.ToTable("MaterialStockLengths"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LengthInches") + .HasPrecision(10, 4) + .HasColumnType("decimal(10,4)"); + + b.Property("MaterialId") + .HasColumnType("int"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("MaterialId", "LengthInches") + .IsUnique(); + + b.ToTable("StockItems"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactInfo") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Suppliers"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.SupplierOffering", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("PartNumber") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("decimal(10,2)"); + + b.Property("StockItemId") + .HasColumnType("int"); + + b.Property("SupplierDescription") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("SupplierId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StockItemId"); + + b.HasIndex("SupplierId", "StockItemId") + .IsUnique(); + + b.ToTable("SupplierOfferings"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.Job", b => + { + b.HasOne("CutList.Web.Data.Entities.CuttingTool", "CuttingTool") + .WithMany("Jobs") + .HasForeignKey("CuttingToolId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CuttingTool"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.JobPart", b => + { + b.HasOne("CutList.Web.Data.Entities.Job", "Job") + .WithMany("Parts") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CutList.Web.Data.Entities.Material", "Material") + .WithMany("JobParts") + .HasForeignKey("MaterialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Job"); + + b.Navigation("Material"); + }); + + 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.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("Jobs"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.Job", b => + { + b.Navigation("Parts"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.Material", b => + { + b.Navigation("JobParts"); + + b.Navigation("StockItems"); + + b.Navigation("StockLengths"); + }); + + 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 + } + } +} diff --git a/CutList.Web/Migrations/20260204214947_RenameProjectToJob.cs b/CutList.Web/Migrations/20260204214947_RenameProjectToJob.cs new file mode 100644 index 0000000..b4d003d --- /dev/null +++ b/CutList.Web/Migrations/20260204214947_RenameProjectToJob.cs @@ -0,0 +1,169 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CutList.Web.Migrations +{ + /// + public partial class RenameProjectToJob : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ProjectParts"); + + migrationBuilder.DropTable( + name: "Projects"); + + migrationBuilder.CreateTable( + name: "Jobs", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Customer = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + CuttingToolId = table.Column(type: "int", nullable: true), + Notes = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()"), + UpdatedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Jobs", x => x.Id); + table.ForeignKey( + name: "FK_Jobs_CuttingTools_CuttingToolId", + column: x => x.CuttingToolId, + principalTable: "CuttingTools", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "JobParts", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + JobId = table.Column(type: "int", nullable: false), + MaterialId = table.Column(type: "int", nullable: false), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + LengthInches = table.Column(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false), + Quantity = table.Column(type: "int", nullable: false), + SortOrder = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_JobParts", x => x.Id); + table.ForeignKey( + name: "FK_JobParts_Jobs_JobId", + column: x => x.JobId, + principalTable: "Jobs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_JobParts_Materials_MaterialId", + column: x => x.MaterialId, + principalTable: "Materials", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_JobParts_JobId", + table: "JobParts", + column: "JobId"); + + migrationBuilder.CreateIndex( + name: "IX_JobParts_MaterialId", + table: "JobParts", + column: "MaterialId"); + + migrationBuilder.CreateIndex( + name: "IX_Jobs_CuttingToolId", + table: "Jobs", + column: "CuttingToolId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "JobParts"); + + migrationBuilder.DropTable( + name: "Jobs"); + + migrationBuilder.CreateTable( + name: "Projects", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CuttingToolId = table.Column(type: "int", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()"), + Customer = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Notes = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Projects", x => x.Id); + table.ForeignKey( + name: "FK_Projects_CuttingTools_CuttingToolId", + column: x => x.CuttingToolId, + principalTable: "CuttingTools", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "ProjectParts", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + MaterialId = table.Column(type: "int", nullable: false), + ProjectId = table.Column(type: "int", nullable: false), + LengthInches = table.Column(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Quantity = table.Column(type: "int", nullable: false), + SortOrder = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProjectParts", x => x.Id); + table.ForeignKey( + name: "FK_ProjectParts_Materials_MaterialId", + column: x => x.MaterialId, + principalTable: "Materials", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ProjectParts_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ProjectParts_MaterialId", + table: "ProjectParts", + column: "MaterialId"); + + migrationBuilder.CreateIndex( + name: "IX_ProjectParts_ProjectId", + table: "ProjectParts", + column: "ProjectId"); + + migrationBuilder.CreateIndex( + name: "IX_Projects_CuttingToolId", + table: "Projects", + column: "CuttingToolId"); + } + } +} diff --git a/CutList.Web/Migrations/20260204222017_AddJobNumber.Designer.cs b/CutList.Web/Migrations/20260204222017_AddJobNumber.Designer.cs new file mode 100644 index 0000000..ad98a1a --- /dev/null +++ b/CutList.Web/Migrations/20260204222017_AddJobNumber.Designer.cs @@ -0,0 +1,494 @@ +// +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("20260204222017_AddJobNumber")] + partial class AddJobNumber + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDefault") + .HasColumnType("bit"); + + b.Property("KerfInches") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("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.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("Customer") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CuttingToolId") + .HasColumnType("int"); + + b.Property("JobNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("CuttingToolId"); + + b.HasIndex("JobNumber") + .IsUnique(); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.JobPart", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("LengthInches") + .HasPrecision(10, 4) + .HasColumnType("decimal(10,4)"); + + b.Property("MaterialId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("MaterialId"); + + b.ToTable("JobParts"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.Material", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("Description") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Shape") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Size") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("Materials"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LengthInches") + .HasPrecision(10, 4) + .HasColumnType("decimal(10,4)"); + + b.Property("MaterialId") + .HasColumnType("int"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Notes") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("QuantityOnHand") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("MaterialId", "LengthInches") + .IsUnique(); + + b.ToTable("StockItems"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.StockTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("StockItemId") + .HasColumnType("int"); + + b.Property("SupplierId") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasPrecision(10, 2) + .HasColumnType("decimal(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("StockItemId"); + + b.HasIndex("SupplierId"); + + b.ToTable("StockTransactions"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactInfo") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Suppliers"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.SupplierOffering", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("PartNumber") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("decimal(10,2)"); + + b.Property("StockItemId") + .HasColumnType("int"); + + b.Property("SupplierDescription") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("SupplierId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StockItemId"); + + b.HasIndex("SupplierId", "StockItemId") + .IsUnique(); + + b.ToTable("SupplierOfferings"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.Job", b => + { + b.HasOne("CutList.Web.Data.Entities.CuttingTool", "CuttingTool") + .WithMany("Jobs") + .HasForeignKey("CuttingToolId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CuttingTool"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.JobPart", b => + { + b.HasOne("CutList.Web.Data.Entities.Job", "Job") + .WithMany("Parts") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CutList.Web.Data.Entities.Material", "Material") + .WithMany("JobParts") + .HasForeignKey("MaterialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Job"); + + b.Navigation("Material"); + }); + + 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.StockTransaction", b => + { + b.HasOne("CutList.Web.Data.Entities.Job", "Job") + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem") + .WithMany("Transactions") + .HasForeignKey("StockItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CutList.Web.Data.Entities.Supplier", "Supplier") + .WithMany() + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Job"); + + b.Navigation("StockItem"); + + b.Navigation("Supplier"); + }); + + 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("Jobs"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.Job", b => + { + b.Navigation("Parts"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.Material", b => + { + b.Navigation("JobParts"); + + b.Navigation("StockItems"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b => + { + b.Navigation("SupplierOfferings"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b => + { + b.Navigation("Offerings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CutList.Web/Migrations/20260204222017_AddJobNumber.cs b/CutList.Web/Migrations/20260204222017_AddJobNumber.cs new file mode 100644 index 0000000..a0d7af7 --- /dev/null +++ b/CutList.Web/Migrations/20260204222017_AddJobNumber.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CutList.Web.Migrations +{ + /// + public partial class AddJobNumber : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Name", + table: "Jobs", + type: "nvarchar(100)", + maxLength: 100, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(100)", + oldMaxLength: 100); + + migrationBuilder.AddColumn( + name: "JobNumber", + table: "Jobs", + type: "nvarchar(20)", + maxLength: 20, + nullable: false, + defaultValue: ""); + + // Generate job numbers for existing jobs + migrationBuilder.Sql(@" + WITH NumberedJobs AS ( + SELECT Id, ROW_NUMBER() OVER (ORDER BY Id) AS RowNum + FROM Jobs + ) + UPDATE j + SET j.JobNumber = 'JOB-' + RIGHT('00000' + CAST(nj.RowNum AS VARCHAR(5)), 5) + FROM Jobs j + INNER JOIN NumberedJobs nj ON j.Id = nj.Id + "); + + migrationBuilder.CreateIndex( + name: "IX_Jobs_JobNumber", + table: "Jobs", + column: "JobNumber", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Jobs_JobNumber", + table: "Jobs"); + + migrationBuilder.DropColumn( + name: "JobNumber", + table: "Jobs"); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Jobs", + type: "nvarchar(100)", + maxLength: 100, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "nvarchar(100)", + oldMaxLength: 100, + oldNullable: true); + } + } +} diff --git a/CutList.Web/Migrations/20260204223202_AddJobStock.Designer.cs b/CutList.Web/Migrations/20260204223202_AddJobStock.Designer.cs new file mode 100644 index 0000000..99cf24f --- /dev/null +++ b/CutList.Web/Migrations/20260204223202_AddJobStock.Designer.cs @@ -0,0 +1,566 @@ +// +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("20260204223202_AddJobStock")] + partial class AddJobStock + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDefault") + .HasColumnType("bit"); + + b.Property("KerfInches") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("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.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("Customer") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CuttingToolId") + .HasColumnType("int"); + + b.Property("JobNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("CuttingToolId"); + + b.HasIndex("JobNumber") + .IsUnique(); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.JobPart", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("LengthInches") + .HasPrecision(10, 4) + .HasColumnType("decimal(10,4)"); + + b.Property("MaterialId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("MaterialId"); + + b.ToTable("JobParts"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.JobStock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsCustomLength") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("LengthInches") + .HasPrecision(10, 4) + .HasColumnType("decimal(10,4)"); + + b.Property("MaterialId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("StockItemId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("MaterialId"); + + b.HasIndex("StockItemId"); + + b.ToTable("JobStocks"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.Material", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("Description") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Shape") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Size") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("Materials"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LengthInches") + .HasPrecision(10, 4) + .HasColumnType("decimal(10,4)"); + + b.Property("MaterialId") + .HasColumnType("int"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Notes") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("QuantityOnHand") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("MaterialId", "LengthInches") + .IsUnique(); + + b.ToTable("StockItems"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.StockTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("StockItemId") + .HasColumnType("int"); + + b.Property("SupplierId") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasPrecision(10, 2) + .HasColumnType("decimal(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("StockItemId"); + + b.HasIndex("SupplierId"); + + b.ToTable("StockTransactions"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactInfo") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Suppliers"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.SupplierOffering", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("PartNumber") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("decimal(10,2)"); + + b.Property("StockItemId") + .HasColumnType("int"); + + b.Property("SupplierDescription") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("SupplierId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StockItemId"); + + b.HasIndex("SupplierId", "StockItemId") + .IsUnique(); + + b.ToTable("SupplierOfferings"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.Job", b => + { + b.HasOne("CutList.Web.Data.Entities.CuttingTool", "CuttingTool") + .WithMany("Jobs") + .HasForeignKey("CuttingToolId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CuttingTool"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.JobPart", b => + { + b.HasOne("CutList.Web.Data.Entities.Job", "Job") + .WithMany("Parts") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CutList.Web.Data.Entities.Material", "Material") + .WithMany("JobParts") + .HasForeignKey("MaterialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Job"); + + b.Navigation("Material"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.JobStock", b => + { + b.HasOne("CutList.Web.Data.Entities.Job", "Job") + .WithMany("Stock") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CutList.Web.Data.Entities.Material", "Material") + .WithMany() + .HasForeignKey("MaterialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem") + .WithMany() + .HasForeignKey("StockItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Job"); + + b.Navigation("Material"); + + b.Navigation("StockItem"); + }); + + 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.StockTransaction", b => + { + b.HasOne("CutList.Web.Data.Entities.Job", "Job") + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem") + .WithMany("Transactions") + .HasForeignKey("StockItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CutList.Web.Data.Entities.Supplier", "Supplier") + .WithMany() + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Job"); + + b.Navigation("StockItem"); + + b.Navigation("Supplier"); + }); + + 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("Jobs"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.Job", b => + { + b.Navigation("Parts"); + + b.Navigation("Stock"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.Material", b => + { + b.Navigation("JobParts"); + + b.Navigation("StockItems"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b => + { + b.Navigation("SupplierOfferings"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b => + { + b.Navigation("Offerings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CutList.Web/Migrations/20260204223202_AddJobStock.cs b/CutList.Web/Migrations/20260204223202_AddJobStock.cs new file mode 100644 index 0000000..9077c7d --- /dev/null +++ b/CutList.Web/Migrations/20260204223202_AddJobStock.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CutList.Web.Migrations +{ + /// + public partial class AddJobStock : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "JobStocks", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + JobId = table.Column(type: "int", nullable: false), + MaterialId = table.Column(type: "int", nullable: false), + StockItemId = table.Column(type: "int", nullable: true), + LengthInches = table.Column(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false), + Quantity = table.Column(type: "int", nullable: false), + IsCustomLength = table.Column(type: "bit", nullable: false), + Priority = table.Column(type: "int", nullable: false), + SortOrder = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_JobStocks", x => x.Id); + table.ForeignKey( + name: "FK_JobStocks_Jobs_JobId", + column: x => x.JobId, + principalTable: "Jobs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_JobStocks_Materials_MaterialId", + column: x => x.MaterialId, + principalTable: "Materials", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_JobStocks_StockItems_StockItemId", + column: x => x.StockItemId, + principalTable: "StockItems", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_JobStocks_JobId", + table: "JobStocks", + column: "JobId"); + + migrationBuilder.CreateIndex( + name: "IX_JobStocks_MaterialId", + table: "JobStocks", + column: "MaterialId"); + + migrationBuilder.CreateIndex( + name: "IX_JobStocks_StockItemId", + table: "JobStocks", + column: "StockItemId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "JobStocks"); + } + } +} diff --git a/CutList.Web/Services/JobService.cs b/CutList.Web/Services/JobService.cs new file mode 100644 index 0000000..bc4f63f --- /dev/null +++ b/CutList.Web/Services/JobService.cs @@ -0,0 +1,320 @@ +using CutList.Web.Data; +using CutList.Web.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace CutList.Web.Services; + +public class JobService +{ + private readonly ApplicationDbContext _context; + + public JobService(ApplicationDbContext context) + { + _context = context; + } + + public async Task> GetAllAsync() + { + return await _context.Jobs + .Include(p => p.CuttingTool) + .Include(p => p.Parts) + .ThenInclude(pt => pt.Material) + .OrderByDescending(p => p.UpdatedAt ?? p.CreatedAt) + .ToListAsync(); + } + + public async Task GetByIdAsync(int id) + { + return await _context.Jobs + .Include(p => p.CuttingTool) + .Include(p => p.Parts.OrderBy(pt => pt.SortOrder)) + .ThenInclude(pt => pt.Material) + .Include(p => p.Stock.OrderBy(s => s.SortOrder)) + .ThenInclude(s => s.Material) + .Include(p => p.Stock) + .ThenInclude(s => s.StockItem) + .FirstOrDefaultAsync(p => p.Id == id); + } + + public async Task CreateAsync(Job? job = null) + { + job ??= new Job(); + job.JobNumber = await GenerateJobNumberAsync(); + job.CreatedAt = DateTime.UtcNow; + _context.Jobs.Add(job); + await _context.SaveChangesAsync(); + return job; + } + + public async Task GenerateJobNumberAsync() + { + var maxNumber = await _context.Jobs + .Where(j => j.JobNumber.StartsWith("JOB-")) + .Select(j => j.JobNumber) + .MaxAsync() as string; + + if (maxNumber == null) + return "JOB-00001"; + + var numPart = maxNumber.Substring(4); + if (int.TryParse(numPart, out var num)) + return $"JOB-{num + 1:D5}"; + + return $"JOB-{DateTime.UtcNow:yyyyMMddHHmmss}"; + } + + public async Task QuickCreateAsync(string? customer = null) + { + var job = new Job { Customer = customer }; + return await CreateAsync(job); + } + + public async Task UpdateAsync(Job job) + { + job.UpdatedAt = DateTime.UtcNow; + _context.Jobs.Update(job); + await _context.SaveChangesAsync(); + } + + public async Task DeleteAsync(int id) + { + var job = await _context.Jobs.FindAsync(id); + if (job != null) + { + _context.Jobs.Remove(job); + await _context.SaveChangesAsync(); + } + } + + public async Task DuplicateAsync(int id) + { + var original = await GetByIdAsync(id); + if (original == null) + { + throw new ArgumentException("Job not found", nameof(id)); + } + + var duplicate = new Job + { + JobNumber = await GenerateJobNumberAsync(), + Name = string.IsNullOrWhiteSpace(original.Name) ? null : $"{original.Name} (Copy)", + Customer = original.Customer, + CuttingToolId = original.CuttingToolId, + Notes = original.Notes, + CreatedAt = DateTime.UtcNow + }; + + _context.Jobs.Add(duplicate); + await _context.SaveChangesAsync(); + + // Copy parts + foreach (var part in original.Parts) + { + _context.JobParts.Add(new JobPart + { + JobId = duplicate.Id, + MaterialId = part.MaterialId, + Name = part.Name, + LengthInches = part.LengthInches, + Quantity = part.Quantity, + SortOrder = part.SortOrder + }); + } + + // Copy stock selections + foreach (var stock in original.Stock) + { + _context.JobStocks.Add(new JobStock + { + JobId = duplicate.Id, + MaterialId = stock.MaterialId, + StockItemId = stock.StockItemId, + LengthInches = stock.LengthInches, + Quantity = stock.Quantity, + IsCustomLength = stock.IsCustomLength, + Priority = stock.Priority, + SortOrder = stock.SortOrder + }); + } + + await _context.SaveChangesAsync(); + return duplicate; + } + + // Parts management + public async Task AddPartAsync(JobPart part) + { + var maxOrder = await _context.JobParts + .Where(p => p.JobId == part.JobId) + .MaxAsync(p => (int?)p.SortOrder) ?? -1; + part.SortOrder = maxOrder + 1; + + _context.JobParts.Add(part); + await _context.SaveChangesAsync(); + + // Update job timestamp + var job = await _context.Jobs.FindAsync(part.JobId); + if (job != null) + { + job.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(); + } + + return part; + } + + public async Task UpdatePartAsync(JobPart part) + { + _context.JobParts.Update(part); + await _context.SaveChangesAsync(); + + var job = await _context.Jobs.FindAsync(part.JobId); + if (job != null) + { + job.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(); + } + } + + public async Task DeletePartAsync(int id) + { + var part = await _context.JobParts.FindAsync(id); + if (part != null) + { + var jobId = part.JobId; + _context.JobParts.Remove(part); + await _context.SaveChangesAsync(); + + var job = await _context.Jobs.FindAsync(jobId); + if (job != null) + { + job.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(); + } + } + } + + // Stock management + public async Task AddStockAsync(JobStock stock) + { + var maxOrder = await _context.JobStocks + .Where(s => s.JobId == stock.JobId) + .MaxAsync(s => (int?)s.SortOrder) ?? -1; + stock.SortOrder = maxOrder + 1; + + _context.JobStocks.Add(stock); + await _context.SaveChangesAsync(); + + var job = await _context.Jobs.FindAsync(stock.JobId); + if (job != null) + { + job.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(); + } + + return stock; + } + + public async Task UpdateStockAsync(JobStock stock) + { + _context.JobStocks.Update(stock); + await _context.SaveChangesAsync(); + + var job = await _context.Jobs.FindAsync(stock.JobId); + if (job != null) + { + job.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(); + } + } + + public async Task DeleteStockAsync(int id) + { + var stock = await _context.JobStocks.FindAsync(id); + if (stock != null) + { + var jobId = stock.JobId; + _context.JobStocks.Remove(stock); + await _context.SaveChangesAsync(); + + var job = await _context.Jobs.FindAsync(jobId); + if (job != null) + { + job.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(); + } + } + } + + public async Task> GetAvailableStockForMaterialAsync(int materialId) + { + return await _context.StockItems + .Include(s => s.Material) + .Where(s => s.MaterialId == materialId && s.IsActive) + .OrderBy(s => s.LengthInches) + .ToListAsync(); + } + + // Cutting tools + public async Task> GetCuttingToolsAsync(bool includeInactive = false) + { + var query = _context.CuttingTools.AsQueryable(); + if (!includeInactive) + { + query = query.Where(t => t.IsActive); + } + return await query.OrderBy(t => t.Name).ToListAsync(); + } + + public async Task GetCuttingToolByIdAsync(int id) + { + return await _context.CuttingTools.FindAsync(id); + } + + public async Task GetDefaultCuttingToolAsync() + { + return await _context.CuttingTools.FirstOrDefaultAsync(t => t.IsDefault && t.IsActive); + } + + public async Task CreateCuttingToolAsync(CuttingTool tool) + { + if (tool.IsDefault) + { + // Clear other defaults + var others = await _context.CuttingTools.Where(t => t.IsDefault).ToListAsync(); + foreach (var other in others) + { + other.IsDefault = false; + } + } + + _context.CuttingTools.Add(tool); + await _context.SaveChangesAsync(); + return tool; + } + + public async Task UpdateCuttingToolAsync(CuttingTool tool) + { + if (tool.IsDefault) + { + var others = await _context.CuttingTools.Where(t => t.IsDefault && t.Id != tool.Id).ToListAsync(); + foreach (var other in others) + { + other.IsDefault = false; + } + } + + _context.CuttingTools.Update(tool); + await _context.SaveChangesAsync(); + } + + public async Task DeleteCuttingToolAsync(int id) + { + var tool = await _context.CuttingTools.FindAsync(id); + if (tool != null) + { + tool.IsActive = false; + await _context.SaveChangesAsync(); + } + } +}