Compare commits

...

9 Commits

Author SHA1 Message Date
ba5766f9d5 Update to .NET 10 preview
All checks were successful
Docker Image CI / build (push) Successful in 8m8s
2025-03-11 23:32:27 +00:00
25876813e5 Update dependencies 2025-03-11 22:21:20 +00:00
680458b0f3 Update dependencies
All checks were successful
Docker Image CI / build (push) Successful in 4m20s
2024-12-30 13:33:49 +00:00
79850c46b5 Tweak New Transaction - Items page styles so content fits on narrow screens
All checks were successful
Docker Image CI / build (push) Successful in 3m5s
2024-10-14 18:26:07 +01:00
e5478a426b Fix last purchased quantity not set when adding loose item 2024-10-14 15:09:54 +01:00
d089065208 Fix format of default item quantity when scanning barcode 2024-10-14 15:08:46 +01:00
d64870bf4f Fix FOUT causing layout shift on full page load
All checks were successful
Docker Image CI / build (push) Successful in 3m5s
2024-10-13 16:45:16 +01:00
00e17497bf Fix error saving transaction with a promotion with items 2024-10-13 16:17:35 +01:00
2c0f6f1cab Fix error loading Add/Edit Transaction Item page
All checks were successful
Docker Image CI / build (push) Successful in 3m8s
2024-10-12 16:00:32 +01:00
28 changed files with 199 additions and 103 deletions

View File

@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7-labs # syntax=docker/dockerfile:1.7-labs
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build1 FROM mcr.microsoft.com/dotnet/sdk:10.0-preview-alpine AS build1
WORKDIR /src WORKDIR /src
COPY ./.config ./ COPY ./.config ./
@ -10,7 +10,7 @@ WORKDIR Groceries
COPY ./Groceries/libman.json ./ COPY ./Groceries/libman.json ./
RUN dotnet libman restore RUN dotnet libman restore
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build2 FROM mcr.microsoft.com/dotnet/sdk:10.0-preview-alpine AS build2
WORKDIR /src WORKDIR /src
COPY ./Groceries.sln ./ COPY ./Groceries.sln ./
@ -23,7 +23,7 @@ COPY . ./
COPY --from=build1 /src ./ COPY --from=build1 /src ./
RUN dotnet publish --no-restore --output /out RUN dotnet publish --no-restore --output /out
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine-composite AS base FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview-alpine-composite AS base
WORKDIR /groceries WORKDIR /groceries
COPY --from=build2 /out . COPY --from=build2 /out .

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors> <WarningsAsErrors>nullable</WarningsAsErrors>
@ -9,10 +9,10 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="DbUp-PostgreSQL" Version="5.0.40" /> <PackageReference Include="DbUp-PostgreSQL" Version="6.0.3" />
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3" /> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" /> <PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0-preview.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -11,6 +11,7 @@
<base href="/" /> <base href="/" />
<link rel="preload" as="font" type="font/woff2" href="lib/inter/files/inter-latin-wght-normal.woff2" crossorigin />
<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

