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..5a205db 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -256,9 +256,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 +271,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 +425,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 +551,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 +582,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 +1194,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/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/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/web/src/api/types.ts b/web/src/api/types.ts index e6dc1f0..290151a 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 { 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/CreateItemPane.tsx b/web/src/components/items/CreateItemPane.tsx index 485eaaa..fc85901 100644 --- a/web/src/components/items/CreateItemPane.tsx +++ b/web/src/components/items/CreateItemPane.tsx @@ -26,9 +26,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { const [category, setCategory] = useState(""); const [description, setDescription] = useState(""); const [sourcingType, setSourcingType] = useState("manufactured"); - const [sourcingLink, setSourcingLink] = useState(""); const [longDescription, setLongDescription] = useState(""); - const [standardCost, setStandardCost] = useState(""); const [selectedProjects, setSelectedProjects] = useState([]); const [catProps, setCatProps] = useState>({}); const [catPropDefs, setCatPropDefs] = useState< @@ -173,9 +171,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { 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, }); const pn = result.part_number; @@ -309,26 +305,6 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { - - setStandardCost(e.target.value)} - style={inputStyle} - placeholder="0.00" - /> - -
- - setSourcingLink(e.target.value)} - style={inputStyle} - placeholder="https://..." - /> - -
{/* Details section */} diff --git a/web/src/components/items/EditItemPane.tsx b/web/src/components/items/EditItemPane.tsx index 9660cbd..19d5d4e 100644 --- a/web/src/components/items/EditItemPane.tsx +++ b/web/src/components/items/EditItemPane.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect } from 'react'; -import { get, put } from '../../api/client'; -import type { Item } from '../../api/types'; +import { useState, useEffect } from "react"; +import { get, put } from "../../api/client"; +import type { Item } from "../../api/types"; interface EditItemPaneProps { partNumber: string; @@ -8,17 +8,19 @@ interface EditItemPaneProps { onCancel: () => void; } -export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProps) { +export function EditItemPane({ + partNumber, + onSaved, + onCancel, +}: EditItemPaneProps) { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const [pn, setPN] = useState(''); - const [itemType, setItemType] = useState(''); - const [description, setDescription] = useState(''); - const [sourcingType, setSourcingType] = useState(''); - const [sourcingLink, setSourcingLink] = useState(''); - const [longDescription, setLongDescription] = useState(''); - const [standardCost, setStandardCost] = useState(''); + const [pn, setPN] = useState(""); + const [itemType, setItemType] = useState(""); + const [description, setDescription] = useState(""); + const [sourcingType, setSourcingType] = useState(""); + const [longDescription, setLongDescription] = useState(""); useEffect(() => { setLoading(true); @@ -27,12 +29,10 @@ export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProp setPN(item.part_number); setItemType(item.item_type); setDescription(item.description); - setSourcingType(item.sourcing_type ?? ''); - setSourcingLink(item.sourcing_link ?? ''); - setLongDescription(item.long_description ?? ''); - setStandardCost(item.standard_cost != null ? String(item.standard_cost) : ''); + setSourcingType(item.sourcing_type ?? ""); + setLongDescription(item.long_description ?? ""); }) - .catch(() => setError('Failed to load item')) + .catch(() => setError("Failed to load item")) .finally(() => setLoading(false)); }, [partNumber]); @@ -45,54 +45,97 @@ export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProp item_type: itemType || undefined, description: description || undefined, sourcing_type: sourcingType || undefined, - sourcing_link: sourcingLink || undefined, long_description: longDescription || undefined, - standard_cost: standardCost ? Number(standardCost) : undefined, }); onSaved(); } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to save item'); + setError(e instanceof Error ? e.message : "Failed to save item"); } finally { setSaving(false); } }; - if (loading) return
Loading...
; + if (loading) + return ( +
+ Loading... +
+ ); return ( -
-
- Edit {partNumber} +
+
+ + Edit {partNumber} + - + -
-
+
{error && ( -
+
{error}
)} - setPN(e.target.value)} style={inputStyle} /> + setPN(e.target.value)} + style={inputStyle} + /> - setItemType(e.target.value)} + style={inputStyle} + > @@ -101,11 +144,19 @@ export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProp - setDescription(e.target.value)} style={inputStyle} /> + setDescription(e.target.value)} + style={inputStyle} + /> - setSourcingType(e.target.value)} + style={inputStyle} + > @@ -113,38 +164,57 @@ export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProp - - setSourcingLink(e.target.value)} style={inputStyle} placeholder="URL" /> - - - - setStandardCost(e.target.value)} style={inputStyle} placeholder="0.00" /> - - -