Add ability to add transaction items using barcode scanner

This commit is contained in:
James Chapman 2023-11-05 23:10:54 +00:00
parent eae1833e2b
commit 929eddd9e8
Signed by: jamsch0
GPG Key ID: 765FE58130277547
9 changed files with 145 additions and 38 deletions

View File

@ -16,10 +16,6 @@ public class Item
{
}
public Item(string brand, string name) : this(default, brand, name)
{
}
public Guid Id { get; init; }
public DateTime UpdatedAt { get; set; }
public string Brand { get; set; }

View File

@ -22,7 +22,7 @@ public class TransactionItem
public decimal Price { get; set; }
public int Quantity { get; set; }
public Item? Item { get; init; }
public Item? Item { get; set; }
public decimal Amount => Price * Quantity;
}

View File

@ -1,5 +1,5 @@
@using Groceries.Data;
@using Microsoft.EntityFrameworkCore;
@using Groceries.Data
@using Microsoft.EntityFrameworkCore
@model (Transaction Transaction, TransactionItem TransactionItem)
@inject AppDbContext dbContext

View File

@ -1,23 +1,25 @@
@using Groceries.Data;
@using Microsoft.EntityFrameworkCore;
@using Groceries.Data
@using Microsoft.EntityFrameworkCore
@model Transaction
@model (Transaction Transaction, TransactionItem? TransactionItem)
@inject AppDbContext dbContext
@{
ViewBag.Title = "New Transaction Item";
var store = await dbContext.Stores
.Where(store => store.Id == Model.StoreId)
.Where(store => store.Id == Model.Transaction.StoreId)
.Select(store => string.Concat(store.Retailer!.Name, " ", store.Name))
.SingleAsync();
}
<h1>New Transaction Item</h1>
<div class="form-field">@Model.CreatedAt.ToShortDateString() @Model.CreatedAt.ToShortTimeString() &ndash; @store</div>
<div class="form-field">
@Model.Transaction.CreatedAt.ToShortDateString() @Model.Transaction.CreatedAt.ToShortTimeString() &ndash; @store
</div>
<form method="post" asp-action="NewTransactionItem">
<partial name="_TransactionItemForm" model="null" />
<partial name="_TransactionItemForm" model="Model.TransactionItem" />
<div class="row">
<button class="button button--primary" type="submit">Add</button>

View File

@ -1,13 +1,13 @@
@using Groceries.Data
@model Transaction
@model (Transaction Transaction, TransactionItem? TransactionItem)
@{
Layout = "_Modal";
ViewBag.Title = "New Transaction Item";
}
<form class="card__content" id="newTransactionItem" method="post" asp-action="NewTransactionItem" data-action="turbo:submit-end->modal#close">
<partial name="_TransactionItemForm" model="null" />
<partial name="_TransactionItemForm" model="Model.TransactionItem" />
</form>
<footer class="card__footer card__footer--shaded row">

View File

