diff --git a/Groceries.Data/AppDbContext.cs b/Groceries.Data/AppDbContext.cs index 64c2e41..2c28ba9 100644 --- a/Groceries.Data/AppDbContext.cs +++ b/Groceries.Data/AppDbContext.cs @@ -1,5 +1,6 @@ namespace Groceries.Data; +using Humanizer; using Microsoft.EntityFrameworkCore; public class AppDbContext : DbContext @@ -81,7 +82,8 @@ public class AppDbContext : DbContext entity.HasMany(e => e.Items) .WithMany(e => e.TransactionPromotions) - .UsingEntity(); + .UsingEntity() + .ToTable("transaction_promotion_items"); }); modelBuilder.Entity(entity => @@ -95,7 +97,7 @@ public class AppDbContext : DbContext var idProperty = entity.FindProperty("Id"); if (idProperty != null) { - idProperty.SetColumnName($"{entity.ClrType.Name.ToLowerInvariant()}_id"); + idProperty.SetColumnName($"{entity.ClrType.Name.Underscore().ToLowerInvariant()}_id"); idProperty.SetDefaultValueSql(string.Empty); } } diff --git a/Groceries.Data/Groceries.Data.csproj b/Groceries.Data/Groceries.Data.csproj index 1e71fa8..19fb594 100644 --- a/Groceries.Data/Groceries.Data.csproj +++ b/Groceries.Data/Groceries.Data.csproj @@ -11,6 +11,7 @@ + diff --git a/Groceries.Data/Items/Item.cs b/Groceries.Data/Items/Item.cs index 3f7e801..ed774da 100644 --- a/Groceries.Data/Items/Item.cs +++ b/Groceries.Data/Items/Item.cs @@ -1,7 +1,10 @@ namespace Groceries.Data; +using System.Text.Json.Serialization; + public class Item { + [JsonConstructor] public Item(Guid id, string brand, string name) { Id = id; diff --git a/Groceries.Data/Transactions/Transaction.cs b/Groceries.Data/Transactions/Transaction.cs index 7a22c64..ea06e6a 100644 --- a/Groceries.Data/Transactions/Transaction.cs +++ b/Groceries.Data/Transactions/Transaction.cs @@ -24,4 +24,6 @@ public class Transaction public ICollection Promotions { get; init; } = new List(); public Store? Store { get; init; } + + public decimal Total => Items.Sum(item => item.Price * item.Quantity) - Promotions.Sum(promotion => promotion.Amount); } diff --git a/Groceries.Data/Transactions/TransactionItem.cs b/Groceries.Data/Transactions/TransactionItem.cs index 35cf335..1aa880b 100644 --- a/Groceries.Data/Transactions/TransactionItem.cs +++ b/Groceries.Data/Transactions/TransactionItem.cs @@ -18,9 +18,11 @@ public class TransactionItem } public Guid TransactionId { get; init; } - public Guid ItemId { get; init; } + public Guid ItemId { get; set; } public decimal Price { get; set; } public int Quantity { get; set; } public Item? Item { get; init; } + + public decimal Amount => Price * Quantity; } diff --git a/Groceries.Data/Transactions/TransactionPromotion.cs b/Groceries.Data/Transactions/TransactionPromotion.cs index 60f9aa6..28a3aa5 100644 --- a/Groceries.Data/Transactions/TransactionPromotion.cs +++ b/Groceries.Data/Transactions/TransactionPromotion.cs @@ -1,7 +1,10 @@ namespace Groceries.Data; +using System.Text.Json.Serialization; + public class TransactionPromotion { + [JsonConstructor] public TransactionPromotion(Guid id, Guid transactionId, string name, decimal amount) { Id = id; @@ -10,12 +13,16 @@ public class TransactionPromotion Amount = amount; } - public Guid Id { get; set; } - public Guid TransactionId { get; set; } - public string Name { get; set; } = null!; + public TransactionPromotion(string name, decimal amount) : this(Guid.NewGuid(), default, name, amount) + { + } + + public Guid Id { get; init; } + public Guid TransactionId { get; init; } + public string Name { get; set; } public decimal Amount { get; set; } - public ICollection Items { get; init; } = new List(); + public ICollection Items { get; set; } = new List(); public Transaction? Transaction { get; init; } } diff --git a/Groceries/Groceries.csproj b/Groceries/Groceries.csproj index fa1bb8e..e59bc24 100644 --- a/Groceries/Groceries.csproj +++ b/Groceries/Groceries.csproj @@ -12,7 +12,6 @@ - diff --git a/Groceries/Transactions/EditTransactionPromotion.cshtml b/Groceries/Transactions/EditTransactionPromotion.cshtml new file mode 100644 index 0000000..6d8e5a8 --- /dev/null +++ b/Groceries/Transactions/EditTransactionPromotion.cshtml @@ -0,0 +1,32 @@ +@using Groceries.Data; +@using Microsoft.EntityFrameworkCore; + +@model (Transaction Transaction, TransactionPromotion Promotion) +@inject AppDbContext dbContext +@{ + ViewBag.Title = "Edit Transaction Promotion"; + + var store = await dbContext.Stores + .Where(store => store.Id == Model.Transaction.StoreId) + .Select(store => string.Concat(store.Retailer!.Name, " ", store.Name)) + .SingleAsync(); +} + +

