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:
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
×{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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,6 +7,7 @@ interface ItemWithCategory {
|
||||
name: string;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
quantity: number;
|
||||
categoryId: number;
|
||||
notes: string | null;
|
||||
productUrl: string | null;
|
||||
|
||||
@@ -16,6 +16,7 @@ interface SetupItemWithCategory {
|
||||
name: string;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
quantity: number;
|
||||
categoryId: number;
|
||||
notes: string | null;
|
||||
productUrl: string | null;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user