diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 5a205db..605cb89 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "sort" "strconv" "strings" @@ -195,10 +196,35 @@ func (s *Server) HandleGetSchema(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, schemaToResponse(sch)) } -// HandleGetPropertySchema returns the property schema for a category. -func (s *Server) HandleGetPropertySchema(w http.ResponseWriter, r *http.Request) { +// FormFieldDescriptor describes a single field in the form descriptor response. +type FormFieldDescriptor struct { + Name string `json:"name"` + Type string `json:"type"` + Widget string `json:"widget,omitempty"` + Label string `json:"label"` + Required bool `json:"required,omitempty"` + Default any `json:"default,omitempty"` + Unit string `json:"unit,omitempty"` + Description string `json:"description,omitempty"` + Options []string `json:"options,omitempty"` + Currency string `json:"currency,omitempty"` + + // Item-field specific + DerivedFromCategory map[string]string `json:"derived_from_category,omitempty"` + SearchEndpoint string `json:"search_endpoint,omitempty"` +} + +// FormFieldGroupDescriptor describes an ordered group of resolved fields. +type FormFieldGroupDescriptor struct { + Key string `json:"key"` + Label string `json:"label"` + Order int `json:"order"` + Fields []FormFieldDescriptor `json:"fields"` +} + +// HandleGetFormDescriptor returns the full form descriptor for a schema. +func (s *Server) HandleGetFormDescriptor(w http.ResponseWriter, r *http.Request) { schemaName := chi.URLParam(r, "name") - category := r.URL.Query().Get("category") sch, ok := s.schemas[schemaName] if !ok { @@ -206,19 +232,194 @@ func (s *Server) HandleGetPropertySchema(w http.ResponseWriter, r *http.Request) return } - if sch.PropertySchemas == nil { - writeJSON(w, http.StatusOK, map[string]any{ - "version": 0, - "properties": map[string]any{}, - }) - return + result := map[string]any{ + "schema_name": sch.Name, + "format": sch.Format, } - props := sch.PropertySchemas.GetPropertiesForCategory(category) - writeJSON(w, http.StatusOK, map[string]any{ - "version": sch.PropertySchemas.Version, - "properties": props, + // Category picker with auto-derived values_by_domain + if sch.UI != nil && sch.UI.CategoryPicker != nil { + picker := map[string]any{ + "style": sch.UI.CategoryPicker.Style, + } + + vbd := sch.ValuesByDomain() + + stages := make([]map[string]any, 0, len(sch.UI.CategoryPicker.Stages)+1) + for _, stage := range sch.UI.CategoryPicker.Stages { + stg := map[string]any{ + "name": stage.Name, + "label": stage.Label, + } + if stage.Values != nil { + stg["values"] = stage.Values + } + stages = append(stages, stg) + } + + // Auto-add subcategory stage from values_by_domain + if vbd != nil { + stages = append(stages, map[string]any{ + "name": "subcategory", + "label": "Type", + "values_by_domain": vbd, + }) + } + + picker["stages"] = stages + result["category_picker"] = picker + } + + // Item fields + if sch.UI != nil && sch.UI.ItemFields != nil { + result["item_fields"] = sch.UI.ItemFields + } + + // Resolve field groups into ordered list with full field metadata + if sch.UI != nil && sch.UI.FieldGroups != nil { + groups := s.resolveFieldGroups(sch, sch.UI.FieldGroups) + result["field_groups"] = groups + } + + // Category field groups + if sch.UI != nil && sch.UI.CategoryFieldGroups != nil { + catGroups := make(map[string][]FormFieldGroupDescriptor) + for prefix, groups := range sch.UI.CategoryFieldGroups { + catGroups[prefix] = s.resolveCategoryFieldGroups(sch, prefix, groups) + } + result["category_field_groups"] = catGroups + } + + // Field overrides (pass through) + if sch.UI != nil && sch.UI.FieldOverrides != nil { + result["field_overrides"] = sch.UI.FieldOverrides + } + + writeJSON(w, http.StatusOK, result) +} + +// resolveFieldGroups converts field group definitions into fully resolved descriptors. +func (s *Server) resolveFieldGroups(sch *schema.Schema, groups map[string]schema.FieldGroup) []FormFieldGroupDescriptor { + result := make([]FormFieldGroupDescriptor, 0, len(groups)) + for key, group := range groups { + desc := FormFieldGroupDescriptor{ + Key: key, + Label: group.Label, + Order: group.Order, + } + for _, fieldName := range group.Fields { + fd := s.resolveField(sch, fieldName) + desc.Fields = append(desc.Fields, fd) + } + result = append(result, desc) + } + // Sort by order + sort.Slice(result, func(i, j int) bool { + return result[i].Order < result[j].Order }) + return result +} + +// resolveCategoryFieldGroups resolves category-specific field groups. +func (s *Server) resolveCategoryFieldGroups(sch *schema.Schema, prefix string, groups map[string]schema.FieldGroup) []FormFieldGroupDescriptor { + result := make([]FormFieldGroupDescriptor, 0, len(groups)) + for key, group := range groups { + desc := FormFieldGroupDescriptor{ + Key: key, + Label: group.Label, + Order: group.Order, + } + for _, fieldName := range group.Fields { + fd := s.resolveCategoryField(sch, prefix, fieldName) + desc.Fields = append(desc.Fields, fd) + } + result = append(result, desc) + } + sort.Slice(result, func(i, j int) bool { + return result[i].Order < result[j].Order + }) + return result +} + +// resolveField builds a FormFieldDescriptor from item_fields or property_schemas.defaults. +func (s *Server) resolveField(sch *schema.Schema, name string) FormFieldDescriptor { + fd := FormFieldDescriptor{Name: name} + + // Check item_fields first + if sch.UI != nil && sch.UI.ItemFields != nil { + if def, ok := sch.UI.ItemFields[name]; ok { + fd.Type = def.Type + fd.Widget = def.Widget + fd.Label = def.Label + fd.Required = def.Required + fd.Default = def.Default + fd.Options = def.Options + fd.DerivedFromCategory = def.DerivedFromCategory + fd.SearchEndpoint = def.SearchEndpoint + s.applyOverrides(sch, name, &fd) + return fd + } + } + + // Check property_schemas.defaults + if sch.PropertySchemas != nil && sch.PropertySchemas.Defaults != nil { + if def, ok := sch.PropertySchemas.Defaults[name]; ok { + fd.Type = def.Type + fd.Label = name // Use field name as label if not overridden + fd.Default = def.Default + fd.Unit = def.Unit + fd.Description = def.Description + fd.Required = def.Required + s.applyOverrides(sch, name, &fd) + return fd + } + } + + // Fallback — field name only + fd.Label = name + fd.Type = "string" + s.applyOverrides(sch, name, &fd) + return fd +} + +// resolveCategoryField builds a FormFieldDescriptor from category-specific property schema. +func (s *Server) resolveCategoryField(sch *schema.Schema, prefix, name string) FormFieldDescriptor { + fd := FormFieldDescriptor{Name: name, Label: name, Type: "string"} + + if sch.PropertySchemas != nil { + if catProps, ok := sch.PropertySchemas.Categories[prefix]; ok { + if def, ok := catProps[name]; ok { + fd.Type = def.Type + fd.Default = def.Default + fd.Unit = def.Unit + fd.Description = def.Description + fd.Required = def.Required + } + } + } + + s.applyOverrides(sch, name, &fd) + return fd +} + +// applyOverrides applies field_overrides to a field descriptor. +func (s *Server) applyOverrides(sch *schema.Schema, name string, fd *FormFieldDescriptor) { + if sch.UI == nil || sch.UI.FieldOverrides == nil { + return + } + ov, ok := sch.UI.FieldOverrides[name] + if !ok { + return + } + if ov.Widget != "" { + fd.Widget = ov.Widget + } + if ov.Currency != "" { + fd.Currency = ov.Currency + } + if len(ov.Options) > 0 { + fd.Options = ov.Options + } } func schemaToResponse(sch *schema.Schema) SchemaResponse { diff --git a/internal/api/routes.go b/internal/api/routes.go index 67b0de6..e0057ad 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -86,7 +86,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Route("/schemas", func(r chi.Router) { r.Get("/", server.HandleListSchemas) r.Get("/{name}", server.HandleGetSchema) - r.Get("/{name}/properties", server.HandleGetPropertySchema) + r.Get("/{name}/form", server.HandleGetFormDescriptor) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) diff --git a/internal/schema/schema.go b/internal/schema/schema.go index 89e86f4..c2ed809 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -22,6 +22,55 @@ type Schema struct { Format string `yaml:"format"` Examples []string `yaml:"examples"` PropertySchemas *PropertySchemas `yaml:"property_schemas,omitempty"` + UI *UIConfig `yaml:"ui,omitempty" json:"ui,omitempty"` +} + +// UIConfig defines form layout for all clients. +type UIConfig struct { + CategoryPicker *CategoryPickerConfig `yaml:"category_picker,omitempty" json:"category_picker,omitempty"` + ItemFields map[string]ItemFieldDef `yaml:"item_fields,omitempty" json:"item_fields,omitempty"` + FieldGroups map[string]FieldGroup `yaml:"field_groups,omitempty" json:"field_groups"` + CategoryFieldGroups map[string]map[string]FieldGroup `yaml:"category_field_groups,omitempty" json:"category_field_groups,omitempty"` + FieldOverrides map[string]FieldOverride `yaml:"field_overrides,omitempty" json:"field_overrides,omitempty"` +} + +// CategoryPickerConfig defines how the category picker is rendered. +type CategoryPickerConfig struct { + Style string `yaml:"style" json:"style"` + Stages []CategoryPickerStage `yaml:"stages" json:"stages"` +} + +// CategoryPickerStage defines one stage of a multi-stage category picker. +type CategoryPickerStage struct { + Name string `yaml:"name" json:"name"` + Label string `yaml:"label" json:"label"` + Values map[string]string `yaml:"values,omitempty" json:"values,omitempty"` +} + +// ItemFieldDef defines a field stored on the items table (not in revision properties). +type ItemFieldDef struct { + Type string `yaml:"type" json:"type"` + Widget string `yaml:"widget" json:"widget"` + Label string `yaml:"label" json:"label"` + Required bool `yaml:"required,omitempty" json:"required,omitempty"` + Default any `yaml:"default,omitempty" json:"default,omitempty"` + Options []string `yaml:"options,omitempty" json:"options,omitempty"` + DerivedFromCategory map[string]string `yaml:"derived_from_category,omitempty" json:"derived_from_category,omitempty"` + SearchEndpoint string `yaml:"search_endpoint,omitempty" json:"search_endpoint,omitempty"` +} + +// FieldGroup defines an ordered group of fields for form layout. +type FieldGroup struct { + Label string `yaml:"label" json:"label"` + Order int `yaml:"order" json:"order"` + Fields []string `yaml:"fields" json:"fields"` +} + +// FieldOverride overrides display hints for a field. +type FieldOverride struct { + Widget string `yaml:"widget,omitempty" json:"widget,omitempty"` + Currency string `yaml:"currency,omitempty" json:"currency,omitempty"` + Options []string `yaml:"options,omitempty" json:"options,omitempty"` } // PropertySchemas defines property schemas per category. @@ -180,6 +229,10 @@ func (s *Schema) Validate() error { } } + if err := s.ValidateUI(); err != nil { + return err + } + return nil } @@ -224,6 +277,112 @@ func (seg *Segment) Validate() error { return nil } +// ValuesByDomain groups category enum values by their first character (domain prefix). +func (s *Schema) ValuesByDomain() map[string]map[string]string { + catSeg := s.GetSegment("category") + if catSeg == nil { + return nil + } + result := make(map[string]map[string]string) + for code, desc := range catSeg.Values { + if len(code) == 0 { + continue + } + domain := string(code[0]) + if result[domain] == nil { + result[domain] = make(map[string]string) + } + result[domain][code] = desc + } + return result +} + +// ValidateUI validates the UI configuration against property schemas and segments. +func (s *Schema) ValidateUI() error { + if s.UI == nil { + return nil + } + + // Build a set of all known fields (item_fields + property defaults) + knownGlobal := make(map[string]bool) + if s.UI.ItemFields != nil { + for k := range s.UI.ItemFields { + knownGlobal[k] = true + } + } + if s.PropertySchemas != nil { + for k := range s.PropertySchemas.Defaults { + knownGlobal[k] = true + } + } + + // Validate field_groups: every field must be a known global field + for groupKey, group := range s.UI.FieldGroups { + for _, field := range group.Fields { + if !knownGlobal[field] { + return fmt.Errorf("ui.field_groups.%s: field %q not found in item_fields or property_schemas.defaults", groupKey, field) + } + } + } + + // Validate category_field_groups: every field must exist in the category's property schema + if s.PropertySchemas != nil { + for prefix, groups := range s.UI.CategoryFieldGroups { + catProps := s.PropertySchemas.Categories[prefix] + for groupKey, group := range groups { + for _, field := range group.Fields { + if catProps == nil { + return fmt.Errorf("ui.category_field_groups.%s.%s: category prefix %q has no property schema", prefix, groupKey, prefix) + } + if _, ok := catProps[field]; !ok { + return fmt.Errorf("ui.category_field_groups.%s.%s: field %q not found in property_schemas.categories.%s", prefix, groupKey, field, prefix) + } + } + } + } + } + + // Validate field_overrides: keys must be known fields + for key := range s.UI.FieldOverrides { + if !knownGlobal[key] { + // Also check category-level properties + found := false + if s.PropertySchemas != nil { + for _, catProps := range s.PropertySchemas.Categories { + if _, ok := catProps[key]; ok { + found = true + break + } + } + } + if !found { + return fmt.Errorf("ui.field_overrides: field %q not found in any property schema", key) + } + } + } + + // Validate category_picker stages: first stage values must be valid domain prefixes + if s.UI.CategoryPicker != nil && len(s.UI.CategoryPicker.Stages) > 0 { + catSeg := s.GetSegment("category") + if catSeg != nil { + validPrefixes := make(map[string]bool) + for code := range catSeg.Values { + if len(code) > 0 { + validPrefixes[string(code[0])] = true + } + } + firstStage := s.UI.CategoryPicker.Stages[0] + for key := range firstStage.Values { + if !validPrefixes[key] { + return fmt.Errorf("ui.category_picker.stages[0]: value %q is not a valid category prefix", key) + } + } + } + } + + return nil +} + // GetSegment returns a segment by name. func (s *Schema) GetSegment(name string) *Segment { for i := range s.Segments { diff --git a/schemas/kindred-rd.yaml b/schemas/kindred-rd.yaml index 54d44c7..0ae5307 100644 --- a/schemas/kindred-rd.yaml +++ b/schemas/kindred-rd.yaml @@ -846,3 +846,255 @@ schema: type: string default: "" description: "Inspection/QC requirements" + + # UI configuration — drives form rendering for all clients. + ui: + category_picker: + style: multi_stage + stages: + - name: domain + label: "Domain" + values: + F: "Fasteners" + C: "Fluid Fittings" + R: "Motion" + S: "Structural" + E: "Electrical" + M: "Mechanical" + T: "Tooling" + A: "Assemblies" + P: "Purchased" + X: "Custom Fabricated" + + # Item-level fields (stored on items table, not in revision properties) + item_fields: + description: + type: string + widget: text + label: "Description" + item_type: + type: string + widget: select + label: "Type" + options: [part, assembly, consumable, tool] + derived_from_category: + A: assembly + T: tool + default: part + sourcing_type: + type: string + widget: select + label: "Sourcing Type" + options: [manufactured, purchased] + default: manufactured + long_description: + type: string + widget: textarea + label: "Long Description" + projects: + type: string_array + widget: tag_input + label: "Projects" + search_endpoint: "/api/projects" + + field_groups: + identity: + label: "Identity" + order: 1 + fields: [item_type, description] + sourcing: + label: "Sourcing" + order: 2 + fields: + [ + sourcing_type, + manufacturer, + manufacturer_pn, + supplier, + supplier_pn, + sourcing_link, + ] + cost: + label: "Cost & Lead Time" + order: 3 + fields: [standard_cost, lead_time_days, minimum_order_qty] + status: + label: "Status" + order: 4 + fields: [lifecycle_status, rohs_compliant, country_of_origin] + details: + label: "Details" + order: 5 + fields: [long_description, projects, notes] + + # Per-category-prefix field groups (rendered after global groups) + category_field_groups: + F: + fastener_specs: + label: "Fastener Specifications" + order: 10 + fields: + [ + material, + finish, + thread_size, + thread_pitch, + length, + head_type, + drive_type, + strength_grade, + torque_spec, + ] + C: + fitting_specs: + label: "Fitting Specifications" + order: 10 + fields: + [ + material, + connection_type, + size_1, + size_2, + pressure_rating, + temperature_min, + temperature_max, + media_compatibility, + seal_material, + ] + R: + motion_specs: + label: "Motion Specifications" + order: 10 + fields: + [ + load_capacity, + speed_rating, + power_rating, + voltage_nominal, + current_nominal, + torque_continuous, + bore_diameter, + travel, + stroke, + operating_pressure, + ] + S: + structural_specs: + label: "Structural Specifications" + order: 10 + fields: + [ + material, + material_spec, + profile, + dimension_a, + dimension_b, + wall_thickness, + length, + weight_per_length, + finish, + temper, + ] + E: + electrical_specs: + label: "Electrical Specifications" + order: 10 + fields: + [ + voltage_rating, + current_rating, + power_rating, + value, + tolerance, + package, + mounting, + pin_count, + wire_gauge, + connector_type, + ] + M: + mechanical_specs: + label: "Mechanical Specifications" + order: 10 + fields: + [ + material, + spring_rate, + free_length, + max_load, + travel, + inner_diameter, + outer_diameter, + hardness, + temperature_range, + ] + T: + tooling_specs: + label: "Tooling Specifications" + order: 10 + fields: + [ + material, + tolerance, + surface_finish, + hardness, + associated_part, + machine, + cycle_life, + ] + A: + assembly_specs: + label: "Assembly Specifications" + order: 10 + fields: + [ + weight, + dimensions, + component_count, + assembly_time, + test_procedure, + voltage_rating, + current_rating, + ip_rating, + ] + P: + purchased_specs: + label: "Purchased Item Specifications" + order: 10 + fields: + [ + material, + form, + grade, + quantity_per_unit, + unit_of_measure, + shelf_life, + ] + X: + fabrication_specs: + label: "Fabrication Specifications" + order: 10 + fields: + [ + material, + material_spec, + finish, + critical_dimensions, + weight, + process, + secondary_operations, + drawing_rev, + inspection_requirements, + ] + + field_overrides: + standard_cost: + widget: currency + currency: USD + sourcing_link: + widget: url + lifecycle_status: + widget: select + options: [active, deprecated, obsolete, prototype] + rohs_compliant: + widget: checkbox diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 290151a..b5e33b2 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -248,6 +248,68 @@ export interface PropertyDef { export type PropertySchema = Record; +// Form Descriptor (from GET /api/schemas/{name}/form) +export interface FormFieldDescriptor { + name: string; + type: string; + widget?: string; + label: string; + required?: boolean; + default?: unknown; + unit?: string; + description?: string; + options?: string[]; + currency?: string; + derived_from_category?: Record; + search_endpoint?: string; +} + +export interface FormFieldGroup { + key: string; + label: string; + order: number; + fields: FormFieldDescriptor[]; +} + +export interface CategoryPickerStage { + name: string; + label: string; + values?: Record; + values_by_domain?: Record>; +} + +export interface CategoryPickerDescriptor { + style: string; + stages: CategoryPickerStage[]; +} + +export interface ItemFieldDef { + type: string; + widget: string; + label: string; + required?: boolean; + default?: unknown; + options?: string[]; + derived_from_category?: Record; + search_endpoint?: string; +} + +export interface FieldOverride { + widget?: string; + currency?: string; + options?: string[]; +} + +export interface FormDescriptor { + schema_name: string; + format: string; + category_picker?: CategoryPickerDescriptor; + item_fields?: Record; + field_groups?: FormFieldGroup[]; + category_field_groups?: Record; + field_overrides?: Record; +} + // API Token export interface ApiToken { id: string; diff --git a/web/src/components/items/CategoryPicker.tsx b/web/src/components/items/CategoryPicker.tsx index 68538dc..07b1910 100644 --- a/web/src/components/items/CategoryPicker.tsx +++ b/web/src/components/items/CategoryPicker.tsx @@ -1,21 +1,48 @@ import { useState, useMemo, useRef, useEffect } from "react"; +import type { CategoryPickerStage } from "../../api/types"; interface CategoryPickerProps { value: string; onChange: (code: string) => void; categories: Record; + stages?: CategoryPickerStage[]; } export function CategoryPicker({ value, onChange, categories, + stages, }: CategoryPickerProps) { + const [selectedDomain, setSelectedDomain] = useState(""); const [search, setSearch] = useState(""); const selectedRef = useRef(null); + // Derive domain from current value + useEffect(() => { + if (value && value.length > 0) { + setSelectedDomain(value[0]!); + } + }, [value]); + + const isMultiStage = stages && stages.length >= 2; + + // Domain stage (first stage) + const domainStage = isMultiStage ? stages[0] : undefined; + const subcatStage = isMultiStage + ? stages.find((s) => s.values_by_domain) + : undefined; + + // Filtered categories for current domain in multi-stage mode + const filteredCategories = useMemo(() => { + if (!isMultiStage || !selectedDomain || !subcatStage?.values_by_domain) { + return categories; + } + return subcatStage.values_by_domain[selectedDomain] ?? {}; + }, [isMultiStage, selectedDomain, subcatStage, categories]); + const entries = useMemo(() => { - const all = Object.entries(categories).sort(([a], [b]) => + const all = Object.entries(filteredCategories).sort(([a], [b]) => a.localeCompare(b), ); if (!search) return all; @@ -24,7 +51,7 @@ export function CategoryPicker({ ([code, desc]) => code.toLowerCase().includes(q) || desc.toLowerCase().includes(q), ); - }, [categories, search]); + }, [filteredCategories, search]); // Scroll selected into view on mount. useEffect(() => { @@ -40,12 +67,70 @@ export function CategoryPicker({ overflow: "hidden", }} > + {/* Multi-stage domain picker */} + {isMultiStage && domainStage?.values && ( +
+ {Object.entries(domainStage.values) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([code, label]) => { + const isActive = code === selectedDomain; + return ( + + ); + })} +
+ )} + {/* Search */} setSearch(e.target.value)} - placeholder="Search categories..." + placeholder={ + isMultiStage && !selectedDomain + ? "Select a domain above..." + : "Search categories..." + } + disabled={isMultiStage && !selectedDomain} style={{ width: "100%", padding: "0.4rem 0.5rem", @@ -61,7 +146,18 @@ export function CategoryPicker({ {/* Scrollable list */}
- {entries.length === 0 ? ( + {isMultiStage && !selectedDomain ? ( +
+ Select a domain to see categories +
+ ) : entries.length === 0 ? (
void; onCancel: () => void; @@ -18,20 +31,13 @@ interface CreateItemPaneProps { export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { const { user } = useAuth(); - const { categories } = useCategories(); + const { descriptor, categories } = useFormDescriptor(); const { upload } = useFileUpload(); - // Form state. - const [itemType, setItemType] = useState("part"); + // Single form state for all fields (item-level + properties). const [category, setCategory] = useState(""); - const [description, setDescription] = useState(""); - const [sourcingType, setSourcingType] = useState("manufactured"); - const [longDescription, setLongDescription] = useState(""); + const [fields, setFields] = useState>({}); const [selectedProjects, setSelectedProjects] = useState([]); - const [catProps, setCatProps] = useState>({}); - const [catPropDefs, setCatPropDefs] = useState< - Record - >({}); // Attachments. const [attachments, setAttachments] = useState([]); @@ -42,27 +48,33 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - // Load category-specific properties. - useEffect(() => { - if (!category) { - setCatPropDefs({}); - setCatProps({}); - return; + 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"; } - 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] = ""; - setCatProps(defaults); - }) - .catch(() => { - setCatPropDefs({}); - setCatProps({}); - }); - }, [category]); + 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 => { @@ -88,10 +100,8 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { 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, @@ -153,12 +163,15 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { setSaving(true); setError(null); + // Split fields into item-level and properties const properties: Record = {}; - for (const [k, v] of Object.entries(catProps)) { + for (const [k, v] of Object.entries(fields)) { 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 (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; } @@ -166,12 +179,12 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { const result = await post<{ part_number: string }>("/api/items", { schema: "kindred-rd", category, - description, - item_type: itemType, + description: getField("description") || undefined, + item_type: deriveItemType(category), projects: selectedProjects.length > 0 ? selectedProjects : undefined, properties: Object.keys(properties).length > 0 ? properties : undefined, - sourcing_type: sourcingType || undefined, - long_description: longDescription || undefined, + sourcing_type: getField("sourcing_type") || undefined, + long_description: getField("long_description") || undefined, }); const pn = result.part_number; @@ -215,6 +228,33 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { } }; + // 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 */} @@ -258,110 +298,52 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
{error &&
{error}
} - {/* Identity section */} - Identity -
- - - - - setDescription(e.target.value)} - style={inputStyle} - placeholder="Item description" - /> - -
- - - -
-
+ {/* Category picker */} + Category * + - {/* Sourcing section */} - Sourcing -
- - - -
- - {/* Details section */} - Details - -