Edit Transaction Promotion

+ +
+ @Model.Transaction.CreatedAt.ToShortDateString() @Model.Transaction.CreatedAt.ToShortTimeString() – @store +
+ +
+ + + +
+ +
+ + Cancel + + +
diff --git a/Groceries/Transactions/EditTransactionPromotion_Modal.cshtml b/Groceries/Transactions/EditTransactionPromotion_Modal.cshtml new file mode 100644 index 0000000..6240816 --- /dev/null +++ b/Groceries/Transactions/EditTransactionPromotion_Modal.cshtml @@ -0,0 +1,20 @@ +@using Groceries.Data + +@model (Transaction Transaction, TransactionPromotion Promotion) +@{ + Layout = "_Modal"; + ViewBag.Title = "Edit Transaction Promotion"; +} + +
+ + + +
+ +
+ + + + +
diff --git a/Groceries/Transactions/NewTransactionItems.cshtml b/Groceries/Transactions/NewTransactionItems.cshtml index b5675cb..e300446 100644 --- a/Groceries/Transactions/NewTransactionItems.cshtml +++ b/Groceries/Transactions/NewTransactionItems.cshtml @@ -12,10 +12,9 @@ .SingleAsync(); var itemIds = Model.Items.Select(item => item.ItemId); - var items = await dbContext.Items + var itemNames = await dbContext.Items .Where(item => itemIds.Contains(item.Id)) - .Select(item => new { item.Id, Name = string.Concat(item.Brand, " ", item.Name) }) - .ToListAsync(); + .ToDictionaryAsync(item => item.Id, item => string.Concat(item.Brand, " ", item.Name)); }

New Transaction

