refactor: move sourcing_link and standard_cost from item columns to revision properties

- Add migration 013 to copy sourcing_link/standard_cost values into
  current revision properties JSONB and drop the columns from items table
- Remove SourcingLink/StandardCost from Go Item struct and all DB queries
  (items.go, audit_queries.go, projects.go)
- Remove from API request/response structs and handlers
- Update CSV/ODS/BOM export/import to read these from revision properties
- Update audit handlers to score as regular property fields
- Remove from frontend Item type and hardcoded form fields
- MainTab now reads sourcing_link/standard_cost from item.properties
- CreateItemPane/EditItemPane no longer have dedicated fields for these;
  they will be rendered as schema-driven property fields
This commit is contained in:
2026-02-11 09:50:31 -06:00
parent 2157b40d06
commit b3c748ef10
16 changed files with 483 additions and 300 deletions

View File

@@ -114,8 +114,6 @@ var manufacturedWeights = map[string]float64{
var itemLevelFields = map[string]bool{ var itemLevelFields = map[string]bool{
"description": true, "description": true,
"sourcing_type": true, "sourcing_type": true,
"sourcing_link": true,
"standard_cost": true,
"long_description": true, "long_description": true,
} }
@@ -258,18 +256,6 @@ func scoreItem(
processField("description", "item", "string", item.Description) processField("description", "item", "string", item.Description)
processField("sourcing_type", "item", "string", item.SourcingType) 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 var longDescVal any
if item.LongDescription != nil { if item.LongDescription != nil {
longDescVal = *item.LongDescription longDescVal = *item.LongDescription
@@ -287,10 +273,6 @@ func scoreItem(
if skipFields[key] || itemLevelFields[key] { if skipFields[key] || itemLevelFields[key] {
continue continue
} }
// sourcing_link and standard_cost are already handled at item level.
if key == "sourcing_link" || key == "standard_cost" {
continue
}
value := item.Properties[key] value := item.Properties[key]
processField(key, "property", def.Type, value) processField(key, "property", def.Type, value)
} }

View File

@@ -573,8 +573,20 @@ func (s *Server) HandleGetBOMCost(w http.ResponseWriter, r *http.Request) {
for i, e := range entries { for i, e := range entries {
unitCost := 0.0 unitCost := 0.0
leaf, err := s.items.GetByID(ctx, e.ItemID) leaf, err := s.items.GetByID(ctx, e.ItemID)
if err == nil && leaf != nil && leaf.StandardCost != nil { if err == nil && leaf != nil {
unitCost = *leaf.StandardCost // 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 extCost := e.TotalQuantity * unitCost
totalCost += extCost totalCost += extCost

View File

@@ -51,9 +51,7 @@ var csvColumns = []string{
"category", "category",
"projects", // comma-separated project codes "projects", // comma-separated project codes
"sourcing_type", "sourcing_type",
"sourcing_link",
"long_description", "long_description",
"standard_cost",
} }
// HandleExportCSV exports items to CSV format. // HandleExportCSV exports items to CSV format.
@@ -158,14 +156,8 @@ func (s *Server) HandleExportCSV(w http.ResponseWriter, r *http.Request) {
row[6] = category row[6] = category
row[7] = projectCodes row[7] = projectCodes
row[8] = item.SourcingType row[8] = item.SourcingType
if item.SourcingLink != nil {
row[9] = *item.SourcingLink
}
if item.LongDescription != nil { if item.LongDescription != nil {
row[10] = *item.LongDescription row[9] = *item.LongDescription
}
if item.StandardCost != nil {
row[11] = strconv.FormatFloat(*item.StandardCost, 'f', -1, 64)
} }
// Property columns // Property columns
@@ -366,9 +358,17 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
// Parse extended fields // Parse extended fields
sourcingType := getCSVValue(record, colIndex, "sourcing_type") sourcingType := getCSVValue(record, colIndex, "sourcing_type")
sourcingLink := getCSVValue(record, colIndex, "sourcing_link")
longDesc := getCSVValue(record, colIndex, "long_description") 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 // Create item
item := &db.Item{ item := &db.Item{
@@ -382,17 +382,9 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
if sourcingType != "" { if sourcingType != "" {
item.SourcingType = sourcingType item.SourcingType = sourcingType
} }
if sourcingLink != "" {
item.SourcingLink = &sourcingLink
}
if longDesc != "" { if longDesc != "" {
item.LongDescription = &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 { if err := s.items.Create(ctx, item, properties); err != nil {
result.Errors = append(result.Errors, CSVImportErr{ result.Errors = append(result.Errors, CSVImportErr{
@@ -585,9 +577,7 @@ func isStandardColumn(col string) bool {
"objects": true, // FreeCAD objects data - skip on import "objects": true, // FreeCAD objects data - skip on import
"archived_at": true, "archived_at": true,
"sourcing_type": true, "sourcing_type": true,
"sourcing_link": true,
"long_description": true, "long_description": true,
"standard_cost": true,
} }
return standardCols[col] return standardCols[col]
} }

View File

@@ -256,9 +256,7 @@ type ItemResponse struct {
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
SourcingType string `json:"sourcing_type"` SourcingType string `json:"sourcing_type"`
SourcingLink *string `json:"sourcing_link,omitempty"`
LongDescription *string `json:"long_description,omitempty"` LongDescription *string `json:"long_description,omitempty"`
StandardCost *float64 `json:"standard_cost,omitempty"`
ThumbnailKey *string `json:"thumbnail_key,omitempty"` ThumbnailKey *string `json:"thumbnail_key,omitempty"`
FileCount int `json:"file_count"` FileCount int `json:"file_count"`
FilesTotalSize int64 `json:"files_total_size"` FilesTotalSize int64 `json:"files_total_size"`
@@ -273,9 +271,7 @@ type CreateItemRequest struct {
Projects []string `json:"projects,omitempty"` Projects []string `json:"projects,omitempty"`
Properties map[string]any `json:"properties,omitempty"` Properties map[string]any `json:"properties,omitempty"`
SourcingType string `json:"sourcing_type,omitempty"` SourcingType string `json:"sourcing_type,omitempty"`
SourcingLink *string `json:"sourcing_link,omitempty"`
LongDescription *string `json:"long_description,omitempty"` LongDescription *string `json:"long_description,omitempty"`
StandardCost *float64 `json:"standard_cost,omitempty"`
} }
// HandleListItems lists items with optional filtering. // HandleListItems lists items with optional filtering.
@@ -429,9 +425,7 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
ItemType: itemType, ItemType: itemType,
Description: req.Description, Description: req.Description,
SourcingType: req.SourcingType, SourcingType: req.SourcingType,
SourcingLink: req.SourcingLink,
LongDescription: req.LongDescription, LongDescription: req.LongDescription,
StandardCost: req.StandardCost,
} }
if user := auth.UserFromContext(ctx); user != nil { if user := auth.UserFromContext(ctx); user != nil {
item.CreatedBy = &user.Username item.CreatedBy = &user.Username
@@ -557,9 +551,7 @@ type UpdateItemRequest struct {
Properties map[string]any `json:"properties,omitempty"` Properties map[string]any `json:"properties,omitempty"`
Comment string `json:"comment,omitempty"` Comment string `json:"comment,omitempty"`
SourcingType *string `json:"sourcing_type,omitempty"` SourcingType *string `json:"sourcing_type,omitempty"`
SourcingLink *string `json:"sourcing_link,omitempty"`
LongDescription *string `json:"long_description,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. // 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, ItemType: item.ItemType,
Description: item.Description, Description: item.Description,
SourcingType: req.SourcingType, SourcingType: req.SourcingType,
SourcingLink: req.SourcingLink,
LongDescription: req.LongDescription, LongDescription: req.LongDescription,
StandardCost: req.StandardCost,
} }
if req.PartNumber != "" { if req.PartNumber != "" {
@@ -1204,9 +1194,7 @@ func itemToResponse(item *db.Item) ItemResponse {
CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
SourcingType: item.SourcingType, SourcingType: item.SourcingType,
SourcingLink: item.SourcingLink,
LongDescription: item.LongDescription, LongDescription: item.LongDescription,
StandardCost: item.StandardCost,
ThumbnailKey: item.ThumbnailKey, ThumbnailKey: item.ThumbnailKey,
} }
} }

View File

@@ -138,21 +138,11 @@ func (s *Server) HandleExportODS(w http.ResponseWriter, r *http.Request) {
ods.StringCell(item.SourcingType), 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 { if item.LongDescription != nil {
cells = append(cells, ods.StringCell(*item.LongDescription)) cells = append(cells, ods.StringCell(*item.LongDescription))
} else { } else {
cells = append(cells, ods.EmptyCell()) cells = append(cells, ods.EmptyCell())
} }
if item.StandardCost != nil {
cells = append(cells, ods.CurrencyCell(*item.StandardCost))
} else {
cells = append(cells, ods.EmptyCell())
}
// Property columns // Property columns
if includeProps { if includeProps {
@@ -419,6 +409,16 @@ func (s *Server) HandleImportODS(w http.ResponseWriter, r *http.Request) {
longDesc := getCellValue("long_description") longDesc := getCellValue("long_description")
stdCostStr := getCellValue("standard_cost") 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{ item := &db.Item{
PartNumber: partNumber, PartNumber: partNumber,
ItemType: itemType, ItemType: itemType,
@@ -430,17 +430,9 @@ func (s *Server) HandleImportODS(w http.ResponseWriter, r *http.Request) {
if sourcingType != "" { if sourcingType != "" {
item.SourcingType = sourcingType item.SourcingType = sourcingType
} }
if sourcingLink != "" {
item.SourcingLink = &sourcingLink
}
if longDesc != "" { if longDesc != "" {
item.LongDescription = &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 { if err := s.items.Create(ctx, item, properties); err != nil {
result.Errors = append(result.Errors, CSVImportErr{ 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) childItem, _ := s.items.GetByPartNumber(ctx, e.ChildPartNumber)
unitCost, hasUnitCost := getMetaFloat(e.Metadata, "unit_cost") unitCost, hasUnitCost := getMetaFloat(e.Metadata, "unit_cost")
if !hasUnitCost && childItem != nil && childItem.StandardCost != nil { if !hasUnitCost && childItem != nil {
unitCost = *childItem.StandardCost // Fall back to standard_cost from revision properties
hasUnitCost = true 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 qty := 0.0
@@ -682,6 +681,21 @@ func (s *Server) HandleProjectSheetODS(w http.ResponseWriter, r *http.Request) {
return 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 // Sheet 1: Items list
itemHeaders := []string{ itemHeaders := []string{
"PN", "Type", "Description", "Revision", "Category", "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}) itemRows = append(itemRows, ods.Row{Cells: itemHeaderCells})
for _, item := range items { for _, item := range items {
props := itemPropsMap[item.ID]
cells := []ods.Cell{ cells := []ods.Cell{
ods.StringCell(item.PartNumber), ods.StringCell(item.PartNumber),
ods.StringCell(item.ItemType), 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(parseCategory(item.PartNumber)),
ods.StringCell(item.SourcingType), ods.StringCell(item.SourcingType),
} }
if item.SourcingLink != nil { if sl, ok := props["sourcing_link"]; ok {
cells = append(cells, ods.StringCell(*item.SourcingLink)) cells = append(cells, ods.StringCell(formatPropertyValue(sl)))
} else { } else {
cells = append(cells, ods.EmptyCell()) cells = append(cells, ods.EmptyCell())
} }
if item.StandardCost != nil { if sc, ok := props["standard_cost"]; ok {
cells = append(cells, ods.CurrencyCell(*item.StandardCost)) if cost, cok := sc.(float64); cok {
cells = append(cells, ods.CurrencyCell(cost))
} else {
cells = append(cells, ods.StringCell(formatPropertyValue(sc)))
}
} else { } else {
cells = append(cells, ods.EmptyCell()) cells = append(cells, ods.EmptyCell())
} }
@@ -746,9 +766,27 @@ func (s *Server) HandleProjectSheetODS(w http.ResponseWriter, r *http.Request) {
for _, e := range bomEntries { for _, e := range bomEntries {
childItem, _ := s.items.GetByPartNumber(ctx, e.ChildPartNumber) childItem, _ := s.items.GetByPartNumber(ctx, e.ChildPartNumber)
unitCost, hasUnitCost := getMetaFloat(e.Metadata, "unit_cost") unitCost, hasUnitCost := getMetaFloat(e.Metadata, "unit_cost")
if !hasUnitCost && childItem != nil && childItem.StandardCost != nil { if !hasUnitCost && childItem != nil {
unitCost = *childItem.StandardCost // Fall back to standard_cost from revision properties
hasUnitCost = true // 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 qty := 0.0
if e.Quantity != nil { if e.Quantity != nil {
@@ -957,7 +995,20 @@ func (s *Server) HandleSheetDiff(w http.ResponseWriter, r *http.Request) {
if costStr != "" { if costStr != "" {
costStr = strings.TrimLeft(costStr, "$") costStr = strings.TrimLeft(costStr, "$")
if cost, err := strconv.ParseFloat(costStr, 64); err == nil { 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 changes["standard_cost"] = cost
} }
} }
@@ -986,8 +1037,11 @@ func buildBOMRow(itemLabel string, depth int, source, pn string, item *db.Item,
if item != nil { if item != nil {
description = item.Description 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)
} }
} }

View File

@@ -31,7 +31,7 @@ func (r *ItemRepository) ListItemsWithProperties(ctx context.Context, opts Audit
query = ` query = `
SELECT DISTINCT i.id, i.part_number, i.schema_id, i.item_type, i.description, 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.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 COALESCE(r.properties, '{}'::jsonb) as properties
FROM items i FROM items i
LEFT JOIN revisions r ON r.item_id = i.id AND r.revision_number = i.current_revision 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 = ` query = `
SELECT i.id, i.part_number, i.schema_id, i.item_type, i.description, 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.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 COALESCE(r.properties, '{}'::jsonb) as properties
FROM items i FROM items i
LEFT JOIN revisions r ON r.item_id = i.id AND r.revision_number = i.current_revision 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( err := rows.Scan(
&iwp.ID, &iwp.PartNumber, &iwp.SchemaID, &iwp.ItemType, &iwp.Description, &iwp.ID, &iwp.PartNumber, &iwp.SchemaID, &iwp.ItemType, &iwp.Description,
&iwp.CreatedAt, &iwp.UpdatedAt, &iwp.ArchivedAt, &iwp.CurrentRevision, &iwp.CreatedAt, &iwp.UpdatedAt, &iwp.ArchivedAt, &iwp.CurrentRevision,
&iwp.SourcingType, &iwp.SourcingLink, &iwp.LongDescription, &iwp.StandardCost, &iwp.SourcingType, &iwp.LongDescription,
&propsJSON, &propsJSON,
) )
if err != nil { if err != nil {

View File

@@ -24,11 +24,9 @@ type Item struct {
CADFilePath *string CADFilePath *string
CreatedBy *string CreatedBy *string
UpdatedBy *string UpdatedBy *string
SourcingType string // "manufactured" or "purchased" SourcingType string // "manufactured" or "purchased"
SourcingLink *string // URL to supplier/datasheet LongDescription *string // extended description
LongDescription *string // extended description ThumbnailKey *string // MinIO key for item thumbnail
StandardCost *float64 // baseline unit cost
ThumbnailKey *string // MinIO key for item thumbnail
} }
// Revision represents a revision record. // Revision represents a revision record.
@@ -96,11 +94,11 @@ func (r *ItemRepository) Create(ctx context.Context, item *Item, properties map[
} }
err := tx.QueryRow(ctx, ` err := tx.QueryRow(ctx, `
INSERT INTO items (part_number, schema_id, item_type, description, created_by, INSERT INTO items (part_number, schema_id, item_type, description, created_by,
sourcing_type, sourcing_link, long_description, standard_cost) sourcing_type, long_description)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at, updated_at, current_revision RETURNING id, created_at, updated_at, current_revision
`, item.PartNumber, item.SchemaID, item.ItemType, item.Description, item.CreatedBy, `, item.PartNumber, item.SchemaID, item.ItemType, item.Description, item.CreatedBy,
sourcingType, item.SourcingLink, item.LongDescription, item.StandardCost, sourcingType, item.LongDescription,
).Scan( ).Scan(
&item.ID, &item.CreatedAt, &item.UpdatedAt, &item.CurrentRevision, &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, SELECT id, part_number, schema_id, item_type, description,
created_at, updated_at, archived_at, current_revision, created_at, updated_at, archived_at, current_revision,
cad_synced_at, cad_file_path, cad_synced_at, cad_file_path,
sourcing_type, sourcing_link, long_description, standard_cost, sourcing_type, long_description,
thumbnail_key thumbnail_key
FROM items FROM items
WHERE part_number = $1 AND archived_at IS NULL 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.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description,
&item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision,
&item.CADSyncedAt, &item.CADFilePath, &item.CADSyncedAt, &item.CADFilePath,
&item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost, &item.SourcingType, &item.LongDescription,
&item.ThumbnailKey, &item.ThumbnailKey,
) )
if err == pgx.ErrNoRows { 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, SELECT id, part_number, schema_id, item_type, description,
created_at, updated_at, archived_at, current_revision, created_at, updated_at, archived_at, current_revision,
cad_synced_at, cad_file_path, cad_synced_at, cad_file_path,
sourcing_type, sourcing_link, long_description, standard_cost, sourcing_type, long_description,
thumbnail_key thumbnail_key
FROM items FROM items
WHERE id = $1 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.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description,
&item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision,
&item.CADSyncedAt, &item.CADFilePath, &item.CADSyncedAt, &item.CADFilePath,
&item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost, &item.SourcingType, &item.LongDescription,
&item.ThumbnailKey, &item.ThumbnailKey,
) )
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
@@ -192,7 +190,7 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e
query = ` query = `
SELECT DISTINCT i.id, i.part_number, i.schema_id, i.item_type, i.description, 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.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 i.thumbnail_key
FROM items i FROM items i
JOIN item_projects ip ON ip.item_id = i.id 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 = ` query = `
SELECT id, part_number, schema_id, item_type, description, SELECT id, part_number, schema_id, item_type, description,
created_at, updated_at, archived_at, current_revision, created_at, updated_at, archived_at, current_revision,
sourcing_type, sourcing_link, long_description, standard_cost, sourcing_type, long_description,
thumbnail_key thumbnail_key
FROM items FROM items
WHERE archived_at IS NULL WHERE archived_at IS NULL
@@ -257,7 +255,7 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e
err := rows.Scan( err := rows.Scan(
&item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description, &item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description,
&item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision,
&item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost, &item.SourcingType, &item.LongDescription,
&item.ThumbnailKey, &item.ThumbnailKey,
) )
if err != nil { if err != nil {
@@ -659,9 +657,7 @@ type UpdateItemFields struct {
Description string Description string
UpdatedBy *string UpdatedBy *string
SourcingType *string SourcingType *string
SourcingLink *string
LongDescription *string LongDescription *string
StandardCost *float64
} }
// Update modifies an item's fields. The UUID remains stable. // 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 UPDATE items
SET part_number = $2, item_type = $3, description = $4, updated_by = $5, SET part_number = $2, item_type = $3, description = $4, updated_by = $5,
sourcing_type = COALESCE($6, sourcing_type), sourcing_type = COALESCE($6, sourcing_type),
sourcing_link = CASE WHEN $7::boolean THEN $8 ELSE sourcing_link END, long_description = CASE WHEN $7::boolean THEN $8 ELSE long_description 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,
updated_at = now() updated_at = now()
WHERE id = $1 AND archived_at IS NULL WHERE id = $1 AND archived_at IS NULL
`, id, fields.PartNumber, fields.ItemType, fields.Description, fields.UpdatedBy, `, id, fields.PartNumber, fields.ItemType, fields.Description, fields.UpdatedBy,
fields.SourcingType, fields.SourcingType,
fields.SourcingLink != nil, fields.SourcingLink,
fields.LongDescription != nil, fields.LongDescription, fields.LongDescription != nil, fields.LongDescription,
fields.StandardCost != nil, fields.StandardCost,
) )
if err != nil { if err != nil {
return fmt.Errorf("updating item: %w", err) return fmt.Errorf("updating item: %w", err)

View File

@@ -134,12 +134,10 @@ func TestItemUpdate(t *testing.T) {
t.Fatalf("Create: %v", err) t.Fatalf("Create: %v", err)
} }
cost := 42.50
err := repo.Update(ctx, item.ID, UpdateItemFields{ err := repo.Update(ctx, item.ID, UpdateItemFields{
PartNumber: "UPD-001", PartNumber: "UPD-001",
ItemType: "part", ItemType: "part",
Description: "updated", Description: "updated",
StandardCost: &cost,
}) })
if err != nil { if err != nil {
t.Fatalf("Update: %v", err) t.Fatalf("Update: %v", err)
@@ -149,9 +147,6 @@ func TestItemUpdate(t *testing.T) {
if got.Description != "updated" { if got.Description != "updated" {
t.Errorf("description: got %q, want %q", 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) { func TestItemArchiveUnarchive(t *testing.T) {

View File

@@ -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, 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.created_at, i.updated_at, i.archived_at, i.current_revision,
i.cad_synced_at, i.cad_file_path, 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 i.thumbnail_key
FROM items i FROM items i
JOIN item_projects ip ON ip.item_id = i.id 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.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description,
&item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision,
&item.CADSyncedAt, &item.CADFilePath, &item.CADSyncedAt, &item.CADFilePath,
&item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost, &item.SourcingType, &item.LongDescription,
&item.ThumbnailKey, &item.ThumbnailKey,
); err != nil { ); err != nil {
return nil, err return nil, err

View File

@@ -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;

View File

@@ -16,9 +16,7 @@ export interface Item {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
sourcing_type: string; sourcing_type: string;
sourcing_link?: string;
long_description?: string; long_description?: string;
standard_cost?: number;
file_count: number; file_count: number;
files_total_size: number; files_total_size: number;
properties?: Record<string, unknown>; properties?: Record<string, unknown>;
@@ -170,9 +168,7 @@ export interface CreateItemRequest {
projects?: string[]; projects?: string[];
properties?: Record<string, unknown>; properties?: Record<string, unknown>;
sourcing_type?: string; sourcing_type?: string;
sourcing_link?: string;
long_description?: string; long_description?: string;
standard_cost?: number;
} }
export interface UpdateItemRequest { export interface UpdateItemRequest {
@@ -182,9 +178,7 @@ export interface UpdateItemRequest {
properties?: Record<string, unknown>; properties?: Record<string, unknown>;
comment?: string; comment?: string;
sourcing_type?: string; sourcing_type?: string;
sourcing_link?: string;
long_description?: string; long_description?: string;
standard_cost?: number;
} }
export interface CreateRevisionRequest { export interface CreateRevisionRequest {

View File

@@ -1,10 +1,6 @@
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { get, put } from "../../api/client"; import { get, put } from "../../api/client";
import type { import type { AuditItemResult, AuditFieldResult, Item } from "../../api/types";
AuditItemResult,
AuditFieldResult,
Item,
} from "../../api/types";
const tierColors: Record<string, string> = { const tierColors: Record<string, string> = {
critical: "var(--ctp-red)", critical: "var(--ctp-red)",
@@ -18,8 +14,6 @@ const tierColors: Record<string, string> = {
const itemFields = new Set([ const itemFields = new Set([
"description", "description",
"sourcing_type", "sourcing_type",
"sourcing_link",
"standard_cost",
"long_description", "long_description",
]); ]);
@@ -83,12 +77,9 @@ export function AuditDetailPanel({
void fetchData(); void fetchData();
}, [fetchData]); }, [fetchData]);
const handleFieldChange = useCallback( const handleFieldChange = useCallback((key: string, value: string) => {
(key: string, value: string) => { setEdits((prev) => ({ ...prev, [key]: value }));
setEdits((prev) => ({ ...prev, [key]: value })); }, []);
},
[],
);
const saveChanges = useCallback(async () => { const saveChanges = useCallback(async () => {
if (!item || Object.keys(edits).length === 0) return; if (!item || Object.keys(edits).length === 0) return;
@@ -102,18 +93,14 @@ export function AuditDetailPanel({
for (const [key, value] of Object.entries(edits)) { for (const [key, value] of Object.entries(edits)) {
if (itemFields.has(key)) { if (itemFields.has(key)) {
if (key === "standard_cost") { itemUpdate[key] = value || undefined;
const num = parseFloat(value);
itemUpdate[key] = isNaN(num) ? undefined : num;
} else {
itemUpdate[key] = value || undefined;
}
} else { } else {
// Attempt number coercion for property fields. // Attempt number coercion for property fields.
const num = parseFloat(value); const num = parseFloat(value);
propUpdate[key] = !isNaN(num) && String(num) === value.trim() propUpdate[key] =
? num !isNaN(num) && String(num) === value.trim()
: value || undefined; ? num
: value || undefined;
} }
} }
@@ -123,7 +110,10 @@ export function AuditDetailPanel({
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
...itemUpdate, ...itemUpdate,
...(hasProps ...(hasProps
? { properties: { ...currentProps, ...propUpdate }, comment: "Audit field update" } ? {
properties: { ...currentProps, ...propUpdate },
comment: "Audit field update",
}
: {}), : {}),
}; };
@@ -423,9 +413,7 @@ function FieldRow({
? String(field.value) ? String(field.value)
: ""; : "";
const borderColor = field.filled const borderColor = field.filled ? "var(--ctp-green)" : "var(--ctp-red)";
? "var(--ctp-green)"
: "var(--ctp-red)";
const label = field.key const label = field.key
.replace(/_/g, " ") .replace(/_/g, " ")
@@ -469,9 +457,7 @@ function FieldRow({
style={{ style={{
flex: 1, flex: 1,
fontSize: "0.8rem", fontSize: "0.8rem",
color: field.filled color: field.filled ? "var(--ctp-text)" : "var(--ctp-subtext0)",
? "var(--ctp-text)"
: "var(--ctp-subtext0)",
fontStyle: field.filled ? "normal" : "italic", fontStyle: field.filled ? "normal" : "italic",
}} }}
> >

View File

@@ -26,9 +26,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
const [category, setCategory] = useState(""); const [category, setCategory] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [sourcingType, setSourcingType] = useState("manufactured"); const [sourcingType, setSourcingType] = useState("manufactured");
const [sourcingLink, setSourcingLink] = useState("");
const [longDescription, setLongDescription] = useState(""); const [longDescription, setLongDescription] = useState("");
const [standardCost, setStandardCost] = useState("");
const [selectedProjects, setSelectedProjects] = useState<string[]>([]); const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [catProps, setCatProps] = useState<Record<string, string>>({}); const [catProps, setCatProps] = useState<Record<string, string>>({});
const [catPropDefs, setCatPropDefs] = useState< const [catPropDefs, setCatPropDefs] = useState<
@@ -173,9 +171,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
projects: selectedProjects.length > 0 ? selectedProjects : undefined, projects: selectedProjects.length > 0 ? selectedProjects : undefined,
properties: Object.keys(properties).length > 0 ? properties : undefined, properties: Object.keys(properties).length > 0 ? properties : undefined,
sourcing_type: sourcingType || undefined, sourcing_type: sourcingType || undefined,
sourcing_link: sourcingLink || undefined,
long_description: longDescription || undefined, long_description: longDescription || undefined,
standard_cost: standardCost ? Number(standardCost) : undefined,
}); });
const pn = result.part_number; const pn = result.part_number;
@@ -309,26 +305,6 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
<option value="purchased">Purchased</option> <option value="purchased">Purchased</option>
</select> </select>
</FormGroup> </FormGroup>
<FormGroup label="Standard Cost">
<input
type="number"
step="0.01"
value={standardCost}
onChange={(e) => setStandardCost(e.target.value)}
style={inputStyle}
placeholder="0.00"
/>
</FormGroup>
<div style={{ gridColumn: "1 / -1" }}>
<FormGroup label="Sourcing Link">
<input
value={sourcingLink}
onChange={(e) => setSourcingLink(e.target.value)}
style={inputStyle}
placeholder="https://..."
/>
</FormGroup>
</div>
</div> </div>
{/* Details section */} {/* Details section */}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { get, put } from '../../api/client'; import { get, put } from "../../api/client";
import type { Item } from '../../api/types'; import type { Item } from "../../api/types";
interface EditItemPaneProps { interface EditItemPaneProps {
partNumber: string; partNumber: string;
@@ -8,17 +8,19 @@ interface EditItemPaneProps {
onCancel: () => void; onCancel: () => void;
} }
export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProps) { export function EditItemPane({
partNumber,
onSaved,
onCancel,
}: EditItemPaneProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [pn, setPN] = useState(''); const [pn, setPN] = useState("");
const [itemType, setItemType] = useState(''); const [itemType, setItemType] = useState("");
const [description, setDescription] = useState(''); const [description, setDescription] = useState("");
const [sourcingType, setSourcingType] = useState(''); const [sourcingType, setSourcingType] = useState("");
const [sourcingLink, setSourcingLink] = useState(''); const [longDescription, setLongDescription] = useState("");
const [longDescription, setLongDescription] = useState('');
const [standardCost, setStandardCost] = useState('');
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
@@ -27,12 +29,10 @@ export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProp
setPN(item.part_number); setPN(item.part_number);
setItemType(item.item_type); setItemType(item.item_type);
setDescription(item.description); setDescription(item.description);
setSourcingType(item.sourcing_type ?? ''); setSourcingType(item.sourcing_type ?? "");
setSourcingLink(item.sourcing_link ?? ''); setLongDescription(item.long_description ?? "");
setLongDescription(item.long_description ?? '');
setStandardCost(item.standard_cost != null ? String(item.standard_cost) : '');
}) })
.catch(() => setError('Failed to load item')) .catch(() => setError("Failed to load item"))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [partNumber]); }, [partNumber]);
@@ -45,54 +45,97 @@ export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProp
item_type: itemType || undefined, item_type: itemType || undefined,
description: description || undefined, description: description || undefined,
sourcing_type: sourcingType || undefined, sourcing_type: sourcingType || undefined,
sourcing_link: sourcingLink || undefined,
long_description: longDescription || undefined, long_description: longDescription || undefined,
standard_cost: standardCost ? Number(standardCost) : undefined,
}); });
onSaved(); onSaved();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Failed to save item'); setError(e instanceof Error ? e.message : "Failed to save item");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
if (loading) return <div style={{ padding: '1rem', color: 'var(--ctp-subtext0)' }}>Loading...</div>; if (loading)
return (
<div style={{ padding: "1rem", color: "var(--ctp-subtext0)" }}>
Loading...
</div>
);
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}> <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ <div
display: 'flex', alignItems: 'center', gap: '0.75rem', style={{
padding: '0.5rem 0.75rem', display: "flex",
borderBottom: '1px solid var(--ctp-surface1)', alignItems: "center",
backgroundColor: 'var(--ctp-mantle)', gap: "0.75rem",
flexShrink: 0, padding: "0.5rem 0.75rem",
}}> borderBottom: "1px solid var(--ctp-surface1)",
<span style={{ color: 'var(--ctp-blue)', fontWeight: 600, fontSize: '0.9rem' }}>Edit {partNumber}</span> backgroundColor: "var(--ctp-mantle)",
flexShrink: 0,
}}
>
<span
style={{
color: "var(--ctp-blue)",
fontWeight: 600,
fontSize: "0.9rem",
}}
>
Edit {partNumber}
</span>
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
<button onClick={() => void handleSave()} disabled={saving} style={{ <button
padding: '0.3rem 0.75rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem', onClick={() => void handleSave()}
backgroundColor: 'var(--ctp-blue)', color: 'var(--ctp-crust)', cursor: 'pointer', disabled={saving}
opacity: saving ? 0.6 : 1, style={{
}}> padding: "0.3rem 0.75rem",
{saving ? 'Saving...' : 'Save'} fontSize: "0.8rem",
border: "none",
borderRadius: "0.3rem",
backgroundColor: "var(--ctp-blue)",
color: "var(--ctp-crust)",
cursor: "pointer",
opacity: saving ? 0.6 : 1,
}}
>
{saving ? "Saving..." : "Save"}
</button>
<button onClick={onCancel} style={headerBtnStyle}>
Cancel
</button> </button>
<button onClick={onCancel} style={headerBtnStyle}>Cancel</button>
</div> </div>
<div style={{ flex: 1, overflow: 'auto', padding: '0.75rem' }}> <div style={{ flex: 1, overflow: "auto", padding: "0.75rem" }}>
{error && ( {error && (
<div style={{ color: 'var(--ctp-red)', backgroundColor: 'rgba(243,139,168,0.1)', padding: '0.5rem', borderRadius: '0.3rem', marginBottom: '0.5rem', fontSize: '0.85rem' }}> <div
style={{
color: "var(--ctp-red)",
backgroundColor: "rgba(243,139,168,0.1)",
padding: "0.5rem",
borderRadius: "0.3rem",
marginBottom: "0.5rem",
fontSize: "0.85rem",
}}
>
{error} {error}
</div> </div>
)} )}
<FormGroup label="Part Number"> <FormGroup label="Part Number">
<input value={pn} onChange={(e) => setPN(e.target.value)} style={inputStyle} /> <input
value={pn}
onChange={(e) => setPN(e.target.value)}
style={inputStyle}
/>
</FormGroup> </FormGroup>
<FormGroup label="Type"> <FormGroup label="Type">
<select value={itemType} onChange={(e) => setItemType(e.target.value)} style={inputStyle}> <select
value={itemType}
onChange={(e) => setItemType(e.target.value)}
style={inputStyle}
>
<option value="part">Part</option> <option value="part">Part</option>
<option value="assembly">Assembly</option> <option value="assembly">Assembly</option>
<option value="document">Document</option> <option value="document">Document</option>
@@ -101,11 +144,19 @@ export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProp
</FormGroup> </FormGroup>
<FormGroup label="Description"> <FormGroup label="Description">
<input value={description} onChange={(e) => setDescription(e.target.value)} style={inputStyle} /> <input
value={description}
onChange={(e) => setDescription(e.target.value)}
style={inputStyle}
/>
</FormGroup> </FormGroup>
<FormGroup label="Sourcing Type"> <FormGroup label="Sourcing Type">
<select value={sourcingType} onChange={(e) => setSourcingType(e.target.value)} style={inputStyle}> <select
value={sourcingType}
onChange={(e) => setSourcingType(e.target.value)}
style={inputStyle}
>
<option value=""></option> <option value=""></option>
<option value="manufactured">Manufactured</option> <option value="manufactured">Manufactured</option>
<option value="purchased">Purchased</option> <option value="purchased">Purchased</option>
@@ -113,38 +164,57 @@ export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProp
</select> </select>
</FormGroup> </FormGroup>
<FormGroup label="Sourcing Link">
<input value={sourcingLink} onChange={(e) => setSourcingLink(e.target.value)} style={inputStyle} placeholder="URL" />
</FormGroup>
<FormGroup label="Standard Cost">
<input type="number" step="0.01" value={standardCost} onChange={(e) => setStandardCost(e.target.value)} style={inputStyle} placeholder="0.00" />
</FormGroup>
<FormGroup label="Long Description"> <FormGroup label="Long Description">
<textarea value={longDescription} onChange={(e) => setLongDescription(e.target.value)} style={{ ...inputStyle, minHeight: 80, resize: 'vertical' }} /> <textarea
value={longDescription}
onChange={(e) => setLongDescription(e.target.value)}
style={{ ...inputStyle, minHeight: 80, resize: "vertical" }}
/>
</FormGroup> </FormGroup>
</div> </div>
</div> </div>
); );
} }
function FormGroup({ label, children }: { label: string; children: React.ReactNode }) { function FormGroup({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return ( return (
<div style={{ marginBottom: '0.6rem' }}> <div style={{ marginBottom: "0.6rem" }}>
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--ctp-subtext0)', marginBottom: '0.2rem' }}>{label}</label> <label
style={{
display: "block",
fontSize: "0.75rem",
color: "var(--ctp-subtext0)",
marginBottom: "0.2rem",
}}
>
{label}
</label>
{children} {children}
</div> </div>
); );
} }
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: '100%', padding: '0.35rem 0.5rem', fontSize: '0.85rem', width: "100%",
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)', padding: "0.35rem 0.5rem",
borderRadius: '0.3rem', color: 'var(--ctp-text)', fontSize: "0.85rem",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.3rem",
color: "var(--ctp-text)",
}; };
const headerBtnStyle: React.CSSProperties = { const headerBtnStyle: React.CSSProperties = {
background: 'none', border: 'none', cursor: 'pointer', background: "none",
color: 'var(--ctp-subtext1)', fontSize: '0.8rem', padding: '0.2rem 0.4rem', border: "none",
cursor: "pointer",
color: "var(--ctp-subtext1)",
fontSize: "0.8rem",
padding: "0.2rem 0.4rem",
}; };

View File

@@ -1,12 +1,15 @@
import { useState, useRef } from 'react'; import { useState, useRef } from "react";
import type { CSVImportResult } from '../../api/types'; import type { CSVImportResult } from "../../api/types";
interface ImportItemsPaneProps { interface ImportItemsPaneProps {
onImported: () => void; onImported: () => void;
onCancel: () => void; onCancel: () => void;
} }
export function ImportItemsPane({ onImported, onCancel }: ImportItemsPaneProps) { export function ImportItemsPane({
onImported,
onCancel,
}: ImportItemsPaneProps) {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [skipExisting, setSkipExisting] = useState(false); const [skipExisting, setSkipExisting] = useState(false);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
@@ -21,19 +24,22 @@ export function ImportItemsPane({ onImported, onCancel }: ImportItemsPaneProps)
setError(null); setError(null);
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append("file", file);
if (dryRun) formData.append('dry_run', 'true'); if (dryRun) formData.append("dry_run", "true");
if (skipExisting) formData.append('skip_existing', 'true'); if (skipExisting) formData.append("skip_existing", "true");
try { try {
const res = await fetch('/api/items/import', { const res = await fetch("/api/items/import", {
method: 'POST', method: "POST",
credentials: 'include', credentials: "include",
body: formData, body: formData,
}); });
const data = await res.json() as CSVImportResult; const data = (await res.json()) as CSVImportResult;
if (!res.ok) { if (!res.ok) {
setError((data as unknown as { message?: string }).message ?? `HTTP ${res.status}`); setError(
(data as unknown as { message?: string }).message ??
`HTTP ${res.status}`,
);
} else { } else {
setResult(data); setResult(data);
if (dryRun) { if (dryRun) {
@@ -43,48 +49,85 @@ export function ImportItemsPane({ onImported, onCancel }: ImportItemsPaneProps)
} }
} }
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Import failed'); setError(e instanceof Error ? e.message : "Import failed");
} finally { } finally {
setImporting(false); setImporting(false);
} }
}; };
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}> <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ <div
display: 'flex', alignItems: 'center', gap: '0.75rem', style={{
padding: '0.5rem 0.75rem', display: "flex",
borderBottom: '1px solid var(--ctp-surface1)', alignItems: "center",
backgroundColor: 'var(--ctp-mantle)', gap: "0.75rem",
flexShrink: 0, padding: "0.5rem 0.75rem",
}}> borderBottom: "1px solid var(--ctp-surface1)",
<span style={{ color: 'var(--ctp-yellow)', fontWeight: 600, fontSize: '0.9rem' }}>Import Items (CSV)</span> backgroundColor: "var(--ctp-mantle)",
flexShrink: 0,
}}
>
<span
style={{
color: "var(--ctp-yellow)",
fontWeight: 600,
fontSize: "0.9rem",
}}
>
Import Items (CSV)
</span>
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
<button onClick={onCancel} style={headerBtnStyle}>Cancel</button> <button onClick={onCancel} style={headerBtnStyle}>
Cancel
</button>
</div> </div>
<div style={{ flex: 1, overflow: 'auto', padding: '0.75rem' }}> <div style={{ flex: 1, overflow: "auto", padding: "0.75rem" }}>
{error && ( {error && (
<div style={{ color: 'var(--ctp-red)', backgroundColor: 'rgba(243,139,168,0.1)', padding: '0.5rem', borderRadius: '0.3rem', marginBottom: '0.5rem', fontSize: '0.85rem' }}> <div
style={{
color: "var(--ctp-red)",
backgroundColor: "rgba(243,139,168,0.1)",
padding: "0.5rem",
borderRadius: "0.3rem",
marginBottom: "0.5rem",
fontSize: "0.85rem",
}}
>
{error} {error}
</div> </div>
)} )}
{/* Instructions */} {/* Instructions */}
<div style={{ fontSize: '0.8rem', color: 'var(--ctp-subtext0)', marginBottom: '0.75rem' }}> <div
<p style={{ marginBottom: '0.25rem' }}>Upload a CSV file with items to import.</p> style={{
<p>Required column: <strong style={{ color: 'var(--ctp-text)' }}>category</strong></p> fontSize: "0.8rem",
<p>Optional: description, projects, sourcing_type, sourcing_link, long_description, standard_cost, + property columns</p> color: "var(--ctp-subtext0)",
marginBottom: "0.75rem",
}}
>
<p style={{ marginBottom: "0.25rem" }}>
Upload a CSV file with items to import.
</p>
<p>
Required column:{" "}
<strong style={{ color: "var(--ctp-text)" }}>category</strong>
</p>
<p>
Optional: description, projects, sourcing_type, long_description, +
property columns (including sourcing_link, standard_cost)
</p>
<a <a
href="/api/items/template.csv" href="/api/items/template.csv"
style={{ color: 'var(--ctp-sapphire)', fontSize: '0.8rem' }} style={{ color: "var(--ctp-sapphire)", fontSize: "0.8rem" }}
> >
Download CSV template Download CSV template
</a> </a>
</div> </div>
{/* File input */} {/* File input */}
<div style={{ marginBottom: '0.75rem' }}> <div style={{ marginBottom: "0.75rem" }}>
<input <input
ref={fileRef} ref={fileRef}
type="file" type="file"
@@ -94,76 +137,144 @@ export function ImportItemsPane({ onImported, onCancel }: ImportItemsPaneProps)
setResult(null); setResult(null);
setValidated(false); setValidated(false);
}} }}
style={{ display: 'none' }} style={{ display: "none" }}
/> />
<button <button
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
style={{ style={{
padding: '0.75rem 1.5rem', border: '2px dashed var(--ctp-surface2)', padding: "0.75rem 1.5rem",
borderRadius: '0.5rem', backgroundColor: 'var(--ctp-surface0)', border: "2px dashed var(--ctp-surface2)",
color: 'var(--ctp-subtext1)', cursor: 'pointer', width: '100%', borderRadius: "0.5rem",
fontSize: '0.85rem', backgroundColor: "var(--ctp-surface0)",
color: "var(--ctp-subtext1)",
cursor: "pointer",
width: "100%",
fontSize: "0.85rem",
}} }}
> >
{file ? file.name : 'Choose CSV file...'} {file ? file.name : "Choose CSV file..."}
</button> </button>
</div> </div>
{/* Options */} {/* Options */}
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.85rem', color: 'var(--ctp-subtext1)', marginBottom: '0.75rem' }}> <label
<input type="checkbox" checked={skipExisting} onChange={(e) => setSkipExisting(e.target.checked)} /> style={{
display: "flex",
alignItems: "center",
gap: "0.4rem",
fontSize: "0.85rem",
color: "var(--ctp-subtext1)",
marginBottom: "0.75rem",
}}
>
<input
type="checkbox"
checked={skipExisting}
onChange={(e) => setSkipExisting(e.target.checked)}
/>
Skip existing items Skip existing items
</label> </label>
{/* Actions */} {/* Actions */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.75rem' }}> <div
style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem" }}
>
{!validated ? ( {!validated ? (
<button <button
onClick={() => void doImport(true)} onClick={() => void doImport(true)}
disabled={!file || importing} disabled={!file || importing}
style={{ style={{
padding: '0.4rem 0.75rem', fontSize: '0.85rem', border: 'none', borderRadius: '0.3rem', padding: "0.4rem 0.75rem",
backgroundColor: 'var(--ctp-yellow)', color: 'var(--ctp-crust)', cursor: 'pointer', fontSize: "0.85rem",
opacity: (!file || importing) ? 0.5 : 1, border: "none",
borderRadius: "0.3rem",
backgroundColor: "var(--ctp-yellow)",
color: "var(--ctp-crust)",
cursor: "pointer",
opacity: !file || importing ? 0.5 : 1,
}} }}
> >
{importing ? 'Validating...' : 'Validate (Dry Run)'} {importing ? "Validating..." : "Validate (Dry Run)"}
</button> </button>
) : ( ) : (
<button <button
onClick={() => void doImport(false)} onClick={() => void doImport(false)}
disabled={importing || (result?.error_count ?? 0) > 0} disabled={importing || (result?.error_count ?? 0) > 0}
style={{ style={{
padding: '0.4rem 0.75rem', fontSize: '0.85rem', border: 'none', borderRadius: '0.3rem', padding: "0.4rem 0.75rem",
backgroundColor: 'var(--ctp-green)', color: 'var(--ctp-crust)', cursor: 'pointer', fontSize: "0.85rem",
opacity: (importing || (result?.error_count ?? 0) > 0) ? 0.5 : 1, border: "none",
borderRadius: "0.3rem",
backgroundColor: "var(--ctp-green)",
color: "var(--ctp-crust)",
cursor: "pointer",
opacity: importing || (result?.error_count ?? 0) > 0 ? 0.5 : 1,
}} }}
> >
{importing ? 'Importing...' : 'Import Now'} {importing ? "Importing..." : "Import Now"}
</button> </button>
)} )}
</div> </div>
{/* Results */} {/* Results */}
{result && ( {result && (
<div style={{ padding: '0.5rem', backgroundColor: 'var(--ctp-surface0)', borderRadius: '0.4rem', fontSize: '0.8rem' }}> <div
<p>Total rows: <strong>{result.total_rows}</strong></p> style={{
<p>Success: <strong style={{ color: 'var(--ctp-green)' }}>{result.success_count}</strong></p> padding: "0.5rem",
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.4rem",
fontSize: "0.8rem",
}}
>
<p>
Total rows: <strong>{result.total_rows}</strong>
</p>
<p>
Success:{" "}
<strong style={{ color: "var(--ctp-green)" }}>
{result.success_count}
</strong>
</p>
{result.error_count > 0 && ( {result.error_count > 0 && (
<p>Errors: <strong style={{ color: 'var(--ctp-red)' }}>{result.error_count}</strong></p> <p>
Errors:{" "}
<strong style={{ color: "var(--ctp-red)" }}>
{result.error_count}
</strong>
</p>
)} )}
{result.errors && result.errors.length > 0 && ( {result.errors && result.errors.length > 0 && (
<div style={{ marginTop: '0.5rem', maxHeight: 200, overflow: 'auto' }}> <div
style={{
marginTop: "0.5rem",
maxHeight: 200,
overflow: "auto",
}}
>
{result.errors.map((err, i) => ( {result.errors.map((err, i) => (
<div key={i} style={{ color: 'var(--ctp-red)', fontSize: '0.75rem', padding: '0.1rem 0' }}> <div
Row {err.row}{err.field ? ` [${err.field}]` : ''}: {err.message} key={i}
style={{
color: "var(--ctp-red)",
fontSize: "0.75rem",
padding: "0.1rem 0",
}}
>
Row {err.row}
{err.field ? ` [${err.field}]` : ""}: {err.message}
</div> </div>
))} ))}
</div> </div>
)} )}
{result.created_items && result.created_items.length > 0 && ( {result.created_items && result.created_items.length > 0 && (
<div style={{ marginTop: '0.5rem', color: 'var(--ctp-green)', fontSize: '0.75rem' }}> <div
Created: {result.created_items.join(', ')} style={{
marginTop: "0.5rem",
color: "var(--ctp-green)",
fontSize: "0.75rem",
}}
>
Created: {result.created_items.join(", ")}
</div> </div>
)} )}
</div> </div>
@@ -174,6 +285,10 @@ export function ImportItemsPane({ onImported, onCancel }: ImportItemsPaneProps)
} }
const headerBtnStyle: React.CSSProperties = { const headerBtnStyle: React.CSSProperties = {
background: 'none', border: 'none', cursor: 'pointer', background: "none",
color: 'var(--ctp-subtext1)', fontSize: '0.8rem', padding: '0.2rem 0.4rem', border: "none",
cursor: "pointer",
color: "var(--ctp-subtext1)",
fontSize: "0.8rem",
padding: "0.2rem 0.4rem",
}; };

View File

@@ -110,15 +110,19 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
{row("Description", item.description)} {row("Description", item.description)}
{row("Type", item.item_type)} {row("Type", item.item_type)}
{row("Sourcing", item.sourcing_type || "—")} {row("Sourcing", item.sourcing_type || "—")}
{item.sourcing_link && {item.properties?.sourcing_link != null &&
row( row(
"Source Link", "Source Link",
<a href={item.sourcing_link} target="_blank" rel="noreferrer"> <a
{item.sourcing_link} href={String(item.properties.sourcing_link)}
target="_blank"
rel="noreferrer"
>
{String(item.properties.sourcing_link)}
</a>, </a>,
)} )}
{item.standard_cost != null && {item.properties?.standard_cost != null &&
row("Std Cost", `$${item.standard_cost.toFixed(2)}`)} row("Std Cost", `$${Number(item.properties.standard_cost).toFixed(2)}`)}
{row("Revision", `Rev ${item.current_revision}`)} {row("Revision", `Rev ${item.current_revision}`)}
{row("Created", formatDate(item.created_at))} {row("Created", formatDate(item.created_at))}
{row("Updated", formatDate(item.updated_at))} {row("Updated", formatDate(item.updated_at))}