@ -176,6 +176,12 @@
{ {
string?[] classes = [ string?[] classes = [
"table__header", "table__header",
column.Align switch
{
Align.Center => "table__header--align-center",
Align.End => "table__header--align-end",
_ => null,
},
column.Sortable ? "table__header--sortable" : null, column.Sortable ? "table__header--sortable" : null,
HeaderClass, HeaderClass,
]; ];

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors> <WarningsAsErrors>nullable</WarningsAsErrors>

View File

@ -4,7 +4,7 @@
@layout Layout @layout Layout
@inject AppDbContext DbContext @inject IDbContextFactory<AppDbContext> DbContextFactory
@inject IHttpContextAccessor HttpContextAccessor @inject IHttpContextAccessor HttpContextAccessor
<HeadContent> <HeadContent>
@ -52,7 +52,8 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
model = await DbContext.ItemTagQuantities using var dbContext = DbContextFactory.CreateDbContext();
model = await dbContext.ItemTagQuantities
.FromSqlRaw(@" .FromSqlRaw(@"
SELECT tag, quantity, coalesce(unit_name, unit) AS unit, is_metric, is_divisible SELECT tag, quantity, coalesce(unit_name, unit) AS unit, is_metric, is_divisible
FROM ( FROM (

View File

@ -32,10 +32,10 @@ public static class HttpRequestExtensions
return origin.IsBaseOf(uri); return origin.IsBaseOf(uri);
} }
public static Uri? GetRefererIfSameOrigin(this HttpRequest request) public static Uri? GetReferrerIfSameOrigin(this HttpRequest request)
{ {
var referer = request.GetTypedHeaders().Referer; var referrer = request.GetTypedHeaders().Referer;
return referer != null && request.IsSameOrigin(referer) ? referer : null; return referrer != null && request.IsSameOrigin(referrer) ? referrer : null;
} }
public static bool IsTurboFrameRequest(this HttpRequest request, string frameId) public static bool IsTurboFrameRequest(this HttpRequest request, string frameId)

View File

@ -2,7 +2,9 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@layout Layout @layout Layout
@inject AppDbContext DbContext
@implements IDisposable
@inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; Items</PageTitle> <PageTitle>Groceries &ndash; Items</PageTitle>
@ -44,6 +46,7 @@
public DateTime? LastPurchasedAt { get; init; } public DateTime? LastPurchasedAt { get; init; }
} }
private AppDbContext? dbContext;
private IQueryable<ItemModel> items = null!; private IQueryable<ItemModel> items = null!;
private PaginationState pagination = new(); private PaginationState pagination = new();
@ -52,7 +55,9 @@
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
var itemsQuery = DbContext.Items.AsQueryable(); dbContext ??= DbContextFactory.CreateDbContext();
var itemsQuery = dbContext.Items.AsQueryable();
if (!string.IsNullOrEmpty(Search)) if (!string.IsNullOrEmpty(Search))
{ {
var searchPattern = $"%{Search}%"; var searchPattern = $"%{Search}%";
@ -60,22 +65,24 @@
} }
items = itemsQuery items = itemsQuery
.GroupJoin( .LeftJoin(
DbContext.ItemPurchases.Where(purchase => purchase.IsLastPurchase), dbContext.ItemPurchases.Where(purchase => purchase.IsLastPurchase),
item => item.Id, item => item.Id,
purchase => purchase.ItemId, purchase => purchase.ItemId,
(item, purchases) => new { item, purchases }) (item, lastPurchase) => new ItemModel
.SelectMany(
group => group.purchases.DefaultIfEmpty(),
(group, lastPurchase) => new ItemModel
{ {
Id = group.item.Id, Id = item.Id,
Brand = group.item.Brand, Brand = item.Brand,
Name = group.item.Name, Name = item.Name,
HasBarcode = group.item.Barcodes.Count != 0, HasBarcode = item.Barcodes.Count > 0,
LastPurchasedAt = lastPurchase != null ? lastPurchase.CreatedAt : null, LastPurchasedAt = lastPurchase != null ? lastPurchase.CreatedAt : null,
}) })
.OrderBy(item => item.Brand) .OrderBy(item => item.Brand)
.ThenBy(item => item.Name); .ThenBy(item => item.Name);
} }
public void Dispose()
{
dbContext?.Dispose();
}
} }

View File

