feat(23-01): create ManualEntryForm component

- 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
This commit is contained in:
2026-04-06 17:38:13 +02:00
parent d736795f2d
commit 153b6cb76a

View File

@@ -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<number | null>(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<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
{/* Back arrow row */}
<div className="flex items-center gap-1.5 mb-4">
<button
type="button"
onClick={onBack}
className="p-0.5 text-gray-400 hover:text-gray-600 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</button>
<p className="text-xs font-medium text-gray-400">Add Manually</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<div>
<label
htmlFor="manual-name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Name <span className="text-red-500">*</span>
</label>
<input
id="manual-name"
type="text"
value={name}
onChange={(e) => 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"
/>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<CategoryPicker
value={categoryId ?? 0}
onChange={(id) => setCategoryId(id)}
/>
</div>
{/* Weight and Price row */}
<div className="grid grid-cols-2 gap-3">
<div>
<label
htmlFor="manual-weight"
className="block text-sm font-medium text-gray-700 mb-1"
>
Weight (g)
</label>
<input
id="manual-weight"
type="number"
value={weightGrams}
onChange={(e) => 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"
/>
</div>
<div>
<label
htmlFor="manual-price"
className="block text-sm font-medium text-gray-700 mb-1"
>
MSRP ($)
</label>
<input
id="manual-price"
type="number"
value={priceDollars}
onChange={(e) => 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"
/>
</div>
</div>
{/* Purchase Price */}
<div>
<label
htmlFor="manual-purchase-price"
className="block text-sm font-medium text-gray-700 mb-1"
>
Purchase Price ($)
</label>
<input
id="manual-purchase-price"
type="number"
value={purchasePrice}
onChange={(e) => 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"
/>
</div>
{/* Product URL */}
<div>
<label
htmlFor="manual-product-url"
className="block text-sm font-medium text-gray-700 mb-1"
>
Product Link
</label>
<input
id="manual-product-url"
type="url"
value={productUrl}
onChange={(e) => 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"
/>
</div>
{/* Notes */}
<div>
<label
htmlFor="manual-notes"
className="block text-sm font-medium text-gray-700 mb-1"
>
Notes
</label>
<textarea
id="manual-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optional notes..."
rows={3}
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 resize-none"
/>
</div>
{/* Image upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Image
</label>
<ImageUpload
value={imageFilename}
onChange={(filename) => setImageFilename(filename)}
/>
</div>
{/* Error */}
{error && <p className="text-sm text-red-600">{error}</p>}
{/* Submit */}
<button
type="submit"
disabled={createItem.isPending || !name.trim() || categoryId === null}
className="w-full px-4 py-2.5 text-sm font-medium text-white bg-gray-900 rounded-lg hover:bg-gray-800 disabled:opacity-50 transition-colors"
>
{createItem.isPending ? "Saving..." : "Add to Collection"}
</button>
</form>
</div>
);
}