feat(web): add CategoryPicker searchable selector component

Replace the flat <select> dropdown for category selection in
CreateItemPane with a searchable, scrollable CategoryPicker component.

New files:
- web/src/hooks/useCategories.ts: fetches and caches category enum
  values from GET /api/schemas/kindred-rd, extracts the category
  segment values map
- web/src/components/items/CategoryPicker.tsx: scrollable list with
  search input filtering by code and description, selected item
  highlighted with mauve, breadcrumb showing current selection

Modified:
- web/src/components/items/CreateItemPane.tsx: replaced <select>
  with <CategoryPicker>, uses useCategories hook instead of
  fetching full schema inline

Closes #13
This commit is contained in:
Forbes
2026-02-07 10:11:59 -06:00
parent cf02ce4231
commit 6f357c2199
3 changed files with 389 additions and 93 deletions

View File

@@ -0,0 +1,153 @@
import { useState, useMemo, useRef, useEffect } from "react";
interface CategoryPickerProps {
value: string;
onChange: (code: string) => void;
categories: Record<string, string>;
}
export function CategoryPicker({
value,
onChange,
categories,
}: CategoryPickerProps) {
const [search, setSearch] = useState("");
const selectedRef = useRef<HTMLDivElement>(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 (
<div
style={{
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.4rem",
backgroundColor: "var(--ctp-base)",
overflow: "hidden",
}}
>
{/* Search */}
<input
type="text"
value={search}
onChange={(e) => 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 */}
<div style={{ maxHeight: 200, overflowY: "auto" }}>
{entries.length === 0 ? (
<div
style={{
padding: "0.75rem",
textAlign: "center",
color: "var(--ctp-subtext0)",
fontSize: "0.8rem",
}}
>
No categories found
</div>
) : (
entries.map(([code, desc]) => {
const isSelected = code === value;
return (
<div
key={code}
ref={isSelected ? selectedRef : undefined}
onClick={() => 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";
}}
>
<span
style={{
fontFamily: "'JetBrains Mono', monospace",
flexShrink: 0,
width: 48,
}}
>
{code}
</span>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{desc}
</span>
</div>
);
})
)}
</div>
{/* Selected breadcrumb */}
{value && categories[value] && (
<div
style={{
padding: "0.3rem 0.5rem",
fontSize: "0.75rem",
color: "var(--ctp-subtext0)",
borderTop: "1px solid var(--ctp-surface0)",
backgroundColor: "var(--ctp-mantle)",
}}
>
Selected:{" "}
<span style={{ color: "var(--ctp-mauve)", fontWeight: 600 }}>
{value}
</span>{" "}
{categories[value]}
</div>
)}
</div>
);
}

View File