@ -1,4 +1,5 @@
using DbUp; using DbUp;
using DbUp.Engine.Output;
using Groceries.Data; using Groceries.Data;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -13,20 +14,6 @@ builder.Configuration
.AddIniFile(Path.Combine(dataDir, $"config_{env.EnvironmentName}.ini"), optional: true, reloadOnChange: true); .AddIniFile(Path.Combine(dataDir, $"config_{env.EnvironmentName}.ini"), optional: true, reloadOnChange: true);
var dbConn = builder.Configuration["Database"]!; var dbConn = builder.Configuration["Database"]!;
EnsureDatabase.For.PostgresqlDatabase(dbConn);
var dbUpgradeResult = DeployChanges.To
.PostgresqlDatabase(dbConn)
.JournalToPostgresqlTable("public", "__dbup_migrations")
.WithScriptsEmbeddedInAssembly(typeof(AppDbContext).Assembly)
.WithTransactionPerScript()
.Build()
.PerformUpgrade();
if (!dbUpgradeResult.Successful)
{
return -1;
}
var dataProtection = builder.Services.AddDataProtection(); var dataProtection = builder.Services.AddDataProtection();
if (env.IsProduction()) if (env.IsProduction())
@ -42,7 +29,7 @@ builder.Services.AddDistributedMemoryCache();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddSession(); builder.Services.AddSession();
builder.Services.AddDbContextPool<AppDbContext>(options => options builder.Services.AddPooledDbContextFactory<AppDbContext>(options => options
.EnableDetailedErrors(env.IsDevelopment()) .EnableDetailedErrors(env.IsDevelopment())
.EnableSensitiveDataLogging(env.IsDevelopment()) .EnableSensitiveDataLogging(env.IsDevelopment())
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking) .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)
@ -58,6 +45,22 @@ app.MapStaticAssets();
app.MapControllers() app.MapControllers()
.WithStaticAssets(); .WithStaticAssets();
await app.RunAsync(); var dbUpgradeLogger = new MicrosoftUpgradeLog(app.Logger);
EnsureDatabase.For.PostgresqlDatabase(dbConn, dbUpgradeLogger);
return 0; var dbUpgradeResult = DeployChanges.To
.PostgresqlDatabase(dbConn)
.JournalToPostgresqlTable("public", "__dbup_migrations")
.WithScriptsEmbeddedInAssembly(typeof(AppDbContext).Assembly)
.WithTransactionPerScript()
.LogTo(dbUpgradeLogger)
.Build()
.PerformUpgrade();
if (!dbUpgradeResult.Successful)
{
Environment.Exit(-1);
return;
}
app.Run();

View File

@ -24,9 +24,9 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
var request = HttpContextAccessor.HttpContext!.Request; var request = HttpContextAccessor.HttpContext!.Request;
if (request.GetRefererIfSameOrigin() is Uri referer && referer != request.GetUri()) if (request.GetReferrerIfSameOrigin() is Uri referrer && referrer != request.GetUri())
{ {
returnUrl = referer.PathAndQuery; returnUrl = referrer.PathAndQuery;
} }
} }
} }

View File

@ -19,9 +19,9 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
var request = HttpContextAccessor.HttpContext!.Request; var request = HttpContextAccessor.HttpContext!.Request;
if (request.GetRefererIfSameOrigin() is Uri referer && referer != request.GetUri()) if (request.GetReferrerIfSameOrigin() is Uri referrer && referrer != request.GetUri())
{ {
returnUrl = referer.PathAndQuery; returnUrl = referrer.PathAndQuery;
} }
} }
} }

View File

@ -1,7 +1,7 @@
@using Groceries.Data @using Groceries.Data
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@inject AppDbContext DbContext @inject IDbContextFactory<AppDbContext> DbContextFactory
<form method="post" @attributes="AdditionalAttributes"> <form method="post" @attributes="AdditionalAttributes">
<div class="form-field"> <div class="form-field">
@ -45,7 +45,8 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
retailers = await DbContext.Retailers using var dbContext = DbContextFactory.CreateDbContext();
retailers = await dbContext.Retailers
.OrderBy(retailer => retailer.Name) .OrderBy(retailer => retailer.Name)
.ToArrayAsync(); .ToArrayAsync();
} }

View File

