diff --git a/client/src/modules/inventory/InventoryDetailPage.tsx b/client/src/modules/inventory/InventoryDetailPage.tsx index 8b03bb6..41b401b 100644 --- a/client/src/modules/inventory/InventoryDetailPage.tsx +++ b/client/src/modules/inventory/InventoryDetailPage.tsx @@ -5,6 +5,7 @@ import type { InventoryTransferInput, WarehouseLocationOptionDto, } from "@mrp/shared/dist/inventory/types.js"; +import type { FileAttachmentDto } from "@mrp/shared"; import { permissions } from "@mrp/shared"; import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; @@ -12,7 +13,7 @@ import { Link, useParams } from "react-router-dom"; import { useAuth } from "../../auth/AuthProvider"; import { api, ApiError } from "../../lib/api"; import { ConfirmActionDialog } from "../../components/ConfirmActionDialog"; -import { emptyInventoryTransactionInput, inventoryTransactionOptions } from "./config"; +import { emptyInventoryTransactionInput, inventoryThumbnailOwnerType, inventoryTransactionOptions } from "./config"; import { InventoryAttachmentsPanel } from "./InventoryAttachmentsPanel"; import { InventoryStatusBadge } from "./InventoryStatusBadge"; import { InventoryTransactionTypeBadge } from "./InventoryTransactionTypeBadge"; @@ -49,6 +50,8 @@ export function InventoryDetailPage() { const [isSavingTransfer, setIsSavingTransfer] = useState(false); const [isSavingReservation, setIsSavingReservation] = useState(false); const [status, setStatus] = useState("Loading inventory item..."); + const [thumbnailAttachment, setThumbnailAttachment] = useState(null); + const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState(null); const [pendingConfirmation, setPendingConfirmation] = useState< | { kind: "transaction" | "transfer" | "reservation"; @@ -64,6 +67,17 @@ export function InventoryDetailPage() { >(null); const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false; + const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false; + + function replaceThumbnailPreview(nextUrl: string | null) { + setThumbnailPreviewUrl((current) => { + if (current) { + window.URL.revokeObjectURL(current); + } + + return nextUrl; + }); + } useEffect(() => { if (!token || !itemId) { @@ -106,6 +120,56 @@ export function InventoryDetailPage() { .catch(() => setLocationOptions([])); }, [itemId, 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]); + function updateTransactionField(key: Key, value: InventoryTransactionInput[Key]) { setTransactionForm((current) => ({ ...current, [key]: value })); } @@ -333,6 +397,24 @@ export function InventoryDetailPage() { +
+

Thumbnail

+
+ {thumbnailPreviewUrl ? ( + {`${item.sku} + ) : ( +
+ No thumbnail image has been attached to this item. +
+ )} +
+
+ {thumbnailAttachment ? `Current file: ${thumbnailAttachment.originalName}` : "Add or replace the thumbnail from the item edit page."} +
+
+ + +

Stock By Location

{item.stockBalances.length === 0 ? (