From d61f939d842d017793c1ed5c56dcb8d1d91d4940 Mon Sep 17 00:00:00 2001 From: Forbes Date: Sat, 7 Feb 2026 10:15:03 -0600 Subject: [PATCH] feat(web): redesign CreateItemPane with two-column layout Rewrite CreateItemPane from single-column scrolling form to a two-column CSS Grid layout (1fr 280px): Left column (scrollable form): - Identity section: Type select, Description input, CategoryPicker - Sourcing section: Sourcing Type, Standard Cost, Sourcing Link - Details section: Long Description textarea, Projects TagInput - Category Properties: dynamic fields from schema (2-column sub-grid) - Section headers with uppercase labels and horizontal dividers Right column (sidebar): - Metadata: auto-assigned revision ('A'), created by (current user) - Attachments: FileDropZone with presigned upload integration - Thumbnail: 4:3 preview box, click to upload image Submission flow: 1. POST /api/items with form data 2. Associate uploaded attachments via POST /api/items/{pn}/files 3. Set thumbnail via PUT /api/items/{pn}/thumbnail 4. File failures are non-blocking (item already created) Integrates: CategoryPicker (#13), TagInput (#11), FileDropZone (#14), useFileUpload presigned upload hook (#12) Closes #15 --- web/src/components/items/CreateItemPane.tsx | 628 ++++++++++++++------ 1 file changed, 461 insertions(+), 167 deletions(-) diff --git a/web/src/components/items/CreateItemPane.tsx b/web/src/components/items/CreateItemPane.tsx index 50c4e86..485eaaa 100644 --- a/web/src/components/items/CreateItemPane.tsx +++ b/web/src/components/items/CreateItemPane.tsx @@ -1,9 +1,15 @@ import { useState, useEffect, useCallback } from "react"; -import { get, post } from "../../api/client"; +import { get, post, put } from "../../api/client"; import type { Project } from "../../api/types"; import { TagInput, type TagOption } from "../TagInput"; import { CategoryPicker } from "./CategoryPicker"; +import { FileDropZone } from "./FileDropZone"; import { useCategories } from "../../hooks/useCategories"; +import { + useFileUpload, + type PendingAttachment, +} from "../../hooks/useFileUpload"; +import { useAuth } from "../../hooks/useAuth"; interface CreateItemPaneProps { onCreated: (partNumber: string) => void; @@ -11,7 +17,12 @@ interface CreateItemPaneProps { } export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { + const { user } = useAuth(); const { categories } = useCategories(); + const { upload } = useFileUpload(); + + // Form state. + const [itemType, setItemType] = useState("part"); const [category, setCategory] = useState(""); const [description, setDescription] = useState(""); const [sourcingType, setSourcingType] = useState("manufactured"); @@ -23,28 +34,17 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { const [catPropDefs, setCatPropDefs] = useState< Record >({}); + + // Attachments. + const [attachments, setAttachments] = useState([]); + const [thumbnailFile, setThumbnailFile] = useState( + null, + ); + const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - 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 : ""), - })); - }, - [], - ); - + // Load category-specific properties. useEffect(() => { if (!category) { setCatPropDefs({}); @@ -66,6 +66,87 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { }); }, [category]); + 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[]) => { + const startIdx = attachments.length; + setAttachments((prev) => [...prev, ...files]); + + // Upload each file. + files.forEach((f, i) => { + const idx = startIdx + i; + // Mark uploading. + setAttachments((prev) => + prev.map((a, j) => + j === idx ? { ...a, uploadStatus: "uploading" } : a, + ), + ); + + upload(f.file, (progress) => { + setAttachments((prev) => + prev.map((a, j) => + j === idx ? { ...a, uploadProgress: progress } : a, + ), + ); + }).then((result) => { + setAttachments((prev) => + prev.map((a, j) => (j === idx ? result : a)), + ); + }); + }); + }, + [attachments.length, upload], + ); + + 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; + + const pending: PendingAttachment = { + file, + objectKey: "", + uploadProgress: 0, + uploadStatus: "uploading", + }; + setThumbnailFile(pending); + + upload(file, (progress) => { + setThumbnailFile((prev) => + prev ? { ...prev, uploadProgress: progress } : null, + ); + }).then((result) => { + setThumbnailFile(result); + }); + }; + input.click(); + }, [upload]); + const handleSubmit = async () => { if (!category) { setError("Category is required"); @@ -88,6 +169,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { schema: "kindred-rd", category, description, + item_type: itemType, projects: selectedProjects.length > 0 ? selectedProjects : undefined, properties: Object.keys(properties).length > 0 ? properties : undefined, sourcing_type: sourcingType || undefined, @@ -95,7 +177,41 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { long_description: longDescription || undefined, standard_cost: standardCost ? Number(standardCost) : undefined, }); - onCreated(result.part_number); + + const pn = result.part_number; + + // Associate uploaded attachments. + const completed = attachments.filter( + (a) => a.uploadStatus === "complete" && a.objectKey, + ); + for (const att of completed) { + try { + await post(`/api/items/${encodeURIComponent(pn)}/files`, { + object_key: att.objectKey, + filename: att.file.name, + content_type: att.file.type || "application/octet-stream", + size: att.file.size, + }); + } catch { + // File association failure is non-blocking. + } + } + + // Set thumbnail. + if ( + thumbnailFile?.uploadStatus === "complete" && + thumbnailFile.objectKey + ) { + try { + await put(`/api/items/${encodeURIComponent(pn)}/thumbnail`, { + object_key: thumbnailFile.objectKey, + }); + } catch { + // Thumbnail failure is non-blocking. + } + } + + onCreated(pn); } catch (e) { setError(e instanceof Error ? e.message : "Failed to create item"); } finally { @@ -106,17 +222,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { return (
{/* Header */} -
+
void handleSubmit()} disabled={saving} style={{ - padding: "0.3rem 0.75rem", - fontSize: "0.8rem", - border: "none", - borderRadius: "0.3rem", + ...actionBtnStyle, backgroundColor: "var(--ctp-green)", - color: "var(--ctp-crust)", - cursor: "pointer", opacity: saving ? 0.6 : 1, }} > {saving ? "Creating..." : "Create"} -
- {/* Form */} -
- {error && ( -
- {error} + {/* Two-column body */} +
+ {/* Left: form */} +
+ {error &&
{error}
} + + {/* Identity section */} + Identity +
+ + + + + setDescription(e.target.value)} + style={inputStyle} + placeholder="Item description" + /> + +
+ + + +
- )} - - - + {/* Sourcing section */} + Sourcing +
+ + + + + setStandardCost(e.target.value)} + style={inputStyle} + placeholder="0.00" + /> + +
+ + setSourcingLink(e.target.value)} + style={inputStyle} + placeholder="https://..." + /> + +
+
- - setDescription(e.target.value)} - style={inputStyle} - placeholder="Item description" - /> - + {/* Details section */} + Details + +