diff --git a/internal/api/audit_handlers.go b/internal/api/audit_handlers.go index 825a755..9a90994 100644 --- a/internal/api/audit_handlers.go +++ b/internal/api/audit_handlers.go @@ -114,8 +114,6 @@ var manufacturedWeights = map[string]float64{ var itemLevelFields = map[string]bool{ "description": true, "sourcing_type": true, - "sourcing_link": true, - "standard_cost": true, "long_description": true, } @@ -258,18 +256,6 @@ func scoreItem( processField("description", "item", "string", item.Description) processField("sourcing_type", "item", "string", item.SourcingType) - var sourcingLinkVal any - if item.SourcingLink != nil { - sourcingLinkVal = *item.SourcingLink - } - processField("sourcing_link", "item", "string", sourcingLinkVal) - - var stdCostVal any - if item.StandardCost != nil { - stdCostVal = *item.StandardCost - } - processField("standard_cost", "item", "number", stdCostVal) - var longDescVal any if item.LongDescription != nil { longDescVal = *item.LongDescription @@ -287,10 +273,6 @@ func scoreItem( if skipFields[key] || itemLevelFields[key] { continue } - // sourcing_link and standard_cost are already handled at item level. - if key == "sourcing_link" || key == "standard_cost" { - continue - } value := item.Properties[key] processField(key, "property", def.Type, value) } diff --git a/internal/api/bom_handlers.go b/internal/api/bom_handlers.go index 83c6008..dbd1a81 100644 --- a/internal/api/bom_handlers.go +++ b/internal/api/bom_handlers.go @@ -573,8 +573,20 @@ func (s *Server) HandleGetBOMCost(w http.ResponseWriter, r *http.Request) { for i, e := range entries { unitCost := 0.0 leaf, err := s.items.GetByID(ctx, e.ItemID) - if err == nil && leaf != nil && leaf.StandardCost != nil { - unitCost = *leaf.StandardCost + if err == nil && leaf != nil { + // Get standard_cost from revision properties + if revs, rerr := s.items.GetRevisions(ctx, leaf.ID); rerr == nil { + for _, rev := range revs { + if rev.RevisionNumber == leaf.CurrentRevision && rev.Properties != nil { + if sc, ok := rev.Properties["standard_cost"]; ok { + if cost, cok := sc.(float64); cok { + unitCost = cost + } + } + break + } + } + } } extCost := e.TotalQuantity * unitCost totalCost += extCost diff --git a/internal/api/csv.go b/internal/api/csv.go index 5766ef6..04d85c6 100644 --- a/internal/api/csv.go +++ b/internal/api/csv.go @@ -51,9 +51,7 @@ var csvColumns = []string{ "category", "projects", // comma-separated project codes "sourcing_type", - "sourcing_link", "long_description", - "standard_cost", } // HandleExportCSV exports items to CSV format. @@ -158,14 +156,8 @@ func (s *Server) HandleExportCSV(w http.ResponseWriter, r *http.Request) { row[6] = category row[7] = projectCodes row[8] = item.SourcingType - if item.SourcingLink != nil { - row[9] = *item.SourcingLink - } if item.LongDescription != nil { - row[10] = *item.LongDescription - } - if item.StandardCost != nil { - row[11] = strconv.FormatFloat(*item.StandardCost, 'f', -1, 64) + row[9] = *item.LongDescription } // Property columns @@ -366,9 +358,17 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) { // Parse extended fields sourcingType := getCSVValue(record, colIndex, "sourcing_type") - sourcingLink := getCSVValue(record, colIndex, "sourcing_link") longDesc := getCSVValue(record, colIndex, "long_description") - stdCostStr := getCSVValue(record, colIndex, "standard_cost") + + // sourcing_link and standard_cost are now properties — add to properties map + if sl := getCSVValue(record, colIndex, "sourcing_link"); sl != "" { + properties["sourcing_link"] = sl + } + if sc := getCSVValue(record, colIndex, "standard_cost"); sc != "" { + if cost, err := strconv.ParseFloat(sc, 64); err == nil { + properties["standard_cost"] = cost + } + } // Create item item := &db.Item{ @@ -382,17 +382,9 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) { if sourcingType != "" { item.SourcingType = sourcingType } - if sourcingLink != "" { - item.SourcingLink = &sourcingLink - } if longDesc != "" { item.LongDescription = &longDesc } - if stdCostStr != "" { - if cost, err := strconv.ParseFloat(stdCostStr, 64); err == nil { - item.StandardCost = &cost - } - } if err := s.items.Create(ctx, item, properties); err != nil { result.Errors = append(result.Errors, CSVImportErr{ @@ -585,9 +577,7 @@ func isStandardColumn(col string) bool { "objects": true, // FreeCAD objects data - skip on import "archived_at": true, "sourcing_type": true, - "sourcing_link": true, "long_description": true, - "standard_cost": true, } return standardCols[col] } diff --git a/internal/api/handlers.go b/internal/api/handlers.go index fb8c3de..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 { @@ -256,9 +457,7 @@ type ItemResponse struct { CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` SourcingType string `json:"sourcing_type"` - SourcingLink *string `json:"sourcing_link,omitempty"` LongDescription *string `json:"long_description,omitempty"` - StandardCost *float64 `json:"standard_cost,omitempty"` ThumbnailKey *string `json:"thumbnail_key,omitempty"` FileCount int `json:"file_count"` FilesTotalSize int64 `json:"files_total_size"` @@ -273,9 +472,7 @@ type CreateItemRequest struct { Projects []string `json:"projects,omitempty"` Properties map[string]any `json:"properties,omitempty"` SourcingType string `json:"sourcing_type,omitempty"` - SourcingLink *string `json:"sourcing_link,omitempty"` LongDescription *string `json:"long_description,omitempty"` - StandardCost *float64 `json:"standard_cost,omitempty"` } // HandleListItems lists items with optional filtering. @@ -429,9 +626,7 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) { ItemType: itemType, Description: req.Description, SourcingType: req.SourcingType, - SourcingLink: req.SourcingLink, LongDescription: req.LongDescription, - StandardCost: req.StandardCost, } if user := auth.UserFromContext(ctx); user != nil { item.CreatedBy = &user.Username @@ -557,9 +752,7 @@ type UpdateItemRequest struct { Properties map[string]any `json:"properties,omitempty"` Comment string `json:"comment,omitempty"` SourcingType *string `json:"sourcing_type,omitempty"` - SourcingLink *string `json:"sourcing_link,omitempty"` LongDescription *string `json:"long_description,omitempty"` - StandardCost *float64 `json:"standard_cost,omitempty"` } // HandleUpdateItem updates an item's fields and/or creates a new revision. @@ -590,9 +783,7 @@ func (s *Server) HandleUpdateItem(w http.ResponseWriter, r *http.Request) { ItemType: item.ItemType, Description: item.Description, SourcingType: req.SourcingType, - SourcingLink: req.SourcingLink, LongDescription: req.LongDescription, - StandardCost: req.StandardCost, } if req.PartNumber != "" { @@ -1204,9 +1395,7 @@ func itemToResponse(item *db.Item) ItemResponse { CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), SourcingType: item.SourcingType, - SourcingLink: item.SourcingLink, LongDescription: item.LongDescription, - StandardCost: item.StandardCost, ThumbnailKey: item.ThumbnailKey, } } diff --git a/internal/api/ods.go b/internal/api/ods.go index 2fcf5e8..60025ed 100644 --- a/internal/api/ods.go +++ b/internal/api/ods.go @@ -138,21 +138,11 @@ func (s *Server) HandleExportODS(w http.ResponseWriter, r *http.Request) { ods.StringCell(item.SourcingType), } - if item.SourcingLink != nil { - cells = append(cells, ods.StringCell(*item.SourcingLink)) - } else { - cells = append(cells, ods.EmptyCell()) - } if item.LongDescription != nil { cells = append(cells, ods.StringCell(*item.LongDescription)) } else { cells = append(cells, ods.EmptyCell()) } - if item.StandardCost != nil { - cells = append(cells, ods.CurrencyCell(*item.StandardCost)) - } else { - cells = append(cells, ods.EmptyCell()) - } // Property columns if includeProps { @@ -419,6 +409,16 @@ func (s *Server) HandleImportODS(w http.ResponseWriter, r *http.Request) { longDesc := getCellValue("long_description") stdCostStr := getCellValue("standard_cost") + // Put sourcing_link and standard_cost into properties + if sourcingLink != "" { + properties["sourcing_link"] = sourcingLink + } + if stdCostStr != "" { + if cost, err := strconv.ParseFloat(strings.TrimLeft(stdCostStr, "$"), 64); err == nil { + properties["standard_cost"] = cost + } + } + item := &db.Item{ PartNumber: partNumber, ItemType: itemType, @@ -430,17 +430,9 @@ func (s *Server) HandleImportODS(w http.ResponseWriter, r *http.Request) { if sourcingType != "" { item.SourcingType = sourcingType } - if sourcingLink != "" { - item.SourcingLink = &sourcingLink - } if longDesc != "" { item.LongDescription = &longDesc } - if stdCostStr != "" { - if cost, err := strconv.ParseFloat(strings.TrimLeft(stdCostStr, "$"), 64); err == nil { - item.StandardCost = &cost - } - } if err := s.items.Create(ctx, item, properties); err != nil { result.Errors = append(result.Errors, CSVImportErr{ @@ -580,9 +572,16 @@ func (s *Server) HandleExportBOMODS(w http.ResponseWriter, r *http.Request) { childItem, _ := s.items.GetByPartNumber(ctx, e.ChildPartNumber) unitCost, hasUnitCost := getMetaFloat(e.Metadata, "unit_cost") - if !hasUnitCost && childItem != nil && childItem.StandardCost != nil { - unitCost = *childItem.StandardCost - hasUnitCost = true + if !hasUnitCost && childItem != nil { + // Fall back to standard_cost from revision properties + if childProps := itemPropsCache[e.ChildPartNumber]; childProps != nil { + if sc, ok := childProps["standard_cost"]; ok { + if cost, cok := sc.(float64); cok { + unitCost = cost + hasUnitCost = true + } + } + } } qty := 0.0 @@ -682,6 +681,21 @@ func (s *Server) HandleProjectSheetODS(w http.ResponseWriter, r *http.Request) { return } + // Build item properties cache for sourcing_link / standard_cost + itemPropsMap := make(map[string]map[string]any) + for _, item := range items { + revisions, err := s.items.GetRevisions(ctx, item.ID) + if err != nil { + continue + } + for _, rev := range revisions { + if rev.RevisionNumber == item.CurrentRevision && rev.Properties != nil { + itemPropsMap[item.ID] = rev.Properties + break + } + } + } + // Sheet 1: Items list itemHeaders := []string{ "PN", "Type", "Description", "Revision", "Category", @@ -696,6 +710,8 @@ func (s *Server) HandleProjectSheetODS(w http.ResponseWriter, r *http.Request) { itemRows = append(itemRows, ods.Row{Cells: itemHeaderCells}) for _, item := range items { + props := itemPropsMap[item.ID] + cells := []ods.Cell{ ods.StringCell(item.PartNumber), ods.StringCell(item.ItemType), @@ -704,13 +720,17 @@ func (s *Server) HandleProjectSheetODS(w http.ResponseWriter, r *http.Request) { ods.StringCell(parseCategory(item.PartNumber)), ods.StringCell(item.SourcingType), } - if item.SourcingLink != nil { - cells = append(cells, ods.StringCell(*item.SourcingLink)) + if sl, ok := props["sourcing_link"]; ok { + cells = append(cells, ods.StringCell(formatPropertyValue(sl))) } else { cells = append(cells, ods.EmptyCell()) } - if item.StandardCost != nil { - cells = append(cells, ods.CurrencyCell(*item.StandardCost)) + if sc, ok := props["standard_cost"]; ok { + if cost, cok := sc.(float64); cok { + cells = append(cells, ods.CurrencyCell(cost)) + } else { + cells = append(cells, ods.StringCell(formatPropertyValue(sc))) + } } else { cells = append(cells, ods.EmptyCell()) } @@ -746,9 +766,27 @@ func (s *Server) HandleProjectSheetODS(w http.ResponseWriter, r *http.Request) { for _, e := range bomEntries { childItem, _ := s.items.GetByPartNumber(ctx, e.ChildPartNumber) unitCost, hasUnitCost := getMetaFloat(e.Metadata, "unit_cost") - if !hasUnitCost && childItem != nil && childItem.StandardCost != nil { - unitCost = *childItem.StandardCost - hasUnitCost = true + if !hasUnitCost && childItem != nil { + // Fall back to standard_cost from revision properties + // Ensure child item props are cached + if _, cached := itemPropsMap[childItem.ID]; !cached { + if revs, err := s.items.GetRevisions(ctx, childItem.ID); err == nil { + for _, rev := range revs { + if rev.RevisionNumber == childItem.CurrentRevision && rev.Properties != nil { + itemPropsMap[childItem.ID] = rev.Properties + break + } + } + } + } + if childRevProps := itemPropsMap[childItem.ID]; childRevProps != nil { + if sc, ok := childRevProps["standard_cost"]; ok { + if cost, cok := sc.(float64); cok { + unitCost = cost + hasUnitCost = true + } + } + } } qty := 0.0 if e.Quantity != nil { @@ -957,7 +995,20 @@ func (s *Server) HandleSheetDiff(w http.ResponseWriter, r *http.Request) { if costStr != "" { costStr = strings.TrimLeft(costStr, "$") if cost, err := strconv.ParseFloat(costStr, 64); err == nil { - if dbItem.StandardCost == nil || *dbItem.StandardCost != cost { + // Compare against standard_cost in revision properties + revisions, _ := s.items.GetRevisions(ctx, dbItem.ID) + var dbCost *float64 + for _, rev := range revisions { + if rev.RevisionNumber == dbItem.CurrentRevision && rev.Properties != nil { + if sc, ok := rev.Properties["standard_cost"]; ok { + if c, cok := sc.(float64); cok { + dbCost = &c + } + } + break + } + } + if dbCost == nil || *dbCost != cost { changes["standard_cost"] = cost } } @@ -986,8 +1037,11 @@ func buildBOMRow(itemLabel string, depth int, source, pn string, item *db.Item, if item != nil { description = item.Description - if sourcingLink == "" && item.SourcingLink != nil { - sourcingLink = *item.SourcingLink + } + // Fall back to sourcing_link from revision properties + if sourcingLink == "" && props != nil { + if sl, ok := props["sourcing_link"]; ok { + sourcingLink = formatPropertyValue(sl) } } 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/api/sse_handler.go b/internal/api/sse_handler.go index 64da978..3734e85 100644 --- a/internal/api/sse_handler.go +++ b/internal/api/sse_handler.go @@ -16,9 +16,12 @@ func (s *Server) HandleEvents(w http.ResponseWriter, r *http.Request) { return } - // Disable the write deadline for this long-lived connection. - // The server's WriteTimeout (15s) would otherwise kill it. + // Disable read and write deadlines for this long-lived connection. + // The server's ReadTimeout/WriteTimeout (15s) would otherwise kill it. rc := http.NewResponseController(w) + if err := rc.SetReadDeadline(time.Time{}); err != nil { + s.logger.Warn().Err(err).Msg("failed to disable read deadline for SSE") + } if err := rc.SetWriteDeadline(time.Time{}); err != nil { s.logger.Warn().Err(err).Msg("failed to disable write deadline for SSE") } diff --git a/internal/db/audit_queries.go b/internal/db/audit_queries.go index 211bd55..5fe2475 100644 --- a/internal/db/audit_queries.go +++ b/internal/db/audit_queries.go @@ -31,7 +31,7 @@ func (r *ItemRepository) ListItemsWithProperties(ctx context.Context, opts Audit query = ` SELECT DISTINCT i.id, i.part_number, i.schema_id, i.item_type, i.description, i.created_at, i.updated_at, i.archived_at, i.current_revision, - i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost, + i.sourcing_type, i.long_description, COALESCE(r.properties, '{}'::jsonb) as properties FROM items i LEFT JOIN revisions r ON r.item_id = i.id AND r.revision_number = i.current_revision @@ -45,7 +45,7 @@ func (r *ItemRepository) ListItemsWithProperties(ctx context.Context, opts Audit query = ` SELECT i.id, i.part_number, i.schema_id, i.item_type, i.description, i.created_at, i.updated_at, i.archived_at, i.current_revision, - i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost, + i.sourcing_type, i.long_description, COALESCE(r.properties, '{}'::jsonb) as properties FROM items i LEFT JOIN revisions r ON r.item_id = i.id AND r.revision_number = i.current_revision @@ -85,7 +85,7 @@ func (r *ItemRepository) ListItemsWithProperties(ctx context.Context, opts Audit err := rows.Scan( &iwp.ID, &iwp.PartNumber, &iwp.SchemaID, &iwp.ItemType, &iwp.Description, &iwp.CreatedAt, &iwp.UpdatedAt, &iwp.ArchivedAt, &iwp.CurrentRevision, - &iwp.SourcingType, &iwp.SourcingLink, &iwp.LongDescription, &iwp.StandardCost, + &iwp.SourcingType, &iwp.LongDescription, &propsJSON, ) if err != nil { diff --git a/internal/db/items.go b/internal/db/items.go index e8c28f0..d1b9015 100644 --- a/internal/db/items.go +++ b/internal/db/items.go @@ -24,11 +24,9 @@ type Item struct { CADFilePath *string CreatedBy *string UpdatedBy *string - SourcingType string // "manufactured" or "purchased" - SourcingLink *string // URL to supplier/datasheet - LongDescription *string // extended description - StandardCost *float64 // baseline unit cost - ThumbnailKey *string // MinIO key for item thumbnail + SourcingType string // "manufactured" or "purchased" + LongDescription *string // extended description + ThumbnailKey *string // MinIO key for item thumbnail } // Revision represents a revision record. @@ -96,11 +94,11 @@ func (r *ItemRepository) Create(ctx context.Context, item *Item, properties map[ } err := tx.QueryRow(ctx, ` INSERT INTO items (part_number, schema_id, item_type, description, created_by, - sourcing_type, sourcing_link, long_description, standard_cost) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + sourcing_type, long_description) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at, current_revision `, item.PartNumber, item.SchemaID, item.ItemType, item.Description, item.CreatedBy, - sourcingType, item.SourcingLink, item.LongDescription, item.StandardCost, + sourcingType, item.LongDescription, ).Scan( &item.ID, &item.CreatedAt, &item.UpdatedAt, &item.CurrentRevision, ) @@ -133,7 +131,7 @@ func (r *ItemRepository) GetByPartNumber(ctx context.Context, partNumber string) SELECT id, part_number, schema_id, item_type, description, created_at, updated_at, archived_at, current_revision, cad_synced_at, cad_file_path, - sourcing_type, sourcing_link, long_description, standard_cost, + sourcing_type, long_description, thumbnail_key FROM items WHERE part_number = $1 AND archived_at IS NULL @@ -141,7 +139,7 @@ func (r *ItemRepository) GetByPartNumber(ctx context.Context, partNumber string) &item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description, &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, &item.CADSyncedAt, &item.CADFilePath, - &item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost, + &item.SourcingType, &item.LongDescription, &item.ThumbnailKey, ) if err == pgx.ErrNoRows { @@ -160,7 +158,7 @@ func (r *ItemRepository) GetByID(ctx context.Context, id string) (*Item, error) SELECT id, part_number, schema_id, item_type, description, created_at, updated_at, archived_at, current_revision, cad_synced_at, cad_file_path, - sourcing_type, sourcing_link, long_description, standard_cost, + sourcing_type, long_description, thumbnail_key FROM items WHERE id = $1 @@ -168,7 +166,7 @@ func (r *ItemRepository) GetByID(ctx context.Context, id string) (*Item, error) &item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description, &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, &item.CADSyncedAt, &item.CADFilePath, - &item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost, + &item.SourcingType, &item.LongDescription, &item.ThumbnailKey, ) if err == pgx.ErrNoRows { @@ -192,7 +190,7 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e query = ` SELECT DISTINCT i.id, i.part_number, i.schema_id, i.item_type, i.description, i.created_at, i.updated_at, i.archived_at, i.current_revision, - i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost, + i.sourcing_type, i.long_description, i.thumbnail_key FROM items i JOIN item_projects ip ON ip.item_id = i.id @@ -205,7 +203,7 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e query = ` SELECT id, part_number, schema_id, item_type, description, created_at, updated_at, archived_at, current_revision, - sourcing_type, sourcing_link, long_description, standard_cost, + sourcing_type, long_description, thumbnail_key FROM items WHERE archived_at IS NULL @@ -257,7 +255,7 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e err := rows.Scan( &item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description, &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, - &item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost, + &item.SourcingType, &item.LongDescription, &item.ThumbnailKey, ) if err != nil { @@ -659,9 +657,7 @@ type UpdateItemFields struct { Description string UpdatedBy *string SourcingType *string - SourcingLink *string LongDescription *string - StandardCost *float64 } // Update modifies an item's fields. The UUID remains stable. @@ -670,16 +666,12 @@ func (r *ItemRepository) Update(ctx context.Context, id string, fields UpdateIte UPDATE items SET part_number = $2, item_type = $3, description = $4, updated_by = $5, sourcing_type = COALESCE($6, sourcing_type), - sourcing_link = CASE WHEN $7::boolean THEN $8 ELSE sourcing_link END, - long_description = CASE WHEN $9::boolean THEN $10 ELSE long_description END, - standard_cost = CASE WHEN $11::boolean THEN $12 ELSE standard_cost END, + long_description = CASE WHEN $7::boolean THEN $8 ELSE long_description END, updated_at = now() WHERE id = $1 AND archived_at IS NULL `, id, fields.PartNumber, fields.ItemType, fields.Description, fields.UpdatedBy, fields.SourcingType, - fields.SourcingLink != nil, fields.SourcingLink, fields.LongDescription != nil, fields.LongDescription, - fields.StandardCost != nil, fields.StandardCost, ) if err != nil { return fmt.Errorf("updating item: %w", err) diff --git a/internal/db/items_test.go b/internal/db/items_test.go index 9c7efc6..dca2054 100644 --- a/internal/db/items_test.go +++ b/internal/db/items_test.go @@ -134,12 +134,10 @@ func TestItemUpdate(t *testing.T) { t.Fatalf("Create: %v", err) } - cost := 42.50 err := repo.Update(ctx, item.ID, UpdateItemFields{ - PartNumber: "UPD-001", - ItemType: "part", - Description: "updated", - StandardCost: &cost, + PartNumber: "UPD-001", + ItemType: "part", + Description: "updated", }) if err != nil { t.Fatalf("Update: %v", err) @@ -149,9 +147,6 @@ func TestItemUpdate(t *testing.T) { if got.Description != "updated" { t.Errorf("description: got %q, want %q", got.Description, "updated") } - if got.StandardCost == nil || *got.StandardCost != 42.50 { - t.Errorf("standard_cost: got %v, want 42.50", got.StandardCost) - } } func TestItemArchiveUnarchive(t *testing.T) { diff --git a/internal/db/projects.go b/internal/db/projects.go index cee95d1..c79e9c5 100644 --- a/internal/db/projects.go +++ b/internal/db/projects.go @@ -240,7 +240,7 @@ func (r *ProjectRepository) GetItemsForProject(ctx context.Context, projectID st SELECT i.id, i.part_number, i.schema_id, i.item_type, i.description, i.created_at, i.updated_at, i.archived_at, i.current_revision, i.cad_synced_at, i.cad_file_path, - i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost, + i.sourcing_type, i.long_description, i.thumbnail_key FROM items i JOIN item_projects ip ON ip.item_id = i.id @@ -259,7 +259,7 @@ func (r *ProjectRepository) GetItemsForProject(ctx context.Context, projectID st &item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description, &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, &item.CADSyncedAt, &item.CADFilePath, - &item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost, + &item.SourcingType, &item.LongDescription, &item.ThumbnailKey, ); err != nil { return nil, err 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/migrations/013_move_cost_sourcing_to_props.sql b/migrations/013_move_cost_sourcing_to_props.sql new file mode 100644 index 0000000..6d2307c --- /dev/null +++ b/migrations/013_move_cost_sourcing_to_props.sql @@ -0,0 +1,25 @@ +-- Migration 013: Move sourcing_link and standard_cost to revision properties +-- +-- These fields are being deduplicated from the items table into revision +-- properties (JSONB). The YAML property_schemas.defaults already defines +-- them, so they belong in the properties system rather than as standalone +-- columns. + +-- Step 1: Copy sourcing_link and standard_cost from items into the current +-- revision's properties JSONB for every item that has non-null values. +UPDATE revisions r +SET properties = r.properties + || CASE WHEN i.sourcing_link IS NOT NULL + THEN jsonb_build_object('sourcing_link', i.sourcing_link) + ELSE '{}'::jsonb END + || CASE WHEN i.standard_cost IS NOT NULL + THEN jsonb_build_object('standard_cost', i.standard_cost) + ELSE '{}'::jsonb END +FROM items i +WHERE r.item_id = i.id + AND r.revision_number = i.current_revision + AND (i.sourcing_link IS NOT NULL OR i.standard_cost IS NOT NULL); + +-- Step 2: Drop the columns from the items table. +ALTER TABLE items DROP COLUMN sourcing_link; +ALTER TABLE items DROP COLUMN standard_cost; 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 e6dc1f0..b5e33b2 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -16,9 +16,7 @@ export interface Item { created_at: string; updated_at: string; sourcing_type: string; - sourcing_link?: string; long_description?: string; - standard_cost?: number; file_count: number; files_total_size: number; properties?: Record; @@ -170,9 +168,7 @@ export interface CreateItemRequest { projects?: string[]; properties?: Record; sourcing_type?: string; - sourcing_link?: string; long_description?: string; - standard_cost?: number; } export interface UpdateItemRequest { @@ -182,9 +178,7 @@ export interface UpdateItemRequest { properties?: Record; comment?: string; sourcing_type?: string; - sourcing_link?: string; long_description?: string; - standard_cost?: number; } export interface CreateRevisionRequest { @@ -254,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/audit/AuditDetailPanel.tsx b/web/src/components/audit/AuditDetailPanel.tsx index 8f35940..dc83757 100644 --- a/web/src/components/audit/AuditDetailPanel.tsx +++ b/web/src/components/audit/AuditDetailPanel.tsx @@ -1,10 +1,6 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { get, put } from "../../api/client"; -import type { - AuditItemResult, - AuditFieldResult, - Item, -} from "../../api/types"; +import type { AuditItemResult, AuditFieldResult, Item } from "../../api/types"; const tierColors: Record = { critical: "var(--ctp-red)", @@ -18,8 +14,6 @@ const tierColors: Record = { const itemFields = new Set([ "description", "sourcing_type", - "sourcing_link", - "standard_cost", "long_description", ]); @@ -83,12 +77,9 @@ export function AuditDetailPanel({ void fetchData(); }, [fetchData]); - const handleFieldChange = useCallback( - (key: string, value: string) => { - setEdits((prev) => ({ ...prev, [key]: value })); - }, - [], - ); + const handleFieldChange = useCallback((key: string, value: string) => { + setEdits((prev) => ({ ...prev, [key]: value })); + }, []); const saveChanges = useCallback(async () => { if (!item || Object.keys(edits).length === 0) return; @@ -102,18 +93,14 @@ export function AuditDetailPanel({ for (const [key, value] of Object.entries(edits)) { if (itemFields.has(key)) { - if (key === "standard_cost") { - const num = parseFloat(value); - itemUpdate[key] = isNaN(num) ? undefined : num; - } else { - itemUpdate[key] = value || undefined; - } + itemUpdate[key] = value || undefined; } else { // Attempt number coercion for property fields. const num = parseFloat(value); - propUpdate[key] = !isNaN(num) && String(num) === value.trim() - ? num - : value || undefined; + propUpdate[key] = + !isNaN(num) && String(num) === value.trim() + ? num + : value || undefined; } } @@ -123,7 +110,10 @@ export function AuditDetailPanel({ const payload: Record = { ...itemUpdate, ...(hasProps - ? { properties: { ...currentProps, ...propUpdate }, comment: "Audit field update" } + ? { + properties: { ...currentProps, ...propUpdate }, + comment: "Audit field update", + } : {}), }; @@ -423,9 +413,7 @@ function FieldRow({ ? String(field.value) : ""; - const borderColor = field.filled - ? "var(--ctp-green)" - : "var(--ctp-red)"; + const borderColor = field.filled ? "var(--ctp-green)" : "var(--ctp-red)"; const label = field.key .replace(/_/g, " ") @@ -469,9 +457,7 @@ function FieldRow({ style={{ flex: 1, fontSize: "0.8rem", - color: field.filled - ? "var(--ctp-text)" - : "var(--ctp-subtext0)", + color: field.filled ? "var(--ctp-text)" : "var(--ctp-subtext0)", fontStyle: field.filled ? "normal" : "italic", }} > 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,22 +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 [sourcingLink, setSourcingLink] = useState(""); - const [longDescription, setLongDescription] = useState(""); - const [standardCost, setStandardCost] = useState(""); + const [fields, setFields] = useState>({}); const [selectedProjects, setSelectedProjects] = useState([]); - const [catProps, setCatProps] = useState>({}); - const [catPropDefs, setCatPropDefs] = useState< - Record - >({}); // Attachments. const [attachments, setAttachments] = useState([]); @@ -44,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 => { @@ -90,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, @@ -155,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; } @@ -168,14 +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, - sourcing_link: sourcingLink || undefined, - long_description: longDescription || undefined, - standard_cost: standardCost ? Number(standardCost) : undefined, + sourcing_type: getField("sourcing_type") || undefined, + long_description: getField("long_description") || undefined, }); const pn = result.part_number; @@ -219,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 */} @@ -262,130 +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 -
- - - - - setStandardCost(e.target.value)} - style={inputStyle} - placeholder="0.00" - /> - -
- - setSourcingLink(e.target.value)} - style={inputStyle} - placeholder="https://..." - /> - -
-
- - {/* Details section */} - Details - -