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:
245
src/client/components/ManualEntryForm.tsx
Normal file
245
src/client/components/ManualEntryForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user