fix: restrict edit mode for reference items to personal fields only
Reference items (linked to global catalog) now show name, brand, weight, and MSRP as read-only in edit mode with "from the catalog" hint. Only personal fields (notes, category, quantity, image, product URL) are editable. Standalone items retain full edit access. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ export const Route = createFileRoute("/items/$itemId")({
|
|||||||
|
|
||||||
interface EditFormState {
|
interface EditFormState {
|
||||||
name: string;
|
name: string;
|
||||||
|
brand: string;
|
||||||
weightGrams: string;
|
weightGrams: string;
|
||||||
priceDollars: string;
|
priceDollars: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
@@ -45,6 +46,7 @@ function ItemDetail() {
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [form, setForm] = useState<EditFormState>({
|
const [form, setForm] = useState<EditFormState>({
|
||||||
name: "",
|
name: "",
|
||||||
|
brand: "",
|
||||||
weightGrams: "",
|
weightGrams: "",
|
||||||
priceDollars: "",
|
priceDollars: "",
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
@@ -57,7 +59,8 @@ function ItemDetail() {
|
|||||||
function enterEditMode() {
|
function enterEditMode() {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
setForm({
|
setForm({
|
||||||
name: item.name,
|
name: item.brand ? item.name.replace(`${item.brand} `, "") : item.name,
|
||||||
|
brand: item.brand || "",
|
||||||
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
|
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
|
||||||
priceDollars:
|
priceDollars:
|
||||||
item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
|
item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
|
||||||
@@ -76,29 +79,41 @@ function ItemDetail() {
|
|||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
const weightGrams = form.weightGrams.trim()
|
const hasGlobalItem = !!(item as Record<string, unknown>).globalItemId;
|
||||||
? Math.round(Number(form.weightGrams))
|
|
||||||
: null;
|
|
||||||
const priceCents = form.priceDollars.trim()
|
|
||||||
? Math.round(Number(form.priceDollars) * 100)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
updateItem.mutate(
|
// Personal fields (always editable)
|
||||||
{
|
const payload: Record<string, unknown> = {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: form.name.trim(),
|
quantity: form.quantity,
|
||||||
weightGrams,
|
categoryId: form.categoryId,
|
||||||
priceCents,
|
notes: form.notes.trim() || undefined,
|
||||||
quantity: form.quantity,
|
productUrl: form.productUrl.trim() || undefined,
|
||||||
categoryId: form.categoryId,
|
imageFilename: form.imageFilename ?? undefined,
|
||||||
notes: form.notes.trim() || null,
|
};
|
||||||
productUrl: form.productUrl.trim() || null,
|
|
||||||
imageFilename: form.imageFilename,
|
// Global fields (only editable for standalone items)
|
||||||
},
|
if (!hasGlobalItem) {
|
||||||
{
|
const weightGrams = form.weightGrams.trim()
|
||||||
onSuccess: () => setIsEditing(false),
|
? Math.round(Number(form.weightGrams))
|
||||||
},
|
: null;
|
||||||
);
|
const priceCents = form.priceDollars.trim()
|
||||||
|
? Math.round(Number(form.priceDollars) * 100)
|
||||||
|
: null;
|
||||||
|
const brandTrimmed = form.brand.trim();
|
||||||
|
const nameTrimmed = form.name.trim();
|
||||||
|
const fullName = brandTrimmed
|
||||||
|
? `${brandTrimmed} ${nameTrimmed}`
|
||||||
|
: nameTrimmed;
|
||||||
|
|
||||||
|
payload.name = fullName;
|
||||||
|
payload.brand = brandTrimmed || undefined;
|
||||||
|
payload.weightGrams = weightGrams ?? undefined;
|
||||||
|
payload.priceCents = priceCents ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItem.mutate(payload as Parameters<typeof updateItem.mutate>[0], {
|
||||||
|
onSuccess: () => setIsEditing(false),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDuplicate() {
|
function handleDuplicate() {
|
||||||
@@ -156,6 +171,7 @@ function ItemDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const imageUrl = item.imageUrl || null;
|
const imageUrl = item.imageUrl || null;
|
||||||
|
const isReference = !!(item as Record<string, unknown>).globalItemId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
@@ -248,56 +264,125 @@ function ItemDetail() {
|
|||||||
{/* Header / Name */}
|
{/* Header / Name */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<input
|
isReference ? (
|
||||||
type="text"
|
/* Reference items: name/brand are read-only (from global catalog) */
|
||||||
value={form.name}
|
<>
|
||||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
{item.brand && (
|
||||||
className="w-full text-2xl font-bold text-gray-900 border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
<p className="text-sm font-medium text-gray-400 uppercase tracking-wide mb-1">
|
||||||
placeholder="Item name"
|
{item.brand}
|
||||||
/>
|
</p>
|
||||||
|
)}
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
{item.brand
|
||||||
|
? item.name.replace(`${item.brand} `, "")
|
||||||
|
: item.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
Name and brand are from the catalog
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.brand}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, brand: e.target.value }))
|
||||||
|
}
|
||||||
|
className="w-full text-sm font-medium text-gray-500 border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
|
placeholder="Brand / Manufacturer (optional)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, name: e.target.value }))
|
||||||
|
}
|
||||||
|
className="w-full text-2xl font-bold text-gray-900 border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
|
placeholder="Item name / Model"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{item.name}</h1>
|
<>
|
||||||
|
{item.brand && (
|
||||||
|
<p className="text-sm font-medium text-gray-400 uppercase tracking-wide mb-1">
|
||||||
|
{item.brand}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
{item.brand ? item.name.replace(`${item.brand} `, "") : item.name}
|
||||||
|
</h1>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Badges / Specs */}
|
{/* Badges / Specs */}
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||||
<div>
|
{isReference ? (
|
||||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
/* Reference items: weight/price are read-only (from catalog) */
|
||||||
Weight (g)
|
<>
|
||||||
</label>
|
{item.weightGrams != null && (
|
||||||
<input
|
<div>
|
||||||
type="number"
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||||
value={form.weightGrams}
|
Weight (g)
|
||||||
onChange={(e) =>
|
</label>
|
||||||
setForm((f) => ({
|
<p className="py-2 px-3 bg-gray-50 border border-gray-100 rounded-lg text-sm text-gray-500">
|
||||||
...f,
|
{item.weightGrams}
|
||||||
weightGrams: e.target.value,
|
</p>
|
||||||
}))
|
</div>
|
||||||
}
|
)}
|
||||||
className="w-full py-2 px-3 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
{item.priceCents != null && (
|
||||||
placeholder="0"
|
<div>
|
||||||
/>
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||||
</div>
|
MSRP
|
||||||
<div>
|
</label>
|
||||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
<p className="py-2 px-3 bg-gray-50 border border-gray-100 rounded-lg text-sm text-gray-500">
|
||||||
Price ($)
|
{price(item.priceCents)}
|
||||||
</label>
|
</p>
|
||||||
<input
|
</div>
|
||||||
type="number"
|
)}
|
||||||
step="0.01"
|
</>
|
||||||
value={form.priceDollars}
|
) : (
|
||||||
onChange={(e) =>
|
<>
|
||||||
setForm((f) => ({
|
<div>
|
||||||
...f,
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||||
priceDollars: e.target.value,
|
Weight (g)
|
||||||
}))
|
</label>
|
||||||
}
|
<input
|
||||||
className="w-full py-2 px-3 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
type="number"
|
||||||
placeholder="0.00"
|
value={form.weightGrams}
|
||||||
/>
|
onChange={(e) =>
|
||||||
</div>
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
weightGrams: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-full py-2 px-3 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||||
|
Price ($)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={form.priceDollars}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
priceDollars: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-full py-2 px-3 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||||
Quantity
|
Quantity
|
||||||
|
|||||||
Reference in New Issue
Block a user