@ -8,11 +8,11 @@ using Microsoft.EntityFrameworkCore;
[Route("/stores")] [Route("/stores")]
public class StoresController : Controller public class StoresController : Controller
{ {
private readonly AppDbContext dbContext; private readonly IDbContextFactory<AppDbContext> dbContextFactory;
public StoresController(AppDbContext dbContext) public StoresController(IDbContextFactory<AppDbContext> dbContextFactory)
{ {
this.dbContext = dbContext; this.dbContextFactory = dbContextFactory;
} }
[HttpGet] [HttpGet]
@ -32,6 +32,8 @@ public class StoresController : Controller
[HttpPost("new")] [HttpPost("new")]
public async Task<IResult> NewStore(Guid retailerId, string name, string? address) public async Task<IResult> NewStore(Guid retailerId, string name, string? address)
{ {
using var dbContext = dbContextFactory.CreateDbContext();
var store = new Store(retailerId, name, address); var store = new Store(retailerId, name, address);
dbContext.Stores.Add(store); dbContext.Stores.Add(store);
@ -45,6 +47,8 @@ public class StoresController : Controller
[HttpGet("edit/{id}")] [HttpGet("edit/{id}")]
public async Task<IResult> EditStore(Guid id) public async Task<IResult> EditStore(Guid id)
{ {
using var dbContext = dbContextFactory.CreateDbContext();
var store = await dbContext.Stores var store = await dbContext.Stores
.SingleOrDefaultAsync(store => store.Id == id, HttpContext.RequestAborted); .SingleOrDefaultAsync(store => store.Id == id, HttpContext.RequestAborted);
@ -61,6 +65,8 @@ public class StoresController : Controller
[HttpPost("edit/{id}")] [HttpPost("edit/{id}")]
public async Task<IResult> EditStore(Guid id, Guid retailerId, string name, string? address, string? returnUrl) public async Task<IResult> EditStore(Guid id, Guid retailerId, string name, string? address, string? returnUrl)
{ {
using var dbContext = dbContextFactory.CreateDbContext();
var store = new Store(id, retailerId, name, address); var store = new Store(id, retailerId, name, address);
dbContext.Stores.Update(store); dbContext.Stores.Update(store);

View File

@ -2,7 +2,9 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@layout Layout @layout Layout
@inject AppDbContext DbContext
@implements IDisposable
@inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; Stores</PageTitle> <PageTitle>Groceries &ndash; Stores</PageTitle>
@ -39,6 +41,7 @@
public int TransactionsCount { get; init; } public int TransactionsCount { get; init; }
} }
private AppDbContext? dbContext;
private IQueryable<StoreModel> stores = null!; private IQueryable<StoreModel> stores = null!;
private PaginationState pagination = new(); private PaginationState pagination = new();
@ -47,7 +50,9 @@
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
var storesQuery = DbContext.Stores.AsQueryable(); dbContext ??= DbContextFactory.CreateDbContext();
var storesQuery = dbContext.Stores.AsQueryable();
if (!string.IsNullOrEmpty(Search)) if (!string.IsNullOrEmpty(Search))
{ {
var searchPattern = $"%{Search}%"; var searchPattern = $"%{Search}%";
@ -66,4 +71,9 @@
.OrderBy(store => store.Retailer) .OrderBy(store => store.Retailer)
.ThenBy(store => store.Name); .ThenBy(store => store.Name);
} }
public void Dispose()
{
dbContext?.Dispose();
}
} }

View File

@ -3,7 +3,7 @@
@layout Layout @layout Layout
@inject AppDbContext DbContext @inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; Edit Transaction Item</PageTitle> <PageTitle>Groceries &ndash; Edit Transaction Item</PageTitle>
@ -19,7 +19,7 @@
<div class="row"> <div class="row">
<button class="button button--primary" type="submit" form="editTransactionItem">Update</button> <button class="button button--primary" type="submit" form="editTransactionItem">Update</button>
<a class="button" href="/transaction/new/items">Cancel</a> <a class="button" href="/transactions/new/items">Cancel</a>
<span class="row__fill"></span> <span class="row__fill"></span>
<button class="button button--danger" type="submit" form="deleteTransactionItem">Remove</button> <button class="button button--danger" type="submit" form="deleteTransactionItem">Remove</button>
</div> </div>
@ -35,7 +35,8 @@
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
store = await DbContext.Stores using var dbContext = DbContextFactory.CreateDbContext();
store = await dbContext.Stores
.Where(store => store.Id == Transaction.StoreId) .Where(store => store.Id == Transaction.StoreId)
.Select(store => string.Concat(store.Retailer!.Name, " ", store.Name)) .Select(store => string.Concat(store.Retailer!.Name, " ", store.Name))
.SingleAsync(); .SingleAsync();

View File

@ -3,7 +3,7 @@
@layout Layout @layout Layout
@inject AppDbContext DbContext @inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; Edit Transaction Promotion</PageTitle> <PageTitle>Groceries &ndash; Edit Transaction Promotion</PageTitle>
@ -35,7 +35,8 @@
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
store = await DbContext.Stores using var dbContext = DbContextFactory.CreateDbContext();
store = await dbContext.Stores
.Where(store => store.Id == Transaction.StoreId) .Where(store => store.Id == Transaction.StoreId)
.Select(store => string.Concat(store.Retailer!.Name, " ", store.Name)) .Select(store => string.Concat(store.Retailer!.Name, " ", store.Name))
.SingleAsync(); .SingleAsync();

View File

@ -3,7 +3,7 @@
@layout Layout @layout Layout
@inject AppDbContext DbContext @inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; New Transaction Item</PageTitle> <PageTitle>Groceries &ndash; New Transaction Item</PageTitle>
@ -16,7 +16,7 @@
<TransactionItemForm TransactionItem="TransactionItem"> <TransactionItemForm TransactionItem="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>
<a class="button" href="/transaction/new/items">Cancel</a> <a class="button" href="/transactions/new/items">Cancel</a>
</div> </div>
</TransactionItemForm> </TransactionItemForm>
@ -31,7 +31,8 @@
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
store = await DbContext.Stores using var dbContext = DbContextFactory.CreateDbContext();
store = await dbContext.Stores
.Where(store => store.Id == Transaction.StoreId) .Where(store => store.Id == Transaction.StoreId)
.Select(store => string.Concat(store.Retailer!.Name, " ", store.Name)) .Select(store => string.Concat(store.Retailer!.Name, " ", store.Name))
.SingleAsync(); .SingleAsync();

View File

@ -2,7 +2,8 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@layout Layout @layout Layout
@inject AppDbContext DbContext
@inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; New Transaction</PageTitle> <PageTitle>Groceries &ndash; New Transaction</PageTitle>
@ -32,10 +33,10 @@
</div> </div>
<div class="card__content card__content--table"> <div class="card__content card__content--table">
<Table Items="Transaction.Items.AsQueryable()" CellClass="table__cell--compact"> <Table Items="Transaction.Items.AsQueryable()" HeaderClass="table__header--compact" CellClass="table__cell--compact">
<ChildContent> <ChildContent>
<TemplateTableColumn Title="Name" Fill="true" Context="item"> <TemplateTableColumn Title="Name" Fill="true" Context="item">
@itemNames.GetValueOrDefault(item.ItemId) <div class="line-clamp-4">@itemNames.GetValueOrDefault(item.ItemId)</div>
</TemplateTableColumn> </TemplateTableColumn>
<PropertyTableColumn Property="i => i.Price" CompositeFormat='i => i.Unit == null ? "{0:c}" : ("{0:c}/" + i.Unit)' /> <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)'> <PropertyTableColumn Property="i => i.Quantity" CompositeFormat='i => i.Unit == null ? "{0:f0}" : ("{0:f3}" + i.Unit)'>
@ -78,13 +79,15 @@
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
store = await DbContext.Stores using var dbContext = DbContextFactory.CreateDbContext();
store = await dbContext.Stores
.Where(store => store.Id == Transaction.StoreId) .Where(store => store.Id == Transaction.StoreId)
.Select(store => string.Concat(store.Retailer!.Name, " ", store.Name)) .Select(store => string.Concat(store.Retailer!.Name, " ", store.Name))
.SingleAsync(); .SingleAsync();
var itemIds = Transaction.Items.Select(item => item.ItemId); var itemIds = Transaction.Items.Select(item => item.ItemId);
itemNames = await DbContext.Items itemNames = await dbContext.Items
.Where(item => itemIds.Contains(item.Id)) .Where(item => itemIds.Contains(item.Id))
.ToDictionaryAsync(item => item.Id, item => string.Concat(item.Brand, " ", item.Name)); .ToDictionaryAsync(item => item.Id, item => string.Concat(item.Brand, " ", item.Name));
} }

View File

@ -3,7 +3,7 @@
@layout Layout @layout Layout
@inject AppDbContext DbContext @inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; New Transaction</PageTitle> <PageTitle>Groceries &ndash; New Transaction</PageTitle>
@ -41,7 +41,8 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
stores = await DbContext.Stores using var dbContext = DbContextFactory.CreateDbContext();
stores = await dbContext.Stores
.OrderBy(store => store.Retailer!.Name) .OrderBy(store => store.Retailer!.Name)
.ThenBy(store => store.Name) .ThenBy(store => store.Name)
.Select(store => new StoreModel(store.Id, string.Concat(store.Retailer!.Name, " ", store.Name))) .Select(store => new StoreModel(store.Id, string.Concat(store.Retailer!.Name, " ", store.Name)))

View File

@ -3,7 +3,7 @@
@layout Layout @layout Layout
@inject AppDbContext DbContext @inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; New Transaction Promotion</PageTitle> <PageTitle>Groceries &ndash; New Transaction Promotion</PageTitle>
@ -28,7 +28,8 @@
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
store = await DbContext.Stores using var dbContext = DbContextFactory.CreateDbContext();
store = await dbContext.Stores
.Where(store => store.Id == Transaction.StoreId) .Where(store => store.Id == Transaction.StoreId)
.Select(store => string.Concat(store.Retailer!.Name, " ", store.Name)) .Select(store => string.Concat(store.Retailer!.Name, " ", store.Name))
.SingleAsync(); .SingleAsync();

View File

@ -2,7 +2,8 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@layout Layout @layout Layout
@inject AppDbContext DbContext
@inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; New Transaction</PageTitle> <PageTitle>Groceries &ndash; New Transaction</PageTitle>
@ -22,7 +23,7 @@
</header> </header>
<div class="card__content card__content--table"> <div class="card__content card__content--table">
<Table Items="Transaction.Promotions.AsQueryable()" CellClass="table__cell--compact"> <Table Items="Transaction.Promotions.AsQueryable()" HeaderClass="table__header--compact" CellClass="table__cell--compact">
<ChildContent> <ChildContent>
<PropertyTableColumn Property="p => p.Name" Fill="true" /> <PropertyTableColumn Property="p => p.Name" Fill="true" />
<TemplateTableColumn Title="Items" Align="Align.End" Context="promotion"> <TemplateTableColumn Title="Items" Align="Align.End" Context="promotion">
@ -64,7 +65,8 @@
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
store = await DbContext.Stores using var dbContext = DbContextFactory.CreateDbContext();
store = await dbContext.Stores
.Where(store => store.Id == Transaction.StoreId) .Where(store => store.Id == Transaction.StoreId)
.Select(store => string.Concat(store.Retailer!.Name, " ", store.Name)) .Select(store => string.Concat(store.Retailer!.Name, " ", store.Name))
.SingleAsync(); .SingleAsync();

View File

@ -1,7 +1,7 @@
@using Groceries.Data @using Groceries.Data
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@inject AppDbContext DbContext @inject IDbContextFactory<AppDbContext> DbContextFactory
<form method="post" @attributes="AdditionalAttributes"> <form method="post" @attributes="AdditionalAttributes">
@* Ensure form action/method are used for implicit submission instead of barcode button *@ @* Ensure form action/method are used for implicit submission instead of barcode button *@
@ -58,7 +58,7 @@
</label> </label>
<div class="form-field__control input"> <div class="form-field__control input">
@{ var step = unit == null ? "1" : "0.001"; } @{ var step = unit == null ? "1" : "0.001"; }
<input class="input__control" id="transactionItemQuantity" name="quantity" value="@quantity" type="number" min="@step" step="@step" required /> <input class="input__control" id="transactionItemQuantity" name="quantity" value="@(unit == null ? (int?)quantity : quantity)" type="number" min="@step" step="@step" required />
</div> </div>
</div> </div>
@ -93,20 +93,18 @@
{ {
barcode = TransactionItem.Item?.Barcodes.FirstOrDefault(); barcode = TransactionItem.Item?.Barcodes.FirstOrDefault();
items = await DbContext.Items using var dbContext = DbContextFactory.CreateDbContext();
items = await dbContext.Items
.OrderBy(item => item.Brand) .OrderBy(item => item.Brand)
.ThenBy(item => item.Name) .ThenBy(item => item.Name)
.GroupJoin( .LeftJoin(
DbContext.ItemPurchases.Where(purchase => purchase.IsLastPurchase), dbContext.ItemPurchases.Where(purchase => purchase.IsLastPurchase),
item => item.Id, item => item.Id,
lastPurchase => lastPurchase.ItemId, lastPurchase => lastPurchase.ItemId,
(item, purchases) => new { item, purchases }) (item, lastPurchase) => new ItemModel(
.SelectMany( item.Id,
group => group.purchases.DefaultIfEmpty(), item.Brand,
(group, lastPurchase) => new ItemModel( item.Name,
group.item.Id,
group.item.Brand,
group.item.Name,
lastPurchase != null ? lastPurchase.Price : null, lastPurchase != null ? lastPurchase.Price : null,
lastPurchase != null ? lastPurchase.Quantity : null)) lastPurchase != null ? lastPurchase.Quantity : null))
.ToArrayAsync(); .ToArrayAsync();

View File

@ -1,7 +1,7 @@
@using Groceries.Data @using Groceries.Data
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@inject AppDbContext DbContext @inject IDbContextFactory<AppDbContext> DbContextFactory
<form method="post" @attributes="AdditionalAttributes"> <form method="post" @attributes="AdditionalAttributes">
<div class="form-field"> <div class="form-field">
@ -56,8 +56,9 @@
{ {
selectedItemIds = Promotion?.Items.Select(item => item.Id).ToArray() ?? []; selectedItemIds = Promotion?.Items.Select(item => item.Id).ToArray() ?? [];
using var dbContext = DbContextFactory.CreateDbContext();
var itemIds = Transaction.Items.Select(item => item.ItemId); var itemIds = Transaction.Items.Select(item => item.ItemId);
itemNames = await DbContext.Items itemNames = await dbContext.Items
.Where(item => itemIds.Contains(item.Id)) .Where(item => itemIds.Contains(item.Id))
.ToDictionaryAsync(item => item.Id, item => string.Concat(item.Brand, " ", item.Name)); .ToDictionaryAsync(item => item.Id, item => string.Concat(item.Brand, " ", item.Name));
} }

View File

@ -9,11 +9,11 @@ using System.Text.Json;
[Route("/transactions")] [Route("/transactions")]
public class TransactionsController : Controller public class TransactionsController : Controller
{ {
private readonly AppDbContext dbContext; private readonly IDbContextFactory<AppDbContext> dbContextFactory;
public TransactionsController(AppDbContext dbContext) public TransactionsController(IDbContextFactory<AppDbContext> dbContextFactory)
{ {
this.dbContext = dbContext; this.dbContextFactory = dbContextFactory;
} }
[HttpGet] [HttpGet]
@ -70,6 +70,8 @@ public class TransactionsController : Controller
Item? item = null; Item? item = null;
if (barcodeData != null && barcodeFormat != null) if (barcodeData != null && barcodeFormat != null)
{ {
using var dbContext = dbContextFactory.CreateDbContext();
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)
@ -105,6 +107,8 @@ public class TransactionsController : Controller
return Results.LocalRedirect("/transactions/new"); return Results.LocalRedirect("/transactions/new");
} }
using var dbContext = dbContextFactory.CreateDbContext();
var itemId = await dbContext.Items var itemId = await dbContext.Items
.Where(item => EF.Functions.ILike(item.Brand, brand) && EF.Functions.ILike(item.Name, name)) .Where(item => EF.Functions.ILike(item.Brand, brand) && EF.Functions.ILike(item.Name, name))
.Select(item => item.Id) .Select(item => item.Id)
@ -171,6 +175,8 @@ public class TransactionsController : Controller
return Results.LocalRedirect("/transactions/new/items"); return Results.LocalRedirect("/transactions/new/items");
} }
using var dbContext = dbContextFactory.CreateDbContext();
var itemId = await dbContext.Items var itemId = await dbContext.Items
.Where(item => EF.Functions.ILike(item.Brand, brand) && EF.Functions.ILike(item.Name, name)) .Where(item => EF.Functions.ILike(item.Brand, brand) && EF.Functions.ILike(item.Name, name))
.Select(item => item.Id) .Select(item => item.Id)
@ -235,11 +241,15 @@ public class TransactionsController : Controller
return Results.LocalRedirect("/transactions/new"); return Results.LocalRedirect("/transactions/new");
} }
using var dbContext = dbContextFactory.CreateDbContext();
foreach (var item in transaction.Items)
{
item.Item = null;
}
// 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( dbContext.Items.AttachRange(transaction.Promotions.SelectMany(promotion => promotion.Items));
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();

