Skip to content

feat: inventory management by location feature#109

Merged
kevbang merged 3 commits intomainfrom
feat/inventory-management
Feb 19, 2026
Merged

feat: inventory management by location feature#109
kevbang merged 3 commits intomainfrom
feat/inventory-management

Conversation

@kevbang
Copy link
Copy Markdown
Collaborator

@kevbang kevbang commented Feb 19, 2026

Screen.Recording.2026-02-19.at.11.29.44.AM.mov

Refer to Copilot review for more information.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) and VendorId (optional) on Ingredient, 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

  • AddItemAsync builds the return DTO using entity.Unit, but that navigation won’t be loaded after SaveChangesAsync() (no lazy loading configured), so new items may come back with the fallback unit ("count"). Also IsLowStock here uses a different rule than the list queries (it doesn’t include the > 0 check), which can cause inconsistent UI state. Re-query the created row with required Includes (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.

Comment on lines +17 to +18
nullable: false,
defaultValue: 0);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +126 to +129
var href = locId.HasValue
? $"/inventory-management/{locId.Value}"
: "/inventory-management";

Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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}";

Copilot uses AI. Check for mistakes.
{
return itemLocations.TryGetValue(id, out var location) ? location : "-";
}
private string GetLocation(int id) => "-"; // location is now the page itself
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
private string GetLocation(int id) => "-"; // location is now the page itself
private string GetLocation(int id) => LocationId.ToString(); // location is now the page itself

Copilot uses AI. Check for mistakes.
Comment on lines 660 to 669
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;
}
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
CurrentQuantity = modalItem.CurrentQuantity,
Price = modalItem.Price,
ReorderLevel = modalItem.ReorderLevel,
UnitId = modalUnitId > 0 ? modalUnitId : 1,
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
UnitId = modalUnitId > 0 ? modalUnitId : 1,
UnitId = modalUnitId,

Copilot uses AI. Check for mistakes.
@@ -1,10 +1,23 @@
@page "/inventory-management"
@page "/inventory-management/{LocationId:int}"
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
@page "/inventory-management/{LocationId:int}"
@page "/inventory-management/{LocationId:int?}"

Copilot uses AI. Check for mistakes.
Comment on lines 331 to 399
@@ -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;
}
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@kevbang kevbang merged commit f73a400 into main Feb 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants