Add support for adding 'loose' items to transactions
All checks were successful
Docker Image CI / build (push) Successful in 3m45s

This commit is contained in:
James Chapman 2024-10-12 02:36:38 +01:00
parent dfcab40d70
commit 47d13ba922
Signed by: jamsch0
GPG Key ID: 765FE58130277547
13 changed files with 178 additions and 46 deletions

View File

@ -7,7 +7,8 @@ public class ItemPurchase
public DateTime CreatedAt { get; init; }
public Guid StoreId { get; init; }
public decimal Price { get; init; }
public int Quantity { get; init; }
public decimal Quantity { get; init; }
public string? Unit { get; init; }
public bool IsLastPurchase { get; init; }
public Item? Item { get; init; }

View File

@ -0,0 +1,35 @@
DROP VIEW item_purchases;
DROP VIEW transaction_totals;
ALTER TABLE transaction_items
ALTER COLUMN quantity TYPE numeric(5, 3);
ALTER TABLE transaction_items
ADD COLUMN IF NOT EXISTS unit text;
CREATE VIEW item_purchases AS
SELECT
item_id,
transaction_id,
created_at,
store_id,
price,
quantity,
unit,
CASE ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY created_at DESC)
WHEN 1 THEN true
ELSE false
END AS is_last_purchase
FROM transaction_items
JOIN transactions USING (transaction_id);
CREATE VIEW transaction_totals AS
SELECT transaction_id, sum(amount) AS total
FROM (
SELECT transaction_id, price * quantity AS amount
FROM transaction_items
UNION ALL
SELECT transaction_id, -amount
FROM transaction_promotions
) AS transaction_amounts
GROUP BY transaction_id;

View File

