item thumbnails

This commit is contained in:
2026-03-15 22:51:35 -05:00
parent 2718e8b4b1
commit 26ee928869
4 changed files with 157 additions and 3 deletions

View File

@@ -7,6 +7,7 @@ This file is the running release and change log for MRP Codex. Keep it updated w
### Added ### Added
- Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form - 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 - 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 - 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 - Session review cues on admin auth sessions, including flagged stale activity, multi-session counts, and multi-IP warnings

View File

@@ -17,6 +17,7 @@ Current foundation scope includes:
- inventory item master, BOM, warehouse, stock-location, and stock-transaction flows - inventory item master, BOM, warehouse, stock-location, and stock-transaction flows
- inventory transfers, reservations, and available-stock visibility - inventory transfers, reservations, and available-stock visibility
- inventory SKU master builder with family-scoped sequence generation and branch-aware taxonomy management - 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 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 - 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 - purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items

View File

@@ -1,4 +1,5 @@
import type { PurchaseVendorOptionDto } from "@mrp/shared"; import type { FileAttachmentDto, PurchaseVendorOptionDto } from "@mrp/shared";
import { permissions } from "@mrp/shared";
import type { import type {
InventoryBomLineInput, InventoryBomLineInput,
InventoryItemInput, InventoryItemInput,
@@ -15,7 +16,7 @@ import { Link, useNavigate, useParams } from "react-router-dom";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog"; import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api"; 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 { interface InventoryFormPageProps {
mode: "create" | "edit"; mode: "create" | "edit";
@@ -23,7 +24,7 @@ interface InventoryFormPageProps {
export function InventoryFormPage({ mode }: InventoryFormPageProps) { export function InventoryFormPage({ mode }: InventoryFormPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { token } = useAuth(); const { token, user } = useAuth();
const { itemId } = useParams(); const { itemId } = useParams();
const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput); const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput);
const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]); const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]);
@@ -40,6 +41,10 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
const [skuLevelOptions, setSkuLevelOptions] = useState<InventorySkuNodeDto[][]>([]); const [skuLevelOptions, setSkuLevelOptions] = useState<InventorySkuNodeDto[][]>([]);
const [selectedSkuNodeIds, setSelectedSkuNodeIds] = useState<Array<string | null>>([]); const [selectedSkuNodeIds, setSelectedSkuNodeIds] = useState<Array<string | null>>([]);
const [skuPreview, setSkuPreview] = useState<InventorySkuBuilderPreviewDto | null>(null); const [skuPreview, setSkuPreview] = useState<InventorySkuBuilderPreviewDto | null>(null);
const [thumbnailAttachment, setThumbnailAttachment] = useState<FileAttachmentDto | null>(null);
const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState<string | null>(null);
const [pendingThumbnailFile, setPendingThumbnailFile] = useState<File | null>(null);
const [removeThumbnailOnSave, setRemoveThumbnailOnSave] = useState(false);
function getComponentOption(componentItemId: string) { function getComponentOption(componentItemId: string) {
return componentOptions.find((option) => option.id === componentItemId) ?? null; 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 ?? ""; 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(() => { useEffect(() => {
if (!token) { if (!token) {
return; return;
@@ -141,6 +159,56 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
api.getInventorySkuFamilies(token).then(setSkuFamilies).catch(() => setSkuFamilies([])); api.getInventorySkuFamilies(token).then(setSkuFamilies).catch(() => setSkuFamilies([]));
}, [token]); }, [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(() => { useEffect(() => {
const familyId = form.skuBuilder?.familyId ?? null; const familyId = form.skuBuilder?.familyId ?? null;
if (!token || !familyId) { if (!token || !familyId) {
@@ -309,6 +377,46 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
setActiveComponentPicker((current) => (current === index ? null : current != null && current > index ? current - 1 : current)); setActiveComponentPicker((current) => (current === index ? null : current != null && current > index ? current - 1 : current));
} }
function handleThumbnailSelect(event: React.ChangeEvent<HTMLInputElement>) {
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 const pendingRemovalDetail = pendingRemoval
? pendingRemoval.kind === "operation" ? pendingRemoval.kind === "operation"
? { label: form.operations[pendingRemoval.index]?.stationId || "this routing operation", typeLabel: "routing operation" } ? { label: form.operations[pendingRemoval.index]?.stationId || "this routing operation", typeLabel: "routing operation" }
@@ -327,6 +435,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
try { try {
const saved = const saved =
mode === "create" ? await api.createInventoryItem(token, form) : await api.updateInventoryItem(token, itemId ?? "", form); mode === "create" ? await api.createInventoryItem(token, form) : await api.updateInventoryItem(token, itemId ?? "", form);
await syncThumbnail(saved.id);
navigate(`/inventory/items/${saved.id}`); navigate(`/inventory/items/${saved.id}`);
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save inventory item."; const message = error instanceof ApiError ? error.message : "Unable to save inventory item.";
@@ -457,6 +566,48 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
/> />
</label> </label>
</div> </div>
<div className="grid gap-4 xl:grid-cols-[220px_1fr]">
<div className="space-y-3">
<div>
<span className="mb-2 block text-sm font-semibold text-text">Thumbnail</span>
<div className="flex aspect-square items-center justify-center overflow-hidden rounded-[18px] border border-line/70 bg-page/70">
{thumbnailPreviewUrl ? (
<img src={thumbnailPreviewUrl} alt="Inventory thumbnail preview" className="h-full w-full object-cover" />
) : (
<div className="px-4 text-center text-xs text-muted">No thumbnail selected</div>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{canWriteFiles ? (
<label className="inline-flex cursor-pointer items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
Choose image
<input className="hidden" type="file" accept="image/*" onChange={handleThumbnailSelect} />
</label>
) : null}
{(thumbnailPreviewUrl || thumbnailAttachment) && canWriteFiles ? (
<button type="button" onClick={clearThumbnailSelection} className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Remove
</button>
) : null}
</div>
</div>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="text-sm font-semibold text-text">Thumbnail attachment</div>
<div className="mt-2 text-sm text-muted">
{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."}
</div>
<div className="mt-3 text-xs text-muted">
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.
</div>
</div>
</div>
<div className="grid gap-3 xl:grid-cols-4"> <div className="grid gap-3 xl:grid-cols-4">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Type</span> <span className="mb-2 block text-sm font-semibold text-text">Type</span>

View File

@@ -107,6 +107,7 @@ export const inventoryStatusPalette: Record<InventoryItemStatus, string> = {
}; };
export const inventoryFileOwnerType = "inventory-item"; export const inventoryFileOwnerType = "inventory-item";
export const inventoryThumbnailOwnerType = "inventory-item-thumbnail";
export const inventoryTypePalette: Record<InventoryItemType, string> = { export const inventoryTypePalette: Record<InventoryItemType, string> = {
PURCHASED: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300", PURCHASED: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",