@@ -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<Schema | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
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<string[]>([]);
const [catProps, setCatProps] = useState<Record<string, string>>({});
const [catPropDefs, setCatPropDefs] = useState<Record<string, { type: string }>>({});
const [catPropDefs, setCatPropDefs] = useState<
Record<string, { type: string }>
>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
get<Schema>('/api/schemas/kindred-rd').then(setSchema).catch(() => {});
get<Project[]>('/api/projects').then(setProjects).catch(() => {});
}, []);
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 : ""),
}));
},
[],
);
useEffect(() => {
if (!category) { setCatPropDefs({}); setCatProps({}); return; }
get<Record<string, { type: string }>>(`/api/schemas/kindred-rd/properties?category=${encodeURIComponent(category)}`)
if (!category) {
setCatPropDefs({});
setCatProps({});
return;
}
get<Record<string, { type: string }>>(
`/api/schemas/kindred-rd/properties?category=${encodeURIComponent(category)}`,
)
.then((defs) => {
setCatPropDefs(defs);
const defaults: Record<string, string> = {};
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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<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,
}}>
<span style={{ color: 'var(--ctp-green)', fontWeight: 600, fontSize: '0.9rem' }}>New Item</span>
<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,
}}
>
<span
style={{
color: "var(--ctp-green)",
fontWeight: 600,
fontSize: "0.9rem",
}}
>
New Item
</span>
<span style={{ flex: 1 }} />
<button onClick={() => void handleSubmit()} disabled={saving} style={{
padding: '0.3rem 0.75rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
backgroundColor: 'var(--ctp-green)', color: 'var(--ctp-crust)', cursor: 'pointer',
opacity: saving ? 0.6 : 1,
}}>
{saving ? 'Creating...' : 'Create'}
<button
onClick={() => void handleSubmit()}
disabled={saving}
style={{
padding: "0.3rem 0.75rem",
fontSize: "0.8rem",
border: "none",
borderRadius: "0.3rem",
backgroundColor: "var(--ctp-green)",
color: "var(--ctp-crust)",
cursor: "pointer",
opacity: saving ? 0.6 : 1,
}}
>
{saving ? "Creating..." : "Create"}
</button>
<button onClick={onCancel} style={headerBtnStyle}>
Cancel
</button>
<button onClick={onCancel} style={headerBtnStyle}>Cancel</button>
</div>
{/* Form */}
<div style={{ flex: 1, overflow: 'auto', padding: '0.75rem' }}>
<div style={{ flex: 1, overflow: "auto", padding: "0.75rem" }}>
{error && (
<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' }}>
<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",
}}
>
{error}
</div>
)}
<FormGroup label="Category *">
<select value={category} onChange={(e) => setCategory(e.target.value)} style={inputStyle}>
<option value="">Select category...</option>
{Object.entries(categories).map(([code, name]) => (
<option key={code} value={code}>{code} {name}</option>
))}
</select>
<CategoryPicker
value={category}
onChange={setCategory}
categories={categories}
/>
</FormGroup>
<FormGroup label="Description">
<input value={description} onChange={(e) => setDescription(e.target.value)} style={inputStyle} placeholder="Item description" />
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
style={inputStyle}
placeholder="Item description"
/>
</FormGroup>
<FormGroup label="Sourcing Type">
<select value={sourcingType} onChange={(e) => setSourcingType(e.target.value)} style={inputStyle}>
<select
value={sourcingType}
onChange={(e) => setSourcingType(e.target.value)}
style={inputStyle}
>
<option value="manufactured">Manufactured</option>
<option value="purchased">Purchased</option>
<option value="outsourced">Outsourced</option>
@@ -133,55 +195,85 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
</FormGroup>
<FormGroup label="Sourcing Link">
<input value={sourcingLink} onChange={(e) => setSourcingLink(e.target.value)} style={inputStyle} placeholder="URL" />
<input
value={sourcingLink}
onChange={(e) => setSourcingLink(e.target.value)}
style={inputStyle}
placeholder="URL"
/>
</FormGroup>
<FormGroup label="Standard Cost">
<input type="number" step="0.01" value={standardCost} onChange={(e) => setStandardCost(e.target.value)} style={inputStyle} placeholder="0.00" />
<input
type="number"
step="0.01"
value={standardCost}
onChange={(e) => setStandardCost(e.target.value)}
style={inputStyle}
placeholder="0.00"
/>
</FormGroup>
<FormGroup label="Long Description">
<textarea value={longDescription} onChange={(e) => setLongDescription(e.target.value)} style={{ ...inputStyle, minHeight: 60, resize: 'vertical' }} placeholder="Detailed description..." />
<textarea
value={longDescription}
onChange={(e) => setLongDescription(e.target.value)}
style={{ ...inputStyle, minHeight: 60, resize: "vertical" }}
placeholder="Detailed description..."
/>
</FormGroup>
{/* Project tags */}
<FormGroup label="Projects">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{projects.map((p) => (
<button
key={p.code}
onClick={() => toggleProject(p.code)}
style={{
padding: '0.15rem 0.5rem', fontSize: '0.75rem', border: 'none', borderRadius: '1rem', cursor: 'pointer',
backgroundColor: selectedProjects.includes(p.code) ? 'rgba(203,166,247,0.3)' : 'var(--ctp-surface0)',
color: selectedProjects.includes(p.code) ? 'var(--ctp-mauve)' : 'var(--ctp-subtext0)',
}}
>
{p.code}
</button>
))}
</div>
<TagInput
value={selectedProjects}
onChange={setSelectedProjects}
placeholder="Search projects\u2026"
searchFn={searchProjects}
/>
</FormGroup>
{/* 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
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>
{Object.entries(catPropDefs).map(([key, def]) => (
<FormGroup key={key} label={key}>
{def.type === 'boolean' ? (
<select value={catProps[key] ?? ''} onChange={(e) => setCatProps({ ...catProps, [key]: e.target.value })} style={inputStyle}>
{def.type === "boolean" ? (
<select
value={catProps[key] ?? ""}
onChange={(e) =>
setCatProps({ ...catProps, [key]: e.target.value })
}
style={inputStyle}
>
<option value=""></option>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : (
<input
type={def.type === 'number' ? 'number' : 'text'}
value={catProps[key] ?? ''}
onChange={(e) => setCatProps({ ...catProps, [key]: e.target.value })}
type={def.type === "number" ? "number" : "text"}
value={catProps[key] ?? ""}
onChange={(e) =>
setCatProps({ ...catProps, [key]: e.target.value })
}
style={inputStyle}
/>
)}
@@ -194,22 +286,45 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
);
}
function FormGroup({ label, children }: { label: string; children: React.ReactNode }) {
function FormGroup({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div style={{ marginBottom: '0.6rem' }}>
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--ctp-subtext0)', marginBottom: '0.2rem' }}>{label}</label>
<div style={{ marginBottom: "0.6rem" }}>
<label
style={{
display: "block",
fontSize: "0.75rem",
color: "var(--ctp-subtext0)",
marginBottom: "0.2rem",
}}
>
{label}
</label>
{children}
</div>
);
}
const inputStyle: React.CSSProperties = {
width: '100%', padding: '0.35rem 0.5rem', fontSize: '0.85rem',
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)',
borderRadius: '0.3rem', color: 'var(--ctp-text)',
width: "100%",
padding: "0.35rem 0.5rem",
fontSize: "0.85rem",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.3rem",
color: "var(--ctp-text)",
};
const headerBtnStyle: React.CSSProperties = {
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--ctp-subtext1)', fontSize: '0.8rem', padding: '0.2rem 0.4rem',
background: "none",
border: "none",
cursor: "pointer",
color: "var(--ctp-subtext1)",
fontSize: "0.8rem",
padding: "0.2rem 0.4rem",
};

View File

@@ -0,0 +1,28 @@
import { useState, useEffect } from "react";
import { get } from "../api/client";
import type { Schema } from "../api/types";
// Module-level cache to avoid refetching across mounts.
let cached: Record<string, string> | null = null;
export function useCategories() {
const [categories, setCategories] = useState<Record<string, string>>(
cached ?? {},
);
const [loading, setLoading] = useState(cached === null);
useEffect(() => {
if (cached) return;
get<Schema>("/api/schemas/kindred-rd")
.then((schema) => {
const seg = schema.segments.find((s) => s.name === "category");
const vals = seg?.values ?? {};
cached = vals;
setCategories(vals);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
return { categories, loading };
}