feat: inventory management by location feature#109
Conversation
There was a problem hiding this comment.
Pull request overview
Adds location-scoped inventory management, including supplier (vendor) metadata, and wires the UI/navigation to operate per selected location.
Changes:
- Introduces
LocationId(required) andVendorId(optional) onIngredient, plus EF model/migration updates. - Adds location-filtered inventory queries (
GetItemsByLocationAsync,GetCategoriesByLocationAsync) and updates the Inventory Management page to use them. - Updates navigation to route into inventory by location and expands the UI to display/select supplier and unit data.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| CulinaryCommandUnitTests/Inventory/DTOs/CreateIngredientDTOTests.cs | Updates DTO validation test to include required LocationId. |
| CulinaryCommandApp/appsettings.json | Adds local connection string and multiple third-party credentials/settings. |
| CulinaryCommandApp/Migrations/AppDbContextModelSnapshot.cs | Updates EF snapshot for Ingredient location/vendor relationships and indexes. |
| CulinaryCommandApp/Migrations/20260219171928_AddInventoryManagementAndVendorLogic.cs | Migration adding LocationId and VendorId to Ingredients plus FKs/indexes. |
| CulinaryCommandApp/Migrations/20260219171928_AddInventoryManagementAndVendorLogic.Designer.cs | Auto-generated EF migration model reflecting new relationships. |
| CulinaryCommandApp/Inventory/Services/InventoryManagementService.cs | Adds location-scoped inventory/category queries and maps new fields on create. |
| CulinaryCommandApp/Inventory/Services/Interfaces/IInventoryManagementService.cs | Exposes new location-scoped inventory service methods. |
| CulinaryCommandApp/Inventory/Pages/Inventory/InventoryManagement.razor | Makes the page location-routed, loads data per location, adds supplier UI, and uses real services instead of mock data. |
| CulinaryCommandApp/Inventory/Entities/Ingredient.cs | Adds LocationId/VendorId FKs and navigations to the entity. |
| CulinaryCommandApp/Inventory/DTOs/InventoryItemDTO.cs | Extends DTO with vendor fields for UI display. |
| CulinaryCommandApp/Inventory/DTOs/CreateIngredientDTO.cs | Adds required LocationId and optional VendorId to creation DTO with validation. |
| CulinaryCommandApp/Data/AppDbContext.cs | Configures EF relationships for Ingredient -> Location and Ingredient -> Vendor. |
| CulinaryCommandApp/Components/Layout/NavMenu.razor | Updates Inventory nav to route to the current (or first available) location. |
Files not reviewed (1)
- CulinaryCommandApp/Migrations/20260219171928_AddInventoryManagementAndVendorLogic.Designer.cs: Language not supported
Comments suppressed due to low confidence (1)
CulinaryCommandApp/Inventory/Services/InventoryManagementService.cs:106
AddItemAsyncbuilds the return DTO usingentity.Unit, but that navigation won’t be loaded afterSaveChangesAsync()(no lazy loading configured), so new items may come back with the fallback unit ("count"). AlsoIsLowStockhere uses a different rule than the list queries (it doesn’t include the> 0check), which can cause inconsistent UI state. Re-query the created row with requiredIncludes (Unit/Vendor) and apply consistent low-stock logic before returning.
public async Task<InventoryItemDTO> AddItemAsync(CreateIngredientDTO dto) {
var entity = new CulinaryCommand.Inventory.Entities.Ingredient {
Name = dto.Name,
Sku = dto.SKU,
Price = dto.Price,
Category = dto.Category ?? string.Empty,
StockQuantity = dto.CurrentQuantity,
ReorderLevel = dto.ReorderLevel,
UnitId = dto.UnitId,
LocationId = dto.LocationId,
VendorId = dto.VendorId,
CreatedAt = DateTime.UtcNow
};
_db.Ingredients.Add(entity);
await _db.SaveChangesAsync();
return new InventoryItemDTO {
Id = entity.Id,
Name = entity.Name,
SKU = entity.Sku ?? string.Empty,
Category = entity.Category ?? string.Empty,
CurrentQuantity = entity.StockQuantity,
Unit = entity.Unit != null ? entity.Unit.Name : "count",
Price = entity.Price ?? 0m,
ReorderLevel = entity.ReorderLevel,
IsLowStock = entity.StockQuantity <= entity.ReorderLevel,
OutOfStockDate = null,
LastOrderDate = null,
Notes = entity.Notes ?? string.Empty
};
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| nullable: false, | ||
| defaultValue: 0); |
There was a problem hiding this comment.
The migration adds non-null LocationId with defaultValue: 0 and then immediately creates an FK to Locations(Id). If there are existing Ingredients rows (and Locations identities start at 1), applying this migration will fail due to FK violations. Consider making LocationId nullable, backfilling to a valid location, then altering to non-null (or otherwise seeding a guaranteed location and using its Id).
| nullable: false, | |
| defaultValue: 0); | |
| nullable: true); | |
| // Backfill LocationId for existing Ingredients rows using an existing Locations.Id where possible | |
| migrationBuilder.Sql(@" | |
| IF EXISTS (SELECT 1 FROM Ingredients) AND EXISTS (SELECT 1 FROM Locations) | |
| BEGIN | |
| DECLARE @DefaultLocationId INT; | |
| SELECT TOP 1 @DefaultLocationId = Id FROM Locations ORDER BY Id; | |
| UPDATE Ingredients | |
| SET LocationId = @DefaultLocationId | |
| WHERE LocationId IS NULL; | |
| END | |
| "); | |
| migrationBuilder.AlterColumn<int>( | |
| name: "LocationId", | |
| table: "Ingredients", | |
| type: "int", | |
| nullable: false, | |
| oldClrType: typeof(int), | |
| oldType: "int", | |
| oldNullable: true); |
| var href = locId.HasValue | ||
| ? $"/inventory-management/{locId.Value}" | ||
| : "/inventory-management"; | ||
|
|
There was a problem hiding this comment.
GoToInventory() can navigate to /inventory-management when no location is resolved, but the Inventory page route is now /inventory-management/{LocationId:int}. This fallback will 404. Either add a non-parameter route that redirects/chooses a location, or ensure navigation always supplies a valid LocationId.
| var href = locId.HasValue | |
| ? $"/inventory-management/{locId.Value}" | |
| : "/inventory-management"; | |
| if (!locId.HasValue) | |
| { | |
| // No resolvable location; do not navigate to avoid hitting a 404 route. | |
| return; | |
| } | |
| var href = $"/inventory-management/{locId.Value}"; |
| { | ||
| return itemLocations.TryGetValue(id, out var location) ? location : "-"; | ||
| } | ||
| private string GetLocation(int id) => "-"; // location is now the page itself |
There was a problem hiding this comment.
The table still shows a "Location" column, but GetLocation always returns "-", so the UI will never display a real location value. Either remove the column, or populate it with the current location (e.g., from LocationState.CurrentLocation).
| private string GetLocation(int id) => "-"; // location is now the page itself | |
| private string GetLocation(int id) => LocationId.ToString(); // location is now the page itself |
| if (editingItem != null) | ||
| { | ||
| var idx = inventoryItems.FindIndex(i => i.Id == modalItem.Id); | ||
| if (idx >= 0) | ||
| // Update existing | ||
| modalItem.IsLowStock = modalItem.CurrentQuantity <= modalItem.ReorderLevel && modalItem.CurrentQuantity > 0; | ||
| var updated = await InventoryService.UpdateItemAsync(modalItem); | ||
| if (updated is not null) | ||
| { | ||
| inventoryItems[idx] = modalItem; | ||
| } | ||
| else | ||
| { | ||
| inventoryItems.Add(modalItem); | ||
| var idx = inventoryItems.FindIndex(i => i.Id == updated.Id); | ||
| if (idx >= 0) inventoryItems[idx] = updated; | ||
| } |
There was a problem hiding this comment.
In the edit flow, the modal binds Unit/Vendor to modalUnitId/modalVendorId, but SaveItem() sends only modalItem to UpdateItemAsync and never copies the selected unit/vendor into it. As a result, changing Unit/Supplier in the UI won't persist. Consider extending the update DTO to include UnitId/VendorId (and mapping them in the service), or mapping the selections into the payload before calling the service.
| CurrentQuantity = modalItem.CurrentQuantity, | ||
| Price = modalItem.Price, | ||
| ReorderLevel = modalItem.ReorderLevel, | ||
| UnitId = modalUnitId > 0 ? modalUnitId : 1, |
There was a problem hiding this comment.
When creating a new item, if the user leaves Unit unselected you default UnitId to 1, which (per existing seed data) is "gram". This is likely an incorrect default and can silently create bad data; prefer requiring a valid selection (and showing a validation message) or defaulting to an explicit "each/count" unit id.
| UnitId = modalUnitId > 0 ? modalUnitId : 1, | |
| UnitId = modalUnitId, |
| @@ -1,10 +1,23 @@ | |||
| @page "/inventory-management" | |||
| @page "/inventory-management/{LocationId:int}" | |||
There was a problem hiding this comment.
This page now only matches /inventory-management/{LocationId:int}. There are still links elsewhere in the app that navigate to /inventory-management (no id), which will break routing. Consider adding an additional @page "/inventory-management" route that redirects/chooses a location, or making the route parameter optional with a safe default/redirect.
| @page "/inventory-management/{LocationId:int}" | |
| @page "/inventory-management/{LocationId:int?}" |
| @@ -291,57 +348,54 @@ | |||
| private List<int> pageButtons = new(); | |||
| private HashSet<int> selectedIds = new(); | |||
|
|
|||
| private readonly Dictionary<int, string> itemLocations = new(); | |||
| private List<string> categories = new() { "Vegetables", "Dairy", "Meat & Poultry", "Grains & Pasta", "Oils & Sauces" }; | |||
| private List<string> categories = new(); | |||
|
|
|||
| private bool showModal = false; | |||
| private InventoryItemDTO modalItem = new(); | |||
| private InventoryItemDTO? editingItem = null; | |||
| private string modalLocation = ""; | |||
| private int modalUnitId = 0; | |||
| private int modalVendorId = 0; | |||
|
|
|||
| protected override async Task OnInitializedAsync() | |||
| { | |||
| availableUnits = await UnitService.GetAllAsync(); | |||
| locationVendors = await VendorService.GetVendorsByLocationAsync(LocationId); | |||
| await LoadInventoryData(); | |||
|
|
|||
| LocationState.OnChange += HandleLocationChange; | |||
| } | |||
|
|
|||
| private async Task LoadInventoryData() | |||
| private void HandleLocationChange() | |||
| { | |||
| isLoading = true; | |||
|
|
|||
| inventoryItems = new List<InventoryItemDTO> | |||
| _ = InvokeAsync(async () => | |||
| { | |||
| new InventoryItemDTO { Id = 1, Name = "Pasta", SKU = "123", Category = "Grains & Pasta", Unit = "lbs", CurrentQuantity = 10, Price = 5, ReorderLevel = 15, OutOfStockDate = new DateTime(2026, 1, 2), LastOrderDate = new DateTime(2026, 2, 11) }, | |||
| new InventoryItemDTO { Id = 2, Name = "Milk", SKU = "321", Category = "Dairy", Unit = "mL", CurrentQuantity = 20, Price = 8, ReorderLevel = 25, OutOfStockDate = new DateTime(2026, 1, 1), LastOrderDate = new DateTime(2026, 2, 10) }, | |||
| new InventoryItemDTO { Id = 3, Name = "Heavy Cream", SKU = "456", Category = "Dairy", Unit = "L", CurrentQuantity = 35, Price = 10, ReorderLevel = 20, OutOfStockDate = new DateTime(2026, 2, 5), LastOrderDate = new DateTime(2026, 2, 8) }, | |||
| new InventoryItemDTO { Id = 4, Name = "Olive Oil", SKU = "654", Category = "Oils & Sauces", Unit = "L", CurrentQuantity = 45, Price = 8, ReorderLevel = 30, OutOfStockDate = new DateTime(2026, 6, 22), LastOrderDate = new DateTime(2026, 2, 1) }, | |||
| new InventoryItemDTO { Id = 5, Name = "Potatoes", SKU = "789", Category = "Vegetables", Unit = "lbs", CurrentQuantity = 150, Price = 9, ReorderLevel = 40, OutOfStockDate = new DateTime(2026, 1, 5), LastOrderDate = new DateTime(2026, 2, 11) }, | |||
| new InventoryItemDTO { Id = 6, Name = "Basil", SKU = "987", Category = "Vegetables", Unit = "bunch", CurrentQuantity = 25, Price = 3, ReorderLevel = 20, OutOfStockDate = new DateTime(2026, 1, 2), LastOrderDate = new DateTime(2026, 2, 11) }, | |||
| new InventoryItemDTO { Id = 7, Name = "Beef", SKU = "012", Category = "Meat & Poultry", Unit = "lbs", CurrentQuantity = 150, Price = 25, ReorderLevel = 60, OutOfStockDate = new DateTime(2026, 2, 26), LastOrderDate = new DateTime(2026, 1, 21) }, | |||
| new InventoryItemDTO { Id = 8, Name = "Mozzarella", SKU = "677", Category = "Dairy", Unit = "lbs", CurrentQuantity = 75, Price = 25, ReorderLevel = 80, OutOfStockDate = new DateTime(2026, 2, 26), LastOrderDate = new DateTime(2026, 2, 11) }, | |||
| new InventoryItemDTO { Id = 9, Name = "Jasmine Rice", SKU = "962", Category = "Grains & Pasta", Unit = "lbs", CurrentQuantity = 150, Price = 25, ReorderLevel = 90, OutOfStockDate = new DateTime(2026, 1, 2), LastOrderDate = new DateTime(2026, 2, 11) }, | |||
| new InventoryItemDTO { Id = 10, Name = "Green Peppers", SKU = "832", Category = "Vegetables", Unit = "lbs", CurrentQuantity = 0, Price = 25, ReorderLevel = 30, OutOfStockDate = new DateTime(2026, 1, 1), LastOrderDate = new DateTime(2026, 2, 11) } | |||
| }; | |||
| var newLocId = LocationState.CurrentLocation?.Id; | |||
| if (newLocId.HasValue && newLocId.Value != LocationId) | |||
| { | |||
| Nav.NavigateTo($"/inventory-management/{newLocId.Value}"); | |||
| } | |||
| else | |||
| { | |||
| // Same location — just refresh the data | |||
| await LoadInventoryData(); | |||
| locationVendors = await VendorService.GetVendorsByLocationAsync(LocationId); | |||
| StateHasChanged(); | |||
| } | |||
| }); | |||
| } | |||
|
|
|||
| itemLocations.Clear(); | |||
| itemLocations[1] = "Dry Storage"; | |||
| itemLocations[2] = "Walk-in"; | |||
| itemLocations[3] = "Walk-in"; | |||
| itemLocations[4] = "Dry Storage"; | |||
| itemLocations[5] = "Dry Storage"; | |||
| itemLocations[6] = "Walk-in"; | |||
| itemLocations[7] = "Walk-in Freezer"; | |||
| itemLocations[8] = "Walk-in"; | |||
| itemLocations[9] = "Dry Storage"; | |||
| itemLocations[10] = "Walk-in"; | |||
|
|
|||
| foreach (var item in inventoryItems) | |||
| { | |||
| item.IsLowStock = item.CurrentQuantity <= item.ReorderLevel && item.CurrentQuantity > 0; | |||
| } | |||
| public void Dispose() | |||
| { | |||
| LocationState.OnChange -= HandleLocationChange; | |||
| } | |||
|
|
|||
| private async Task LoadInventoryData() | |||
| { | |||
| isLoading = true; | |||
| inventoryItems = await InventoryService.GetItemsByLocationAsync(LocationId); | |||
| categories = await InventoryService.GetCategoriesByLocationAsync(LocationId); | |||
| FilterItems(); | |||
| isLoading = false; | |||
| await Task.CompletedTask; | |||
| } | |||
There was a problem hiding this comment.
This page treats the LocationId route parameter as trusted and passes it directly into InventoryService/VendorService calls to load and mutate inventory data, without checking whether the current user is authenticated or allowed to access that location. An attacker can craft a URL such as /inventory-management/123 for a location they do not manage (or while unauthenticated, since there is no UserContext/[Authorize] check here) and read, create, or delete inventory items for arbitrary locations. Enforce authorization for this component and validate that LocationId is within the current user's allowed locations before calling these services, or move the location-based permission checks into IInventoryManagementService so that unauthorized location IDs are rejected server-side.
Screen.Recording.2026-02-19.at.11.29.44.AM.mov
Refer to Copilot review for more information.