Add three new endpoints that bypass the MinIO presigned URL flow:
- POST /api/items/{pn}/files/upload — multipart file upload
- POST /api/items/{pn}/thumbnail/upload — multipart thumbnail upload
- GET /api/items/{pn}/files/{fileId}/download — stream file download
Rewrite frontend upload flow: files are held in browser memory on drop
and uploaded directly after item creation via multipart POST. The old
presign+associate endpoints remain for MinIO backward compatibility.
Closes #129
678 lines
18 KiB
TypeScript
678 lines
18 KiB
TypeScript
import { useState, useCallback } from "react";
|
|
import { get, post } from "../../api/client";
|
|
import type {
|
|
Project,
|
|
FormFieldDescriptor,
|
|
FormFieldGroup,
|
|
} from "../../api/types";
|
|
import { TagInput, type TagOption } from "../TagInput";
|
|
import { CategoryPicker } from "./CategoryPicker";
|
|
import { FileDropZone } from "./FileDropZone";
|
|
import { useFormDescriptor } from "../../hooks/useFormDescriptor";
|
|
import {
|
|
useFileUpload,
|
|
type PendingAttachment,
|
|
} from "../../hooks/useFileUpload";
|
|
import { useAuth } from "../../hooks/useAuth";
|
|
|
|
// Item-level field names that are sent as top-level API fields, not properties.
|
|
const ITEM_LEVEL_FIELDS = new Set([
|
|
"item_type",
|
|
"description",
|
|
"sourcing_type",
|
|
"long_description",
|
|
"projects",
|
|
]);
|
|
|
|
interface CreateItemPaneProps {
|
|
onCreated: (partNumber: string) => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
|
const { user } = useAuth();
|
|
const { descriptor, categories } = useFormDescriptor();
|
|
const { upload } = useFileUpload();
|
|
|
|
// Single form state for all fields (item-level + properties).
|
|
const [category, setCategory] = useState("");
|
|
const [fields, setFields] = useState<Record<string, string>>({});
|
|
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
|
|
|
// Attachments.
|
|
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
|
const [thumbnailFile, setThumbnailFile] = useState<PendingAttachment | null>(
|
|
null,
|
|
);
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const setField = (name: string, value: string) =>
|
|
setFields((prev) => ({ ...prev, [name]: value }));
|
|
|
|
const getField = (name: string) => fields[name] ?? "";
|
|
|
|
// Derive item_type from category using derived_from_category mapping
|
|
const deriveItemType = (cat: string): string => {
|
|
if (!cat || !descriptor?.item_fields?.item_type?.derived_from_category) {
|
|
return getField("item_type") || "part";
|
|
}
|
|
const mapping = descriptor.item_fields.item_type.derived_from_category;
|
|
const prefix = cat[0]!;
|
|
return mapping[prefix] ?? mapping["default"] ?? "part";
|
|
};
|
|
|
|
const handleCategoryChange = (cat: string) => {
|
|
setCategory(cat);
|
|
// Auto-derive item_type when category changes
|
|
if (descriptor?.item_fields?.item_type?.derived_from_category) {
|
|
const derived = cat
|
|
? (descriptor.item_fields.item_type.derived_from_category[cat[0]!] ??
|
|
descriptor.item_fields.item_type.derived_from_category["default"] ??
|
|
"part")
|
|
: "part";
|
|
setField("item_type", derived);
|
|
}
|
|
};
|
|
|
|
const searchProjects = useCallback(
|
|
async (query: string): Promise<TagOption[]> => {
|
|
const all = await get<Project[]>("/api/projects");
|
|
const q = query.toLowerCase();
|
|
return all
|
|
.filter(
|
|
(p) =>
|
|
!q ||
|
|
p.code.toLowerCase().includes(q) ||
|
|
(p.name ?? "").toLowerCase().includes(q),
|
|
)
|
|
.map((p) => ({
|
|
id: p.code,
|
|
label: p.code + (p.name ? " \u2014 " + p.name : ""),
|
|
}));
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleFilesAdded = useCallback((files: PendingAttachment[]) => {
|
|
setAttachments((prev) => [...prev, ...files]);
|
|
}, []);
|
|
|
|
const handleFileRemoved = useCallback((index: number) => {
|
|
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
|
}, []);
|
|
|
|
const handleThumbnailSelect = useCallback(() => {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = ".png,.jpg,.jpeg,.webp";
|
|
input.onchange = () => {
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
|
|
setThumbnailFile({
|
|
file,
|
|
objectKey: "",
|
|
uploadProgress: 0,
|
|
uploadStatus: "pending",
|
|
});
|
|
};
|
|
input.click();
|
|
}, []);
|
|
|
|
const handleSubmit = async () => {
|
|
if (!category) {
|
|
setError("Category is required");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
// Split fields into item-level and properties
|
|
const properties: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(fields)) {
|
|
if (!v) continue;
|
|
if (ITEM_LEVEL_FIELDS.has(k)) continue; // handled separately
|
|
// Coerce type from descriptor
|
|
const fieldDef = findFieldDef(k);
|
|
if (fieldDef?.type === "number") properties[k] = Number(v);
|
|
else if (fieldDef?.type === "boolean") properties[k] = v === "true";
|
|
else properties[k] = v;
|
|
}
|
|
|
|
try {
|
|
const result = await post<{ part_number: string }>("/api/items", {
|
|
schema: "kindred-rd",
|
|
category,
|
|
description: getField("description") || undefined,
|
|
item_type: deriveItemType(category),
|
|
projects: selectedProjects.length > 0 ? selectedProjects : undefined,
|
|
properties: Object.keys(properties).length > 0 ? properties : undefined,
|
|
sourcing_type: getField("sourcing_type") || undefined,
|
|
long_description: getField("long_description") || undefined,
|
|
});
|
|
|
|
const pn = result.part_number;
|
|
const encodedPN = encodeURIComponent(pn);
|
|
|
|
// Upload attachments via direct multipart POST.
|
|
for (const att of attachments) {
|
|
try {
|
|
await upload(att.file, `/api/items/${encodedPN}/files/upload`);
|
|
} catch {
|
|
// File upload failure is non-blocking.
|
|
}
|
|
}
|
|
|
|
// Upload thumbnail via direct multipart POST.
|
|
if (thumbnailFile) {
|
|
try {
|
|
await upload(
|
|
thumbnailFile.file,
|
|
`/api/items/${encodedPN}/thumbnail/upload`,
|
|
);
|
|
} catch {
|
|
// Thumbnail failure is non-blocking.
|
|
}
|
|
}
|
|
|
|
onCreated(pn);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Failed to create item");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
// Find field definition from descriptor (global groups + category groups).
|
|
function findFieldDef(name: string): FormFieldDescriptor | undefined {
|
|
if (descriptor?.field_groups) {
|
|
for (const group of descriptor.field_groups) {
|
|
const f = group.fields.find((fd) => fd.name === name);
|
|
if (f) return f;
|
|
}
|
|
}
|
|
if (descriptor?.category_field_groups && category) {
|
|
const prefix = category[0]!;
|
|
const catGroups = descriptor.category_field_groups[prefix];
|
|
if (catGroups) {
|
|
for (const group of catGroups) {
|
|
const f = group.fields.find((fd) => fd.name === name);
|
|
if (f) return f;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// Get category-specific field groups for the selected category.
|
|
const catFieldGroups: FormFieldGroup[] =
|
|
category && descriptor?.category_field_groups
|
|
? (descriptor.category_field_groups[category[0]!] ?? [])
|
|
: [];
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
|
{/* Header */}
|
|
<div style={headerStyle}>
|
|
<span
|
|
style={{
|
|
color: "var(--ctp-green)",
|
|
fontWeight: 600,
|
|
fontSize: "var(--font-body)",
|
|
}}
|
|
>
|
|
New Item
|
|
</span>
|
|
<span style={{ flex: 1 }} />
|
|
<button
|
|
onClick={() => void handleSubmit()}
|
|
disabled={saving}
|
|
style={{
|
|
...actionBtnStyle,
|
|
backgroundColor: "var(--ctp-green)",
|
|
opacity: saving ? 0.6 : 1,
|
|
}}
|
|
>
|
|
{saving ? "Creating..." : "Create"}
|
|
</button>
|
|
<button onClick={onCancel} style={cancelBtnStyle}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
|
|
{/* Two-column body */}
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
display: "grid",
|
|
gridTemplateColumns: "1fr 280px",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{/* Left: form */}
|
|
<div style={{ overflow: "auto", padding: "0.75rem" }}>
|
|
{error && <div style={errorStyle}>{error}</div>}
|
|
|
|
{/* Category picker */}
|
|
<SectionHeader>Category *</SectionHeader>
|
|
<CategoryPicker
|
|
value={category}
|
|
onChange={handleCategoryChange}
|
|
categories={categories}
|
|
stages={descriptor?.category_picker?.stages}
|
|
/>
|
|
|
|
{/* Dynamic field groups from descriptor */}
|
|
{descriptor?.field_groups?.map((group) => (
|
|
<div key={group.key}>
|
|
<SectionHeader>{group.label}</SectionHeader>
|
|
<div style={fieldGridStyle}>
|
|
{group.fields.map((field) =>
|
|
renderField(
|
|
field,
|
|
getField(field.name),
|
|
(v) => setField(field.name, v),
|
|
selectedProjects,
|
|
setSelectedProjects,
|
|
searchProjects,
|
|
),
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Category-specific field groups */}
|
|
{catFieldGroups.map((group) => (
|
|
<div key={group.key}>
|
|
<SectionHeader>{group.label}</SectionHeader>
|
|
<div style={fieldGridStyle}>
|
|
{group.fields.map((field) =>
|
|
renderField(
|
|
field,
|
|
getField(field.name),
|
|
(v) => setField(field.name, v),
|
|
selectedProjects,
|
|
setSelectedProjects,
|
|
searchProjects,
|
|
),
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Right: sidebar */}
|
|
<div
|
|
style={{
|
|
borderLeft: "1px solid var(--ctp-surface1)",
|
|
overflow: "auto",
|
|
padding: "0.75rem",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "0.75rem",
|
|
}}
|
|
>
|
|
{/* Metadata */}
|
|
<SidebarSection title="Metadata">
|
|
<MetaRow label="Revision" value="A" />
|
|
<MetaRow
|
|
label="Created By"
|
|
value={user?.display_name ?? user?.username ?? "---"}
|
|
/>
|
|
</SidebarSection>
|
|
|
|
{/* Attachments */}
|
|
<SidebarSection title="Attachments">
|
|
<FileDropZone
|
|
files={attachments}
|
|
onFilesAdded={handleFilesAdded}
|
|
onFileRemoved={handleFileRemoved}
|
|
accept=".FCStd,.step,.stl,.pdf,.png,.jpg,.dxf"
|
|
/>
|
|
</SidebarSection>
|
|
|
|
{/* Thumbnail */}
|
|
<SidebarSection title="Thumbnail">
|
|
<div
|
|
onClick={handleThumbnailSelect}
|
|
style={{
|
|
aspectRatio: "4/3",
|
|
borderRadius: "0.5rem",
|
|
border: "1px dashed var(--ctp-surface1)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
cursor: "pointer",
|
|
overflow: "hidden",
|
|
backgroundColor: "var(--ctp-mantle)",
|
|
}}
|
|
>
|
|
{thumbnailFile ? (
|
|
<img
|
|
src={URL.createObjectURL(thumbnailFile.file)}
|
|
alt="Thumbnail preview"
|
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
/>
|
|
) : (
|
|
<span
|
|
style={{
|
|
fontSize: "var(--font-table)",
|
|
color: "var(--ctp-subtext0)",
|
|
}}
|
|
>
|
|
Click to select
|
|
</span>
|
|
)}
|
|
</div>
|
|
</SidebarSection>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Field renderer ---
|
|
|
|
function renderField(
|
|
field: FormFieldDescriptor,
|
|
value: string,
|
|
onChange: (v: string) => void,
|
|
selectedProjects: string[],
|
|
setSelectedProjects: (v: string[]) => void,
|
|
searchProjects: (q: string) => Promise<{ id: string; label: string }[]>,
|
|
) {
|
|
const widget =
|
|
field.widget ?? (field.type === "boolean" ? "checkbox" : "text");
|
|
|
|
// Projects field gets special tag_input treatment
|
|
if (widget === "tag_input") {
|
|
return (
|
|
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
|
|
<FormGroup label={field.label}>
|
|
<TagInput
|
|
value={selectedProjects}
|
|
onChange={setSelectedProjects}
|
|
placeholder="Search projects\u2026"
|
|
searchFn={searchProjects}
|
|
/>
|
|
</FormGroup>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (widget === "textarea") {
|
|
return (
|
|
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
|
|
<FormGroup label={field.label}>
|
|
<textarea
|
|
className="silo-input"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
style={{ ...inputStyle, minHeight: 60, resize: "vertical" }}
|
|
placeholder={field.description ?? ""}
|
|
/>
|
|
</FormGroup>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (widget === "select" && field.options) {
|
|
return (
|
|
<FormGroup key={field.name} label={field.label}>
|
|
<select
|
|
className="silo-input"
|
|
value={value || (field.default != null ? String(field.default) : "")}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
style={inputStyle}
|
|
>
|
|
{!field.required && <option value="">---</option>}
|
|
{field.options.map((opt) => (
|
|
<option key={opt} value={opt}>
|
|
{opt}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</FormGroup>
|
|
);
|
|
}
|
|
|
|
if (widget === "checkbox") {
|
|
return (
|
|
<FormGroup key={field.name} label={field.label}>
|
|
<select
|
|
className="silo-input"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
style={inputStyle}
|
|
>
|
|
<option value="">---</option>
|
|
<option value="true">Yes</option>
|
|
<option value="false">No</option>
|
|
</select>
|
|
</FormGroup>
|
|
);
|
|
}
|
|
|
|
if (widget === "currency") {
|
|
return (
|
|
<FormGroup
|
|
key={field.name}
|
|
label={`${field.label}${field.currency ? ` (${field.currency})` : ""}`}
|
|
>
|
|
<input
|
|
className="silo-input"
|
|
type="number"
|
|
step="0.01"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
style={inputStyle}
|
|
placeholder="0.00"
|
|
/>
|
|
</FormGroup>
|
|
);
|
|
}
|
|
|
|
if (widget === "url") {
|
|
return (
|
|
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
|
|
<FormGroup label={field.label}>
|
|
<input
|
|
className="silo-input"
|
|
type="url"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
style={inputStyle}
|
|
placeholder="https://..."
|
|
/>
|
|
</FormGroup>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Default: text or number input
|
|
const inputType = field.type === "number" ? "number" : "text";
|
|
const placeholder = field.unit
|
|
? `${field.description ?? ""} (${field.unit})`
|
|
: (field.description ?? "");
|
|
|
|
return (
|
|
<FormGroup key={field.name} label={field.label}>
|
|
<input
|
|
className="silo-input"
|
|
type={inputType}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
style={inputStyle}
|
|
placeholder={placeholder}
|
|
/>
|
|
</FormGroup>
|
|
);
|
|
}
|
|
|
|
// --- Sub-components ---
|
|
|
|
function SectionHeader({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.5rem",
|
|
margin: "0.75rem 0 0.5rem",
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontSize: "var(--font-sm)",
|
|
fontWeight: 600,
|
|
textTransform: "uppercase",
|
|
letterSpacing: "0.05em",
|
|
color: "var(--ctp-subtext0)",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{children}
|
|
</span>
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
height: 1,
|
|
backgroundColor: "var(--ctp-surface1)",
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SidebarSection({
|
|
title,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div
|
|
style={{
|
|
borderBottom: "1px solid var(--ctp-surface0)",
|
|
paddingBottom: "0.75rem",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
fontSize: "var(--font-sm)",
|
|
fontWeight: 600,
|
|
textTransform: "uppercase",
|
|
letterSpacing: "0.05em",
|
|
color: "var(--ctp-subtext0)",
|
|
marginBottom: "0.5rem",
|
|
}}
|
|
>
|
|
{title}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetaRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
fontSize: "var(--font-table)",
|
|
padding: "0.25rem 0",
|
|
}}
|
|
>
|
|
<span style={{ color: "var(--ctp-subtext0)" }}>{label}</span>
|
|
<span style={{ color: "var(--ctp-text)" }}>{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FormGroup({
|
|
label,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div style={{ marginBottom: "0.5rem" }}>
|
|
<label
|
|
style={{
|
|
display: "block",
|
|
fontSize: "0.75rem",
|
|
color: "var(--ctp-subtext0)",
|
|
marginBottom: "0.25rem",
|
|
}}
|
|
>
|
|
{label}
|
|
</label>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Styles ---
|
|
|
|
const headerStyle: React.CSSProperties = {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.75rem",
|
|
padding: "0.5rem 0.75rem",
|
|
borderBottom: "1px solid var(--ctp-surface1)",
|
|
backgroundColor: "var(--ctp-mantle)",
|
|
flexShrink: 0,
|
|
};
|
|
|
|
const actionBtnStyle: React.CSSProperties = {
|
|
padding: "0.25rem 0.75rem",
|
|
fontSize: "0.75rem",
|
|
fontWeight: 500,
|
|
border: "none",
|
|
borderRadius: "0.25rem",
|
|
color: "var(--ctp-crust)",
|
|
cursor: "pointer",
|
|
};
|
|
|
|
const cancelBtnStyle: React.CSSProperties = {
|
|
background: "none",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
color: "var(--ctp-subtext1)",
|
|
fontSize: "0.75rem",
|
|
fontWeight: 500,
|
|
padding: "0.25rem 0.5rem",
|
|
borderRadius: "0.25rem",
|
|
};
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
width: "100%",
|
|
padding: "0.25rem 0.5rem",
|
|
fontSize: "var(--font-body)",
|
|
backgroundColor: "var(--ctp-base)",
|
|
border: "1px solid var(--ctp-surface1)",
|
|
borderRadius: "0.25rem",
|
|
color: "var(--ctp-text)",
|
|
boxSizing: "border-box",
|
|
};
|
|
|
|
const fieldGridStyle: React.CSSProperties = {
|
|
display: "grid",
|
|
gridTemplateColumns: "1fr 1fr",
|
|
gap: "0 1rem",
|
|
};
|
|
|
|
const errorStyle: React.CSSProperties = {
|
|
color: "var(--ctp-red)",
|
|
backgroundColor: "rgba(243,139,168,0.1)",
|
|
padding: "0.5rem",
|
|
borderRadius: "0.25rem",
|
|
marginBottom: "0.5rem",
|
|
fontSize: "var(--font-body)",
|
|
};
|