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 Guid Id { get; init; }
|
||||||
public DateTime UpdatedAt { get; set; }
|
public DateTime UpdatedAt { get; set; }
|
||||||
public string Brand { get; set; }
|
public string Brand { get; set; }
|
||||||
|
@ -22,7 +22,7 @@ public class TransactionItem
|
|||||||
public decimal Price { get; set; }
|
public decimal Price { get; set; }
|
||||||
public int Quantity { get; set; }
|
public int Quantity { get; set; }
|
||||||
|
|
||||||
public Item? Item { get; init; }
|
public Item? Item { get; set; }
|
||||||
|
|
||||||
public decimal Amount => Price * Quantity;
|
public decimal Amount => Price * Quantity;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
@using Groceries.Data;
|
@using Groceries.Data
|
||||||
@using Microsoft.EntityFrameworkCore;
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
|
||||||
@model (Transaction Transaction, TransactionItem TransactionItem)
|
@model (Transaction Transaction, TransactionItem TransactionItem)
|
||||||
@inject AppDbContext dbContext
|
@inject AppDbContext dbContext
|
||||||
|
@ -1,23 +1,25 @@
|
|||||||
@using Groceries.Data;
|
@using Groceries.Data
|
||||||
@using Microsoft.EntityFrameworkCore;
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
|
||||||
@model Transaction
|
@model (Transaction Transaction, TransactionItem? TransactionItem)
|
||||||
@inject AppDbContext dbContext
|
@inject AppDbContext dbContext
|
||||||
@{
|
@{
|
||||||
ViewBag.Title = "New Transaction Item";
|
ViewBag.Title = "New Transaction Item";
|
||||||
|
|
||||||
var store = await dbContext.Stores
|
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))
|
.Select(store => string.Concat(store.Retailer!.Name, " ", store.Name))
|
||||||
.SingleAsync();
|
.SingleAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
<h1>New Transaction Item</h1>
|
<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">
|
<form method="post" asp-action="NewTransactionItem">
|
||||||
<partial name="_TransactionItemForm" model="null" />
|
<partial name="_TransactionItemForm" model="Model.TransactionItem" />
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="button button--primary" type="submit">Add</button>
|
<button class="button button--primary" type="submit">Add</button>
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
@using Groceries.Data
|
@using Groceries.Data
|
||||||
|
|
||||||
@model Transaction
|
@model (Transaction Transaction, TransactionItem? TransactionItem)
|
||||||
@{
|
@{
|
||||||
Layout = "_Modal";
|
Layout = "_Modal";
|
||||||
ViewBag.Title = "New Transaction Item";
|
ViewBag.Title = "New Transaction Item";
|
||||||
}
|
}
|
||||||
|
|
||||||
<form class="card__content" id="newTransactionItem" method="post" asp-action="NewTransactionItem" data-action="turbo:submit-end->modal#close">
|
<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>
|
</form>
|
||||||
|
|
||||||
<footer class="card__footer card__footer--shaded row">
|
<footer class="card__footer card__footer--shaded row">
|
||||||
|
@ -99,20 +99,35 @@ public class TransactionsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("new/items/new")]
|
[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)
|
if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize<Transaction>(json) is not Transaction transaction)
|
||||||
{
|
{
|
||||||
return RedirectToAction(nameof(NewTransaction));
|
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")
|
return Request.IsTurboFrameRequest("modal")
|
||||||
? View($"{nameof(NewTransactionItem)}_Modal", transaction)
|
? View($"{nameof(NewTransactionItem)}_Modal", model)
|
||||||
: View(transaction);
|
: View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("new/items/new")]
|
[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)
|
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)
|
.Select(item => item.Id)
|
||||||
.SingleOrDefaultAsync();
|
.SingleOrDefaultAsync();
|
||||||
|
|
||||||
if (itemId == default)
|
var item = new Item(itemId, brand, name);
|
||||||
|
if (barcodeData != null && barcodeFormat != null)
|
||||||
{
|
{
|
||||||
var item = new Item(brand, name);
|
item.Barcodes.Add(new ItemBarcode(itemId, barcodeData.Value, barcodeFormat));
|
||||||
dbContext.Items.Add(item);
|
|
||||||
await dbContext.SaveChangesAsync();
|
|
||||||
itemId = item.Id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dbContext.Items.Attach(item);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
// TODO: Handle item already in transaction - merge, replace, error?
|
// 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);
|
transaction.Items.Add(transactionItem);
|
||||||
|
|
||||||
TempData["NewTransaction"] = JsonSerializer.Serialize(transaction);
|
TempData["NewTransaction"] = JsonSerializer.Serialize(transaction);
|
||||||
@ -183,18 +199,18 @@ public class TransactionsController : Controller
|
|||||||
.Select(item => item.Id)
|
.Select(item => item.Id)
|
||||||
.SingleOrDefaultAsync();
|
.SingleOrDefaultAsync();
|
||||||
|
|
||||||
if (itemId == default)
|
var item = new Item(itemId, brand, name);
|
||||||
{
|
|
||||||
var item = new Item(brand, name);
|
|
||||||
dbContext.Items.Add(item);
|
|
||||||
await dbContext.SaveChangesAsync();
|
|
||||||
itemId = item.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
transactionItem.ItemId = itemId;
|
dbContext.Items.Attach(item);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
transactionItem.Item = item;
|
||||||
|
transactionItem.ItemId = item.Id;
|
||||||
transactionItem.Price = price;
|
transactionItem.Price = price;
|
||||||
transactionItem.Quantity = quantity;
|
transactionItem.Quantity = quantity;
|
||||||
|
|
||||||
|
// TODO: Handle barcode when editing item - replace, disable?
|
||||||
|
|
||||||
TempData["NewTransaction"] = JsonSerializer.Serialize(transaction);
|
TempData["NewTransaction"] = JsonSerializer.Serialize(transaction);
|
||||||
|
|
||||||
return Request.IsTurboFrameRequest("modal")
|
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
|
// 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);
|
dbContext.Transactions.Add(transaction);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
|
@ -25,13 +25,30 @@
|
|||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
var selectedItem = items.SingleOrDefault(item => item.Id == Model?.ItemId);
|
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 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">
|
<fieldset class="form-field">
|
||||||
<legend class="form-field__label">Item</legend>
|
<legend class="form-field__label">Item</legend>
|
||||||
<div class="form-field__control input">
|
<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" />
|
<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">
|
<datalist id="itemBrands">
|
||||||
@ -54,14 +71,14 @@
|
|||||||
<label class="form-field__label" for="transactionItemPrice">Price</label>
|
<label class="form-field__label" for="transactionItemPrice">Price</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="@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>
|
</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</label>
|
||||||
<div class="form-field__control input">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -13,6 +13,10 @@ export default class ModalController extends Controller {
|
|||||||
if (!this.element.open) {
|
if (!this.element.open) {
|
||||||
return;
|
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();
|
event.preventDefault();
|
||||||
|
|
||||||
|
@ -1,7 +1,26 @@
|
|||||||
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 = ["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) {
|
filterNames(event) {
|
||||||
for (const option of this.optionTargets) {
|
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