View File

@ -1,7 +1,10 @@
@using Groceries.Data @using Groceries.Data
@using Microsoft.EntityFrameworkCore
@layout Layout @layout Layout
@inject AppDbContext DbContext
@implements IDisposable
@inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; Transactions</PageTitle> <PageTitle>Groceries &ndash; Transactions</PageTitle>
@ -35,14 +38,16 @@
public int TotalItems { get; init; } public int TotalItems { get; init; }
} }
private AppDbContext? dbContext;
private IQueryable<TransactionModel> transactions = null!; private IQueryable<TransactionModel> transactions = null!;
private PaginationState pagination = new(); private PaginationState pagination = new();
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
transactions = DbContext.Transactions dbContext ??= DbContextFactory.CreateDbContext();
transactions = dbContext.Transactions
.Join( .Join(
DbContext.TransactionTotals, dbContext.TransactionTotals,
transaction => transaction.Id, transaction => transaction.Id,
transactionTotal => transactionTotal.TransactionId, transactionTotal => transactionTotal.TransactionId,
(transaction, transactionTotal) => new TransactionModel (transaction, transactionTotal) => new TransactionModel
@ -55,4 +60,9 @@
}) })
.OrderByDescending(transaction => transaction.CreatedAt); .OrderByDescending(transaction => transaction.CreatedAt);
} }
public void Dispose()
{
dbContext?.Dispose();
}
} }

