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:
@@ -1,4 +1,14 @@
|
||||
<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 {
|
||||
id: number;
|
||||
kind: 'Global' | 'Family';
|
||||
@@ -6,6 +16,7 @@
|
||||
brand: string | null;
|
||||
notes: string | null;
|
||||
isOverridden: boolean;
|
||||
allowedUnitCategories: number;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,17 +7,20 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { units } from '$lib/units.svelte';
|
||||
import { units, type UnitCategory } from '$lib/units.svelte';
|
||||
|
||||
interface Props {
|
||||
value: QuantityValue;
|
||||
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(() => {
|
||||
if (value.unitOfMeasureId !== null) return `Global:${value.unitOfMeasureId}`;
|
||||
if (value.familyUnitOfMeasureId !== null) return `Family:${value.familyUnitOfMeasureId}`;
|
||||
@@ -47,32 +50,59 @@
|
||||
value.quantity = raw === '' ? null : Number(raw);
|
||||
}
|
||||
|
||||
const categoryBit: Record<UnitCategory, number> = {
|
||||
Count: 1, Weight: 2, Volume: 4, Packaging: 8,
|
||||
};
|
||||
|
||||
const sorted = $derived(
|
||||
[...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>
|
||||
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
inputmode="decimal"
|
||||
value={value.quantity ?? ''}
|
||||
oninput={onQuantityInput}
|
||||
placeholder="Qty"
|
||||
aria-label={ariaLabel}
|
||||
class="w-20 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={composite}
|
||||
onchange={onUnitChange}
|
||||
aria-label="Unit"
|
||||
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>
|
||||
{#each sorted as unit}
|
||||
<option value="{unit.kind}:{unit.id}">{unit.abbreviation}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
inputmode="decimal"
|
||||
value={value.quantity ?? ''}
|
||||
oninput={onQuantityInput}
|
||||
placeholder="Qty"
|
||||
aria-label={ariaLabel}
|
||||
class="w-20 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={composite}
|
||||
onchange={onUnitChange}
|
||||
aria-label="Unit"
|
||||
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>
|
||||
{#each visible as unit}
|
||||
<option value="{unit.kind}:{unit.id}">{unit.abbreviation}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</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 newItemIsApproximate = $state(false);
|
||||
let newItemQuantityNote = $state('');
|
||||
let newItemAllowedUnitCategories = $state(0);
|
||||
let loading = $state(true);
|
||||
let connection: HubConnection | null = null;
|
||||
|
||||
@@ -190,6 +191,7 @@
|
||||
newItemQuantity = { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null };
|
||||
newItemIsApproximate = false;
|
||||
newItemQuantityNote = '';
|
||||
newItemAllowedUnitCategories = 0;
|
||||
}
|
||||
|
||||
function toggleApproximateNewItem() {
|
||||
@@ -205,12 +207,15 @@
|
||||
if (product === null) {
|
||||
newItemProductId = null;
|
||||
newItemFamilyProductId = null;
|
||||
newItemAllowedUnitCategories = 0;
|
||||
} else if (product.kind === 'Global') {
|
||||
newItemProductId = product.id;
|
||||
newItemFamilyProductId = null;
|
||||
newItemAllowedUnitCategories = product.allowedUnitCategories;
|
||||
} else {
|
||||
newItemProductId = null;
|
||||
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"
|
||||
/>
|
||||
{:else}
|
||||
<QuantityInput bind:value={newItemQuantity} />
|
||||
<QuantityInput
|
||||
bind:value={newItemQuantity}
|
||||
allowedUnitCategories={newItemAllowedUnitCategories}
|
||||
/>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<ProductTypeahead
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
quantityNote: string;
|
||||
productId: number | null;
|
||||
familyProductId: number | null;
|
||||
allowedUnitCategories: number;
|
||||
}
|
||||
|
||||
let title = $state('');
|
||||
@@ -28,6 +29,7 @@
|
||||
quantityNote: '',
|
||||
productId: null,
|
||||
familyProductId: null,
|
||||
allowedUnitCategories: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,12 +56,15 @@
|
||||
if (product === null) {
|
||||
next.productId = null;
|
||||
next.familyProductId = null;
|
||||
next.allowedUnitCategories = 0;
|
||||
} else if (product.kind === 'Global') {
|
||||
next.productId = product.id;
|
||||
next.familyProductId = null;
|
||||
next.allowedUnitCategories = product.allowedUnitCategories;
|
||||
} else {
|
||||
next.productId = null;
|
||||
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"
|
||||
/>
|
||||
{:else}
|
||||
<QuantityInput bind:value={ingredient.quantity} />
|
||||
<QuantityInput
|
||||
bind:value={ingredient.quantity}
|
||||
allowedUnitCategories={ingredient.allowedUnitCategories}
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<ProductTypeahead
|
||||
|
||||
Reference in New Issue
Block a user