@ -99,20 +99,35 @@ public class TransactionsController : Controller
}
[HttpGet("new/items/new")]
public IActionResult NewTransactionItem()
public async Task<IActionResult> NewTransactionItem(long? barcodeData, string? barcodeFormat)
{
if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize<Transaction>(json) is not Transaction transaction)
{
return RedirectToAction(nameof(NewTransaction));
}
TransactionItem? transactionItem = null;
if (barcodeData != null && barcodeFormat != null)
{
var item = await dbContext.Items
.Where(item => item.Barcodes.Any(barcode => barcode.BarcodeData == barcodeData))
.FirstOrDefaultAsync();
item ??= new Item(id: default);
item.Barcodes.Add(new ItemBarcode(item.Id, barcodeData.Value, barcodeFormat));
// TODO: Fix `MinValue` hack - view models?
transactionItem = new TransactionItem(item.Id, decimal.MinValue, int.MinValue) { Item = item };
}
var model = (transaction, transactionItem);
return Request.IsTurboFrameRequest("modal")
? View($"{nameof(NewTransactionItem)}_Modal", transaction)
: View(transaction);
? View($"{nameof(NewTransactionItem)}_Modal", model)
: View(model);
}
[HttpPost("new/items/new")]
public async Task<IActionResult> NewTransactionItem(string brand, string name, decimal price, int quantity)
public async Task<IActionResult> NewTransactionItem(string brand, string name, decimal price, int quantity, long? barcodeData, string? barcodeFormat)
{
if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize<Transaction>(json) is not Transaction transaction)
{
@ -124,17 +139,18 @@ public class TransactionsController : Controller
.Select(item => item.Id)
.SingleOrDefaultAsync();
if (itemId == default)
var item = new Item(itemId, brand, name);
if (barcodeData != null && barcodeFormat != null)
{
var item = new Item(brand, name);
dbContext.Items.Add(item);
await dbContext.SaveChangesAsync();
itemId = item.Id;
item.Barcodes.Add(new ItemBarcode(itemId, barcodeData.Value, barcodeFormat));
}
dbContext.Items.Attach(item);
await dbContext.SaveChangesAsync();
// TODO: Handle item already in transaction - merge, replace, error?
var transactionItem = new TransactionItem(itemId, price, quantity);
var transactionItem = new TransactionItem(item.Id, price, quantity) { Item = item };
transaction.Items.Add(transactionItem);
TempData["NewTransaction"] = JsonSerializer.Serialize(transaction);
@ -183,18 +199,18 @@ public class TransactionsController : Controller
.Select(item => item.Id)
.SingleOrDefaultAsync();
if (itemId == default)
{
var item = new Item(brand, name);
dbContext.Items.Add(item);
await dbContext.SaveChangesAsync();
itemId = item.Id;
}
var item = new Item(itemId, brand, name);
transactionItem.ItemId = itemId;
dbContext.Items.Attach(item);
await dbContext.SaveChangesAsync();
transactionItem.Item = item;
transactionItem.ItemId = item.Id;
transactionItem.Price = price;
transactionItem.Quantity = quantity;
// TODO: Handle barcode when editing item - replace, disable?
TempData["NewTransaction"] = JsonSerializer.Serialize(transaction);
return Request.IsTurboFrameRequest("modal")
@ -243,7 +259,10 @@ public class TransactionsController : Controller
}
// Work around EF trying to insert items by explicitly tracking them as unchanged
dbContext.Items.AttachRange(transaction.Promotions.SelectMany(promotion => promotion.Items));
dbContext.Items.AttachRange(
transaction.Items
.Select(item => item.Item!)
.Concat(transaction.Promotions.SelectMany(promotion => promotion.Items)));
dbContext.Transactions.Add(transaction);
await dbContext.SaveChangesAsync();

View File

