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>({}); const [selectedProjects, setSelectedProjects] = useState([]); // Attachments. const [attachments, setAttachments] = useState([]); const [thumbnailFile, setThumbnailFile] = useState( null, ); const [saving, setSaving] = useState(false); const [error, setError] = useState(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 => { const all = await get("/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 = {}; 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 (
{/* Header */}
New Item
{/* Two-column body */}
{/* Left: form */}
{error &&
{error}
} {/* Category picker */} Category * {/* Dynamic field groups from descriptor */} {descriptor?.field_groups?.map((group) => (
{group.label}
{group.fields.map((field) => renderField( field, getField(field.name), (v) => setField(field.name, v), selectedProjects, setSelectedProjects, searchProjects, ), )}
))} {/* Category-specific field groups */} {catFieldGroups.map((group) => (
{group.label}
{group.fields.map((field) => renderField( field, getField(field.name), (v) => setField(field.name, v), selectedProjects, setSelectedProjects, searchProjects, ), )}
))}
{/* Right: sidebar */}
{/* Metadata */} {/* Attachments */} {/* Thumbnail */}
{thumbnailFile ? ( Thumbnail preview ) : ( Click to select )}
); } // --- 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 (
); } if (widget === "textarea") { return (