- CollectionView: t() for empty state, stats labels, filter text - ItemCard: t() for tooltip title attributes - ItemForm: t() for all form labels, placeholders, error messages, buttons - CategoryPicker: t() for search placeholder, create button, no results - CategoryFilterDropdown: t() for all categories label, search placeholder - CategoryHeader: t() for save/cancel buttons, item count - WeightSummaryCard: t() for title, legend labels, view mode toggle - ItemPicker: t() for panel title, empty state, action buttons - ManualEntryForm: t() for all form labels, error messages, submit button - LinkToGlobalItem: t() for all UI chrome strings - ProfileSection: t() for all form labels, messages, buttons - collection.json: added new keys for categoryPicker, categoryFilter, weightSummary, itemPicker, categoryHeader, linkToGlobal, manualEntry, profileSection, itemCard
320 lines
8.7 KiB
TypeScript
320 lines
8.7 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useCurrency } from "../hooks/useCurrency";
|
|
import { useCreateItem, useItems, useUpdateItem } from "../hooks/useItems";
|
|
import { useUIStore } from "../stores/uiStore";
|
|
import { CategoryPicker } from "./CategoryPicker";
|
|
import { ImageUpload } from "./ImageUpload";
|
|
|
|
interface ItemFormProps {
|
|
mode: "add" | "edit";
|
|
itemId?: number | null;
|
|
onClose?: () => void;
|
|
}
|
|
|
|
interface FormData {
|
|
name: string;
|
|
weightGrams: string;
|
|
priceDollars: string;
|
|
quantity: number;
|
|
categoryId: number;
|
|
notes: string;
|
|
productUrl: string;
|
|
imageFilename: string | null;
|
|
}
|
|
|
|
const INITIAL_FORM: FormData = {
|
|
name: "",
|
|
weightGrams: "",
|
|
priceDollars: "",
|
|
quantity: 1,
|
|
categoryId: 1,
|
|
notes: "",
|
|
productUrl: "",
|
|
imageFilename: null,
|
|
};
|
|
|
|
export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
|
const { t } = useTranslation(["collection", "common"]);
|
|
const { data: items } = useItems();
|
|
const { currency } = useCurrency();
|
|
const createItem = useCreateItem();
|
|
const updateItem = useUpdateItem();
|
|
const openConfirmDelete = useUIStore((s) => s.openConfirmDelete);
|
|
|
|
const [form, setForm] = useState<FormData>(INITIAL_FORM);
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
|
|
// Pre-fill form when editing
|
|
useEffect(() => {
|
|
if (mode === "edit" && itemId != null && items) {
|
|
const item = items.find((i) => i.id === itemId);
|
|
if (item) {
|
|
setForm({
|
|
name: item.name,
|
|
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 ?? "",
|
|
imageFilename: item.imageFilename,
|
|
});
|
|
}
|
|
} else if (mode === "add") {
|
|
setForm(INITIAL_FORM);
|
|
}
|
|
}, [mode, itemId, items]);
|
|
|
|
function validate(): boolean {
|
|
const newErrors: Record<string, string> = {};
|
|
if (!form.name.trim()) {
|
|
newErrors.name = t("common:errors.nameRequired");
|
|
}
|
|
if (
|
|
form.weightGrams &&
|
|
(Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
|
|
) {
|
|
newErrors.weightGrams = t("common:errors.positiveNumber");
|
|
}
|
|
if (
|
|
form.priceDollars &&
|
|
(Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
|
|
) {
|
|
newErrors.priceDollars = t("common:errors.positiveNumber");
|
|
}
|
|
if (
|
|
form.productUrl &&
|
|
form.productUrl.trim() !== "" &&
|
|
!form.productUrl.match(/^https?:\/\//)
|
|
) {
|
|
newErrors.productUrl = t("common:errors.validUrl");
|
|
}
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
}
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!validate()) return;
|
|
|
|
const payload = {
|
|
name: form.name.trim(),
|
|
weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
|
|
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,
|
|
imageFilename: form.imageFilename ?? undefined,
|
|
};
|
|
|
|
if (mode === "add") {
|
|
createItem.mutate(payload, {
|
|
onSuccess: () => {
|
|
setForm(INITIAL_FORM);
|
|
onClose?.();
|
|
},
|
|
});
|
|
} else if (itemId != null) {
|
|
updateItem.mutate(
|
|
{ id: itemId, ...payload },
|
|
{ onSuccess: () => onClose?.() },
|
|
);
|
|
}
|
|
}
|
|
|
|
const isPending = createItem.isPending || updateItem.isPending;
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-5">
|
|
{/* Image */}
|
|
<ImageUpload
|
|
value={form.imageFilename}
|
|
imageUrl={
|
|
mode === "edit" && itemId != null
|
|
? items?.find((i) => i.id === itemId)?.imageUrl
|
|
: null
|
|
}
|
|
onChange={(filename) =>
|
|
setForm((f) => ({ ...f, imageFilename: filename }))
|
|
}
|
|
/>
|
|
|
|
{/* Name */}
|
|
<div>
|
|
<label
|
|
htmlFor="item-name"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
{t("collection:form.nameRequired")}
|
|
</label>
|
|
<input
|
|
id="item-name"
|
|
type="text"
|
|
value={form.name}
|
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
|
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"
|
|
placeholder={t("collection:form.namePlaceholder")}
|
|
/>
|
|
{errors.name && (
|
|
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Weight */}
|
|
<div>
|
|
<label
|
|
htmlFor="item-weight"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
{t("collection:form.weight")}
|
|
</label>
|
|
<input
|
|
id="item-weight"
|
|
type="number"
|
|
min="0"
|
|
step="any"
|
|
value={form.weightGrams}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, weightGrams: e.target.value }))
|
|
}
|
|
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"
|
|
placeholder={t("collection:form.weightPlaceholder")}
|
|
/>
|
|
{errors.weightGrams && (
|
|
<p className="mt-1 text-xs text-red-500">{errors.weightGrams}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Price */}
|
|
<div>
|
|
<label
|
|
htmlFor="item-price"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
{`${t("collection:form.price")} (${currency})`}
|
|
</label>
|
|
<input
|
|
id="item-price"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={form.priceDollars}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, priceDollars: e.target.value }))
|
|
}
|
|
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"
|
|
placeholder={t("collection:form.pricePlaceholder")}
|
|
/>
|
|
{errors.priceDollars && (
|
|
<p className="mt-1 text-xs text-red-500">{errors.priceDollars}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Quantity */}
|
|
<div>
|
|
<label
|
|
htmlFor="item-quantity"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
{t("collection:form.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">
|
|
{t("collection:form.category")}
|
|
</label>
|
|
<CategoryPicker
|
|
value={form.categoryId}
|
|
onChange={(id) => setForm((f) => ({ ...f, categoryId: id }))}
|
|
/>
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
<div>
|
|
<label
|
|
htmlFor="item-notes"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
{t("collection:form.notes")}
|
|
</label>
|
|
<textarea
|
|
id="item-notes"
|
|
value={form.notes}
|
|
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
|
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"
|
|
placeholder={t("collection:form.notesPlaceholder")}
|
|
/>
|
|
</div>
|
|
|
|
{/* Product Link */}
|
|
<div>
|
|
<label
|
|
htmlFor="item-url"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
{t("collection:form.productLink")}
|
|
</label>
|
|
<input
|
|
id="item-url"
|
|
type="url"
|
|
value={form.productUrl}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, productUrl: e.target.value }))
|
|
}
|
|
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"
|
|
placeholder={t("collection:form.urlPlaceholder")}
|
|
/>
|
|
{errors.productUrl && (
|
|
<p className="mt-1 text-xs text-red-500">{errors.productUrl}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-3 pt-2">
|
|
<button
|
|
type="submit"
|
|
disabled={isPending}
|
|
className="flex-1 py-2.5 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
|
>
|
|
{isPending
|
|
? t("common:actions.saving")
|
|
: mode === "add"
|
|
? t("common:actions.addItem")
|
|
: t("common:actions.saveChanges")}
|
|
</button>
|
|
{mode === "edit" && itemId != null && (
|
|
<button
|
|
type="button"
|
|
onClick={() => openConfirmDelete(itemId)}
|
|
className="py-2.5 px-4 text-red-600 hover:bg-red-50 text-sm font-medium rounded-lg transition-colors"
|
|
>
|
|
{t("common:actions.delete")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|