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
This commit is contained in:
Forbes
2026-02-07 10:15:03 -06:00
parent 3358e7dd1c
commit d61f939d84

View File

@@ -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<string, { type: 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 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 : ""),
}));
},
[],
);
// 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<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[]) => {
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 (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.5rem 0.75rem",
borderBottom: "1px solid var(--ctp-surface1)",
backgroundColor: "var(--ctp-mantle)",
flexShrink: 0,
}}
>
<div style={headerStyle}>
<span
style={{
color: "var(--ctp-green)",
@@ -131,48 +237,46 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
onClick={() => 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"}
</button>
<button onClick={onCancel} style={headerBtnStyle}>
<button onClick={onCancel} style={cancelBtnStyle}>
Cancel
</button>
</div>
{/* Form */}
<div style={{ flex: 1, overflow: "auto", padding: "0.75rem" }}>
{error && (
{/* Two-column body */}
<div
style={{
color: "var(--ctp-red)",
backgroundColor: "rgba(243,139,168,0.1)",
padding: "0.5rem",
borderRadius: "0.3rem",
marginBottom: "0.5rem",
fontSize: "0.85rem",
flex: 1,
display: "grid",
gridTemplateColumns: "1fr 280px",
overflow: "hidden",
}}
>
{error}
</div>
)}
{/* Left: form */}
<div style={{ overflow: "auto", padding: "0.75rem" }}>
{error && <div style={errorStyle}>{error}</div>}
<FormGroup label="Category *">
<CategoryPicker
value={category}
onChange={setCategory}
categories={categories}
/>
{/* Identity section */}
<SectionHeader>Identity</SectionHeader>
<div style={fieldGridStyle}>
<FormGroup label="Type *">
<select
value={itemType}
onChange={(e) => setItemType(e.target.value)}
style={inputStyle}
>
<option value="part">Part</option>
<option value="assembly">Assembly</option>
<option value="consumable">Consumable</option>
<option value="tool">Tool</option>
</select>
</FormGroup>
<FormGroup label="Description">
<input
value={description}
@@ -181,7 +285,20 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
placeholder="Item description"
/>
</FormGroup>
<div style={{ gridColumn: "1 / -1" }}>
<FormGroup label="Category *">
<CategoryPicker
value={category}
onChange={setCategory}
categories={categories}
/>
</FormGroup>
</div>
</div>
{/* Sourcing section */}
<SectionHeader>Sourcing</SectionHeader>
<div style={fieldGridStyle}>
<FormGroup label="Sourcing Type">
<select
value={sourcingType}
@@ -190,19 +307,8 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
>
<option value="manufactured">Manufactured</option>
<option value="purchased">Purchased</option>
<option value="outsourced">Outsourced</option>
</select>
</FormGroup>
<FormGroup label="Sourcing Link">
<input
value={sourcingLink}
onChange={(e) => setSourcingLink(e.target.value)}
style={inputStyle}
placeholder="URL"
/>
</FormGroup>
<FormGroup label="Standard Cost">
<input
type="number"
@@ -213,7 +319,20 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
placeholder="0.00"
/>
</FormGroup>
<div style={{ gridColumn: "1 / -1" }}>
<FormGroup label="Sourcing Link">
<input
value={sourcingLink}
onChange={(e) => setSourcingLink(e.target.value)}
style={inputStyle}
placeholder="https://..."
/>
</FormGroup>
</div>
</div>
{/* Details section */}
<SectionHeader>Details</SectionHeader>
<FormGroup label="Long Description">
<textarea
value={longDescription}
@@ -222,8 +341,6 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
placeholder="Detailed description..."
/>
</FormGroup>
{/* Project tags */}
<FormGroup label="Projects">
<TagInput
value={selectedProjects}
@@ -236,23 +353,10 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
{/* Category properties */}
{Object.keys(catPropDefs).length > 0 && (
<>
<div
style={{
borderTop: "1px solid var(--ctp-surface1)",
margin: "0.75rem 0 0.5rem",
paddingTop: "0.5rem",
}}
>
<span
style={{
fontSize: "0.8rem",
color: "var(--ctp-subtext0)",
fontWeight: 600,
}}
>
Category Properties
</span>
</div>
<SectionHeader>
{categories[category] ?? category} Properties
</SectionHeader>
<div style={fieldGridStyle}>
{Object.entries(catPropDefs).map(([key, def]) => (
<FormGroup key={key} label={key}>
{def.type === "boolean" ? (
@@ -263,7 +367,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
}
style={inputStyle}
>
<option value=""></option>
<option value="">---</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
@@ -279,9 +383,162 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
)}
</FormGroup>
))}
</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.4rem",
border: "1px dashed var(--ctp-surface1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
overflow: "hidden",
backgroundColor: "var(--ctp-mantle)",
}}
>
{thumbnailFile?.uploadStatus === "complete" ? (
<img
src={URL.createObjectURL(thumbnailFile.file)}
alt="Thumbnail preview"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : thumbnailFile?.uploadStatus === "uploading" ? (
<span
style={{ fontSize: "0.8rem", color: "var(--ctp-subtext0)" }}
>
Uploading... {thumbnailFile.uploadProgress}%
</span>
) : (
<span
style={{ fontSize: "0.8rem", color: "var(--ctp-subtext0)" }}
>
Click to upload
</span>
)}
</div>
</SidebarSection>
</div>
</div>
</div>
);
}
// --- 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: "0.7rem",
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: "0.7rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--ctp-subtext0)",
marginBottom: "0.4rem",
}}
>
{title}
</div>
{children}
</div>
);
}
function MetaRow({ label, value }: { label: string; value: string }) {
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "0.8rem",
padding: "0.15rem 0",
}}
>
<span style={{ color: "var(--ctp-subtext0)" }}>{label}</span>
<span style={{ color: "var(--ctp-text)" }}>{value}</span>
</div>
);
}
@@ -310,6 +567,36 @@ function FormGroup({
);
}
// --- 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.3rem 0.75rem",
fontSize: "0.8rem",
border: "none",
borderRadius: "0.3rem",
color: "var(--ctp-crust)",
cursor: "pointer",
};
const cancelBtnStyle: React.CSSProperties = {
background: "none",
border: "none",
cursor: "pointer",
color: "var(--ctp-subtext1)",
fontSize: "0.8rem",
padding: "0.2rem 0.4rem",
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "0.35rem 0.5rem",
@@ -318,13 +605,20 @@ const inputStyle: React.CSSProperties = {
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.3rem",
color: "var(--ctp-text)",
boxSizing: "border-box",
};
const headerBtnStyle: React.CSSProperties = {
background: "none",
border: "none",
cursor: "pointer",
color: "var(--ctp-subtext1)",
fontSize: "0.8rem",
padding: "0.2rem 0.4rem",
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.3rem",
marginBottom: "0.5rem",
fontSize: "0.85rem",
};