Filter unit dropdown by product allowed-unit categories
Adds a UnitCategoryFlags column to Product, FamilyProduct, and FamilyProductOverride so each product can advertise which unit categories it is typically packaged by (e.g. flour: Weight | Volume). The product endpoints round-trip the flag, search projects the effective value with the override applied, and the frontend QuantityInput soft-filters its dropdown by the selected product's flag, with a "show all units" escape hatch for ad-hoc overrides. No backend rejection on a unit outside the allowed set — the flag is purely a hint. Default value is None (no filter), so existing data is unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -247,6 +247,115 @@ public class ProductEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Create_family_product_persists_allowed_unit_categories()
|
||||||
|
{
|
||||||
|
var response = await Client.PostAsJsonAsync("/api/products",
|
||||||
|
new ProductEndpoints.CreateProductRequest("House Flour", null, null,
|
||||||
|
UnitCategoryFlags.Weight | UnitCategoryFlags.Volume));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||||
|
var stored = await UseDbAsync(db => db.FamilyProducts.SingleAsync());
|
||||||
|
await Assert.That(stored.AllowedUnitCategories)
|
||||||
|
.IsEqualTo(UnitCategoryFlags.Weight | UnitCategoryFlags.Volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Update_family_product_changes_allowed_unit_categories()
|
||||||
|
{
|
||||||
|
var familyId = await GetFamilyIdAsync();
|
||||||
|
var product = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var p = new FamilyProduct
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
Name = "Flour",
|
||||||
|
AllowedUnitCategories = UnitCategoryFlags.Weight,
|
||||||
|
};
|
||||||
|
db.FamilyProducts.Add(p);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await Client.PutAsJsonAsync($"/api/products/family/{product.Id}",
|
||||||
|
new ProductEndpoints.UpdateProductRequest(null, null, null,
|
||||||
|
UnitCategoryFlags.Weight | UnitCategoryFlags.Volume));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
|
||||||
|
var stored = await UseDbAsync(db => db.FamilyProducts.SingleAsync());
|
||||||
|
await Assert.That(stored.AllowedUnitCategories)
|
||||||
|
.IsEqualTo(UnitCategoryFlags.Weight | UnitCategoryFlags.Volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Update_global_product_writes_allowed_unit_categories_to_override()
|
||||||
|
{
|
||||||
|
var apples = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var p = new Product
|
||||||
|
{
|
||||||
|
Name = "Milk",
|
||||||
|
AllowedUnitCategories = UnitCategoryFlags.Volume,
|
||||||
|
};
|
||||||
|
db.Products.Add(p);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await Client.PutAsJsonAsync($"/api/products/global/{apples.Id}",
|
||||||
|
new ProductEndpoints.UpdateProductRequest(null, null, null,
|
||||||
|
UnitCategoryFlags.Volume | UnitCategoryFlags.Packaging));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
|
||||||
|
var ovr = await UseDbAsync(db => db.FamilyProductOverrides.SingleAsync());
|
||||||
|
await Assert.That(ovr.AllowedUnitCategories)
|
||||||
|
.IsEqualTo(UnitCategoryFlags.Volume | UnitCategoryFlags.Packaging);
|
||||||
|
|
||||||
|
// The global row remains unchanged.
|
||||||
|
var global = await UseDbAsync(db => db.Products.SingleAsync(p => p.Id == apples.Id));
|
||||||
|
await Assert.That(global.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Search_projects_effective_allowed_unit_categories()
|
||||||
|
{
|
||||||
|
var familyId = await GetFamilyIdAsync();
|
||||||
|
await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
db.Products.Add(new Product { Name = "Milk", AllowedUnitCategories = UnitCategoryFlags.Volume });
|
||||||
|
db.Products.Add(new Product { Name = "Eggs", AllowedUnitCategories = UnitCategoryFlags.Count });
|
||||||
|
db.FamilyProducts.Add(new FamilyProduct
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
Name = "House Flour",
|
||||||
|
AllowedUnitCategories = UnitCategoryFlags.Weight,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Override Eggs to widen to Count | Packaging for this family.
|
||||||
|
var eggs = db.Products.Single(p => p.Name == "Eggs");
|
||||||
|
db.FamilyProductOverrides.Add(new FamilyProductOverride
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
ProductId = eggs.Id,
|
||||||
|
AllowedUnitCategories = UnitCategoryFlags.Count | UnitCategoryFlags.Packaging,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
var results = await Client.GetFromJsonAsync<List<ProductEndpoints.ProductDto>>("/api/products?q=");
|
||||||
|
|
||||||
|
var milk = results!.Single(r => r.Name == "Milk");
|
||||||
|
await Assert.That(milk.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Volume);
|
||||||
|
|
||||||
|
var eggs = results.Single(r => r.Name == "Eggs");
|
||||||
|
await Assert.That(eggs.AllowedUnitCategories)
|
||||||
|
.IsEqualTo(UnitCategoryFlags.Count | UnitCategoryFlags.Packaging);
|
||||||
|
|
||||||
|
var flour = results.Single(r => r.Name == "House Flour");
|
||||||
|
await Assert.That(flour.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Weight);
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task Endpoints_require_authentication()
|
public async Task Endpoints_require_authentication()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
|||||||
e.Property(p => p.Name).HasMaxLength(200);
|
e.Property(p => p.Name).HasMaxLength(200);
|
||||||
e.Property(p => p.Brand).HasMaxLength(200);
|
e.Property(p => p.Brand).HasMaxLength(200);
|
||||||
e.Property(p => p.Notes).HasMaxLength(1000);
|
e.Property(p => p.Notes).HasMaxLength(1000);
|
||||||
|
e.Property(p => p.AllowedUnitCategories).HasConversion<int>();
|
||||||
e.HasIndex(p => p.Name).IsUnique();
|
e.HasIndex(p => p.Name).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,6 +149,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
|||||||
e.Property(p => p.Name).HasMaxLength(200);
|
e.Property(p => p.Name).HasMaxLength(200);
|
||||||
e.Property(p => p.Brand).HasMaxLength(200);
|
e.Property(p => p.Brand).HasMaxLength(200);
|
||||||
e.Property(p => p.Notes).HasMaxLength(1000);
|
e.Property(p => p.Notes).HasMaxLength(1000);
|
||||||
|
e.Property(p => p.AllowedUnitCategories).HasConversion<int>();
|
||||||
e.HasOne(p => p.Family).WithMany().HasForeignKey(p => p.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
e.HasOne(p => p.Family).WithMany().HasForeignKey(p => p.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
||||||
e.HasIndex(p => new { p.FamilyId, p.Name }).IsUnique();
|
e.HasIndex(p => new { p.FamilyId, p.Name }).IsUnique();
|
||||||
});
|
});
|
||||||
@@ -158,6 +160,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
|||||||
e.Property(o => o.Name).HasMaxLength(200);
|
e.Property(o => o.Name).HasMaxLength(200);
|
||||||
e.Property(o => o.Brand).HasMaxLength(200);
|
e.Property(o => o.Brand).HasMaxLength(200);
|
||||||
e.Property(o => o.Notes).HasMaxLength(1000);
|
e.Property(o => o.Notes).HasMaxLength(1000);
|
||||||
|
e.Property(o => o.AllowedUnitCategories).HasConversion<int?>();
|
||||||
e.HasOne(o => o.Family).WithMany().HasForeignKey(o => o.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
e.HasOne(o => o.Family).WithMany().HasForeignKey(o => o.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
||||||
e.HasOne(o => o.Product).WithMany().HasForeignKey(o => o.ProductId).OnDelete(DeleteBehavior.Cascade);
|
e.HasOne(o => o.Product).WithMany().HasForeignKey(o => o.ProductId).OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,5 +12,6 @@ public class FamilyProduct
|
|||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public string? Brand { get; set; }
|
public string? Brand { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public UnitCategoryFlags AllowedUnitCategories { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,8 @@ public class FamilyProductOverride
|
|||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
public string? Brand { get; set; }
|
public string? Brand { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
// Nullable so "inherit global" (null) is distinguishable from
|
||||||
|
// "explicitly None / any unit" (UnitCategoryFlags.None).
|
||||||
|
public UnitCategoryFlags? AllowedUnitCategories { get; set; }
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,8 @@ public class Product
|
|||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public string? Brand { get; set; }
|
public string? Brand { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
// None = "any unit". Non-zero narrows the unit-dropdown suggestions to the
|
||||||
|
// flagged categories. Families can replace this with FamilyProductOverride.
|
||||||
|
public UnitCategoryFlags AllowedUnitCategories { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace YesChef.Api.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Multi-select sibling of <see cref="UnitCategory"/>. A product can be
|
||||||
|
/// "typically packaged" by one or more categories (e.g. flour: Weight | Volume),
|
||||||
|
/// and the unit dropdown filters its suggestions accordingly. Stored as a
|
||||||
|
/// 32-bit integer so families can OR in additional categories over time.
|
||||||
|
/// </summary>
|
||||||
|
[System.Flags]
|
||||||
|
public enum UnitCategoryFlags
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Count = 1 << 0,
|
||||||
|
Weight = 1 << 1,
|
||||||
|
Volume = 1 << 2,
|
||||||
|
Packaging = 1 << 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class UnitCategoryFlagsExtensions
|
||||||
|
{
|
||||||
|
public static UnitCategoryFlags ToFlag(this UnitCategory c) => c switch
|
||||||
|
{
|
||||||
|
UnitCategory.Count => UnitCategoryFlags.Count,
|
||||||
|
UnitCategory.Weight => UnitCategoryFlags.Weight,
|
||||||
|
UnitCategory.Volume => UnitCategoryFlags.Volume,
|
||||||
|
UnitCategory.Packaging => UnitCategoryFlags.Packaging,
|
||||||
|
_ => UnitCategoryFlags.None,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,10 +18,11 @@ public static class ProductEndpoints
|
|||||||
string Name,
|
string Name,
|
||||||
string? Brand,
|
string? Brand,
|
||||||
string? Notes,
|
string? Notes,
|
||||||
bool IsOverridden);
|
bool IsOverridden,
|
||||||
|
UnitCategoryFlags AllowedUnitCategories);
|
||||||
|
|
||||||
public record CreateProductRequest(string Name, string? Brand, string? Notes);
|
public record CreateProductRequest(string Name, string? Brand, string? Notes, UnitCategoryFlags AllowedUnitCategories = UnitCategoryFlags.None);
|
||||||
public record UpdateProductRequest(string? Name, string? Brand, string? Notes);
|
public record UpdateProductRequest(string? Name, string? Brand, string? Notes, UnitCategoryFlags? AllowedUnitCategories = null);
|
||||||
|
|
||||||
private const int SearchResultLimit = 50;
|
private const int SearchResultLimit = 50;
|
||||||
|
|
||||||
@@ -54,9 +55,11 @@ public static class ProductEndpoints
|
|||||||
GlobalName = p.Name,
|
GlobalName = p.Name,
|
||||||
GlobalBrand = p.Brand,
|
GlobalBrand = p.Brand,
|
||||||
GlobalNotes = p.Notes,
|
GlobalNotes = p.Notes,
|
||||||
|
GlobalAllowedUnitCategories = p.AllowedUnitCategories,
|
||||||
OverrideName = o != null ? o.Name : null,
|
OverrideName = o != null ? o.Name : null,
|
||||||
OverrideBrand = o != null ? o.Brand : null,
|
OverrideBrand = o != null ? o.Brand : null,
|
||||||
OverrideNotes = o != null ? o.Notes : null,
|
OverrideNotes = o != null ? o.Notes : null,
|
||||||
|
OverrideAllowedUnitCategories = o != null ? o.AllowedUnitCategories : null,
|
||||||
HasOverride = o != null,
|
HasOverride = o != null,
|
||||||
})
|
})
|
||||||
.Take(SearchResultLimit)
|
.Take(SearchResultLimit)
|
||||||
@@ -82,11 +85,12 @@ public static class ProductEndpoints
|
|||||||
name,
|
name,
|
||||||
r.OverrideBrand ?? r.GlobalBrand,
|
r.OverrideBrand ?? r.GlobalBrand,
|
||||||
r.OverrideNotes ?? r.GlobalNotes,
|
r.OverrideNotes ?? r.GlobalNotes,
|
||||||
r.HasOverride);
|
r.HasOverride,
|
||||||
|
r.OverrideAllowedUnitCategories ?? r.GlobalAllowedUnitCategories);
|
||||||
}).Where(d => d is not null).Cast<ProductDto>();
|
}).Where(d => d is not null).Cast<ProductDto>();
|
||||||
|
|
||||||
var familyDtos = familyRows.Select(p =>
|
var familyDtos = familyRows.Select(p =>
|
||||||
new ProductDto(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false));
|
new ProductDto(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false, p.AllowedUnitCategories));
|
||||||
|
|
||||||
var results = globalDtos.Concat(familyDtos)
|
var results = globalDtos.Concat(familyDtos)
|
||||||
.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
@@ -114,6 +118,7 @@ public static class ProductEndpoints
|
|||||||
Name = name,
|
Name = name,
|
||||||
Brand = request.Brand,
|
Brand = request.Brand,
|
||||||
Notes = request.Notes,
|
Notes = request.Notes,
|
||||||
|
AllowedUnitCategories = request.AllowedUnitCategories,
|
||||||
};
|
};
|
||||||
db.FamilyProducts.Add(product);
|
db.FamilyProducts.Add(product);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -142,6 +147,7 @@ public static class ProductEndpoints
|
|||||||
}
|
}
|
||||||
product.Brand = request.Brand;
|
product.Brand = request.Brand;
|
||||||
product.Notes = request.Notes;
|
product.Notes = request.Notes;
|
||||||
|
if (request.AllowedUnitCategories is { } cats) product.AllowedUnitCategories = cats;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(ToDto(product));
|
return Results.Ok(ToDto(product));
|
||||||
@@ -174,6 +180,10 @@ public static class ProductEndpoints
|
|||||||
ovr.Name = trimmedName;
|
ovr.Name = trimmedName;
|
||||||
ovr.Brand = request.Brand;
|
ovr.Brand = request.Brand;
|
||||||
ovr.Notes = request.Notes;
|
ovr.Notes = request.Notes;
|
||||||
|
// null in the request leaves the override's value alone (which may
|
||||||
|
// itself be null, i.e. "inherit global"). Pass an explicit value to
|
||||||
|
// either narrow categories or restore "any" (UnitCategoryFlags.None).
|
||||||
|
if (request.AllowedUnitCategories is { } cats) ovr.AllowedUnitCategories = cats;
|
||||||
ovr.UpdatedAt = DateTime.UtcNow;
|
ovr.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -183,12 +193,13 @@ public static class ProductEndpoints
|
|||||||
ovr.Name ?? product.Name,
|
ovr.Name ?? product.Name,
|
||||||
ovr.Brand ?? product.Brand,
|
ovr.Brand ?? product.Brand,
|
||||||
ovr.Notes ?? product.Notes,
|
ovr.Notes ?? product.Notes,
|
||||||
IsOverridden: true));
|
IsOverridden: true,
|
||||||
|
ovr.AllowedUnitCategories ?? product.AllowedUnitCategories));
|
||||||
});
|
});
|
||||||
|
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ProductDto ToDto(FamilyProduct p) =>
|
private static ProductDto ToDto(FamilyProduct p) =>
|
||||||
new(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false);
|
new(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false, p.AllowedUnitCategories);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1100
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace YesChef.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddProductAllowedUnitCategories : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "AllowedUnitCategories",
|
||||||
|
table: "Products",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "AllowedUnitCategories",
|
||||||
|
table: "FamilyProducts",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "AllowedUnitCategories",
|
||||||
|
table: "FamilyProductOverrides",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AllowedUnitCategories",
|
||||||
|
table: "Products");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AllowedUnitCategories",
|
||||||
|
table: "FamilyProducts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AllowedUnitCategories",
|
||||||
|
table: "FamilyProductOverrides");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,9 @@ namespace YesChef.Api.Migrations
|
|||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("AllowedUnitCategories")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<string>("Brand")
|
b.Property<string>("Brand")
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
@@ -115,6 +118,9 @@ namespace YesChef.Api.Migrations
|
|||||||
b.Property<int>("ProductId")
|
b.Property<int>("ProductId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("AllowedUnitCategories")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<string>("Brand")
|
b.Property<string>("Brand")
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
@@ -273,6 +279,9 @@ namespace YesChef.Api.Migrations
|
|||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("AllowedUnitCategories")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<string>("Brand")
|
b.Property<string>("Brand")
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
|
// Bitfield matching backend UnitCategoryFlags. Stored as an int on the wire.
|
||||||
|
export const UnitCategoryFlag = {
|
||||||
|
None: 0,
|
||||||
|
Count: 1,
|
||||||
|
Weight: 2,
|
||||||
|
Volume: 4,
|
||||||
|
Packaging: 8,
|
||||||
|
} as const;
|
||||||
|
export type UnitCategoryFlag = typeof UnitCategoryFlag[keyof typeof UnitCategoryFlag];
|
||||||
|
|
||||||
export interface ProductSuggestion {
|
export interface ProductSuggestion {
|
||||||
id: number;
|
id: number;
|
||||||
kind: 'Global' | 'Family';
|
kind: 'Global' | 'Family';
|
||||||
@@ -6,6 +16,7 @@
|
|||||||
brand: string | null;
|
brand: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
isOverridden: boolean;
|
isOverridden: boolean;
|
||||||
|
allowedUnitCategories: number;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -7,17 +7,20 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { units } from '$lib/units.svelte';
|
import { units, type UnitCategory } from '$lib/units.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: QuantityValue;
|
value: QuantityValue;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
|
// When non-zero, soft-filters the unit dropdown to units whose category
|
||||||
|
// is one of the flagged categories. Users can still expand to all units
|
||||||
|
// via the "show all" link. 0 (None) = no filter applied.
|
||||||
|
allowedUnitCategories?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { value = $bindable(), ariaLabel = 'Quantity' }: Props = $props();
|
let { value = $bindable(), ariaLabel = 'Quantity', allowedUnitCategories = 0 }: Props = $props();
|
||||||
|
let showAll = $state(false);
|
||||||
|
|
||||||
// The unit dropdown carries a composite id "kind:id" so we can route to
|
|
||||||
// the right FK column on the server. "" = no unit selected.
|
|
||||||
const composite = $derived.by(() => {
|
const composite = $derived.by(() => {
|
||||||
if (value.unitOfMeasureId !== null) return `Global:${value.unitOfMeasureId}`;
|
if (value.unitOfMeasureId !== null) return `Global:${value.unitOfMeasureId}`;
|
||||||
if (value.familyUnitOfMeasureId !== null) return `Family:${value.familyUnitOfMeasureId}`;
|
if (value.familyUnitOfMeasureId !== null) return `Family:${value.familyUnitOfMeasureId}`;
|
||||||
@@ -47,11 +50,32 @@
|
|||||||
value.quantity = raw === '' ? null : Number(raw);
|
value.quantity = raw === '' ? null : Number(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const categoryBit: Record<UnitCategory, number> = {
|
||||||
|
Count: 1, Weight: 2, Volume: 4, Packaging: 8,
|
||||||
|
};
|
||||||
|
|
||||||
const sorted = $derived(
|
const sorted = $derived(
|
||||||
[...units.all].sort((a, b) => a.sortOrder - b.sortOrder || a.singularName.localeCompare(b.singularName))
|
[...units.all].sort((a, b) => a.sortOrder - b.sortOrder || a.singularName.localeCompare(b.singularName))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If the filter mask is non-zero and showAll isn't on, keep only matching
|
||||||
|
// units — plus the currently-selected one so the dropdown can render it
|
||||||
|
// even if the product was changed to one with a tighter filter.
|
||||||
|
const visible = $derived.by(() => {
|
||||||
|
const mask = allowedUnitCategories;
|
||||||
|
if (mask === 0 || showAll) return sorted;
|
||||||
|
const selectedId = value.unitOfMeasureId ?? value.familyUnitOfMeasureId;
|
||||||
|
const selectedKind = value.unitOfMeasureId !== null ? 'Global' : 'Family';
|
||||||
|
return sorted.filter((u) => {
|
||||||
|
if ((categoryBit[u.category] & mask) !== 0) return true;
|
||||||
|
return selectedId !== null && u.id === selectedId && u.kind === selectedKind;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterIsActive = $derived(allowedUnitCategories !== 0 && !showAll && visible.length !== sorted.length);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -71,8 +95,14 @@
|
|||||||
class="w-24 rounded-lg border border-gray-300 px-1 py-2 text-sm focus:border-primary focus:outline-none"
|
class="w-24 rounded-lg border border-gray-300 px-1 py-2 text-sm focus:border-primary focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">unit</option>
|
<option value="">unit</option>
|
||||||
{#each sorted as unit}
|
{#each visible as unit}
|
||||||
<option value="{unit.kind}:{unit.id}">{unit.abbreviation}</option>
|
<option value="{unit.kind}:{unit.id}">{unit.abbreviation}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{#if filterIsActive}
|
||||||
|
<button type="button" onclick={() => (showAll = true)} class="text-[10px] text-gray-400 hover:text-primary">
|
||||||
|
show all units
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
let newItemQuantity = $state<QuantityValue>({ quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null });
|
let newItemQuantity = $state<QuantityValue>({ quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null });
|
||||||
let newItemIsApproximate = $state(false);
|
let newItemIsApproximate = $state(false);
|
||||||
let newItemQuantityNote = $state('');
|
let newItemQuantityNote = $state('');
|
||||||
|
let newItemAllowedUnitCategories = $state(0);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let connection: HubConnection | null = null;
|
let connection: HubConnection | null = null;
|
||||||
|
|
||||||
@@ -190,6 +191,7 @@
|
|||||||
newItemQuantity = { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null };
|
newItemQuantity = { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null };
|
||||||
newItemIsApproximate = false;
|
newItemIsApproximate = false;
|
||||||
newItemQuantityNote = '';
|
newItemQuantityNote = '';
|
||||||
|
newItemAllowedUnitCategories = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleApproximateNewItem() {
|
function toggleApproximateNewItem() {
|
||||||
@@ -205,12 +207,15 @@
|
|||||||
if (product === null) {
|
if (product === null) {
|
||||||
newItemProductId = null;
|
newItemProductId = null;
|
||||||
newItemFamilyProductId = null;
|
newItemFamilyProductId = null;
|
||||||
|
newItemAllowedUnitCategories = 0;
|
||||||
} else if (product.kind === 'Global') {
|
} else if (product.kind === 'Global') {
|
||||||
newItemProductId = product.id;
|
newItemProductId = product.id;
|
||||||
newItemFamilyProductId = null;
|
newItemFamilyProductId = null;
|
||||||
|
newItemAllowedUnitCategories = product.allowedUnitCategories;
|
||||||
} else {
|
} else {
|
||||||
newItemProductId = null;
|
newItemProductId = null;
|
||||||
newItemFamilyProductId = product.id;
|
newItemFamilyProductId = product.id;
|
||||||
|
newItemAllowedUnitCategories = product.allowedUnitCategories;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +283,10 @@
|
|||||||
class="w-36 rounded-lg border border-gray-300 px-2 py-2.5 text-sm focus:border-primary focus:outline-none"
|
class="w-36 rounded-lg border border-gray-300 px-2 py-2.5 text-sm focus:border-primary focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<QuantityInput bind:value={newItemQuantity} />
|
<QuantityInput
|
||||||
|
bind:value={newItemQuantity}
|
||||||
|
allowedUnitCategories={newItemAllowedUnitCategories}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<ProductTypeahead
|
<ProductTypeahead
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
quantityNote: string;
|
quantityNote: string;
|
||||||
productId: number | null;
|
productId: number | null;
|
||||||
familyProductId: number | null;
|
familyProductId: number | null;
|
||||||
|
allowedUnitCategories: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let title = $state('');
|
let title = $state('');
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
quantityNote: '',
|
quantityNote: '',
|
||||||
productId: null,
|
productId: null,
|
||||||
familyProductId: null,
|
familyProductId: null,
|
||||||
|
allowedUnitCategories: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,12 +56,15 @@
|
|||||||
if (product === null) {
|
if (product === null) {
|
||||||
next.productId = null;
|
next.productId = null;
|
||||||
next.familyProductId = null;
|
next.familyProductId = null;
|
||||||
|
next.allowedUnitCategories = 0;
|
||||||
} else if (product.kind === 'Global') {
|
} else if (product.kind === 'Global') {
|
||||||
next.productId = product.id;
|
next.productId = product.id;
|
||||||
next.familyProductId = null;
|
next.familyProductId = null;
|
||||||
|
next.allowedUnitCategories = product.allowedUnitCategories;
|
||||||
} else {
|
} else {
|
||||||
next.productId = null;
|
next.productId = null;
|
||||||
next.familyProductId = product.id;
|
next.familyProductId = product.id;
|
||||||
|
next.allowedUnitCategories = product.allowedUnitCategories;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +148,10 @@
|
|||||||
class="w-44 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
|
class="w-44 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<QuantityInput bind:value={ingredient.quantity} />
|
<QuantityInput
|
||||||
|
bind:value={ingredient.quantity}
|
||||||
|
allowedUnitCategories={ingredient.allowedUnitCategories}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<ProductTypeahead
|
<ProductTypeahead
|
||||||
|
|||||||
Reference in New Issue
Block a user