|
|
|
|
@@ -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<InventoryItemInput>(emptyInventoryItemInput);
|
|
|
|
|
const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]);
|
|
|
|
|
@@ -40,6 +41,10 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|
|
|
|
const [skuLevelOptions, setSkuLevelOptions] = useState<InventorySkuNodeDto[][]>([]);
|
|
|
|
|
const [selectedSkuNodeIds, setSelectedSkuNodeIds] = useState<Array<string | 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) {
|
|
|
|
|
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<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
|
|
|
|
|
? 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) {
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
</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">
|
|
|
|
|
<label className="block">
|
|
|
|
|
<span className="mb-2 block text-sm font-semibold text-text">Type</span>
|
|
|
|
|
|