@ -5,22 +5,24 @@ using System.Text.Json.Serialization;
public class TransactionItem
{
[JsonConstructor]
public TransactionItem(Guid transactionId, Guid itemId, decimal price, int quantity)
public TransactionItem(Guid transactionId, Guid itemId, decimal price, decimal quantity, string? unit)
{
TransactionId = transactionId;
ItemId = itemId;
Price = price;
Quantity = quantity;
Unit = unit;
}
public TransactionItem(Guid itemId, decimal price, int quantity) : this(default, itemId, price, quantity)
public TransactionItem(Guid itemId, decimal price, decimal quantity, string? unit) : this(default, itemId, price, quantity, unit)
{
}
public Guid TransactionId { get; init; }
public Guid ItemId { get; set; }
public decimal Price { get; set; }
public int Quantity { get; set; }
public decimal Quantity { get; set; }
public string? Unit { get; set; }
public Item? Item { get; set; }

View File

@ -20,6 +20,9 @@ public class PropertyTableColumn<TItem, TProp> : TableColumn<TItem>
[Parameter]
public string? Format { get; set; }
[Parameter]
public Func<TItem, string>? CompositeFormat { get; set; }
[Parameter]
public override bool Sortable { get; set; }
@ -55,7 +58,11 @@ public class PropertyTableColumn<TItem, TProp> : TableColumn<TItem>
if (ChildContent == null)
{
if (!string.IsNullOrEmpty(Format) &&
if (CompositeFormat != null)
{
cellTextFunc = item => string.Format(CompositeFormat(item), compiledPropertyExpression(item));
}
else if (!string.IsNullOrEmpty(Format) &&
typeof(IFormattable).IsAssignableFrom(Nullable.GetUnderlyingType(typeof(TProp)) ?? typeof(TProp)))
{
cellTextFunc = item => ((IFormattable?)compiledPropertyExpression(item))?.ToString(Format, null);

View File

@ -4,7 +4,7 @@
<span>
Showing @FirstItem to @LastItem of @State.TotalItemCount results
</span>
<nav class="button-group">
<nav>
@if (State.CurrentPage == 1)
{
<span class="link link--disabled">Previous</span>

View File

@ -16,5 +16,5 @@
public required Transaction Transaction { get; set; }
[Parameter]
public TransactionItem? TransactionItem { get; set; }
public required TransactionItem TransactionItem { get; set; }
}

View File

@ -25,7 +25,7 @@
public required Transaction Transaction { get; set; }
[Parameter]
public TransactionItem? TransactionItem { get; set; }
public required TransactionItem TransactionItem { get; set; }
private string store = string.Empty;

View File

@ -16,9 +16,19 @@
<section class="card form-field">
<div class="card__header row">
<h2 class="row__fill">Items</h2>
<a class="button button--primary" href="/transactions/new/items/new" autofocus data-turbo-frame="modal">
New item
</a>
<div class="button-group dropdown">
<a class="button button--primary" href="/transactions/new/items/new" autofocus data-turbo-frame="modal">
New item
</a>
<button class="button button--primary dropdown__toggle" type="button" popovertarget="newItemMenu"></button>
<ul class="dropdown__menu" id="newItemMenu" popover>
<li>
<a class="button dropdown__item" href="/transactions/new/items/new?unit=kg" data-turbo-frame="modal">
New loose item
</a>
</li>
</ul>
</div>
</div>
<div class="card__content card__content--table">
@ -27,8 +37,8 @@
<TemplateTableColumn Title="Name" Fill="true" Context="item">
@itemNames.GetValueOrDefault(item.ItemId)
</TemplateTableColumn>
<PropertyTableColumn Property="i => i.Price" Format="c" />
<PropertyTableColumn Property="i => i.Quantity">
<PropertyTableColumn Property="i => i.Price" CompositeFormat='i => i.Unit == null ? "{0:c}" : ("{0:c}/" + i.Unit)' />
<PropertyTableColumn Property="i => i.Quantity" CompositeFormat='i => i.Unit == null ? "{0:f0}" : ("{0:f3}" + i.Unit)'>
<HeaderContent>
<abbr title="Quantity">Qty</abbr>
</HeaderContent>

View File

@ -36,34 +36,41 @@
<datalist id="itemNames">
@foreach (var item in items)
{
<option value="@item.Name" data-transaction-item-form-target="option" data-brand="@item.Brand" data-price="@item.Price" data-quantity="@item.Quantity" />
<option value="@item.Name" data-transaction-item-form-target="option" data-brand="@item.Brand" data-price="@item.Price" data-quantity="@(unit == null ? (int?)item.Quantity : item.Quantity)" />
}
</datalist>
</div>
</fieldset>
<div class="form-field">
<label class="form-field__label" for="transactionItemPrice">Price</label>
<label class="form-field__label" for="transactionItemPrice">
Price @if (unit != null) { <text>(per @unit)</text> }
</label>
<div class="form-field__control input">
@*<span class="input__inset">@CultureInfo.CurrentCulture.NumberFormat.CurrencySymbol</span>*@
<input class="input__control" id="transactionItemPrice" name="price" value="@price" type="number" min="0" step="0.01" required data-transaction-item-form-target="price" />
<input class="input__control" id="transactionItemPrice" name="price" value="@price" type="number" min="0" step="0.01" required />
</div>
</div>
<div class="form-field">
<label class="form-field__label" for="transactionItemQuantity">Quantity</label>
<label class="form-field__label" for="transactionItemQuantity">
Quantity @if (unit != null) { <text>(@unit)</text> }
</label>
<div class="form-field__control input">
<input class="input__control" id="transactionItemQuantity" name="quantity" value="@quantity" type="number" min="1" required data-transaction-item-form-target="quantity" />
@{ var step = unit == null ? "1" : "0.001"; }
<input class="input__control" id="transactionItemQuantity" name="quantity" value="@quantity" type="number" min="@step" step="@step" required />
</div>
</div>
<input type="hidden" name="unit" value="@unit" />
</div>
@ChildContent
</form>
@code {
[Parameter]
public TransactionItem? TransactionItem { get; set; }
[Parameter, EditorRequired]
public required TransactionItem TransactionItem { get; set; }
[Parameter]
public RenderFragment? ChildContent { get; set; }
@ -71,7 +78,7 @@
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private record ItemModel(Guid Id, string Brand, string Name, decimal? Price, int? Quantity);
private record ItemModel(Guid Id, string Brand, string Name, decimal? Price, decimal? Quantity);
private ItemBarcode? barcode;
@ -79,11 +86,12 @@
private ItemModel? selectedItem;
private decimal? price;
private int quantity;
private decimal? quantity;
private string? unit;
protected override async Task OnParametersSetAsync()
{
barcode = TransactionItem?.Item?.Barcodes.FirstOrDefault();
barcode = TransactionItem.Item?.Barcodes.FirstOrDefault();
items = await DbContext.Items
.OrderBy(item => item.Brand)
@ -103,9 +111,15 @@
lastPurchase != null ? lastPurchase.Quantity : null))
.ToArrayAsync();
selectedItem = items.SingleOrDefault(item => item.Id == TransactionItem?.ItemId);
selectedItem = items.SingleOrDefault(item => item.Id == TransactionItem.ItemId);
price = TransactionItem?.Price >= 0 ? TransactionItem.Price : selectedItem?.Price;
quantity = TransactionItem?.Quantity >= 1 ? TransactionItem.Quantity : (selectedItem?.Quantity ?? 1);
price = TransactionItem.Price >= 0 ? TransactionItem.Price : selectedItem?.Price;
quantity = TransactionItem.Quantity >= 0 ? TransactionItem.Quantity : selectedItem?.Quantity;
unit = TransactionItem.Unit;
if (unit == null)
{
quantity ??= 1;
}
}
}

View File

@ -60,17 +60,17 @@ public class TransactionsController : Controller
}
[HttpGet("new/items/new")]
public async Task<IResult> NewTransactionItem(long? barcodeData, string? barcodeFormat)
public async Task<IResult> NewTransactionItem(string? unit, long? barcodeData, string? barcodeFormat)
{
if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize<Transaction>(json) is not Transaction transaction)
{
return Results.LocalRedirect("/transactions/new");
}
TransactionItem? transactionItem = null;
Item? item = null;
if (barcodeData != null && barcodeFormat != null)
{
var item = await dbContext.Items
item = await dbContext.Items
.Where(item => item.Barcodes.Any(barcode => barcode.BarcodeData == barcodeData))
.OrderByDescending(item => item.UpdatedAt)
.FirstOrDefaultAsync();
@ -86,11 +86,11 @@ public class TransactionsController : Controller
dbContext.Update(barcode);
await dbContext.SaveChangesAsync();
}
// TODO: Fix `MinValue` hack - view models?
transactionItem = new TransactionItem(item.Id, decimal.MinValue, int.MinValue) { Item = item };
}
// TODO: Fix `MinValue` hack - view models?
var transactionItem = new TransactionItem(item?.Id ?? default, decimal.MinValue, decimal.MinValue, unit) { Item = item };
var parameters = new { Transaction = transaction, TransactionItem = transactionItem };
return Request.IsTurboFrameRequest("modal")
? new RazorComponentResult<NewTransactionItemModal>(parameters)
@ -98,7 +98,7 @@ public class TransactionsController : Controller
}
[HttpPost("new/items/new")]
public async Task<IResult> NewTransactionItem(string brand, string name, decimal price, int quantity, long? barcodeData, string? barcodeFormat)
public async Task<IResult> NewTransactionItem(string brand, string name, decimal price, decimal quantity, string? unit, long? barcodeData, string? barcodeFormat)
{
if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize<Transaction>(json) is not Transaction transaction)
{
@ -127,7 +127,7 @@ public class TransactionsController : Controller
// TODO: Handle item already in transaction - merge, replace, error?
var transactionItem = new TransactionItem(item.Id, price, quantity) { Item = item };
var transactionItem = new TransactionItem(item.Id, price, quantity, unit) { Item = item };
transaction.Items.Add(transactionItem);
TempData["NewTransaction"] = JsonSerializer.Serialize(transaction);

View File

@ -51,7 +51,7 @@
CreatedAt = transaction.CreatedAt,
Store = string.Concat(transaction.Store!.Retailer!.Name, " ", transaction.Store.Name),
TotalAmount = transactionTotal.Total,
TotalItems = transaction.Items.Sum(item => item.Quantity),
TotalItems = transaction.Items.Sum(item => item.Unit == null ? (int)item.Quantity : 1),
})
.OrderByDescending(transaction => transaction.CreatedAt);
}