View File

@ -3,11 +3,11 @@
"defaultProvider": "unpkg", "defaultProvider": "unpkg",
"libraries": [ "libraries": [
{ {
"library": "@fontsource-variable/inter@5.1.0", "library": "@fontsource-variable/inter@5.2.5",
"destination": "wwwroot/lib/inter/" "destination": "wwwroot/lib/inter/"
}, },
{ {
"library": "@hotwired/turbo@8.0.10", "library": "@hotwired/turbo@8.0.13",
"destination": "wwwroot/lib/hotwired/turbo/" "destination": "wwwroot/lib/hotwired/turbo/"
}, },
{ {

View File

@ -69,6 +69,14 @@ h1, h2, h3, h4, h5, h6 {
flex: 5; flex: 5;
} }
.line-clamp-4 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
white-space: initial;
}
.row { .row {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@ -312,6 +320,12 @@ html:has(.modal[open]) {
border-color: transparent; border-color: transparent;
} }
@media (max-width: 40rem) {
.card__header, .card__footer {
padding-inline: 1rem;
}
}
/* Table */ /* Table */
.table { .table {
@ -374,15 +388,15 @@ html:has(.modal[open]) {
white-space: nowrap; white-space: nowrap;
} }
.table__cell--align-center { .table__header--align-center, .table__cell--align-center {
text-align: center; text-align: center;
} }
.table__cell--align-end { .table__header--align-end, .table__cell--align-end {
text-align: end; text-align: end;
} }
.table__cell--compact { .table__header--compact, .table__cell--compact {
line-height: 1rem; line-height: 1rem;
padding-block: 0.75rem; padding-block: 0.75rem;
} }
@ -408,6 +422,24 @@ html:has(.modal[open]) {
gap: 1rem; gap: 1rem;
} }
@media (max-width: 40rem) {
.table__header, .table__cell {
padding-inline: 1rem;
}
.table__header--compact, .table__cell--compact {
font-size: 0.75rem;
}
.table__header--compact:not(:first-child), .table__cell--compact:not(:first-child) {
padding-inline-start: 0.5rem;
}
.table__header--compact:not(:last-child), .table__cell--compact:not(:last-child) {
padding-inline-end: 0.5rem;
}
}
/*@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);

View File

@ -51,7 +51,7 @@ export default class TransactionItemFormController extends Controller {
if (!price.value) { if (!price.value) {
price.value = option.getAttribute("data-price"); price.value = option.getAttribute("data-price");
} }
if (quantity.value || (!unit.value && quantity.value === "1")) { if (!quantity.value || (!unit.value && quantity.value === "1")) {
quantity.value = option.getAttribute("data-quantity") || (!unit.value ? "1" : ""); quantity.value = option.getAttribute("data-quantity") || (!unit.value ? "1" : "");
} }
} }