Add ability to enter promotions when creating transaction

This commit is contained in:
James Chapman 2023-07-24 15:17:44 +01:00
parent c0c1743d78
commit bb2820f7df
Signed by: jamsch0
GPG Key ID: 765FE58130277547
17 changed files with 382 additions and 42 deletions

View File

@ -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<TransactionPromotionItem>();
.UsingEntity<TransactionPromotionItem>()
.ToTable("transaction_promotion_items");
});
modelBuilder.Entity<TransactionTotal>(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);
}
}

View File

@ -11,6 +11,7 @@
<ItemGroup>
<PackageReference Include="DbUp-PostgreSQL" Version="5.0.8" />
<PackageReference Include="EFCore.NamingConventions" Version="7.0.2" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.4" />
</ItemGroup>

View File

@ -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;

View File

@ -24,4 +24,6 @@ public class Transaction
public ICollection<TransactionPromotion> Promotions { get; init; } = new List<TransactionPromotion>();
public Store? Store { get; init; }
public decimal Total => Items.Sum(item => item.Price * item.Quantity) - Promotions.Sum(promotion => promotion.Amount);
}

View File

@ -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;
}

View File

@ -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<Item> Items { get; init; } = new List<Item>();
public ICollection<Item> Items { get; set; } = new List<Item>();
public Transaction? Transaction { get; init; }
}

View File

@ -12,7 +12,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="7.0.9" />
<PackageReference Include="Microsoft.Web.LibraryManager.Build" Version="2.1.175" />
</ItemGroup>

View File

@ -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();
}
<h1>Edit Transaction Promotion</h1>
<div class="form-field">
@Model.Transaction.CreatedAt.ToShortDateString() @Model.Transaction.CreatedAt.ToShortTimeString() &ndash; @store
</div>
<form id="editTransactionPromotion" method="post" asp-action="EditTransactionPromotion">
<partial name="_TransactionPromotionForm" model="Model" />
</form>
<form id="deleteTransactionPromotion" method="post" asp-action="DeleteTransactionPromotion" asp-route-id="@Model.Promotion.Id"></form>
<div class="row">
<button class="button button--primary" type="submit" form="editTransactionPromotion">Update</button>
<a class="button" asp-action="NewTransactionPromotions">Cancel</a>
<span class="row__fill"></span>
<button class="button button--danger" type="submit" form="deleteTransactionPromotion">Remove</button>
</div>

View File

@ -0,0 +1,20 @@
@using Groceries.Data
@model (Transaction Transaction, TransactionPromotion Promotion)
@{
Layout = "_Modal";
ViewBag.Title = "Edit Transaction Promotion";
}
<form class="card__content" id="editTransactionPromotion" method="post" asp-action="EditTransactionPromotion" data-action="turbo:submit-end->modal#close">
<partial name="_TransactionPromotionForm" model="Model" />
</form>
<form id="deleteTransactionPromotion" method="post" asp-action="DeleteTransactionPromotion" asp-route-id="@Model.Promotion.Id" data-action="turbo:submit-end->modal#close"></form>
<footer class="card__footer card__footer--shaded row">
<button class="button button--primary" type="submit" form="editTransactionPromotion">Update</button>
<button class="button" data-action="modal#close">Cancel</button>
<span class="row__fill"></span>
<button class="button button--danger" type="submit" form="deleteTransactionPromotion">Remove</button>
</footer>

View File

@ -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));
}
<h1>New Transaction</h1>
@ -45,7 +44,7 @@
{
<tr>
<td class="table__cell table__cell--compact">
@items.Single(i => i.Id == item.ItemId).Name
@itemNames[item.ItemId]
</td>
<td class="table__cell table__cell--compact table__cell--numeric">
@item.Price.ToString("c")
@ -54,7 +53,7 @@
@item.Quantity
</td>
<td class="table__cell table__cell--compact table__cell--numeric">
@((item.Price * item.Quantity).ToString("c"))
@item.Amount.ToString("c")
</td>
<td class="table__cell table__cell--compact">
<a class="link" asp-action="EditTransactionItem" asp-route-id="@item.ItemId" data-turbo-frame="modal">Edit</a>
@ -64,9 +63,9 @@
</tbody>
<tfoot>
<tr>
<td class="table__cell table__cell--compact table__cell--total" colspan="3">Total</td>
<td class="table__cell table__cell--compact table__cell--total" colspan="3">Subtotal</td>
<td class="table__cell table__cell--compact table__cell--numeric table__cell--total">
@Model.Items.Sum(item => item.Price * item.Quantity).ToString("c")
@Model.Items.Sum(item => item.Amount).ToString("c")
</td>
<td class="table__cell table__cell--compact"></td>
</tr>
@ -76,7 +75,7 @@
</div>
<div class="row">
<button class="button button--primary" type="submit" @(Model.Items.Count == 0 ? "disabled" : "")>Save</button>
<a class="button" asp-action="Index">Cancel</a>
<button class="button button--primary" type="submit" @(Model.Items.Count == 0 ? "disabled" : "")>Next</button>
<a class="button" asp-action="NewTransaction">Back</a>
</div>
</form>

View File

@ -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();
}
<h1>New Transaction Promotion</h1>
<div class="form-field">@Model.CreatedAt.ToShortDateString() @Model.CreatedAt.ToShortTimeString() &ndash; @store</div>
<form method="post" asp-action="NewTransactionPromotion">
<partial name="_TransactionPromotionForm" model="(Model, (TransactionPromotion?)null)" />
<div class="row">
<button class="button button--primary" type="submit">Add</button>
<a class="button" asp-action="NewTransactionPromotions">Cancel</a>
</div>
</form>

