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