From 26ee928869d0a12723bae7e4b995fa0f75b8a9e8 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 15 Mar 2026 22:51:35 -0500 Subject: [PATCH] item thumbnails --- CHANGELOG.md | 1 + README.md | 1 + .../modules/inventory/InventoryFormPage.tsx | 157 +++++++++++++++++- client/src/modules/inventory/config.ts | 1 + 4 files changed, 157 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bda8f9..1d73094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This file is the running release and change log for MRP Codex. Keep it updated w ### Added - Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form +- Thumbnail image attachment staging on inventory item create/edit pages, with upload-on-save and replacement/removal support - Revision comparison views for sales quotes, sales orders, and purchase orders with field- and line-level before/after diffs - Purchase-order revision snapshots covering document edits, status changes, and receipt posting - Session review cues on admin auth sessions, including flagged stale activity, multi-session counts, and multi-IP warnings diff --git a/README.md b/README.md index 37fd804..c5c5e98 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Current foundation scope includes: - inventory item master, BOM, warehouse, stock-location, and stock-transaction flows - inventory transfers, reservations, and available-stock visibility - inventory SKU master builder with family-scoped sequence generation and branch-aware taxonomy management +- staged thumbnail image attachment on inventory item create/edit workflows - sales quotes and sales orders with searchable customer and SKU entry - sales approvals, approval stamps, automatic revision history, and revision comparison on quotes and sales orders - purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items diff --git a/client/src/modules/inventory/InventoryFormPage.tsx b/client/src/modules/inventory/InventoryFormPage.tsx index 3f0e899..f94fa91 100644 --- a/client/src/modules/inventory/InventoryFormPage.tsx +++ b/client/src/modules/inventory/InventoryFormPage.tsx @@ -1,4 +1,5 @@ -import type { PurchaseVendorOptionDto } from "@mrp/shared"; +import type { FileAttachmentDto, PurchaseVendorOptionDto } from "@mrp/shared"; +import { permissions } from "@mrp/shared"; import type { InventoryBomLineInput, InventoryItemInput, @@ -15,7 +16,7 @@ import { Link, useNavigate, useParams } from "react-router-dom"; import { ConfirmActionDialog } from "../../components/ConfirmActionDialog"; import { useAuth } from "../../auth/AuthProvider"; import { api, ApiError } from "../../lib/api"; -import { emptyInventoryBomLineInput, emptyInventoryItemInput, emptyInventoryOperationInput, inventoryStatusOptions, inventoryTypeOptions, inventoryUnitOptions } from "./config"; +import { emptyInventoryBomLineInput, emptyInventoryItemInput, emptyInventoryOperationInput, inventoryStatusOptions, inventoryThumbnailOwnerType, inventoryTypeOptions, inventoryUnitOptions } from "./config"; interface InventoryFormPageProps { mode: "create" | "edit"; @@ -23,7 +24,7 @@ interface InventoryFormPageProps { export function InventoryFormPage({ mode }: InventoryFormPageProps) { const navigate = useNavigate(); - const { token } = useAuth(); + const { token, user } = useAuth(); const { itemId } = useParams(); const [form, setForm] = useState(emptyInventoryItemInput); const [componentOptions, setComponentOptions] = useState([]); @@ -40,6 +41,10 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) { const [skuLevelOptions, setSkuLevelOptions] = useState([]); const [selectedSkuNodeIds, setSelectedSkuNodeIds] = useState>([]); const [skuPreview, setSkuPreview] = useState(null); + const [thumbnailAttachment, setThumbnailAttachment] = useState(null); + const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState(null); + const [pendingThumbnailFile, setPendingThumbnailFile] = useState(null); + const [removeThumbnailOnSave, setRemoveThumbnailOnSave] = useState(false); function getComponentOption(componentItemId: string) { return componentOptions.find((option) => option.id === componentItemId) ?? null; @@ -57,6 +62,19 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) { return skuFamilies.find((family) => family.id === familyId)?.name ?? ""; } + function replaceThumbnailPreview(nextUrl: string | null) { + setThumbnailPreviewUrl((current) => { + if (current) { + window.URL.revokeObjectURL(current); + } + + return nextUrl; + }); + } + + const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false; + const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false; + useEffect(() => { if (!token) { return; @@ -141,6 +159,56 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) { api.getInventorySkuFamilies(token).then(setSkuFamilies).catch(() => setSkuFamilies([])); }, [token]); + useEffect(() => { + return () => { + if (thumbnailPreviewUrl) { + window.URL.revokeObjectURL(thumbnailPreviewUrl); + } + }; + }, [thumbnailPreviewUrl]); + + useEffect(() => { + if (!token || !itemId || !canReadFiles) { + setThumbnailAttachment(null); + replaceThumbnailPreview(null); + return; + } + + let cancelled = false; + const activeToken: string = token; + const activeItemId: string = itemId; + + async function loadThumbnail() { + const attachments = await api.getAttachments(activeToken, inventoryThumbnailOwnerType, activeItemId); + const latestAttachment = attachments[0] ?? null; + + if (!latestAttachment) { + if (!cancelled) { + setThumbnailAttachment(null); + replaceThumbnailPreview(null); + } + return; + } + + const blob = await api.getFileContentBlob(activeToken, latestAttachment.id); + if (!cancelled) { + setThumbnailAttachment(latestAttachment); + replaceThumbnailPreview(window.URL.createObjectURL(blob)); + } + } + + void loadThumbnail().catch(() => { + if (!cancelled) { + setThumbnailAttachment(null); + replaceThumbnailPreview(null); + } + }); + + return () => { + cancelled = true; + }; + }, [canReadFiles, itemId, token]); + useEffect(() => { const familyId = form.skuBuilder?.familyId ?? null; if (!token || !familyId) { @@ -309,6 +377,46 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) { setActiveComponentPicker((current) => (current === index ? null : current != null && current > index ? current - 1 : current)); } + function handleThumbnailSelect(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) { + return; + } + + setPendingThumbnailFile(file); + setRemoveThumbnailOnSave(false); + replaceThumbnailPreview(window.URL.createObjectURL(file)); + event.target.value = ""; + } + + function clearThumbnailSelection() { + setPendingThumbnailFile(null); + setRemoveThumbnailOnSave(true); + replaceThumbnailPreview(null); + } + + async function syncThumbnail(savedItemId: string) { + if (!token || !canWriteFiles) { + return; + } + + if (thumbnailAttachment && (removeThumbnailOnSave || pendingThumbnailFile)) { + await api.deleteAttachment(token, thumbnailAttachment.id); + } + + if (pendingThumbnailFile) { + const uploaded = await api.uploadFile(token, pendingThumbnailFile, inventoryThumbnailOwnerType, savedItemId); + setThumbnailAttachment(uploaded); + const blob = await api.getFileContentBlob(token, uploaded.id); + replaceThumbnailPreview(window.URL.createObjectURL(blob)); + } else if (removeThumbnailOnSave) { + setThumbnailAttachment(null); + } + + setPendingThumbnailFile(null); + setRemoveThumbnailOnSave(false); + } + const pendingRemovalDetail = pendingRemoval ? pendingRemoval.kind === "operation" ? { label: form.operations[pendingRemoval.index]?.stationId || "this routing operation", typeLabel: "routing operation" } @@ -327,6 +435,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) { try { const saved = mode === "create" ? await api.createInventoryItem(token, form) : await api.updateInventoryItem(token, itemId ?? "", form); + await syncThumbnail(saved.id); navigate(`/inventory/items/${saved.id}`); } catch (error: unknown) { const message = error instanceof ApiError ? error.message : "Unable to save inventory item."; @@ -457,6 +566,48 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) { /> +
+
+
+ Thumbnail +
+ {thumbnailPreviewUrl ? ( + Inventory thumbnail preview + ) : ( +
No thumbnail selected
+ )} +
+
+
+ {canWriteFiles ? ( + + ) : null} + {(thumbnailPreviewUrl || thumbnailAttachment) && canWriteFiles ? ( + + ) : null} +
+
+
+
Thumbnail attachment
+
+ {pendingThumbnailFile + ? `${pendingThumbnailFile.name} will upload when you save this item.` + : removeThumbnailOnSave + ? "Thumbnail removal is staged and will apply when you save." + : thumbnailAttachment + ? `${thumbnailAttachment.originalName} is attached as the current item thumbnail.` + : "Attach a product image, render, or reference photo for this item."} +
+
+ Supported by the existing file-attachment system. The thumbnail is stored separately from general item documents so the item editor can treat it as the primary visual. +
+
+