View File

@ -0,0 +1,16 @@
@using Groceries.Data
@model Transaction
@{
Layout = "_Modal";
ViewBag.Title = "New Transaction Promotion";
}
<form class="card__content" id="newTransactionPromotion" method="post" asp-action="NewTransactionPromotion" data-action="turbo:submit-end->modal#close">
<partial name="_TransactionPromotionForm" model="(Model, (TransactionPromotion?)null)" />
</form>
<footer class="card__footer card__footer--shaded row">
<button class="button button--primary" type="submit" form="newTransactionPromotion">Add</button>
<button class="button" data-action="modal#close">Cancel</button>
</footer>

View File

@ -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();
}
<h1>New Transaction</h1>
<div class="form-field">@Model.CreatedAt.ToShortDateString() @Model.CreatedAt.ToLongTimeString() &ndash; @store</div>
<form method="post" asp-action="NewTransactionPromotions">
<div class="card form-field">
<div class="card__header row">
<h2 class="row__fill">Promotions</h2>
<a class="button button--primary" asp-action="NewTransactionPromotion" autofocus data-turbo-frame="modal">
New promotion
</a>
</div>
<div class="card__content card__content--table">
<table>
<thead>
<tr>
<th scope="col" class="table__header" style="width: 100%">Name</th>
<th scope="col" class="table__header">Items</th>
<th scope="col" class="table__header">Amount</th>
<th scope="col" class="table__header"></th>
</tr>
</thead>
<tbody>
@foreach (var promotion in Model.Promotions)
{
<tr>
<td class="table__cell table__cell--compact">
@promotion.Name
</td>
<td class="table__cell table__cell--compact table__cell--numeric">
@promotion.Items.Sum(item => Model.Items.Single(i => i.ItemId == item.Id).Quantity)
</td>
<td class="table__cell table__cell--compact table__cell--numeric">
@(-promotion.Amount)
</td>
<td class="table__cell table__cell--compact">
<a class="link" asp-action="EditTransactionPromotion" asp-route-id="@promotion.Id" data-turbo-frame="modal">Edit</a>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td class="table__cell table__cell--compact table__cell--total" colspan="3">Total</td>
<td class="table__cell table__cell--compact table__cell--numeric table__cell--total">
@Model.Total.ToString("c")
</td>
<td class="table__cell table__cell--compact"></td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="row">
<button class="button button--primary" type="submit" @(Model.Items.Count == 0 ? "disabled" : "")>Save</button>
<a class="button" asp-action="NewTransactionItems">Back</a>
</div>
</form>

View File

@ -88,17 +88,14 @@ public class TransactionsController : Controller
}
[HttpPost("new/items")]
public async Task<IActionResult> PostNewTransactionItems()
public IActionResult PostNewTransactionItems()
{
if (TempData["NewTransaction"] is not string json || JsonSerializer.Deserialize<Transaction>(json) is not Transaction transaction)
if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize<Transaction>(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<Transaction>(json) is not Transaction transaction)
{
return RedirectToAction(nameof(NewTransaction));
}
return View(transaction);
}
[HttpPost("new/promotions")]
public async Task<IActionResult> PostNewTransactionPromotions()
{
if (TempData["NewTransaction"] is not string json || JsonSerializer.Deserialize<Transaction>(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<Transaction>(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<Transaction>(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<Transaction>(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<Transaction>(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<Transaction>(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));
}
}

View File

@ -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 @@
</datalist>
<datalist id="itemNames">
@foreach (var item in items.OrderBy(item => item.Name))
@foreach (var item in items)
{
<option value="@item.Name" data-list-filter-target="option" data-list-filter-value="@item.Brand" />
}

View File

@ -0,0 +1,38 @@
@using Groceries.Data
@using Microsoft.EntityFrameworkCore
@model (Transaction Transaction, TransactionPromotion? Promotion)
@inject AppDbContext dbContext
@{
var selectedItemIds = Model.Promotion?.Items.Select(item => item.Id).ToArray() ?? Array.Empty<Guid>();
var itemIds = Model.Transaction.Items.Select(item => item.ItemId);
var itemNames = await dbContext.Items
.Where(item => itemIds.Contains(item.Id))
.ToDictionaryAsync(item => item.Id, item => string.Concat(item.Brand, " ", item.Name));
}
<div class="form-field">
<label class="form-field__label" for="transactionPromotionName">Name</label>
<div class="form-field__control input">
<input class="input__control" id="transactionPromotionName" name="name" value="@Model.Promotion?.Name" required autofocus />
</div>
</div>
<div class="form-field">
<label class="form-field__label" for="transactionPromotionAmount">Amount</label>
<div class="form-field__control input">
@*<span class="input__inset">@CultureInfo.CurrentCulture.NumberFormat.CurrencySymbol</span>*@
<input class="input__control" id="transactionPromotionAmount" name="amount" value="@Model.Promotion?.Amount" type="number" min="0" step="0.01" required />
</div>
</div>
<div class="form-field">
<label class="form-field__label" for="transactionPromotionItemIds">Items</label>
<select class="form-field__control select" id="transactionPromotionItemIds" name="itemIds" multiple required>
@foreach (var item in Model.Transaction.Items)
{
<option value="@item.ItemId" selected="@selectedItemIds.Contains(item.ItemId)">@itemNames[item.ItemId]</option>
}
</select>
</div>

View File

@ -650,6 +650,9 @@ html:has(.modal[open]) {
line-height: 1.25rem;
background-color: rgb(255, 255, 255);
border: 1px solid rgb(209, 213, 219);
}
.select:not([multiple]) {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236B7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-size: 1.5rem 1.5rem;