Add ability to add transaction items using barcode scanner
This commit is contained in:
parent
eae1833e2b
commit
929eddd9e8
@ -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; }
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
@using Groceries.Data;
|
||||
@using Microsoft.EntityFrameworkCore;
|
||||
@using Groceries.Data
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
@model (Transaction Transaction, TransactionItem TransactionItem)
|
||||
@inject AppDbContext dbContext
|
||||
|
@ -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() – @store</div>
|
||||
<div class="form-field">
|
||||
@Model.Transaction.CreatedAt.ToShortDateString() @Model.Transaction.CreatedAt.ToShortTimeString() – @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>
|
||||
|
@ -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">
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user