feat: add quantity support to totals, UI, and thread resolution

- totals.service: multiply weight/cost sums by quantity in category and global totals
- setup.service: multiply by quantity in getAllSetups SQL subqueries; expose quantity in getSetupWithItems item list
- thread.service: explicitly pass quantity: 1 when inserting resolved item
- ItemForm: add Quantity number input (min=1, default=1) after price field
- ItemCard: show ×N badge next to item name when quantity > 1
- CollectionView: pass quantity prop to ItemCard in both filtered and grouped views
- $setupId.tsx: pass quantity to ItemCard; multiply by quantity in client-side per-setup totals
- WeightSummaryCard: multiply by quantity in all chart and legend weight calculations
- useItems / useSetups: add quantity to ItemWithCategory / SetupItemWithCategory interfaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 18:04:27 +02:00
parent 923a0f66b0
commit 1a5e6a303e
10 changed files with 79 additions and 20 deletions

View File

@@ -224,6 +224,7 @@ export function CollectionView() {
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
quantity={item.quantity}
categoryName={item.categoryName}
categoryIcon={item.categoryIcon}
imageFilename={item.imageFilename}
@@ -257,6 +258,7 @@ export function CollectionView() {
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
quantity={item.quantity}
categoryName={categoryName}
categoryIcon={categoryIcon}
imageFilename={item.imageFilename}

View File

@@ -8,6 +8,7 @@ interface ItemCardProps {
name: string;
weightGrams: number | null;
priceCents: number | null;
quantity?: number;
categoryName: string;
categoryIcon: string;
imageFilename: string | null;
@@ -22,6 +23,7 @@ export function ItemCard({
name,
weightGrams,
priceCents,
quantity,
categoryName,
categoryIcon,
imageFilename,
@@ -122,9 +124,16 @@ export function ItemCard({
)}
</div>
<div className="p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-2 truncate">
{name}
</h3>
<div className="flex items-center gap-1.5 mb-2">
<h3 className="text-sm font-semibold text-gray-900 truncate min-w-0">
{name}
</h3>
{quantity != null && quantity > 1 && (
<span className="shrink-0 inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
&times;{quantity}
</span>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{weightGrams != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">

View File

@@ -13,6 +13,7 @@ interface FormData {
name: string;
weightGrams: string;
priceDollars: string;
quantity: number;
categoryId: number;
notes: string;
productUrl: string;
@@ -23,6 +24,7 @@ const INITIAL_FORM: FormData = {
name: "",
weightGrams: "",
priceDollars: "",
quantity: 1,
categoryId: 1,
notes: "",
productUrl: "",
@@ -49,6 +51,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
priceDollars:
item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
quantity: item.quantity ?? 1,
categoryId: item.categoryId,
notes: item.notes ?? "",
productUrl: item.productUrl ?? "",
@@ -98,6 +101,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
priceCents: form.priceDollars
? Math.round(Number(form.priceDollars) * 100)
: undefined,
quantity: form.quantity,
categoryId: form.categoryId,
notes: form.notes.trim() || undefined,
productUrl: form.productUrl.trim() || undefined,
@@ -202,6 +206,30 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
)}
</div>
{/* Quantity */}
<div>
<label
htmlFor="item-quantity"
className="block text-sm font-medium text-gray-700 mb-1"
>
Quantity
</label>
<input
id="item-quantity"
type="number"
min="1"
step="1"
value={form.quantity}
onChange={(e) =>
setForm((f) => ({
...f,
quantity: Math.max(1, Number(e.target.value) || 1),
}))
}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
/>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">

View File

@@ -55,9 +55,15 @@ function buildCategoryChartData(items: SetupItemWithCategory[]): ChartDatum[] {
const groups = new Map<string, number>();
for (const item of items) {
const current = groups.get(item.categoryName) ?? 0;
groups.set(item.categoryName, current + (item.weightGrams ?? 0));
groups.set(
item.categoryName,
current + (item.weightGrams ?? 0) * (item.quantity ?? 1),
);
}
const total = items.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0);
const total = items.reduce(
(sum, i) => sum + (i.weightGrams ?? 0) * (i.quantity ?? 1),
0,
);
return Array.from(groups.entries())
.filter(([, weight]) => weight > 0)
.map(([name, weight]) => ({
@@ -76,7 +82,8 @@ function buildClassificationChartData(
consumable: 0,
};
for (const item of items) {
groups[item.classification] += item.weightGrams ?? 0;
groups[item.classification] +=
(item.weightGrams ?? 0) * (item.quantity ?? 1);
}
const total = Object.values(groups).reduce((a, b) => a + b, 0);
return Object.entries(groups)
@@ -148,17 +155,23 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
const baseWeight = items.reduce(
(sum, i) =>
i.classification === "base" ? sum + (i.weightGrams ?? 0) : sum,
i.classification === "base"
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
: sum,
0,
);
const wornWeight = items.reduce(
(sum, i) =>
i.classification === "worn" ? sum + (i.weightGrams ?? 0) : sum,
i.classification === "worn"
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
: sum,
0,
);
const consumableWeight = items.reduce(
(sum, i) =>
i.classification === "consumable" ? sum + (i.weightGrams ?? 0) : sum,
i.classification === "consumable"
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
: sum,
0,
);
const totalWeight = baseWeight + wornWeight + consumableWeight;

View File

@@ -7,6 +7,7 @@ interface ItemWithCategory {
name: string;
weightGrams: number | null;
priceCents: number | null;
quantity: number;
categoryId: number;
notes: string | null;
productUrl: string | null;

View File

@@ -16,6 +16,7 @@ interface SetupItemWithCategory {
name: string;
weightGrams: number | null;
priceCents: number | null;
quantity: number;
categoryId: number;
notes: string | null;
productUrl: string | null;

View File

@@ -53,13 +53,13 @@ function SetupDetailPage() {
);
}
// Compute totals from items
// Compute totals from items (multiply by quantity)
const totalWeight = setup.items.reduce(
(sum, item) => sum + (item.weightGrams ?? 0),
(sum, item) => sum + (item.weightGrams ?? 0) * (item.quantity ?? 1),
0,
);
const totalCost = setup.items.reduce(
(sum, item) => sum + (item.priceCents ?? 0),
(sum, item) => sum + (item.priceCents ?? 0) * (item.quantity ?? 1),
0,
);
const itemCount = setup.items.length;
@@ -207,11 +207,13 @@ function SetupDetailPage() {
{ items: categoryItems, categoryName, categoryIcon },
]) => {
const catWeight = categoryItems.reduce(
(sum, item) => sum + (item.weightGrams ?? 0),
(sum, item) =>
sum + (item.weightGrams ?? 0) * (item.quantity ?? 1),
0,
);
const catCost = categoryItems.reduce(
(sum, item) => sum + (item.priceCents ?? 0),
(sum, item) =>
sum + (item.priceCents ?? 0) * (item.quantity ?? 1),
0,
);
return (
@@ -232,6 +234,7 @@ function SetupDetailPage() {
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
quantity={item.quantity}
categoryName={categoryName}
categoryIcon={categoryIcon}
imageFilename={item.imageFilename}

View File

@@ -21,12 +21,12 @@ export function getAllSetups(db: Db = prodDb) {
WHERE setup_items.setup_id = setups.id
), 0)`.as("item_count"),
totalWeight: sql<number>`COALESCE((
SELECT SUM(items.weight_grams) FROM setup_items
SELECT SUM(items.weight_grams * items.quantity) FROM setup_items
JOIN items ON items.id = setup_items.item_id
WHERE setup_items.setup_id = setups.id
), 0)`.as("total_weight"),
totalCost: sql<number>`COALESCE((
SELECT SUM(items.price_cents) FROM setup_items
SELECT SUM(items.price_cents * items.quantity) FROM setup_items
JOIN items ON items.id = setup_items.item_id
WHERE setup_items.setup_id = setups.id
), 0)`.as("total_cost"),
@@ -45,6 +45,7 @@ export function getSetupWithItems(db: Db = prodDb, setupId: number) {
name: items.name,
weightGrams: items.weightGrams,
priceCents: items.priceCents,
quantity: items.quantity,
categoryId: items.categoryId,
notes: items.notes,
productUrl: items.productUrl,

View File

@@ -299,6 +299,7 @@ export function resolveThread(
productUrl: candidate.productUrl,
imageFilename: candidate.imageFilename,
imageSourceUrl: candidate.imageSourceUrl,
quantity: 1,
})
.returning()
.get();

View File

@@ -10,8 +10,8 @@ export function getCategoryTotals(db: Db = prodDb) {
categoryId: items.categoryId,
categoryName: categories.name,
categoryIcon: categories.icon,
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams} * ${items.quantity}), 0)`,
totalCost: sql<number>`COALESCE(SUM(${items.priceCents} * ${items.quantity}), 0)`,
itemCount: sql<number>`COUNT(*)`,
})
.from(items)
@@ -23,8 +23,8 @@ export function getCategoryTotals(db: Db = prodDb) {
export function getGlobalTotals(db: Db = prodDb) {
return db
.select({
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams} * ${items.quantity}), 0)`,
totalCost: sql<number>`COALESCE(SUM(${items.priceCents} * ${items.quantity}), 0)`,
itemCount: sql<number>`COUNT(*)`,
})
.from(items)