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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
25
migrations/013_move_cost_sourcing_to_props.sql
Normal file
25
migrations/013_move_cost_sourcing_to_props.sql
Normal 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;
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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))}
|
||||||
|
|||||||
Reference in New Issue
Block a user