@@ -45,7 +44,7 @@ { - @items.Single(i => i.Id == item.ItemId).Name + @itemNames[item.ItemId] @item.Price.ToString("c") @@ -54,7 +53,7 @@ @item.Quantity - @((item.Price * item.Quantity).ToString("c")) + @item.Amount.ToString("c") Edit @@ -64,9 +63,9 @@ - Total + Subtotal - @Model.Items.Sum(item => item.Price * item.Quantity).ToString("c") + @Model.Items.Sum(item => item.Amount).ToString("c") @@ -76,7 +75,7 @@
- - Cancel + + Back
diff --git a/Groceries/Transactions/NewTransactionPromotion.cshtml b/Groceries/Transactions/NewTransactionPromotion.cshtml new file mode 100644 index 0000000..6146e7a --- /dev/null +++ b/Groceries/Transactions/NewTransactionPromotion.cshtml @@ -0,0 +1,26 @@ +@using Groceries.Data; +@using Microsoft.EntityFrameworkCore; + +@model Transaction +@inject AppDbContext dbContext +@{ + ViewBag.Title = "New Transaction Promotion"; + + var store = await dbContext.Stores + .Where(store => store.Id == Model.StoreId) + .Select(store => string.Concat(store.Retailer!.Name, " ", store.Name)) + .SingleAsync(); +} + +

New Transaction Promotion

+ +
@Model.CreatedAt.ToShortDateString() @Model.CreatedAt.ToShortTimeString() – @store
+ +
+ + +
+ + Cancel +
+ diff --git a/Groceries/Transactions/NewTransactionPromotion_Modal.cshtml b/Groceries/Transactions/NewTransactionPromotion_Modal.cshtml new file mode 100644 index 0000000..7820e70 --- /dev/null +++ b/Groceries/Transactions/NewTransactionPromotion_Modal.cshtml @@ -0,0 +1,16 @@ +@using Groceries.Data + +@model Transaction +@{ + Layout = "_Modal"; + ViewBag.Title = "New Transaction Promotion"; +} + +
+ + + +
+ + +
diff --git a/Groceries/Transactions/NewTransactionPromotions.cshtml b/Groceries/Transactions/NewTransactionPromotions.cshtml new file mode 100644 index 0000000..9addd3a --- /dev/null +++ b/Groceries/Transactions/NewTransactionPromotions.cshtml @@ -0,0 +1,74 @@ +@using Groceries.Data; +@using Microsoft.EntityFrameworkCore; + +@model Transaction +@inject AppDbContext dbContext +@{ + ViewBag.Title = "New Transaction"; + + var store = await dbContext.Stores + .Where(store => store.Id == Model.StoreId) + .Select(store => string.Concat(store.Retailer!.Name, " ", store.Name)) + .SingleAsync(); +} + +

New Transaction

+ +
@Model.CreatedAt.ToShortDateString() @Model.CreatedAt.ToLongTimeString() – @store
+ +
+
+
+

Promotions

+ + New promotion + +
+ +
+ + + + + + + + + + + @foreach (var promotion in Model.Promotions) + { + + + + + + + } + + + + + + + + +
NameItemsAmount
+ @promotion.Name + + @promotion.Items.Sum(item => Model.Items.Single(i => i.ItemId == item.Id).Quantity) + + @(-promotion.Amount) + + Edit +
Total + @Model.Total.ToString("c") +
+
+
+ +
+ + Back +
+
diff --git a/Groceries/Transactions/TransactionsController.cs b/Groceries/Transactions/TransactionsController.cs index e5096eb..f715fbc 100644 --- a/Groceries/Transactions/TransactionsController.cs +++ b/Groceries/Transactions/TransactionsController.cs @@ -88,17 +88,14 @@ public class TransactionsController : Controller } [HttpPost("new/items")] - public async Task PostNewTransactionItems() + public IActionResult PostNewTransactionItems() { - if (TempData["NewTransaction"] is not string json || JsonSerializer.Deserialize(json) is not Transaction transaction) + if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize(json) is not Transaction transaction) { return RedirectToAction(nameof(NewTransaction)); } - dbContext.Transactions.Add(transaction); - await dbContext.SaveChangesAsync(); - - return RedirectToAction(nameof(Index), new { page = 1 }); + return RedirectToAction(nameof(NewTransactionPromotions)); } [HttpGet("new/items/new")] @@ -186,26 +183,17 @@ public class TransactionsController : Controller .Select(item => item.Id) .SingleOrDefaultAsync(); - if (itemId == transactionItem.ItemId) + if (itemId == default) { - transactionItem.Price = price; - transactionItem.Quantity = quantity; + var item = new Item(brand, name); + dbContext.Items.Add(item); + await dbContext.SaveChangesAsync(); + itemId = item.Id; } - else - { - if (itemId == default) - { - var item = new Item(brand, name); - dbContext.Items.Add(item); - await dbContext.SaveChangesAsync(); - itemId = item.Id; - } - transaction.Items.Remove(transactionItem); - - transactionItem = new TransactionItem(itemId, price, quantity); - transaction.Items.Add(transactionItem); - } + transactionItem.ItemId = itemId; + transactionItem.Price = price; + transactionItem.Quantity = quantity; TempData["NewTransaction"] = JsonSerializer.Serialize(transaction); @@ -234,4 +222,131 @@ public class TransactionsController : Controller ? NoContent() : RedirectToAction(nameof(NewTransactionItems)); } + + [HttpGet("new/promotions")] + public IActionResult NewTransactionPromotions() + { + if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize(json) is not Transaction transaction) + { + return RedirectToAction(nameof(NewTransaction)); + } + + return View(transaction); + } + + [HttpPost("new/promotions")] + public async Task PostNewTransactionPromotions() + { + if (TempData["NewTransaction"] is not string json || JsonSerializer.Deserialize(json) is not Transaction transaction) + { + return RedirectToAction(nameof(NewTransaction)); + } + + // Work around EF trying to insert items by explicitly tracking them as unchanged + dbContext.Items.AttachRange(transaction.Promotions.SelectMany(promotion => promotion.Items)); + + dbContext.Transactions.Add(transaction); + await dbContext.SaveChangesAsync(); + + return RedirectToAction(nameof(Index), new { page = 1 }); + } + + [HttpGet("new/promotions/new")] + public IActionResult NewTransactionPromotion() + { + if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize(json) is not Transaction transaction) + { + return RedirectToAction(nameof(NewTransaction)); + } + + return Request.IsTurboFrameRequest("modal") + ? View($"{nameof(NewTransactionPromotion)}_Modal", transaction) + : View(transaction); + } + + [HttpPost("new/promotions/new")] + public IActionResult NewTransactionPromotion(string name, decimal amount, Guid[] itemIds) + { + if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize(json) is not Transaction transaction) + { + return RedirectToAction(nameof(NewTransaction)); + } + + // TODO: Handle promotion already in transaction - merge, replace, error? + + var promotion = new TransactionPromotion(name, amount) { Items = itemIds.Select(id => new Item(id)).ToArray() }; + transaction.Promotions.Add(promotion); + + TempData["NewTransaction"] = JsonSerializer.Serialize(transaction); + + return Request.IsTurboFrameRequest("modal") + ? NoContent() + : RedirectToAction(nameof(NewTransactionPromotions)); + } + + [HttpGet("new/promotions/edit/{id}")] + public IActionResult EditTransactionPromotion(Guid id) + { + if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize(json) is not Transaction transaction) + { + return RedirectToAction(nameof(NewTransaction)); + } + + var promotion = transaction.Promotions.SingleOrDefault(promotion => promotion.Id == id); + if (promotion == null) + { + return RedirectToAction(nameof(NewTransactionPromotions)); + } + + var model = (transaction, promotion); + return Request.IsTurboFrameRequest("modal") + ? View($"{nameof(EditTransactionPromotion)}_Modal", model) + : View(model); + } + + [HttpPost("new/promotions/edit/{id}")] + public IActionResult EditTransactionPromotion(Guid id, string name, decimal amount, Guid[] itemIds) + { + if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize(json) is not Transaction transaction) + { + return RedirectToAction(nameof(NewTransaction)); + } + + var promotion = transaction.Promotions.SingleOrDefault(promotion => promotion.Id == id); + if (promotion == null) + { + return RedirectToAction(nameof(NewTransactionPromotions)); + } + + promotion.Name = name; + promotion.Amount = amount; + promotion.Items = itemIds.Select(id => new Item(id)).ToArray(); + + TempData["NewTransaction"] = JsonSerializer.Serialize(transaction); + + return Request.IsTurboFrameRequest("modal") + ? NoContent() + : RedirectToAction(nameof(NewTransactionPromotions)); + } + + [HttpPost("new/promotions/delete/{id}")] + public IActionResult DeleteTransactionPromotion(Guid id) + { + if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize(json) is not Transaction transaction) + { + return RedirectToAction(nameof(NewTransaction)); + } + + var promotion = transaction.Promotions.SingleOrDefault(promotion => promotion.Id == id); + if (promotion != null) + { + transaction.Promotions.Remove(promotion); + } + + TempData["NewTransaction"] = JsonSerializer.Serialize(transaction); + + return Request.IsTurboFrameRequest("modal") + ? NoContent() + : RedirectToAction(nameof(NewTransactionPromotions)); + } } diff --git a/Groceries/Transactions/_TransactionItemForm.cshtml b/Groceries/Transactions/_TransactionItemForm.cshtml index 4d92d3c..1c6ad6c 100644 --- a/Groceries/Transactions/_TransactionItemForm.cshtml +++ b/Groceries/Transactions/_TransactionItemForm.cshtml @@ -6,7 +6,8 @@ @{ var items = await dbContext.Items .OrderBy(item => item.Brand) - .ToListAsync(); + .ThenBy(item => item.Name) + .ToArrayAsync(); var selectedItem = items.SingleOrDefault(item => item.Id == Model?.ItemId); } @@ -25,7 +26,7 @@ - @foreach (var item in items.OrderBy(item => item.Name)) + @foreach (var item in items) {