diff --git a/web/src/components/items/CategoryPicker.tsx b/web/src/components/items/CategoryPicker.tsx new file mode 100644 index 0000000..68538dc --- /dev/null +++ b/web/src/components/items/CategoryPicker.tsx @@ -0,0 +1,153 @@ +import { useState, useMemo, useRef, useEffect } from "react"; + +interface CategoryPickerProps { + value: string; + onChange: (code: string) => void; + categories: Record; +} + +export function CategoryPicker({ + value, + onChange, + categories, +}: CategoryPickerProps) { + const [search, setSearch] = useState(""); + const selectedRef = useRef(null); + + const entries = useMemo(() => { + const all = Object.entries(categories).sort(([a], [b]) => + a.localeCompare(b), + ); + if (!search) return all; + const q = search.toLowerCase(); + return all.filter( + ([code, desc]) => + code.toLowerCase().includes(q) || desc.toLowerCase().includes(q), + ); + }, [categories, search]); + + // Scroll selected into view on mount. + useEffect(() => { + selectedRef.current?.scrollIntoView({ block: "nearest" }); + }, []); + + return ( +
+ {/* Search */} + setSearch(e.target.value)} + placeholder="Search categories..." + style={{ + width: "100%", + padding: "0.4rem 0.5rem", + fontSize: "0.8rem", + border: "none", + borderBottom: "1px solid var(--ctp-surface1)", + backgroundColor: "var(--ctp-mantle)", + color: "var(--ctp-text)", + outline: "none", + boxSizing: "border-box", + }} + /> + + {/* Scrollable list */} +
+ {entries.length === 0 ? ( +
+ No categories found +
+ ) : ( + entries.map(([code, desc]) => { + const isSelected = code === value; + return ( +
onChange(code)} + style={{ + display: "flex", + alignItems: "center", + gap: "0.5rem", + padding: "0.3rem 0.5rem", + cursor: "pointer", + fontSize: "0.8rem", + backgroundColor: isSelected + ? "rgba(203,166,247,0.12)" + : "transparent", + color: isSelected + ? "var(--ctp-mauve)" + : "var(--ctp-text)", + fontWeight: isSelected ? 600 : 400, + transition: "background-color 0.1s", + }} + onMouseEnter={(e) => { + if (!isSelected) + e.currentTarget.style.backgroundColor = + "var(--ctp-surface0)"; + }} + onMouseLeave={(e) => { + if (!isSelected) + e.currentTarget.style.backgroundColor = "transparent"; + }} + > + + {code} + + + {desc} + +
+ ); + }) + )} +
+ + {/* Selected breadcrumb */} + {value && categories[value] && ( +
+ Selected:{" "} + + {value} + {" "} + — {categories[value]} +
+ )} +
+ ); +} diff --git a/web/src/components/items/CreateItemPane.tsx b/web/src/components/items/CreateItemPane.tsx index dbfad01..50c4e86 100644 --- a/web/src/components/items/CreateItemPane.tsx +++ b/web/src/components/items/CreateItemPane.tsx @@ -1,6 +1,9 @@ -import { useState, useEffect } from 'react'; -import { get, post } from '../../api/client'; -import type { Schema, Project } from '../../api/types'; +import { useState, useEffect, useCallback } from "react"; +import { get, post } from "../../api/client"; +import type { Project } from "../../api/types"; +import { TagInput, type TagOption } from "../TagInput"; +import { CategoryPicker } from "./CategoryPicker"; +import { useCategories } from "../../hooks/useCategories"; interface CreateItemPaneProps { onCreated: (partNumber: string) => void; @@ -8,41 +11,66 @@ interface CreateItemPaneProps { } export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { - const [schema, setSchema] = useState(null); - const [projects, setProjects] = useState([]); - const [category, setCategory] = useState(''); - const [description, setDescription] = useState(''); - const [sourcingType, setSourcingType] = useState('manufactured'); - const [sourcingLink, setSourcingLink] = useState(''); - const [longDescription, setLongDescription] = useState(''); - const [standardCost, setStandardCost] = useState(''); + const { categories } = useCategories(); + const [category, setCategory] = useState(""); + const [description, setDescription] = useState(""); + const [sourcingType, setSourcingType] = useState("manufactured"); + const [sourcingLink, setSourcingLink] = useState(""); + const [longDescription, setLongDescription] = useState(""); + const [standardCost, setStandardCost] = useState(""); const [selectedProjects, setSelectedProjects] = useState([]); const [catProps, setCatProps] = useState>({}); - const [catPropDefs, setCatPropDefs] = useState>({}); + const [catPropDefs, setCatPropDefs] = useState< + Record + >({}); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - get('/api/schemas/kindred-rd').then(setSchema).catch(() => {}); - get('/api/projects').then(setProjects).catch(() => {}); - }, []); + 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 : ""), + })); + }, + [], + ); useEffect(() => { - if (!category) { setCatPropDefs({}); setCatProps({}); return; } - get>(`/api/schemas/kindred-rd/properties?category=${encodeURIComponent(category)}`) + if (!category) { + setCatPropDefs({}); + setCatProps({}); + return; + } + get>( + `/api/schemas/kindred-rd/properties?category=${encodeURIComponent(category)}`, + ) .then((defs) => { setCatPropDefs(defs); const defaults: Record = {}; - for (const key of Object.keys(defs)) defaults[key] = ''; + for (const key of Object.keys(defs)) defaults[key] = ""; setCatProps(defaults); }) - .catch(() => { setCatPropDefs({}); setCatProps({}); }); + .catch(() => { + setCatPropDefs({}); + setCatProps({}); + }); }, [category]); - const categories = schema?.segments.find((s) => s.name === 'category')?.values ?? {}; - const handleSubmit = async () => { - if (!category) { setError('Category is required'); return; } + if (!category) { + setError("Category is required"); + return; + } setSaving(true); setError(null); @@ -50,14 +78,14 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { for (const [k, v] of Object.entries(catProps)) { if (!v) continue; const def = catPropDefs[k]; - if (def?.type === 'number') properties[k] = Number(v); - else if (def?.type === 'boolean') properties[k] = v === 'true'; + if (def?.type === "number") properties[k] = Number(v); + else if (def?.type === "boolean") properties[k] = v === "true"; else properties[k] = v; } try { - const result = await post<{ part_number: string }>('/api/items', { - schema: 'kindred-rd', + const result = await post<{ part_number: string }>("/api/items", { + schema: "kindred-rd", category, description, projects: selectedProjects.length > 0 ? selectedProjects : undefined, @@ -69,63 +97,97 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { }); onCreated(result.part_number); } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to create item'); + setError(e instanceof Error ? e.message : "Failed to create item"); } finally { setSaving(false); } }; - const toggleProject = (code: string) => { - setSelectedProjects((prev) => - prev.includes(code) ? prev.filter((p) => p !== code) : [...prev, code] - ); - }; - return ( -
+
{/* Header */} -
- New Item +
+ + New Item + - + -
{/* Form */} -
+
{error && ( -
+
{error}
)} - + - setDescription(e.target.value)} style={inputStyle} placeholder="Item description" /> + setDescription(e.target.value)} + style={inputStyle} + placeholder="Item description" + /> - setSourcingType(e.target.value)} + style={inputStyle} + > @@ -133,55 +195,85 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { - setSourcingLink(e.target.value)} style={inputStyle} placeholder="URL" /> + setSourcingLink(e.target.value)} + style={inputStyle} + placeholder="URL" + /> - setStandardCost(e.target.value)} style={inputStyle} placeholder="0.00" /> + setStandardCost(e.target.value)} + style={inputStyle} + placeholder="0.00" + /> -