@ -25,13 +25,30 @@
.ToArrayAsync();
var selectedItem = items.SingleOrDefault(item => item.Id == Model?.ItemId);
var barcode = Model?.Item?.Barcodes.FirstOrDefault();
var price = Model?.Price >= 0 ? Model.Price : selectedItem?.Price;
var quantity = Model?.Quantity >= 1 ? Model.Quantity : (selectedItem?.Quantity ?? 1);
}
<div data-controller="transaction-item-form">
<div class="form-field" data-transaction-item-form-target="barcodeFormField" hidden>
<label class="form-field__label" for="transactionItemBarcode">Barcode</label>
<div class="form-field__control input">
<input type="hidden" name="barcodeFormat" value="@barcode?.Format" data-transaction-item-form-target="barcodeFormat" />
<input class="input__control" id="transactionItemBarcode" name="barcodeData" value="@barcode?.BarcodeData" data-transaction-item-form-target="barcodeData" />
<button class="input__addon button" formmethod="get" formnovalidate data-action="transaction-item-form#scanBarcode" data-transaction-item-form-target="barcodeButton">
@* Barcode scanner icon *@
<svg class="icon icon--sm" xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M40-120v-200h80v120h120v80H40Zm680 0v-80h120v-120h80v200H720ZM160-240v-480h80v480h-80Zm120 0v-480h40v480h-40Zm120 0v-480h80v480h-80Zm120 0v-480h120v480H520Zm160 0v-480h40v480h-40Zm80 0v-480h40v480h-40ZM40-640v-200h200v80H120v120H40Zm800 0v-120H720v-80h200v200h-80Z" /></svg>
</button>
</div>
</div>
<fieldset class="form-field">
<legend class="form-field__label">Item</legend>
<div class="form-field__control input">
<input class="input__control flex-2" name="brand" value="@selectedItem?.Brand" placeholder="Brand" list="itemBrands" autocomplete="off" required autofocus data-action="transaction-item-form#filterNames transaction-item-form#setPriceAndQuantity" />
<input class="input__control flex-2" name="brand" value="@selectedItem?.Brand" placeholder="Brand" list="itemBrands" autocomplete="off" required autofocus data-action="transaction-item-form#filterNames transaction-item-form#setPriceAndQuantity" data-transaction-item-form-target="brand" />
<input class="input__control flex-5" name="name" value="@selectedItem?.Name" placeholder="Name" list="itemNames" autocomplete="off" required data-action="transaction-item-form#setPriceAndQuantity" />
<datalist id="itemBrands">
@ -54,14 +71,14 @@
<label class="form-field__label" for="transactionItemPrice">Price</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="@Model?.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 data-transaction-item-form-target="price" />
</div>
</div>
<div class="form-field">
<label class="form-field__label" for="transactionItemQuantity">Quantity</label>
<div class="form-field__control input">
<input class="input__control" id="transactionItemQuantity" name="quantity" value="@(Model?.Quantity ?? 1)" type="number" min="1" required data-transaction-item-form-target="quantity" />
<input class="input__control" id="transactionItemQuantity" name="quantity" value="@quantity" type="number" min="1" required data-transaction-item-form-target="quantity" />
</div>
</div>
</div>

View File

@ -13,6 +13,10 @@ export default class ModalController extends Controller {
if (!this.element.open) {
return;
}
if (event.type === "turbo:submit-end" && (event.detail.formSubmission.method === 0 || !event.detail.success)) {
// Don't close modal if form method was GET or submission failed
return;
}
event.preventDefault();

View File

@ -1,7 +1,26 @@
import { Controller } from "/lib/hotwired/stimulus/dist/stimulus.js";
export default class TransactionItemFormController extends Controller {
static targets = ["option", "price", "quantity"];
static targets = ["barcodeButton", "barcodeData", "barcodeFormat", "barcodeFormField", "brand", "option", "price", "quantity"];
#scanning = false;
#scanIntervalId;
#stream;
connect() {
if ('BarcodeDetector' in globalThis) {
this.barcodeFormFieldTarget.hidden = false;
if (this.barcodeDataTarget.value) {
this.brandTarget.autofocus = false;
}
}
}
disconnect() {
this.barcodeFormFieldTarget.hidden = true;
this.brandTarget.autofocus = true;
this.stopScanning();
}
filterNames(event) {
for (const option of this.optionTargets) {
@ -36,4 +55,54 @@ export default class TransactionItemFormController extends Controller {
}
}
}
async scanBarcode(event) {
event?.preventDefault();
if (this.#scanning) {
return;
}
this.#scanning = true;
this.barcodeDataTarget.value = "";
this.barcodeFormatTarget.value = "";
this.#stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } });
const video = document.createElement("video");
video.srcObject = this.#stream;
await video.play();
const detector = new BarcodeDetector();
this.#scanIntervalId = setInterval(async () => {
const barcodes = await detector.detect(video);
if (barcodes.length === 0) {
return;
}
const barcode = barcodes[0];
this.barcodeDataTarget.value = barcode.rawValue;
this.barcodeFormatTarget.value = barcode.format;
this.stopScanning();
const form = this.element.closest("form");
for (const element of form.elements) {
element.disabled = !element.name.startsWith("barcode");
}
form.requestSubmit(this.barcodeButtonTarget);
}, 250);
}
stopScanning() {
if (this.#scanning) {
this.#stream?.getTracks()
.forEach(track => track.stop());
clearInterval(this.#scanIntervalId);
this.#scanning = false;
}
}
}