From 153b6cb76a71084d2878f48b657fdbc8c1b410d8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Apr 2026 17:38:13 +0200 Subject: [PATCH] feat(23-01): create ManualEntryForm component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Compact form with name, category, weight, price, purchase price, product URL, notes, image - Uses CategoryPicker and ImageUpload reusable components - Calls useCreateItem without globalItemId for standalone item creation - Back arrow (ArrowLeft) calls onBack prop to return to search results - Converts price strings to cents via Math.round(Number(val) * 100) - No toast.success on save — success card in overlay handles feedback --- src/client/components/ManualEntryForm.tsx | 245 ++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 src/client/components/ManualEntryForm.tsx diff --git a/src/client/components/ManualEntryForm.tsx b/src/client/components/ManualEntryForm.tsx new file mode 100644 index 0000000..3f12328 --- /dev/null +++ b/src/client/components/ManualEntryForm.tsx @@ -0,0 +1,245 @@ +import { ArrowLeft } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useCategories } from "../hooks/useCategories"; +import { useCreateItem } from "../hooks/useItems"; +import { CategoryPicker } from "./CategoryPicker"; +import { ImageUpload } from "./ImageUpload"; + +interface ManualEntryFormProps { + initialName?: string; + onSuccess: (itemName: string) => void; + onBack: () => void; +} + +export function ManualEntryForm({ + initialName, + onSuccess, + onBack, +}: ManualEntryFormProps) { + const { data: categories } = useCategories(); + const createItem = useCreateItem(); + + const [name, setName] = useState(initialName ?? ""); + const [categoryId, setCategoryId] = useState(null); + const [weightGrams, setWeightGrams] = useState(""); + const [priceDollars, setPriceDollars] = useState(""); + const [purchasePrice, setPurchasePrice] = useState(""); + const [notes, setNotes] = useState(""); + const [productUrl, setProductUrl] = useState(""); + const [imageFilename, setImageFilename] = useState(null); + const [error, setError] = useState(null); + + // Pre-select first category when categories load + useEffect(() => { + if (categories && categories.length > 0 && categoryId === null) { + setCategoryId(categories[0].id); + } + }, [categories, categoryId]); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + if (!name.trim()) { + setError("Name is required"); + return; + } + if (categoryId === null) { + setError("Please select a category"); + return; + } + + setError(null); + + createItem.mutate( + { + name: name.trim(), + categoryId, + weightGrams: weightGrams ? Number(weightGrams) : undefined, + priceCents: priceDollars + ? Math.round(Number(priceDollars) * 100) + : undefined, + purchasePriceCents: purchasePrice + ? Math.round(Number(purchasePrice) * 100) + : undefined, + notes: notes || undefined, + productUrl: productUrl || undefined, + imageFilename: imageFilename ?? undefined, + }, + { + onSuccess: (item) => { + onSuccess(item.name); + }, + onError: (err) => { + setError(err instanceof Error ? err.message : "Failed to save"); + }, + }, + ); + } + + return ( +
+ {/* Back arrow row */} +
+ +

Add Manually

+
+ +
+ {/* Name */} +
+ + setName(e.target.value)} + placeholder="Item name" + required + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" + /> +
+ + {/* Category */} +
+ + setCategoryId(id)} + /> +
+ + {/* Weight and Price row */} +
+
+ + setWeightGrams(e.target.value)} + placeholder="0" + min="0" + step="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" + /> +
+
+ + setPriceDollars(e.target.value)} + placeholder="0.00" + min="0" + step="0.01" + 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" + /> +
+
+ + {/* Purchase Price */} +
+ + setPurchasePrice(e.target.value)} + placeholder="0.00" + min="0" + step="0.01" + 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" + /> +
+ + {/* Product URL */} +
+ + setProductUrl(e.target.value)} + placeholder="https://..." + 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" + /> +
+ + {/* Notes */} +
+ +