View File

@ -249,6 +249,7 @@ html:has(.modal[open]) {
/* HACK: should probably be a .button--icon */
.modal__close-button {
justify-content: center;
padding: 0 !important;
margin-block: -1rem;
width: 2rem;
@ -402,6 +403,11 @@ html:has(.modal[open]) {
padding: 0.75rem 1.5rem;
}
.table__paginator > nav {
display: flex;
gap: 1rem;
}
/*@media (prefers-color-scheme: dark) {
.table__header {
background-color: rgb(55, 65, 81);
@ -447,7 +453,8 @@ html:has(.modal[open]) {
/* Button */
.button {
display: inline-block;
display: inline-flex;
align-items: center;
text-decoration: none;
appearance: none;
background-color: rgb(255, 255, 255);
@ -457,7 +464,7 @@ html:has(.modal[open]) {
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
padding: 0.5rem 1rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
}
@ -483,9 +490,64 @@ html:has(.modal[open]) {
opacity: 50%;
}
/* Button group */
.button-group {
display: flex;
gap: 1rem;
}
.button-group > .button:not(:nth-child(1 of .button)) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-inline-start: 1px solid;
}
.button-group > .button:not(:nth-last-child(1 of .button)) {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
/* Dropdown */
.dropdown__toggle {
anchor-name: --dropdown-toggle;
}
.dropdown__toggle::after {
content: "";
display: inline-block;
border-block-start: 0.3rem solid;
border-block-end: 0;
border-inline: 0.3rem solid transparent;
}
.dropdown__toggle::after:not(:empty) {
margin-inline-start: 0.375rem;
}
.dropdown:has(> :popover-open) > .dropdown__toggle[popovertarget] {
outline: none;
filter: brightness(0.85);
}
.dropdown__menu {
position-anchor: --dropdown-toggle;
inset: calc(anchor(end) + 0.125rem) anchor(end) auto auto;
padding-block: 0.5rem;
border: 1px solid rgb(209, 213, 219);
border-radius: 0.375rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
.dropdown__item {
border-block-start: 1px solid rgb(229, 231, 235);
border-block-end: none;
border-inline: none;
border-radius: 0;
}
.dropdown__item:first-child {
border-block-start: none;
}
/* Form field */

View File

@ -1,7 +1,7 @@
import { Controller } from "/lib/hotwired/stimulus/dist/stimulus.js";
export default class TransactionItemFormController extends Controller {
static targets = ["barcodeButton", "barcodeData", "barcodeFormat", "barcodeFormField", "brand", "option", "price", "quantity"];
static targets = ["barcodeButton", "barcodeData", "barcodeFormat", "barcodeFormField", "brand", "option"];
#scanning = false;
#scanIntervalId;
@ -35,10 +35,11 @@ export default class TransactionItemFormController extends Controller {
}
setPriceAndQuantity(event) {
const { brand, name } = event.target.form.elements;
const { brand, name, price, quantity, unit } = event.target.form.elements;
if (!brand.value || !name.value) {
this.priceTarget.value = "";
this.quantityTarget.value = "1";
price.value = "";
quantity.value = !unit.value ? "1" : "";
return;
}
@ -47,11 +48,11 @@ export default class TransactionItemFormController extends Controller {
option.value === name.value);
if (option != null) {
if (!this.priceTarget.value) {
this.priceTarget.value = option.getAttribute("data-price");
if (!price.value) {
price.value = option.getAttribute("data-price");
}
if (!this.quantityTarget.value || this.quantityTarget.value === "1") {
this.quantityTarget.value = option.getAttribute("data-quantity") || "1";
if (quantity.value || (!unit.value && quantity.value === "1")) {
quantity.value = option.getAttribute("data-quantity") || (!unit.value ? "1" : "");
}
}
}