Compare commits

...

2 Commits

Author SHA1 Message Date
47d13ba922
Add support for adding 'loose' items to transactions
All checks were successful
Docker Image CI / build (push) Successful in 3m45s
2024-10-12 02:36:38 +01:00
dfcab40d70
Fix CSS/JS not resolving on full-page reload for New Transaction pages 2024-10-06 21:19:55 +01:00
14 changed files with 180 additions and 46 deletions

View File

@ -7,7 +7,8 @@ public class ItemPurchase
public DateTime CreatedAt { get; init; } public DateTime CreatedAt { get; init; }
public Guid StoreId { get; init; } public Guid StoreId { get; init; }
public decimal Price { 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 bool IsLastPurchase { get; init; }
public Item? Item { 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 public class TransactionItem
{ {
[JsonConstructor] [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; TransactionId = transactionId;
ItemId = itemId; ItemId = itemId;
Price = price; Price = price;
Quantity = quantity; 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 TransactionId { get; init; }
public Guid ItemId { get; set; } public Guid ItemId { get; set; }
public decimal Price { 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; } public Item? Item { get; set; }

View File

@ -9,6 +9,8 @@
<meta name="view-transition" content="same-origin" /> <meta name="view-transition" content="same-origin" />
<meta name="turbo-prefetch" content="false" /> <meta name="turbo-prefetch" content="false" />
<base href="/" />
<link rel="stylesheet" type="text/css" href="@Assets["lib/inter/index.css"]" data-turbo-track="reload" /> <link rel="stylesheet" type="text/css" href="@Assets["lib/inter/index.css"]" data-turbo-track="reload" />
<link rel="stylesheet" type="text/css" href="@Assets["css/main.css"]" data-turbo-track="reload" /> <link rel="stylesheet" type="text/css" href="@Assets["css/main.css"]" data-turbo-track="reload" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,34 +36,41 @@
<datalist id="itemNames"> <datalist id="itemNames">
@foreach (var item in items) @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> </datalist>
</div> </div>
</fieldset> </fieldset>
<div class="form-field"> <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"> <div class="form-field__control input">
@*<span class="input__inset">@CultureInfo.CurrentCulture.NumberFormat.CurrencySymbol</span>*@ @*<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> </div>
<div class="form-field"> <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"> <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>
</div> </div>
<input type="hidden" name="unit" value="@unit" />
</div> </div>
@ChildContent @ChildContent
</form> </form>
@code { @code {
[Parameter] [Parameter, EditorRequired]
public TransactionItem? TransactionItem { get; set; } public required TransactionItem TransactionItem { get; set; }
[Parameter] [Parameter]
public RenderFragment? ChildContent { get; set; } public RenderFragment? ChildContent { get; set; }
@ -71,7 +78,7 @@
[Parameter(CaptureUnmatchedValues = true)] [Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; } 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; private ItemBarcode? barcode;
@ -79,11 +86,12 @@
private ItemModel? selectedItem; private ItemModel? selectedItem;
private decimal? price; private decimal? price;
private int quantity; private decimal? quantity;
private string? unit;
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
barcode = TransactionItem?.Item?.Barcodes.FirstOrDefault(); barcode = TransactionItem.Item?.Barcodes.FirstOrDefault();
items = await DbContext.Items items = await DbContext.Items
.OrderBy(item => item.Brand) .OrderBy(item => item.Brand)
@ -103,9 +111,15 @@
lastPurchase != null ? lastPurchase.Quantity : null)) lastPurchase != null ? lastPurchase.Quantity : null))
.ToArrayAsync(); .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; price = TransactionItem.Price >= 0 ? TransactionItem.Price : selectedItem?.Price;
quantity = TransactionItem?.Quantity >= 1 ? TransactionItem.Quantity : (selectedItem?.Quantity ?? 1); 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")] [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) if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize<Transaction>(json) is not Transaction transaction)
{ {
return Results.LocalRedirect("/transactions/new"); return Results.LocalRedirect("/transactions/new");
} }
TransactionItem? transactionItem = null; Item? item = null;
if (barcodeData != null && barcodeFormat != null) if (barcodeData != null && barcodeFormat != null)
{ {
var item = await dbContext.Items item = await dbContext.Items
.Where(item => item.Barcodes.Any(barcode => barcode.BarcodeData == barcodeData)) .Where(item => item.Barcodes.Any(barcode => barcode.BarcodeData == barcodeData))
.OrderByDescending(item => item.UpdatedAt) .OrderByDescending(item => item.UpdatedAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
@ -86,11 +86,11 @@ public class TransactionsController : Controller
dbContext.Update(barcode); dbContext.Update(barcode);
await dbContext.SaveChangesAsync(); 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 }; var parameters = new { Transaction = transaction, TransactionItem = transactionItem };
return Request.IsTurboFrameRequest("modal") return Request.IsTurboFrameRequest("modal")
? new RazorComponentResult<NewTransactionItemModal>(parameters) ? new RazorComponentResult<NewTransactionItemModal>(parameters)
@ -98,7 +98,7 @@ public class TransactionsController : Controller
} }
[HttpPost("new/items/new")] [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) 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? // 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); transaction.Items.Add(transactionItem);
TempData["NewTransaction"] = JsonSerializer.Serialize(transaction); TempData["NewTransaction"] = JsonSerializer.Serialize(transaction);

View File

@ -51,7 +51,7 @@
CreatedAt = transaction.CreatedAt, CreatedAt = transaction.CreatedAt,
Store = string.Concat(transaction.Store!.Retailer!.Name, " ", transaction.Store.Name), Store = string.Concat(transaction.Store!.Retailer!.Name, " ", transaction.Store.Name),
TotalAmount = transactionTotal.Total, 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); .OrderByDescending(transaction => transaction.CreatedAt);
} }

View File

@ -249,6 +249,7 @@ html:has(.modal[open]) {
/* HACK: should probably be a .button--icon */ /* HACK: should probably be a .button--icon */
.modal__close-button { .modal__close-button {
justify-content: center;
padding: 0 !important; padding: 0 !important;
margin-block: -1rem; margin-block: -1rem;
width: 2rem; width: 2rem;
@ -402,6 +403,11 @@ html:has(.modal[open]) {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
} }
.table__paginator > nav {
display: flex;
gap: 1rem;
}
/*@media (prefers-color-scheme: dark) { /*@media (prefers-color-scheme: dark) {
.table__header { .table__header {
background-color: rgb(55, 65, 81); background-color: rgb(55, 65, 81);
@ -447,7 +453,8 @@ html:has(.modal[open]) {
/* Button */ /* Button */
.button { .button {
display: inline-block; display: inline-flex;
align-items: center;
text-decoration: none; text-decoration: none;
appearance: none; appearance: none;
background-color: rgb(255, 255, 255); background-color: rgb(255, 255, 255);
@ -457,7 +464,7 @@ html:has(.modal[open]) {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
line-height: 1.25rem; line-height: 1.25rem;
padding: 0.5rem 1rem; padding: 0.5rem 0.75rem;
cursor: pointer; cursor: pointer;
} }
@ -483,9 +490,64 @@ html:has(.modal[open]) {
opacity: 50%; opacity: 50%;
} }
/* Button group */
.button-group { .button-group {
display: flex; 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 */ /* Form field */

View File

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