Compare commits
22 Commits
ui-density
...
issue-dedu
| Author | SHA1 | Date | |
|---|---|---|---|
| beaf091d62 | |||
| 4edaa35c49 | |||
| b3c748ef10 | |||
| 701a5c21ce | |||
|
|
f7aa673d2c | ||
| 2157b40d06 | |||
|
|
25c42bd70b | ||
| 8d88f77ff6 | |||
|
|
50985ed805 | ||
| 9be6f45f09 | |||
| ef05aec619 | |||
| 64075d88b5 | |||
| eac64f863b | |||
| aa414adc43 | |||
| 9ce9468474 | |||
| 2dad658e91 | |||
| ce37fdd169 | |||
| c557ca736c | |||
|
|
08e84703d5 | ||
|
|
fbe4f3a36c | ||
|
|
163dc9f0f0 | ||
|
|
e5ddb30a4a |
0
docs/BOM_MERGE.md
Normal file
0
docs/BOM_MERGE.md
Normal file
@@ -101,6 +101,8 @@ var manufacturedWeights = map[string]float64{
|
||||
// Weight 1: engineering detail (category-specific default)
|
||||
"sourcing_type": 1,
|
||||
"lifecycle_status": 1,
|
||||
// Weight 1: engineering detail
|
||||
"has_files": 1,
|
||||
// Weight 0.5: less relevant for in-house
|
||||
"manufacturer": 0.5,
|
||||
"supplier": 0.5,
|
||||
@@ -112,8 +114,6 @@ var manufacturedWeights = map[string]float64{
|
||||
var itemLevelFields = map[string]bool{
|
||||
"description": true,
|
||||
"sourcing_type": true,
|
||||
"sourcing_link": true,
|
||||
"standard_cost": true,
|
||||
"long_description": true,
|
||||
}
|
||||
|
||||
@@ -207,6 +207,7 @@ func scoreItem(
|
||||
categoryProps map[string]schema.PropertyDefinition,
|
||||
hasBOM bool,
|
||||
bomChildCount int,
|
||||
hasFiles bool,
|
||||
categoryName string,
|
||||
projects []string,
|
||||
includeFields bool,
|
||||
@@ -255,18 +256,6 @@ func scoreItem(
|
||||
processField("description", "item", "string", item.Description)
|
||||
processField("sourcing_type", "item", "string", item.SourcingType)
|
||||
|
||||
var sourcingLinkVal any
|
||||
if item.SourcingLink != nil {
|
||||
sourcingLinkVal = *item.SourcingLink
|
||||
}
|
||||
processField("sourcing_link", "item", "string", sourcingLinkVal)
|
||||
|
||||
var stdCostVal any
|
||||
if item.StandardCost != nil {
|
||||
stdCostVal = *item.StandardCost
|
||||
}
|
||||
processField("standard_cost", "item", "number", stdCostVal)
|
||||
|
||||
var longDescVal any
|
||||
if item.LongDescription != nil {
|
||||
longDescVal = *item.LongDescription
|
||||
@@ -276,6 +265,7 @@ func scoreItem(
|
||||
// Score has_bom for manufactured/assembly items.
|
||||
if sourcingType == "manufactured" || isAssembly {
|
||||
processField("has_bom", "computed", "boolean", hasBOM)
|
||||
processField("has_files", "computed", "boolean", hasFiles)
|
||||
}
|
||||
|
||||
// Score property fields from schema.
|
||||
@@ -283,10 +273,6 @@ func scoreItem(
|
||||
if skipFields[key] || itemLevelFields[key] {
|
||||
continue
|
||||
}
|
||||
// sourcing_link and standard_cost are already handled at item level.
|
||||
if key == "sourcing_link" || key == "standard_cost" {
|
||||
continue
|
||||
}
|
||||
value := item.Properties[key]
|
||||
processField(key, "property", def.Type, value)
|
||||
}
|
||||
@@ -412,6 +398,13 @@ func (s *Server) HandleAuditCompleteness(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
fileStats, err := s.items.BatchGetFileStats(ctx, itemIDs)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to batch get file stats")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load file stats")
|
||||
return
|
||||
}
|
||||
|
||||
// Look up the schema for category resolution.
|
||||
sch := s.schemas["kindred-rd"]
|
||||
var catSegment *schema.Segment
|
||||
@@ -440,9 +433,10 @@ func (s *Server) HandleAuditCompleteness(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
bomCount := bomCounts[item.ID]
|
||||
hasBOM := bomCount > 0
|
||||
hasFiles := fileStats[item.ID].Count > 0
|
||||
projects := projectCodes[item.ID]
|
||||
|
||||
result := scoreItem(item, categoryProps, hasBOM, bomCount, categoryName, projects, false)
|
||||
result := scoreItem(item, categoryProps, hasBOM, bomCount, hasFiles, categoryName, projects, false)
|
||||
allResults = append(allResults, *result)
|
||||
}
|
||||
|
||||
@@ -544,6 +538,15 @@ func (s *Server) HandleAuditItemDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
projects := projectCodes[item.ID]
|
||||
|
||||
// Get file stats.
|
||||
fileStats, err := s.items.BatchGetFileStats(ctx, []string{item.ID})
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Str("pn", partNumber).Msg("failed to get file stats for audit")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load file stats")
|
||||
return
|
||||
}
|
||||
hasFiles := fileStats[item.ID].Count > 0
|
||||
|
||||
// Category resolution.
|
||||
cat := extractCategory(item.PartNumber)
|
||||
categoryName := cat
|
||||
@@ -561,7 +564,7 @@ func (s *Server) HandleAuditItemDetail(w http.ResponseWriter, r *http.Request) {
|
||||
categoryProps = sch.PropertySchemas.GetPropertiesForCategory(cat)
|
||||
}
|
||||
|
||||
result := scoreItem(iwp, categoryProps, hasBOM, bomCount, categoryName, projects, true)
|
||||
result := scoreItem(iwp, categoryProps, hasBOM, bomCount, hasFiles, categoryName, projects, true)
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ type BOMEntryResponse struct {
|
||||
ChildRevision *int `json:"child_revision,omitempty"`
|
||||
EffectiveRevision int `json:"effective_revision"`
|
||||
Depth *int `json:"depth,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
@@ -51,6 +52,7 @@ type AddBOMEntryRequest struct {
|
||||
Unit *string `json:"unit,omitempty"`
|
||||
ReferenceDesignators []string `json:"reference_designators,omitempty"`
|
||||
ChildRevision *int `json:"child_revision,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
@@ -240,6 +242,7 @@ func (s *Server) HandleAddBOMEntry(w http.ResponseWriter, r *http.Request) {
|
||||
Unit: req.Unit,
|
||||
ReferenceDesignators: req.ReferenceDesignators,
|
||||
ChildRevision: req.ChildRevision,
|
||||
Source: req.Source,
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
@@ -273,6 +276,7 @@ func (s *Server) HandleAddBOMEntry(w http.ResponseWriter, r *http.Request) {
|
||||
ReferenceDesignators: req.ReferenceDesignators,
|
||||
ChildRevision: req.ChildRevision,
|
||||
EffectiveRevision: child.CurrentRevision,
|
||||
Source: rel.Source,
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
if req.ChildRevision != nil {
|
||||
@@ -434,6 +438,7 @@ func bomEntryToResponse(e *db.BOMEntry) BOMEntryResponse {
|
||||
ReferenceDesignators: refDes,
|
||||
ChildRevision: e.ChildRevision,
|
||||
EffectiveRevision: e.EffectiveRevision,
|
||||
Source: e.Source,
|
||||
Metadata: e.Metadata,
|
||||
}
|
||||
}
|
||||
@@ -568,8 +573,20 @@ func (s *Server) HandleGetBOMCost(w http.ResponseWriter, r *http.Request) {
|
||||
for i, e := range entries {
|
||||
unitCost := 0.0
|
||||
leaf, err := s.items.GetByID(ctx, e.ItemID)
|
||||
if err == nil && leaf != nil && leaf.StandardCost != nil {
|
||||
unitCost = *leaf.StandardCost
|
||||
if err == nil && leaf != nil {
|
||||
// Get standard_cost from revision properties
|
||||
if revs, rerr := s.items.GetRevisions(ctx, leaf.ID); rerr == nil {
|
||||
for _, rev := range revs {
|
||||
if rev.RevisionNumber == leaf.CurrentRevision && rev.Properties != nil {
|
||||
if sc, ok := rev.Properties["standard_cost"]; ok {
|
||||
if cost, cok := sc.(float64); cok {
|
||||
unitCost = cost
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
extCost := e.TotalQuantity * unitCost
|
||||
totalCost += extCost
|
||||
@@ -589,6 +606,56 @@ func (s *Server) HandleGetBOMCost(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// BOM merge request/response types
|
||||
|
||||
// MergeBOMRequest represents a request to merge assembly BOM entries.
|
||||
type MergeBOMRequest struct {
|
||||
Source string `json:"source"`
|
||||
Entries []MergeBOMEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// MergeBOMEntry represents a single entry in a merge request.
|
||||
type MergeBOMEntry struct {
|
||||
ChildPartNumber string `json:"child_part_number"`
|
||||
Quantity *float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// MergeBOMResponse represents the result of a BOM merge.
|
||||
type MergeBOMResponse struct {
|
||||
Status string `json:"status"`
|
||||
Diff MergeBOMDiff `json:"diff"`
|
||||
Warnings []MergeWarning `json:"warnings"`
|
||||
ResolveURL string `json:"resolve_url"`
|
||||
}
|
||||
|
||||
// MergeBOMDiff categorizes changes from a merge operation.
|
||||
type MergeBOMDiff struct {
|
||||
Added []MergeDiffEntry `json:"added"`
|
||||
Removed []MergeDiffEntry `json:"removed"`
|
||||
QuantityChanged []MergeQtyChange `json:"quantity_changed"`
|
||||
Unchanged []MergeDiffEntry `json:"unchanged"`
|
||||
}
|
||||
|
||||
// MergeDiffEntry represents an added, removed, or unchanged BOM entry.
|
||||
type MergeDiffEntry struct {
|
||||
PartNumber string `json:"part_number"`
|
||||
Quantity *float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// MergeQtyChange represents a BOM entry whose quantity changed.
|
||||
type MergeQtyChange struct {
|
||||
PartNumber string `json:"part_number"`
|
||||
OldQuantity *float64 `json:"old_quantity"`
|
||||
NewQuantity *float64 `json:"new_quantity"`
|
||||
}
|
||||
|
||||
// MergeWarning represents a warning generated during merge.
|
||||
type MergeWarning struct {
|
||||
Type string `json:"type"`
|
||||
PartNumber string `json:"part_number"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// BOM CSV headers matching the user-specified format.
|
||||
var bomCSVHeaders = []string{
|
||||
"Item", "Level", "Source", "PN", "Seller Description",
|
||||
@@ -686,14 +753,14 @@ func (s *Server) HandleExportBOMCSV(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
row := []string{
|
||||
strconv.Itoa(i + 1), // Item
|
||||
strconv.Itoa(e.Depth), // Level
|
||||
getMetaString(e.Metadata, "source"), // Source
|
||||
e.ChildPartNumber, // PN
|
||||
strconv.Itoa(i + 1), // Item
|
||||
strconv.Itoa(e.Depth), // Level
|
||||
e.Source, // Source
|
||||
e.ChildPartNumber, // PN
|
||||
getMetaString(e.Metadata, "seller_description"), // Seller Description
|
||||
unitCostStr, // Unit Cost
|
||||
qtyStr, // QTY
|
||||
extCost, // Ext Cost
|
||||
unitCostStr, // Unit Cost
|
||||
qtyStr, // QTY
|
||||
extCost, // Ext Cost
|
||||
getMetaString(e.Metadata, "sourcing_link"), // Sourcing Link
|
||||
}
|
||||
if err := writer.Write(row); err != nil {
|
||||
@@ -853,12 +920,11 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Build metadata from CSV columns
|
||||
metadata := make(map[string]any)
|
||||
source := ""
|
||||
if idx, ok := headerIdx["source"]; ok && idx < len(record) {
|
||||
if v := strings.TrimSpace(record[idx]); v != "" {
|
||||
metadata["source"] = v
|
||||
}
|
||||
source = strings.TrimSpace(record[idx])
|
||||
}
|
||||
metadata := make(map[string]any)
|
||||
if idx, ok := headerIdx["seller description"]; ok && idx < len(record) {
|
||||
if v := strings.TrimSpace(record[idx]); v != "" {
|
||||
metadata["seller_description"] = v
|
||||
@@ -942,6 +1008,7 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) {
|
||||
ChildItemID: child.ID,
|
||||
RelType: "component",
|
||||
Quantity: quantity,
|
||||
Source: source,
|
||||
Metadata: metadata,
|
||||
CreatedBy: importUsername,
|
||||
}
|
||||
@@ -971,3 +1038,197 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// HandleMergeBOM merges assembly-derived BOM entries into the server's BOM.
|
||||
// Added entries are created, quantity changes are applied, and entries present
|
||||
// in the server but missing from the request are flagged as warnings (not deleted).
|
||||
func (s *Server) HandleMergeBOM(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
partNumber := chi.URLParam(r, "partNumber")
|
||||
|
||||
parent, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to get parent item")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get parent item")
|
||||
return
|
||||
}
|
||||
if parent == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Parent item not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req MergeBOMRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
if len(req.Entries) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "entries must not be empty")
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch existing BOM (includes Source field)
|
||||
existing, err := s.relationships.GetBOM(ctx, parent.ID)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to get existing BOM")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get existing BOM")
|
||||
return
|
||||
}
|
||||
|
||||
// Build lookup map by child part number
|
||||
existingMap := make(map[string]*db.BOMEntry, len(existing))
|
||||
for _, e := range existing {
|
||||
existingMap[e.ChildPartNumber] = e
|
||||
}
|
||||
|
||||
var username *string
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
username = &user.Username
|
||||
}
|
||||
|
||||
diff := MergeBOMDiff{
|
||||
Added: make([]MergeDiffEntry, 0),
|
||||
Removed: make([]MergeDiffEntry, 0),
|
||||
QuantityChanged: make([]MergeQtyChange, 0),
|
||||
Unchanged: make([]MergeDiffEntry, 0),
|
||||
}
|
||||
var warnings []MergeWarning
|
||||
|
||||
// Process incoming entries
|
||||
for _, entry := range req.Entries {
|
||||
if entry.ChildPartNumber == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
child, err := s.items.GetByPartNumber(ctx, entry.ChildPartNumber)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Str("child", entry.ChildPartNumber).Msg("failed to look up child")
|
||||
warnings = append(warnings, MergeWarning{
|
||||
Type: "error",
|
||||
PartNumber: entry.ChildPartNumber,
|
||||
Message: fmt.Sprintf("Error looking up item: %s", err.Error()),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if child == nil {
|
||||
warnings = append(warnings, MergeWarning{
|
||||
Type: "not_found",
|
||||
PartNumber: entry.ChildPartNumber,
|
||||
Message: fmt.Sprintf("Item '%s' not found in database", entry.ChildPartNumber),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if ex, ok := existingMap[entry.ChildPartNumber]; ok {
|
||||
// Entry already exists — check quantity
|
||||
oldQty := ex.Quantity
|
||||
newQty := entry.Quantity
|
||||
if quantitiesEqual(oldQty, newQty) {
|
||||
diff.Unchanged = append(diff.Unchanged, MergeDiffEntry{
|
||||
PartNumber: entry.ChildPartNumber,
|
||||
Quantity: newQty,
|
||||
})
|
||||
} else {
|
||||
// Update quantity
|
||||
if err := s.relationships.Update(ctx, ex.RelationshipID, nil, newQty, nil, nil, nil, nil, username); err != nil {
|
||||
s.logger.Error().Err(err).Str("child", entry.ChildPartNumber).Msg("failed to update quantity")
|
||||
warnings = append(warnings, MergeWarning{
|
||||
Type: "error",
|
||||
PartNumber: entry.ChildPartNumber,
|
||||
Message: fmt.Sprintf("Failed to update quantity: %s", err.Error()),
|
||||
})
|
||||
} else {
|
||||
diff.QuantityChanged = append(diff.QuantityChanged, MergeQtyChange{
|
||||
PartNumber: entry.ChildPartNumber,
|
||||
OldQuantity: oldQty,
|
||||
NewQuantity: newQty,
|
||||
})
|
||||
}
|
||||
}
|
||||
delete(existingMap, entry.ChildPartNumber)
|
||||
} else {
|
||||
// New entry — create
|
||||
rel := &db.Relationship{
|
||||
ParentItemID: parent.ID,
|
||||
ChildItemID: child.ID,
|
||||
RelType: "component",
|
||||
Quantity: entry.Quantity,
|
||||
Source: "assembly",
|
||||
CreatedBy: username,
|
||||
}
|
||||
if err := s.relationships.Create(ctx, rel); err != nil {
|
||||
if strings.Contains(err.Error(), "cycle") {
|
||||
warnings = append(warnings, MergeWarning{
|
||||
Type: "cycle",
|
||||
PartNumber: entry.ChildPartNumber,
|
||||
Message: fmt.Sprintf("Adding '%s' would create a cycle", entry.ChildPartNumber),
|
||||
})
|
||||
} else {
|
||||
s.logger.Error().Err(err).Str("child", entry.ChildPartNumber).Msg("failed to create relationship")
|
||||
warnings = append(warnings, MergeWarning{
|
||||
Type: "error",
|
||||
PartNumber: entry.ChildPartNumber,
|
||||
Message: fmt.Sprintf("Failed to create: %s", err.Error()),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
diff.Added = append(diff.Added, MergeDiffEntry{
|
||||
PartNumber: entry.ChildPartNumber,
|
||||
Quantity: entry.Quantity,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining entries in existingMap are not in the merge request
|
||||
for pn, e := range existingMap {
|
||||
if e.Source == "assembly" {
|
||||
diff.Removed = append(diff.Removed, MergeDiffEntry{
|
||||
PartNumber: pn,
|
||||
Quantity: e.Quantity,
|
||||
})
|
||||
warnings = append(warnings, MergeWarning{
|
||||
Type: "unreferenced",
|
||||
PartNumber: pn,
|
||||
Message: "Present in server BOM but not in assembly",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
resp := MergeBOMResponse{
|
||||
Status: "merged",
|
||||
Diff: diff,
|
||||
Warnings: warnings,
|
||||
ResolveURL: fmt.Sprintf("/items/%s/bom", partNumber),
|
||||
}
|
||||
|
||||
s.logger.Info().
|
||||
Str("parent", partNumber).
|
||||
Int("added", len(diff.Added)).
|
||||
Int("updated", len(diff.QuantityChanged)).
|
||||
Int("unchanged", len(diff.Unchanged)).
|
||||
Int("unreferenced", len(diff.Removed)).
|
||||
Int("warnings", len(warnings)).
|
||||
Msg("BOM merge completed")
|
||||
|
||||
s.broker.Publish("bom.merged", mustMarshal(map[string]any{
|
||||
"part_number": partNumber,
|
||||
"added": len(diff.Added),
|
||||
"quantity_changed": len(diff.QuantityChanged),
|
||||
"unchanged": len(diff.Unchanged),
|
||||
"unreferenced": len(diff.Removed),
|
||||
}))
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// quantitiesEqual compares two nullable float64 quantities.
|
||||
func quantitiesEqual(a, b *float64) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
@@ -51,9 +51,7 @@ var csvColumns = []string{
|
||||
"category",
|
||||
"projects", // comma-separated project codes
|
||||
"sourcing_type",
|
||||
"sourcing_link",
|
||||
"long_description",
|
||||
"standard_cost",
|
||||
}
|
||||
|
||||
// HandleExportCSV exports items to CSV format.
|
||||
@@ -158,14 +156,8 @@ func (s *Server) HandleExportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
row[6] = category
|
||||
row[7] = projectCodes
|
||||
row[8] = item.SourcingType
|
||||
if item.SourcingLink != nil {
|
||||
row[9] = *item.SourcingLink
|
||||
}
|
||||
if item.LongDescription != nil {
|
||||
row[10] = *item.LongDescription
|
||||
}
|
||||
if item.StandardCost != nil {
|
||||
row[11] = strconv.FormatFloat(*item.StandardCost, 'f', -1, 64)
|
||||
row[9] = *item.LongDescription
|
||||
}
|
||||
|
||||
// Property columns
|
||||
@@ -366,9 +358,17 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Parse extended fields
|
||||
sourcingType := getCSVValue(record, colIndex, "sourcing_type")
|
||||
sourcingLink := getCSVValue(record, colIndex, "sourcing_link")
|
||||
longDesc := getCSVValue(record, colIndex, "long_description")
|
||||
stdCostStr := getCSVValue(record, colIndex, "standard_cost")
|
||||
|
||||
// sourcing_link and standard_cost are now properties — add to properties map
|
||||
if sl := getCSVValue(record, colIndex, "sourcing_link"); sl != "" {
|
||||
properties["sourcing_link"] = sl
|
||||
}
|
||||
if sc := getCSVValue(record, colIndex, "standard_cost"); sc != "" {
|
||||
if cost, err := strconv.ParseFloat(sc, 64); err == nil {
|
||||
properties["standard_cost"] = cost
|
||||
}
|
||||
}
|
||||
|
||||
// Create item
|
||||
item := &db.Item{
|
||||
@@ -382,17 +382,9 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
if sourcingType != "" {
|
||||
item.SourcingType = sourcingType
|
||||
}
|
||||
if sourcingLink != "" {
|
||||
item.SourcingLink = &sourcingLink
|
||||
}
|
||||
if longDesc != "" {
|
||||
item.LongDescription = &longDesc
|
||||
}
|
||||
if stdCostStr != "" {
|
||||
if cost, err := strconv.ParseFloat(stdCostStr, 64); err == nil {
|
||||
item.StandardCost = &cost
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.items.Create(ctx, item, properties); err != nil {
|
||||
result.Errors = append(result.Errors, CSVImportErr{
|
||||
@@ -585,9 +577,7 @@ func isStandardColumn(col string) bool {
|
||||
"objects": true, // FreeCAD objects data - skip on import
|
||||
"archived_at": true,
|
||||
"sourcing_type": true,
|
||||
"sourcing_link": true,
|
||||
"long_description": true,
|
||||
"standard_cost": true,
|
||||
}
|
||||
return standardCols[col]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -195,10 +196,35 @@ func (s *Server) HandleGetSchema(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, schemaToResponse(sch))
|
||||
}
|
||||
|
||||
// HandleGetPropertySchema returns the property schema for a category.
|
||||
func (s *Server) HandleGetPropertySchema(w http.ResponseWriter, r *http.Request) {
|
||||
// FormFieldDescriptor describes a single field in the form descriptor response.
|
||||
type FormFieldDescriptor struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Widget string `json:"widget,omitempty"`
|
||||
Label string `json:"label"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
|
||||
// Item-field specific
|
||||
DerivedFromCategory map[string]string `json:"derived_from_category,omitempty"`
|
||||
SearchEndpoint string `json:"search_endpoint,omitempty"`
|
||||
}
|
||||
|
||||
// FormFieldGroupDescriptor describes an ordered group of resolved fields.
|
||||
type FormFieldGroupDescriptor struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Order int `json:"order"`
|
||||
Fields []FormFieldDescriptor `json:"fields"`
|
||||
}
|
||||
|
||||
// HandleGetFormDescriptor returns the full form descriptor for a schema.
|
||||
func (s *Server) HandleGetFormDescriptor(w http.ResponseWriter, r *http.Request) {
|
||||
schemaName := chi.URLParam(r, "name")
|
||||
category := r.URL.Query().Get("category")
|
||||
|
||||
sch, ok := s.schemas[schemaName]
|
||||
if !ok {
|
||||
@@ -206,19 +232,194 @@ func (s *Server) HandleGetPropertySchema(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if sch.PropertySchemas == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"version": 0,
|
||||
"properties": map[string]any{},
|
||||
})
|
||||
return
|
||||
result := map[string]any{
|
||||
"schema_name": sch.Name,
|
||||
"format": sch.Format,
|
||||
}
|
||||
|
||||
props := sch.PropertySchemas.GetPropertiesForCategory(category)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"version": sch.PropertySchemas.Version,
|
||||
"properties": props,
|
||||
// Category picker with auto-derived values_by_domain
|
||||
if sch.UI != nil && sch.UI.CategoryPicker != nil {
|
||||
picker := map[string]any{
|
||||
"style": sch.UI.CategoryPicker.Style,
|
||||
}
|
||||
|
||||
vbd := sch.ValuesByDomain()
|
||||
|
||||
stages := make([]map[string]any, 0, len(sch.UI.CategoryPicker.Stages)+1)
|
||||
for _, stage := range sch.UI.CategoryPicker.Stages {
|
||||
stg := map[string]any{
|
||||
"name": stage.Name,
|
||||
"label": stage.Label,
|
||||
}
|
||||
if stage.Values != nil {
|
||||
stg["values"] = stage.Values
|
||||
}
|
||||
stages = append(stages, stg)
|
||||
}
|
||||
|
||||
// Auto-add subcategory stage from values_by_domain
|
||||
if vbd != nil {
|
||||
stages = append(stages, map[string]any{
|
||||
"name": "subcategory",
|
||||
"label": "Type",
|
||||
"values_by_domain": vbd,
|
||||
})
|
||||
}
|
||||
|
||||
picker["stages"] = stages
|
||||
result["category_picker"] = picker
|
||||
}
|
||||
|
||||
// Item fields
|
||||
if sch.UI != nil && sch.UI.ItemFields != nil {
|
||||
result["item_fields"] = sch.UI.ItemFields
|
||||
}
|
||||
|
||||
// Resolve field groups into ordered list with full field metadata
|
||||
if sch.UI != nil && sch.UI.FieldGroups != nil {
|
||||
groups := s.resolveFieldGroups(sch, sch.UI.FieldGroups)
|
||||
result["field_groups"] = groups
|
||||
}
|
||||
|
||||
// Category field groups
|
||||
if sch.UI != nil && sch.UI.CategoryFieldGroups != nil {
|
||||
catGroups := make(map[string][]FormFieldGroupDescriptor)
|
||||
for prefix, groups := range sch.UI.CategoryFieldGroups {
|
||||
catGroups[prefix] = s.resolveCategoryFieldGroups(sch, prefix, groups)
|
||||
}
|
||||
result["category_field_groups"] = catGroups
|
||||
}
|
||||
|
||||
// Field overrides (pass through)
|
||||
if sch.UI != nil && sch.UI.FieldOverrides != nil {
|
||||
result["field_overrides"] = sch.UI.FieldOverrides
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// resolveFieldGroups converts field group definitions into fully resolved descriptors.
|
||||
func (s *Server) resolveFieldGroups(sch *schema.Schema, groups map[string]schema.FieldGroup) []FormFieldGroupDescriptor {
|
||||
result := make([]FormFieldGroupDescriptor, 0, len(groups))
|
||||
for key, group := range groups {
|
||||
desc := FormFieldGroupDescriptor{
|
||||
Key: key,
|
||||
Label: group.Label,
|
||||
Order: group.Order,
|
||||
}
|
||||
for _, fieldName := range group.Fields {
|
||||
fd := s.resolveField(sch, fieldName)
|
||||
desc.Fields = append(desc.Fields, fd)
|
||||
}
|
||||
result = append(result, desc)
|
||||
}
|
||||
// Sort by order
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Order < result[j].Order
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveCategoryFieldGroups resolves category-specific field groups.
|
||||
func (s *Server) resolveCategoryFieldGroups(sch *schema.Schema, prefix string, groups map[string]schema.FieldGroup) []FormFieldGroupDescriptor {
|
||||
result := make([]FormFieldGroupDescriptor, 0, len(groups))
|
||||
for key, group := range groups {
|
||||
desc := FormFieldGroupDescriptor{
|
||||
Key: key,
|
||||
Label: group.Label,
|
||||
Order: group.Order,
|
||||
}
|
||||
for _, fieldName := range group.Fields {
|
||||
fd := s.resolveCategoryField(sch, prefix, fieldName)
|
||||
desc.Fields = append(desc.Fields, fd)
|
||||
}
|
||||
result = append(result, desc)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Order < result[j].Order
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveField builds a FormFieldDescriptor from item_fields or property_schemas.defaults.
|
||||
func (s *Server) resolveField(sch *schema.Schema, name string) FormFieldDescriptor {
|
||||
fd := FormFieldDescriptor{Name: name}
|
||||
|
||||
// Check item_fields first
|
||||
if sch.UI != nil && sch.UI.ItemFields != nil {
|
||||
if def, ok := sch.UI.ItemFields[name]; ok {
|
||||
fd.Type = def.Type
|
||||
fd.Widget = def.Widget
|
||||
fd.Label = def.Label
|
||||
fd.Required = def.Required
|
||||
fd.Default = def.Default
|
||||
fd.Options = def.Options
|
||||
fd.DerivedFromCategory = def.DerivedFromCategory
|
||||
fd.SearchEndpoint = def.SearchEndpoint
|
||||
s.applyOverrides(sch, name, &fd)
|
||||
return fd
|
||||
}
|
||||
}
|
||||
|
||||
// Check property_schemas.defaults
|
||||
if sch.PropertySchemas != nil && sch.PropertySchemas.Defaults != nil {
|
||||
if def, ok := sch.PropertySchemas.Defaults[name]; ok {
|
||||
fd.Type = def.Type
|
||||
fd.Label = name // Use field name as label if not overridden
|
||||
fd.Default = def.Default
|
||||
fd.Unit = def.Unit
|
||||
fd.Description = def.Description
|
||||
fd.Required = def.Required
|
||||
s.applyOverrides(sch, name, &fd)
|
||||
return fd
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback — field name only
|
||||
fd.Label = name
|
||||
fd.Type = "string"
|
||||
s.applyOverrides(sch, name, &fd)
|
||||
return fd
|
||||
}
|
||||
|
||||
// resolveCategoryField builds a FormFieldDescriptor from category-specific property schema.
|
||||
func (s *Server) resolveCategoryField(sch *schema.Schema, prefix, name string) FormFieldDescriptor {
|
||||
fd := FormFieldDescriptor{Name: name, Label: name, Type: "string"}
|
||||
|
||||
if sch.PropertySchemas != nil {
|
||||
if catProps, ok := sch.PropertySchemas.Categories[prefix]; ok {
|
||||
if def, ok := catProps[name]; ok {
|
||||
fd.Type = def.Type
|
||||
fd.Default = def.Default
|
||||
fd.Unit = def.Unit
|
||||
fd.Description = def.Description
|
||||
fd.Required = def.Required
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.applyOverrides(sch, name, &fd)
|
||||
return fd
|
||||
}
|
||||
|
||||
// applyOverrides applies field_overrides to a field descriptor.
|
||||
func (s *Server) applyOverrides(sch *schema.Schema, name string, fd *FormFieldDescriptor) {
|
||||
if sch.UI == nil || sch.UI.FieldOverrides == nil {
|
||||
return
|
||||
}
|
||||
ov, ok := sch.UI.FieldOverrides[name]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ov.Widget != "" {
|
||||
fd.Widget = ov.Widget
|
||||
}
|
||||
if ov.Currency != "" {
|
||||
fd.Currency = ov.Currency
|
||||
}
|
||||
if len(ov.Options) > 0 {
|
||||
fd.Options = ov.Options
|
||||
}
|
||||
}
|
||||
|
||||
func schemaToResponse(sch *schema.Schema) SchemaResponse {
|
||||
@@ -256,10 +457,10 @@ type ItemResponse struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
SourcingType string `json:"sourcing_type"`
|
||||
SourcingLink *string `json:"sourcing_link,omitempty"`
|
||||
LongDescription *string `json:"long_description,omitempty"`
|
||||
StandardCost *float64 `json:"standard_cost,omitempty"`
|
||||
ThumbnailKey *string `json:"thumbnail_key,omitempty"`
|
||||
FileCount int `json:"file_count"`
|
||||
FilesTotalSize int64 `json:"files_total_size"`
|
||||
Properties map[string]any `json:"properties,omitempty"`
|
||||
}
|
||||
|
||||
@@ -271,9 +472,7 @@ type CreateItemRequest struct {
|
||||
Projects []string `json:"projects,omitempty"`
|
||||
Properties map[string]any `json:"properties,omitempty"`
|
||||
SourcingType string `json:"sourcing_type,omitempty"`
|
||||
SourcingLink *string `json:"sourcing_link,omitempty"`
|
||||
LongDescription *string `json:"long_description,omitempty"`
|
||||
StandardCost *float64 `json:"standard_cost,omitempty"`
|
||||
}
|
||||
|
||||
// HandleListItems lists items with optional filtering.
|
||||
@@ -304,9 +503,20 @@ func (s *Server) HandleListItems(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Batch-fetch file attachment stats
|
||||
ids := make([]string, len(items))
|
||||
for i, item := range items {
|
||||
ids[i] = item.ID
|
||||
}
|
||||
fileStats, _ := s.items.BatchGetFileStats(ctx, ids)
|
||||
|
||||
response := make([]ItemResponse, len(items))
|
||||
for i, item := range items {
|
||||
response[i] = itemToResponse(item)
|
||||
if fs, ok := fileStats[item.ID]; ok {
|
||||
response[i].FileCount = fs.Count
|
||||
response[i].FilesTotalSize = fs.TotalSize
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
@@ -416,9 +626,7 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
|
||||
ItemType: itemType,
|
||||
Description: req.Description,
|
||||
SourcingType: req.SourcingType,
|
||||
SourcingLink: req.SourcingLink,
|
||||
LongDescription: req.LongDescription,
|
||||
StandardCost: req.StandardCost,
|
||||
}
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
item.CreatedBy = &user.Username
|
||||
@@ -465,6 +673,34 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
|
||||
s.broker.Publish("item.created", mustMarshal(resp))
|
||||
}
|
||||
|
||||
// HandleGetItemByUUID retrieves an item by its stable UUID (the items.id column).
|
||||
// Used by silo-mod to resolve FreeCAD document SiloUUID properties to part numbers.
|
||||
func (s *Server) HandleGetItemByUUID(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
uuid := chi.URLParam(r, "uuid")
|
||||
|
||||
item, err := s.items.GetByID(ctx, uuid)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to get item by UUID")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
||||
return
|
||||
}
|
||||
if item == nil || item.ArchivedAt != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
|
||||
response := itemToResponse(item)
|
||||
if fileStats, err := s.items.BatchGetFileStats(ctx, []string{item.ID}); err == nil {
|
||||
if fs, ok := fileStats[item.ID]; ok {
|
||||
response.FileCount = fs.Count
|
||||
response.FilesTotalSize = fs.TotalSize
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// HandleGetItem retrieves an item by part number.
|
||||
// Supports query param: ?include=properties to include current revision properties.
|
||||
func (s *Server) HandleGetItem(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -484,6 +720,14 @@ func (s *Server) HandleGetItem(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
response := itemToResponse(item)
|
||||
|
||||
// File attachment stats
|
||||
if fileStats, err := s.items.BatchGetFileStats(ctx, []string{item.ID}); err == nil {
|
||||
if fs, ok := fileStats[item.ID]; ok {
|
||||
response.FileCount = fs.Count
|
||||
response.FilesTotalSize = fs.TotalSize
|
||||
}
|
||||
}
|
||||
|
||||
// Include properties from current revision if requested
|
||||
if r.URL.Query().Get("include") == "properties" {
|
||||
revisions, err := s.items.GetRevisions(ctx, item.ID)
|
||||
@@ -508,9 +752,7 @@ type UpdateItemRequest struct {
|
||||
Properties map[string]any `json:"properties,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
SourcingType *string `json:"sourcing_type,omitempty"`
|
||||
SourcingLink *string `json:"sourcing_link,omitempty"`
|
||||
LongDescription *string `json:"long_description,omitempty"`
|
||||
StandardCost *float64 `json:"standard_cost,omitempty"`
|
||||
}
|
||||
|
||||
// HandleUpdateItem updates an item's fields and/or creates a new revision.
|
||||
@@ -541,9 +783,7 @@ func (s *Server) HandleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
ItemType: item.ItemType,
|
||||
Description: item.Description,
|
||||
SourcingType: req.SourcingType,
|
||||
SourcingLink: req.SourcingLink,
|
||||
LongDescription: req.LongDescription,
|
||||
StandardCost: req.StandardCost,
|
||||
}
|
||||
|
||||
if req.PartNumber != "" {
|
||||
@@ -1155,9 +1395,7 @@ func itemToResponse(item *db.Item) ItemResponse {
|
||||
CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
SourcingType: item.SourcingType,
|
||||
SourcingLink: item.SourcingLink,
|
||||
LongDescription: item.LongDescription,
|
||||
StandardCost: item.StandardCost,
|
||||
ThumbnailKey: item.ThumbnailKey,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,21 +138,11 @@ func (s *Server) HandleExportODS(w http.ResponseWriter, r *http.Request) {
|
||||
ods.StringCell(item.SourcingType),
|
||||
}
|
||||
|
||||
if item.SourcingLink != nil {
|
||||
cells = append(cells, ods.StringCell(*item.SourcingLink))
|
||||
} else {
|
||||
cells = append(cells, ods.EmptyCell())
|
||||
}
|
||||
if item.LongDescription != nil {
|
||||
cells = append(cells, ods.StringCell(*item.LongDescription))
|
||||
} else {
|
||||
cells = append(cells, ods.EmptyCell())
|
||||
}
|
||||
if item.StandardCost != nil {
|
||||
cells = append(cells, ods.CurrencyCell(*item.StandardCost))
|
||||
} else {
|
||||
cells = append(cells, ods.EmptyCell())
|
||||
}
|
||||
|
||||
// Property columns
|
||||
if includeProps {
|
||||
@@ -419,6 +409,16 @@ func (s *Server) HandleImportODS(w http.ResponseWriter, r *http.Request) {
|
||||
longDesc := getCellValue("long_description")
|
||||
stdCostStr := getCellValue("standard_cost")
|
||||
|
||||
// Put sourcing_link and standard_cost into properties
|
||||
if sourcingLink != "" {
|
||||
properties["sourcing_link"] = sourcingLink
|
||||
}
|
||||
if stdCostStr != "" {
|
||||
if cost, err := strconv.ParseFloat(strings.TrimLeft(stdCostStr, "$"), 64); err == nil {
|
||||
properties["standard_cost"] = cost
|
||||
}
|
||||
}
|
||||
|
||||
item := &db.Item{
|
||||
PartNumber: partNumber,
|
||||
ItemType: itemType,
|
||||
@@ -430,17 +430,9 @@ func (s *Server) HandleImportODS(w http.ResponseWriter, r *http.Request) {
|
||||
if sourcingType != "" {
|
||||
item.SourcingType = sourcingType
|
||||
}
|
||||
if sourcingLink != "" {
|
||||
item.SourcingLink = &sourcingLink
|
||||
}
|
||||
if longDesc != "" {
|
||||
item.LongDescription = &longDesc
|
||||
}
|
||||
if stdCostStr != "" {
|
||||
if cost, err := strconv.ParseFloat(strings.TrimLeft(stdCostStr, "$"), 64); err == nil {
|
||||
item.StandardCost = &cost
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.items.Create(ctx, item, properties); err != nil {
|
||||
result.Errors = append(result.Errors, CSVImportErr{
|
||||
@@ -580,9 +572,16 @@ func (s *Server) HandleExportBOMODS(w http.ResponseWriter, r *http.Request) {
|
||||
childItem, _ := s.items.GetByPartNumber(ctx, e.ChildPartNumber)
|
||||
|
||||
unitCost, hasUnitCost := getMetaFloat(e.Metadata, "unit_cost")
|
||||
if !hasUnitCost && childItem != nil && childItem.StandardCost != nil {
|
||||
unitCost = *childItem.StandardCost
|
||||
hasUnitCost = true
|
||||
if !hasUnitCost && childItem != nil {
|
||||
// Fall back to standard_cost from revision properties
|
||||
if childProps := itemPropsCache[e.ChildPartNumber]; childProps != nil {
|
||||
if sc, ok := childProps["standard_cost"]; ok {
|
||||
if cost, cok := sc.(float64); cok {
|
||||
unitCost = cost
|
||||
hasUnitCost = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
qty := 0.0
|
||||
@@ -599,7 +598,7 @@ func (s *Server) HandleExportBOMODS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
source := getMetaString(e.Metadata, "source")
|
||||
source := e.Source
|
||||
if source == "" && childItem != nil {
|
||||
st := childItem.SourcingType
|
||||
if st == "manufactured" {
|
||||
@@ -682,6 +681,21 @@ func (s *Server) HandleProjectSheetODS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Build item properties cache for sourcing_link / standard_cost
|
||||
itemPropsMap := make(map[string]map[string]any)
|
||||
for _, item := range items {
|
||||
revisions, err := s.items.GetRevisions(ctx, item.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, rev := range revisions {
|
||||
if rev.RevisionNumber == item.CurrentRevision && rev.Properties != nil {
|
||||
itemPropsMap[item.ID] = rev.Properties
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sheet 1: Items list
|
||||
itemHeaders := []string{
|
||||
"PN", "Type", "Description", "Revision", "Category",
|
||||
@@ -696,6 +710,8 @@ func (s *Server) HandleProjectSheetODS(w http.ResponseWriter, r *http.Request) {
|
||||
itemRows = append(itemRows, ods.Row{Cells: itemHeaderCells})
|
||||
|
||||
for _, item := range items {
|
||||
props := itemPropsMap[item.ID]
|
||||
|
||||
cells := []ods.Cell{
|
||||
ods.StringCell(item.PartNumber),
|
||||
ods.StringCell(item.ItemType),
|
||||
@@ -704,13 +720,17 @@ func (s *Server) HandleProjectSheetODS(w http.ResponseWriter, r *http.Request) {
|
||||
ods.StringCell(parseCategory(item.PartNumber)),
|
||||
ods.StringCell(item.SourcingType),
|
||||
}
|
||||
if item.SourcingLink != nil {
|
||||
cells = append(cells, ods.StringCell(*item.SourcingLink))
|
||||
if sl, ok := props["sourcing_link"]; ok {
|
||||
cells = append(cells, ods.StringCell(formatPropertyValue(sl)))
|
||||
} else {
|
||||
cells = append(cells, ods.EmptyCell())
|
||||
}
|
||||
if item.StandardCost != nil {
|
||||
cells = append(cells, ods.CurrencyCell(*item.StandardCost))
|
||||
if sc, ok := props["standard_cost"]; ok {
|
||||
if cost, cok := sc.(float64); cok {
|
||||
cells = append(cells, ods.CurrencyCell(cost))
|
||||
} else {
|
||||
cells = append(cells, ods.StringCell(formatPropertyValue(sc)))
|
||||
}
|
||||
} else {
|
||||
cells = append(cells, ods.EmptyCell())
|
||||
}
|
||||
@@ -746,15 +766,33 @@ func (s *Server) HandleProjectSheetODS(w http.ResponseWriter, r *http.Request) {
|
||||
for _, e := range bomEntries {
|
||||
childItem, _ := s.items.GetByPartNumber(ctx, e.ChildPartNumber)
|
||||
unitCost, hasUnitCost := getMetaFloat(e.Metadata, "unit_cost")
|
||||
if !hasUnitCost && childItem != nil && childItem.StandardCost != nil {
|
||||
unitCost = *childItem.StandardCost
|
||||
hasUnitCost = true
|
||||
if !hasUnitCost && childItem != nil {
|
||||
// Fall back to standard_cost from revision properties
|
||||
// Ensure child item props are cached
|
||||
if _, cached := itemPropsMap[childItem.ID]; !cached {
|
||||
if revs, err := s.items.GetRevisions(ctx, childItem.ID); err == nil {
|
||||
for _, rev := range revs {
|
||||
if rev.RevisionNumber == childItem.CurrentRevision && rev.Properties != nil {
|
||||
itemPropsMap[childItem.ID] = rev.Properties
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if childRevProps := itemPropsMap[childItem.ID]; childRevProps != nil {
|
||||
if sc, ok := childRevProps["standard_cost"]; ok {
|
||||
if cost, cok := sc.(float64); cok {
|
||||
unitCost = cost
|
||||
hasUnitCost = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
qty := 0.0
|
||||
if e.Quantity != nil {
|
||||
qty = *e.Quantity
|
||||
}
|
||||
source := getMetaString(e.Metadata, "source")
|
||||
source := e.Source
|
||||
if source == "" && childItem != nil {
|
||||
if childItem.SourcingType == "manufactured" {
|
||||
source = "M"
|
||||
@@ -957,7 +995,20 @@ func (s *Server) HandleSheetDiff(w http.ResponseWriter, r *http.Request) {
|
||||
if costStr != "" {
|
||||
costStr = strings.TrimLeft(costStr, "$")
|
||||
if cost, err := strconv.ParseFloat(costStr, 64); err == nil {
|
||||
if dbItem.StandardCost == nil || *dbItem.StandardCost != cost {
|
||||
// Compare against standard_cost in revision properties
|
||||
revisions, _ := s.items.GetRevisions(ctx, dbItem.ID)
|
||||
var dbCost *float64
|
||||
for _, rev := range revisions {
|
||||
if rev.RevisionNumber == dbItem.CurrentRevision && rev.Properties != nil {
|
||||
if sc, ok := rev.Properties["standard_cost"]; ok {
|
||||
if c, cok := sc.(float64); cok {
|
||||
dbCost = &c
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if dbCost == nil || *dbCost != cost {
|
||||
changes["standard_cost"] = cost
|
||||
}
|
||||
}
|
||||
@@ -986,8 +1037,11 @@ func buildBOMRow(itemLabel string, depth int, source, pn string, item *db.Item,
|
||||
|
||||
if item != nil {
|
||||
description = item.Description
|
||||
if sourcingLink == "" && item.SourcingLink != nil {
|
||||
sourcingLink = *item.SourcingLink
|
||||
}
|
||||
// Fall back to sourcing_link from revision properties
|
||||
if sourcingLink == "" && props != nil {
|
||||
if sl, ok := props["sourcing_link"]; ok {
|
||||
sourcingLink = formatPropertyValue(sl)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
||||
r.Route("/schemas", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListSchemas)
|
||||
r.Get("/{name}", server.HandleGetSchema)
|
||||
r.Get("/{name}/properties", server.HandleGetPropertySchema)
|
||||
r.Get("/{name}/form", server.HandleGetFormDescriptor)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.RequireWritable)
|
||||
@@ -119,6 +119,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
||||
r.Route("/items", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListItems)
|
||||
r.Get("/search", server.HandleFuzzySearch)
|
||||
r.Get("/by-uuid/{uuid}", server.HandleGetItemByUUID)
|
||||
r.Get("/export.csv", server.HandleExportCSV)
|
||||
r.Get("/template.csv", server.HandleCSVTemplate)
|
||||
r.Get("/export.ods", server.HandleExportODS)
|
||||
@@ -165,6 +166,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
||||
r.Put("/thumbnail", server.HandleSetItemThumbnail)
|
||||
r.Post("/bom", server.HandleAddBOMEntry)
|
||||
r.Post("/bom/import", server.HandleImportBOMCSV)
|
||||
r.Post("/bom/merge", server.HandleMergeBOM)
|
||||
r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry)
|
||||
r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry)
|
||||
})
|
||||
|
||||
@@ -16,9 +16,12 @@ func (s *Server) HandleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Disable the write deadline for this long-lived connection.
|
||||
// The server's WriteTimeout (15s) would otherwise kill it.
|
||||
// Disable read and write deadlines for this long-lived connection.
|
||||
// The server's ReadTimeout/WriteTimeout (15s) would otherwise kill it.
|
||||
rc := http.NewResponseController(w)
|
||||
if err := rc.SetReadDeadline(time.Time{}); err != nil {
|
||||
s.logger.Warn().Err(err).Msg("failed to disable read deadline for SSE")
|
||||
}
|
||||
if err := rc.SetWriteDeadline(time.Time{}); err != nil {
|
||||
s.logger.Warn().Err(err).Msg("failed to disable write deadline for SSE")
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func (r *ItemRepository) ListItemsWithProperties(ctx context.Context, opts Audit
|
||||
query = `
|
||||
SELECT DISTINCT i.id, i.part_number, i.schema_id, i.item_type, i.description,
|
||||
i.created_at, i.updated_at, i.archived_at, i.current_revision,
|
||||
i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost,
|
||||
i.sourcing_type, i.long_description,
|
||||
COALESCE(r.properties, '{}'::jsonb) as properties
|
||||
FROM items i
|
||||
LEFT JOIN revisions r ON r.item_id = i.id AND r.revision_number = i.current_revision
|
||||
@@ -45,7 +45,7 @@ func (r *ItemRepository) ListItemsWithProperties(ctx context.Context, opts Audit
|
||||
query = `
|
||||
SELECT i.id, i.part_number, i.schema_id, i.item_type, i.description,
|
||||
i.created_at, i.updated_at, i.archived_at, i.current_revision,
|
||||
i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost,
|
||||
i.sourcing_type, i.long_description,
|
||||
COALESCE(r.properties, '{}'::jsonb) as properties
|
||||
FROM items i
|
||||
LEFT JOIN revisions r ON r.item_id = i.id AND r.revision_number = i.current_revision
|
||||
@@ -85,7 +85,7 @@ func (r *ItemRepository) ListItemsWithProperties(ctx context.Context, opts Audit
|
||||
err := rows.Scan(
|
||||
&iwp.ID, &iwp.PartNumber, &iwp.SchemaID, &iwp.ItemType, &iwp.Description,
|
||||
&iwp.CreatedAt, &iwp.UpdatedAt, &iwp.ArchivedAt, &iwp.CurrentRevision,
|
||||
&iwp.SourcingType, &iwp.SourcingLink, &iwp.LongDescription, &iwp.StandardCost,
|
||||
&iwp.SourcingType, &iwp.LongDescription,
|
||||
&propsJSON,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -134,6 +134,43 @@ func (r *ItemRepository) BatchCheckBOM(ctx context.Context, itemIDs []string) (m
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FileStats holds aggregated file attachment statistics for an item.
|
||||
type FileStats struct {
|
||||
Count int
|
||||
TotalSize int64
|
||||
}
|
||||
|
||||
// BatchGetFileStats returns a map of item ID to file attachment statistics
|
||||
// for the given item IDs. Items not in the map have no files.
|
||||
func (r *ItemRepository) BatchGetFileStats(ctx context.Context, itemIDs []string) (map[string]FileStats, error) {
|
||||
if len(itemIDs) == 0 {
|
||||
return map[string]FileStats{}, nil
|
||||
}
|
||||
|
||||
rows, err := r.db.pool.Query(ctx, `
|
||||
SELECT item_id, COUNT(*), COALESCE(SUM(size), 0)
|
||||
FROM item_files
|
||||
WHERE item_id = ANY($1)
|
||||
GROUP BY item_id
|
||||
`, itemIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("batch getting file stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]FileStats)
|
||||
for rows.Next() {
|
||||
var itemID string
|
||||
var fs FileStats
|
||||
if err := rows.Scan(&itemID, &fs.Count, &fs.TotalSize); err != nil {
|
||||
return nil, fmt.Errorf("scanning file stats: %w", err)
|
||||
}
|
||||
result[itemID] = fs
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// BatchGetProjectCodes returns a map of item ID to project code list for
|
||||
// the given item IDs.
|
||||
func (r *ItemRepository) BatchGetProjectCodes(ctx context.Context, itemIDs []string) (map[string][]string, error) {
|
||||
|
||||
@@ -24,11 +24,9 @@ type Item struct {
|
||||
CADFilePath *string
|
||||
CreatedBy *string
|
||||
UpdatedBy *string
|
||||
SourcingType string // "manufactured" or "purchased"
|
||||
SourcingLink *string // URL to supplier/datasheet
|
||||
LongDescription *string // extended description
|
||||
StandardCost *float64 // baseline unit cost
|
||||
ThumbnailKey *string // MinIO key for item thumbnail
|
||||
SourcingType string // "manufactured" or "purchased"
|
||||
LongDescription *string // extended description
|
||||
ThumbnailKey *string // MinIO key for item thumbnail
|
||||
}
|
||||
|
||||
// Revision represents a revision record.
|
||||
@@ -96,11 +94,11 @@ func (r *ItemRepository) Create(ctx context.Context, item *Item, properties map[
|
||||
}
|
||||
err := tx.QueryRow(ctx, `
|
||||
INSERT INTO items (part_number, schema_id, item_type, description, created_by,
|
||||
sourcing_type, sourcing_link, long_description, standard_cost)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
sourcing_type, long_description)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, created_at, updated_at, current_revision
|
||||
`, item.PartNumber, item.SchemaID, item.ItemType, item.Description, item.CreatedBy,
|
||||
sourcingType, item.SourcingLink, item.LongDescription, item.StandardCost,
|
||||
sourcingType, item.LongDescription,
|
||||
).Scan(
|
||||
&item.ID, &item.CreatedAt, &item.UpdatedAt, &item.CurrentRevision,
|
||||
)
|
||||
@@ -133,7 +131,7 @@ func (r *ItemRepository) GetByPartNumber(ctx context.Context, partNumber string)
|
||||
SELECT id, part_number, schema_id, item_type, description,
|
||||
created_at, updated_at, archived_at, current_revision,
|
||||
cad_synced_at, cad_file_path,
|
||||
sourcing_type, sourcing_link, long_description, standard_cost,
|
||||
sourcing_type, long_description,
|
||||
thumbnail_key
|
||||
FROM items
|
||||
WHERE part_number = $1 AND archived_at IS NULL
|
||||
@@ -141,7 +139,7 @@ func (r *ItemRepository) GetByPartNumber(ctx context.Context, partNumber string)
|
||||
&item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description,
|
||||
&item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision,
|
||||
&item.CADSyncedAt, &item.CADFilePath,
|
||||
&item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost,
|
||||
&item.SourcingType, &item.LongDescription,
|
||||
&item.ThumbnailKey,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
@@ -160,7 +158,7 @@ func (r *ItemRepository) GetByID(ctx context.Context, id string) (*Item, error)
|
||||
SELECT id, part_number, schema_id, item_type, description,
|
||||
created_at, updated_at, archived_at, current_revision,
|
||||
cad_synced_at, cad_file_path,
|
||||
sourcing_type, sourcing_link, long_description, standard_cost,
|
||||
sourcing_type, long_description,
|
||||
thumbnail_key
|
||||
FROM items
|
||||
WHERE id = $1
|
||||
@@ -168,7 +166,7 @@ func (r *ItemRepository) GetByID(ctx context.Context, id string) (*Item, error)
|
||||
&item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description,
|
||||
&item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision,
|
||||
&item.CADSyncedAt, &item.CADFilePath,
|
||||
&item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost,
|
||||
&item.SourcingType, &item.LongDescription,
|
||||
&item.ThumbnailKey,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
@@ -192,7 +190,7 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e
|
||||
query = `
|
||||
SELECT DISTINCT i.id, i.part_number, i.schema_id, i.item_type, i.description,
|
||||
i.created_at, i.updated_at, i.archived_at, i.current_revision,
|
||||
i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost,
|
||||
i.sourcing_type, i.long_description,
|
||||
i.thumbnail_key
|
||||
FROM items i
|
||||
JOIN item_projects ip ON ip.item_id = i.id
|
||||
@@ -205,7 +203,7 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e
|
||||
query = `
|
||||
SELECT id, part_number, schema_id, item_type, description,
|
||||
created_at, updated_at, archived_at, current_revision,
|
||||
sourcing_type, sourcing_link, long_description, standard_cost,
|
||||
sourcing_type, long_description,
|
||||
thumbnail_key
|
||||
FROM items
|
||||
WHERE archived_at IS NULL
|
||||
@@ -257,7 +255,7 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e
|
||||
err := rows.Scan(
|
||||
&item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description,
|
||||
&item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision,
|
||||
&item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost,
|
||||
&item.SourcingType, &item.LongDescription,
|
||||
&item.ThumbnailKey,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -659,9 +657,7 @@ type UpdateItemFields struct {
|
||||
Description string
|
||||
UpdatedBy *string
|
||||
SourcingType *string
|
||||
SourcingLink *string
|
||||
LongDescription *string
|
||||
StandardCost *float64
|
||||
}
|
||||
|
||||
// Update modifies an item's fields. The UUID remains stable.
|
||||
@@ -670,16 +666,12 @@ func (r *ItemRepository) Update(ctx context.Context, id string, fields UpdateIte
|
||||
UPDATE items
|
||||
SET part_number = $2, item_type = $3, description = $4, updated_by = $5,
|
||||
sourcing_type = COALESCE($6, sourcing_type),
|
||||
sourcing_link = CASE WHEN $7::boolean THEN $8 ELSE sourcing_link END,
|
||||
long_description = CASE WHEN $9::boolean THEN $10 ELSE long_description END,
|
||||
standard_cost = CASE WHEN $11::boolean THEN $12 ELSE standard_cost END,
|
||||
long_description = CASE WHEN $7::boolean THEN $8 ELSE long_description END,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND archived_at IS NULL
|
||||
`, id, fields.PartNumber, fields.ItemType, fields.Description, fields.UpdatedBy,
|
||||
fields.SourcingType,
|
||||
fields.SourcingLink != nil, fields.SourcingLink,
|
||||
fields.LongDescription != nil, fields.LongDescription,
|
||||
fields.StandardCost != nil, fields.StandardCost,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating item: %w", err)
|
||||
|
||||
@@ -134,12 +134,10 @@ func TestItemUpdate(t *testing.T) {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
cost := 42.50
|
||||
err := repo.Update(ctx, item.ID, UpdateItemFields{
|
||||
PartNumber: "UPD-001",
|
||||
ItemType: "part",
|
||||
Description: "updated",
|
||||
StandardCost: &cost,
|
||||
PartNumber: "UPD-001",
|
||||
ItemType: "part",
|
||||
Description: "updated",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
@@ -149,9 +147,6 @@ func TestItemUpdate(t *testing.T) {
|
||||
if got.Description != "updated" {
|
||||
t.Errorf("description: got %q, want %q", got.Description, "updated")
|
||||
}
|
||||
if got.StandardCost == nil || *got.StandardCost != 42.50 {
|
||||
t.Errorf("standard_cost: got %v, want 42.50", got.StandardCost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemArchiveUnarchive(t *testing.T) {
|
||||
|
||||
@@ -240,7 +240,7 @@ func (r *ProjectRepository) GetItemsForProject(ctx context.Context, projectID st
|
||||
SELECT i.id, i.part_number, i.schema_id, i.item_type, i.description,
|
||||
i.created_at, i.updated_at, i.archived_at, i.current_revision,
|
||||
i.cad_synced_at, i.cad_file_path,
|
||||
i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost,
|
||||
i.sourcing_type, i.long_description,
|
||||
i.thumbnail_key
|
||||
FROM items i
|
||||
JOIN item_projects ip ON ip.item_id = i.id
|
||||
@@ -259,7 +259,7 @@ func (r *ProjectRepository) GetItemsForProject(ctx context.Context, projectID st
|
||||
&item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description,
|
||||
&item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision,
|
||||
&item.CADSyncedAt, &item.CADFilePath,
|
||||
&item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost,
|
||||
&item.SourcingType, &item.LongDescription,
|
||||
&item.ThumbnailKey,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -23,6 +23,7 @@ type Relationship struct {
|
||||
ChildRevision *int
|
||||
Metadata map[string]any
|
||||
ParentRevisionID *string
|
||||
Source string // "manual" or "assembly"
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CreatedBy *string
|
||||
@@ -46,6 +47,7 @@ type BOMEntry struct {
|
||||
ChildRevision *int
|
||||
EffectiveRevision int
|
||||
Metadata map[string]any
|
||||
Source string
|
||||
}
|
||||
|
||||
// BOMTreeEntry extends BOMEntry with depth for multi-level BOM expansion.
|
||||
@@ -84,16 +86,21 @@ func (r *RelationshipRepository) Create(ctx context.Context, rel *Relationship)
|
||||
}
|
||||
}
|
||||
|
||||
source := rel.Source
|
||||
if source == "" {
|
||||
source = "manual"
|
||||
}
|
||||
|
||||
err = r.db.pool.QueryRow(ctx, `
|
||||
INSERT INTO relationships (
|
||||
parent_item_id, child_item_id, rel_type, quantity, unit,
|
||||
reference_designators, child_revision, metadata, parent_revision_id, created_by
|
||||
reference_designators, child_revision, metadata, parent_revision_id, created_by, source
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id, created_at, updated_at
|
||||
`, rel.ParentItemID, rel.ChildItemID, rel.RelType, rel.Quantity, rel.Unit,
|
||||
rel.ReferenceDesignators, rel.ChildRevision, metadataJSON, rel.ParentRevisionID,
|
||||
rel.CreatedBy,
|
||||
rel.CreatedBy, source,
|
||||
).Scan(&rel.ID, &rel.CreatedAt, &rel.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inserting relationship: %w", err)
|
||||
@@ -256,7 +263,7 @@ func (r *RelationshipRepository) GetBOM(ctx context.Context, parentItemID string
|
||||
rel.rel_type, rel.quantity, rel.unit,
|
||||
rel.reference_designators, rel.child_revision,
|
||||
COALESCE(rel.child_revision, child.current_revision) AS effective_revision,
|
||||
rel.metadata
|
||||
rel.metadata, rel.source
|
||||
FROM relationships rel
|
||||
JOIN items parent ON parent.id = rel.parent_item_id
|
||||
JOIN items child ON child.id = rel.child_item_id
|
||||
@@ -281,7 +288,7 @@ func (r *RelationshipRepository) GetWhereUsed(ctx context.Context, childItemID s
|
||||
rel.rel_type, rel.quantity, rel.unit,
|
||||
rel.reference_designators, rel.child_revision,
|
||||
COALESCE(rel.child_revision, child.current_revision) AS effective_revision,
|
||||
rel.metadata
|
||||
rel.metadata, rel.source
|
||||
FROM relationships rel
|
||||
JOIN items parent ON parent.id = rel.parent_item_id
|
||||
JOIN items child ON child.id = rel.child_item_id
|
||||
@@ -315,7 +322,7 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI
|
||||
rel.rel_type, rel.quantity, rel.unit,
|
||||
rel.reference_designators, rel.child_revision,
|
||||
COALESCE(rel.child_revision, child.current_revision) AS effective_revision,
|
||||
rel.metadata,
|
||||
rel.metadata, rel.source,
|
||||
1 AS depth
|
||||
FROM relationships rel
|
||||
JOIN items parent ON parent.id = rel.parent_item_id
|
||||
@@ -334,7 +341,7 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI
|
||||
rel.rel_type, rel.quantity, rel.unit,
|
||||
rel.reference_designators, rel.child_revision,
|
||||
COALESCE(rel.child_revision, child.current_revision),
|
||||
rel.metadata,
|
||||
rel.metadata, rel.source,
|
||||
bt.depth + 1
|
||||
FROM relationships rel
|
||||
JOIN items parent ON parent.id = rel.parent_item_id
|
||||
@@ -347,7 +354,7 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI
|
||||
SELECT id, parent_item_id, parent_part_number, parent_description,
|
||||
child_item_id, child_part_number, child_description,
|
||||
rel_type, quantity, unit, reference_designators,
|
||||
child_revision, effective_revision, metadata, depth
|
||||
child_revision, effective_revision, metadata, source, depth
|
||||
FROM bom_tree
|
||||
ORDER BY depth, child_part_number
|
||||
`, parentItemID, maxDepth)
|
||||
@@ -366,7 +373,7 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI
|
||||
&e.ChildItemID, &e.ChildPartNumber, &childDesc,
|
||||
&e.RelType, &e.Quantity, &e.Unit,
|
||||
&e.ReferenceDesignators, &e.ChildRevision,
|
||||
&e.EffectiveRevision, &metadataJSON, &e.Depth,
|
||||
&e.EffectiveRevision, &metadataJSON, &e.Source, &e.Depth,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning BOM tree entry: %w", err)
|
||||
@@ -553,7 +560,7 @@ func scanBOMEntries(rows pgx.Rows) ([]*BOMEntry, error) {
|
||||
&e.RelType, &e.Quantity, &e.Unit,
|
||||
&e.ReferenceDesignators, &e.ChildRevision,
|
||||
&e.EffectiveRevision,
|
||||
&metadataJSON,
|
||||
&metadataJSON, &e.Source,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning BOM entry: %w", err)
|
||||
|
||||
@@ -22,6 +22,55 @@ type Schema struct {
|
||||
Format string `yaml:"format"`
|
||||
Examples []string `yaml:"examples"`
|
||||
PropertySchemas *PropertySchemas `yaml:"property_schemas,omitempty"`
|
||||
UI *UIConfig `yaml:"ui,omitempty" json:"ui,omitempty"`
|
||||
}
|
||||
|
||||
// UIConfig defines form layout for all clients.
|
||||
type UIConfig struct {
|
||||
CategoryPicker *CategoryPickerConfig `yaml:"category_picker,omitempty" json:"category_picker,omitempty"`
|
||||
ItemFields map[string]ItemFieldDef `yaml:"item_fields,omitempty" json:"item_fields,omitempty"`
|
||||
FieldGroups map[string]FieldGroup `yaml:"field_groups,omitempty" json:"field_groups"`
|
||||
CategoryFieldGroups map[string]map[string]FieldGroup `yaml:"category_field_groups,omitempty" json:"category_field_groups,omitempty"`
|
||||
FieldOverrides map[string]FieldOverride `yaml:"field_overrides,omitempty" json:"field_overrides,omitempty"`
|
||||
}
|
||||
|
||||
// CategoryPickerConfig defines how the category picker is rendered.
|
||||
type CategoryPickerConfig struct {
|
||||
Style string `yaml:"style" json:"style"`
|
||||
Stages []CategoryPickerStage `yaml:"stages" json:"stages"`
|
||||
}
|
||||
|
||||
// CategoryPickerStage defines one stage of a multi-stage category picker.
|
||||
type CategoryPickerStage struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Label string `yaml:"label" json:"label"`
|
||||
Values map[string]string `yaml:"values,omitempty" json:"values,omitempty"`
|
||||
}
|
||||
|
||||
// ItemFieldDef defines a field stored on the items table (not in revision properties).
|
||||
type ItemFieldDef struct {
|
||||
Type string `yaml:"type" json:"type"`
|
||||
Widget string `yaml:"widget" json:"widget"`
|
||||
Label string `yaml:"label" json:"label"`
|
||||
Required bool `yaml:"required,omitempty" json:"required,omitempty"`
|
||||
Default any `yaml:"default,omitempty" json:"default,omitempty"`
|
||||
Options []string `yaml:"options,omitempty" json:"options,omitempty"`
|
||||
DerivedFromCategory map[string]string `yaml:"derived_from_category,omitempty" json:"derived_from_category,omitempty"`
|
||||
SearchEndpoint string `yaml:"search_endpoint,omitempty" json:"search_endpoint,omitempty"`
|
||||
}
|
||||
|
||||
// FieldGroup defines an ordered group of fields for form layout.
|
||||
type FieldGroup struct {
|
||||
Label string `yaml:"label" json:"label"`
|
||||
Order int `yaml:"order" json:"order"`
|
||||
Fields []string `yaml:"fields" json:"fields"`
|
||||
}
|
||||
|
||||
// FieldOverride overrides display hints for a field.
|
||||
type FieldOverride struct {
|
||||
Widget string `yaml:"widget,omitempty" json:"widget,omitempty"`
|
||||
Currency string `yaml:"currency,omitempty" json:"currency,omitempty"`
|
||||
Options []string `yaml:"options,omitempty" json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// PropertySchemas defines property schemas per category.
|
||||
@@ -180,6 +229,10 @@ func (s *Schema) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.ValidateUI(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -224,6 +277,112 @@ func (seg *Segment) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValuesByDomain groups category enum values by their first character (domain prefix).
|
||||
func (s *Schema) ValuesByDomain() map[string]map[string]string {
|
||||
catSeg := s.GetSegment("category")
|
||||
if catSeg == nil {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]map[string]string)
|
||||
for code, desc := range catSeg.Values {
|
||||
if len(code) == 0 {
|
||||
continue
|
||||
}
|
||||
domain := string(code[0])
|
||||
if result[domain] == nil {
|
||||
result[domain] = make(map[string]string)
|
||||
}
|
||||
result[domain][code] = desc
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidateUI validates the UI configuration against property schemas and segments.
|
||||
func (s *Schema) ValidateUI() error {
|
||||
if s.UI == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build a set of all known fields (item_fields + property defaults)
|
||||
knownGlobal := make(map[string]bool)
|
||||
if s.UI.ItemFields != nil {
|
||||
for k := range s.UI.ItemFields {
|
||||
knownGlobal[k] = true
|
||||
}
|
||||
}
|
||||
if s.PropertySchemas != nil {
|
||||
for k := range s.PropertySchemas.Defaults {
|
||||
knownGlobal[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Validate field_groups: every field must be a known global field
|
||||
for groupKey, group := range s.UI.FieldGroups {
|
||||
for _, field := range group.Fields {
|
||||
if !knownGlobal[field] {
|
||||
return fmt.Errorf("ui.field_groups.%s: field %q not found in item_fields or property_schemas.defaults", groupKey, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate category_field_groups: every field must exist in the category's property schema
|
||||
if s.PropertySchemas != nil {
|
||||
for prefix, groups := range s.UI.CategoryFieldGroups {
|
||||
catProps := s.PropertySchemas.Categories[prefix]
|
||||
for groupKey, group := range groups {
|
||||
for _, field := range group.Fields {
|
||||
if catProps == nil {
|
||||
return fmt.Errorf("ui.category_field_groups.%s.%s: category prefix %q has no property schema", prefix, groupKey, prefix)
|
||||
}
|
||||
if _, ok := catProps[field]; !ok {
|
||||
return fmt.Errorf("ui.category_field_groups.%s.%s: field %q not found in property_schemas.categories.%s", prefix, groupKey, field, prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate field_overrides: keys must be known fields
|
||||
for key := range s.UI.FieldOverrides {
|
||||
if !knownGlobal[key] {
|
||||
// Also check category-level properties
|
||||
found := false
|
||||
if s.PropertySchemas != nil {
|
||||
for _, catProps := range s.PropertySchemas.Categories {
|
||||
if _, ok := catProps[key]; ok {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("ui.field_overrides: field %q not found in any property schema", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate category_picker stages: first stage values must be valid domain prefixes
|
||||
if s.UI.CategoryPicker != nil && len(s.UI.CategoryPicker.Stages) > 0 {
|
||||
catSeg := s.GetSegment("category")
|
||||
if catSeg != nil {
|
||||
validPrefixes := make(map[string]bool)
|
||||
for code := range catSeg.Values {
|
||||
if len(code) > 0 {
|
||||
validPrefixes[string(code[0])] = true
|
||||
}
|
||||
}
|
||||
firstStage := s.UI.CategoryPicker.Stages[0]
|
||||
for key := range firstStage.Values {
|
||||
if !validPrefixes[key] {
|
||||
return fmt.Errorf("ui.category_picker.stages[0]: value %q is not a valid category prefix", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSegment returns a segment by name.
|
||||
func (s *Schema) GetSegment(name string) *Segment {
|
||||
for i := range s.Segments {
|
||||
|
||||
16
migrations/012_bom_source.sql
Normal file
16
migrations/012_bom_source.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Add source column to relationships table to distinguish assembly-derived
|
||||
-- BOM entries from manually-added ones.
|
||||
ALTER TABLE relationships
|
||||
ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'manual'
|
||||
CHECK (source IN ('manual', 'assembly'));
|
||||
|
||||
-- Migrate existing metadata.source values where they exist.
|
||||
-- The metadata field stores source as a free-form string; promote to column.
|
||||
UPDATE relationships
|
||||
SET source = 'manual'
|
||||
WHERE metadata->>'source' IS NOT NULL;
|
||||
|
||||
-- Remove the source key from metadata since it's now a dedicated column.
|
||||
UPDATE relationships
|
||||
SET metadata = metadata - 'source'
|
||||
WHERE metadata ? 'source';
|
||||
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;
|
||||
@@ -846,3 +846,255 @@ schema:
|
||||
type: string
|
||||
default: ""
|
||||
description: "Inspection/QC requirements"
|
||||
|
||||
# UI configuration — drives form rendering for all clients.
|
||||
ui:
|
||||
category_picker:
|
||||
style: multi_stage
|
||||
stages:
|
||||
- name: domain
|
||||
label: "Domain"
|
||||
values:
|
||||
F: "Fasteners"
|
||||
C: "Fluid Fittings"
|
||||
R: "Motion"
|
||||
S: "Structural"
|
||||
E: "Electrical"
|
||||
M: "Mechanical"
|
||||
T: "Tooling"
|
||||
A: "Assemblies"
|
||||
P: "Purchased"
|
||||
X: "Custom Fabricated"
|
||||
|
||||
# Item-level fields (stored on items table, not in revision properties)
|
||||
item_fields:
|
||||
description:
|
||||
type: string
|
||||
widget: text
|
||||
label: "Description"
|
||||
item_type:
|
||||
type: string
|
||||
widget: select
|
||||
label: "Type"
|
||||
options: [part, assembly, consumable, tool]
|
||||
derived_from_category:
|
||||
A: assembly
|
||||
T: tool
|
||||
default: part
|
||||
sourcing_type:
|
||||
type: string
|
||||
widget: select
|
||||
label: "Sourcing Type"
|
||||
options: [manufactured, purchased]
|
||||
default: manufactured
|
||||
long_description:
|
||||
type: string
|
||||
widget: textarea
|
||||
label: "Long Description"
|
||||
projects:
|
||||
type: string_array
|
||||
widget: tag_input
|
||||
label: "Projects"
|
||||
search_endpoint: "/api/projects"
|
||||
|
||||
field_groups:
|
||||
identity:
|
||||
label: "Identity"
|
||||
order: 1
|
||||
fields: [item_type, description]
|
||||
sourcing:
|
||||
label: "Sourcing"
|
||||
order: 2
|
||||
fields:
|
||||
[
|
||||
sourcing_type,
|
||||
manufacturer,
|
||||
manufacturer_pn,
|
||||
supplier,
|
||||
supplier_pn,
|
||||
sourcing_link,
|
||||
]
|
||||
cost:
|
||||
label: "Cost & Lead Time"
|
||||
order: 3
|
||||
fields: [standard_cost, lead_time_days, minimum_order_qty]
|
||||
status:
|
||||
label: "Status"
|
||||
order: 4
|
||||
fields: [lifecycle_status, rohs_compliant, country_of_origin]
|
||||
details:
|
||||
label: "Details"
|
||||
order: 5
|
||||
fields: [long_description, projects, notes]
|
||||
|
||||
# Per-category-prefix field groups (rendered after global groups)
|
||||
category_field_groups:
|
||||
F:
|
||||
fastener_specs:
|
||||
label: "Fastener Specifications"
|
||||
order: 10
|
||||
fields:
|
||||
[
|
||||
material,
|
||||
finish,
|
||||
thread_size,
|
||||
thread_pitch,
|
||||
length,
|
||||
head_type,
|
||||
drive_type,
|
||||
strength_grade,
|
||||
torque_spec,
|
||||
]
|
||||
C:
|
||||
fitting_specs:
|
||||
label: "Fitting Specifications"
|
||||
order: 10
|
||||
fields:
|
||||
[
|
||||
material,
|
||||
connection_type,
|
||||
size_1,
|
||||
size_2,
|
||||
pressure_rating,
|
||||
temperature_min,
|
||||
temperature_max,
|
||||
media_compatibility,
|
||||
seal_material,
|
||||
]
|
||||
R:
|
||||
motion_specs:
|
||||
label: "Motion Specifications"
|
||||
order: 10
|
||||
fields:
|
||||
[
|
||||
load_capacity,
|
||||
speed_rating,
|
||||
power_rating,
|
||||
voltage_nominal,
|
||||
current_nominal,
|
||||
torque_continuous,
|
||||
bore_diameter,
|
||||
travel,
|
||||
stroke,
|
||||
operating_pressure,
|
||||
]
|
||||
S:
|
||||
structural_specs:
|
||||
label: "Structural Specifications"
|
||||
order: 10
|
||||
fields:
|
||||
[
|
||||
material,
|
||||
material_spec,
|
||||
profile,
|
||||
dimension_a,
|
||||
dimension_b,
|
||||
wall_thickness,
|
||||
length,
|
||||
weight_per_length,
|
||||
finish,
|
||||
temper,
|
||||
]
|
||||
E:
|
||||
electrical_specs:
|
||||
label: "Electrical Specifications"
|
||||
order: 10
|
||||
fields:
|
||||
[
|
||||
voltage_rating,
|
||||
current_rating,
|
||||
power_rating,
|
||||
value,
|
||||
tolerance,
|
||||
package,
|
||||
mounting,
|
||||
pin_count,
|
||||
wire_gauge,
|
||||
connector_type,
|
||||
]
|
||||
M:
|
||||
mechanical_specs:
|
||||
label: "Mechanical Specifications"
|
||||
order: 10
|
||||
fields:
|
||||
[
|
||||
material,
|
||||
spring_rate,
|
||||
free_length,
|
||||
max_load,
|
||||
travel,
|
||||
inner_diameter,
|
||||
outer_diameter,
|
||||
hardness,
|
||||
temperature_range,
|
||||
]
|
||||
T:
|
||||
tooling_specs:
|
||||
label: "Tooling Specifications"
|
||||
order: 10
|
||||
fields:
|
||||
[
|
||||
material,
|
||||
tolerance,
|
||||
surface_finish,
|
||||
hardness,
|
||||
associated_part,
|
||||
machine,
|
||||
cycle_life,
|
||||
]
|
||||
A:
|
||||
assembly_specs:
|
||||
label: "Assembly Specifications"
|
||||
order: 10
|
||||
fields:
|
||||
[
|
||||
weight,
|
||||
dimensions,
|
||||
component_count,
|
||||
assembly_time,
|
||||
test_procedure,
|
||||
voltage_rating,
|
||||
current_rating,
|
||||
ip_rating,
|
||||
]
|
||||
P:
|
||||
purchased_specs:
|
||||
label: "Purchased Item Specifications"
|
||||
order: 10
|
||||
fields:
|
||||
[
|
||||
material,
|
||||
form,
|
||||
grade,
|
||||
quantity_per_unit,
|
||||
unit_of_measure,
|
||||
shelf_life,
|
||||
]
|
||||
X:
|
||||
fabrication_specs:
|
||||
label: "Fabrication Specifications"
|
||||
order: 10
|
||||
fields:
|
||||
[
|
||||
material,
|
||||
material_spec,
|
||||
finish,
|
||||
critical_dimensions,
|
||||
weight,
|
||||
process,
|
||||
secondary_operations,
|
||||
drawing_rev,
|
||||
inspection_requirements,
|
||||
]
|
||||
|
||||
field_overrides:
|
||||
standard_cost:
|
||||
widget: currency
|
||||
currency: USD
|
||||
sourcing_link:
|
||||
widget: url
|
||||
lifecycle_status:
|
||||
widget: select
|
||||
options: [active, deprecated, obsolete, prototype]
|
||||
rohs_compliant:
|
||||
widget: checkbox
|
||||
|
||||
@@ -16,9 +16,9 @@ export interface Item {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
sourcing_type: string;
|
||||
sourcing_link?: string;
|
||||
long_description?: string;
|
||||
standard_cost?: number;
|
||||
file_count: number;
|
||||
files_total_size: number;
|
||||
properties?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface BOMEntry {
|
||||
child_revision?: number;
|
||||
effective_revision: number;
|
||||
depth?: number;
|
||||
source: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -167,9 +168,7 @@ export interface CreateItemRequest {
|
||||
projects?: string[];
|
||||
properties?: Record<string, unknown>;
|
||||
sourcing_type?: string;
|
||||
sourcing_link?: string;
|
||||
long_description?: string;
|
||||
standard_cost?: number;
|
||||
}
|
||||
|
||||
export interface UpdateItemRequest {
|
||||
@@ -179,9 +178,7 @@ export interface UpdateItemRequest {
|
||||
properties?: Record<string, unknown>;
|
||||
comment?: string;
|
||||
sourcing_type?: string;
|
||||
sourcing_link?: string;
|
||||
long_description?: string;
|
||||
standard_cost?: number;
|
||||
}
|
||||
|
||||
export interface CreateRevisionRequest {
|
||||
@@ -196,6 +193,7 @@ export interface AddBOMEntryRequest {
|
||||
unit?: string;
|
||||
reference_designators?: string[];
|
||||
child_revision?: number;
|
||||
source?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -208,6 +206,38 @@ export interface UpdateBOMEntryRequest {
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// BOM Merge
|
||||
export interface MergeBOMResponse {
|
||||
status: string;
|
||||
diff: MergeBOMDiff;
|
||||
warnings: MergeWarning[];
|
||||
resolve_url: string;
|
||||
}
|
||||
|
||||
export interface MergeBOMDiff {
|
||||
added: MergeDiffEntry[];
|
||||
removed: MergeDiffEntry[];
|
||||
quantity_changed: MergeQtyChange[];
|
||||
unchanged: MergeDiffEntry[];
|
||||
}
|
||||
|
||||
export interface MergeDiffEntry {
|
||||
part_number: string;
|
||||
quantity: number | null;
|
||||
}
|
||||
|
||||
export interface MergeQtyChange {
|
||||
part_number: string;
|
||||
old_quantity: number | null;
|
||||
new_quantity: number | null;
|
||||
}
|
||||
|
||||
export interface MergeWarning {
|
||||
type: string;
|
||||
part_number: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Schema properties
|
||||
export interface PropertyDef {
|
||||
type: string;
|
||||
@@ -218,6 +248,68 @@ export interface PropertyDef {
|
||||
|
||||
export type PropertySchema = Record<string, PropertyDef>;
|
||||
|
||||
// Form Descriptor (from GET /api/schemas/{name}/form)
|
||||
export interface FormFieldDescriptor {
|
||||
name: string;
|
||||
type: string;
|
||||
widget?: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
default?: unknown;
|
||||
unit?: string;
|
||||
description?: string;
|
||||
options?: string[];
|
||||
currency?: string;
|
||||
derived_from_category?: Record<string, string>;
|
||||
search_endpoint?: string;
|
||||
}
|
||||
|
||||
export interface FormFieldGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
order: number;
|
||||
fields: FormFieldDescriptor[];
|
||||
}
|
||||
|
||||
export interface CategoryPickerStage {
|
||||
name: string;
|
||||
label: string;
|
||||
values?: Record<string, string>;
|
||||
values_by_domain?: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface CategoryPickerDescriptor {
|
||||
style: string;
|
||||
stages: CategoryPickerStage[];
|
||||
}
|
||||
|
||||
export interface ItemFieldDef {
|
||||
type: string;
|
||||
widget: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
default?: unknown;
|
||||
options?: string[];
|
||||
derived_from_category?: Record<string, string>;
|
||||
search_endpoint?: string;
|
||||
}
|
||||
|
||||
export interface FieldOverride {
|
||||
widget?: string;
|
||||
currency?: string;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
export interface FormDescriptor {
|
||||
schema_name: string;
|
||||
format: string;
|
||||
category_picker?: CategoryPickerDescriptor;
|
||||
item_fields?: Record<string, ItemFieldDef>;
|
||||
field_groups?: FormFieldGroup[];
|
||||
category_field_groups?: Record<string, FormFieldGroup[]>;
|
||||
field_overrides?: Record<string, FieldOverride>;
|
||||
}
|
||||
|
||||
// API Token
|
||||
export interface ApiToken {
|
||||
id: string;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { get, put } from "../../api/client";
|
||||
import type {
|
||||
AuditItemResult,
|
||||
AuditFieldResult,
|
||||
Item,
|
||||
} from "../../api/types";
|
||||
import type { AuditItemResult, AuditFieldResult, Item } from "../../api/types";
|
||||
|
||||
const tierColors: Record<string, string> = {
|
||||
critical: "var(--ctp-red)",
|
||||
@@ -18,8 +14,6 @@ const tierColors: Record<string, string> = {
|
||||
const itemFields = new Set([
|
||||
"description",
|
||||
"sourcing_type",
|
||||
"sourcing_link",
|
||||
"standard_cost",
|
||||
"long_description",
|
||||
]);
|
||||
|
||||
@@ -83,12 +77,9 @@ export function AuditDetailPanel({
|
||||
void fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(key: string, value: string) => {
|
||||
setEdits((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleFieldChange = useCallback((key: string, value: string) => {
|
||||
setEdits((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const saveChanges = useCallback(async () => {
|
||||
if (!item || Object.keys(edits).length === 0) return;
|
||||
@@ -102,18 +93,14 @@ export function AuditDetailPanel({
|
||||
|
||||
for (const [key, value] of Object.entries(edits)) {
|
||||
if (itemFields.has(key)) {
|
||||
if (key === "standard_cost") {
|
||||
const num = parseFloat(value);
|
||||
itemUpdate[key] = isNaN(num) ? undefined : num;
|
||||
} else {
|
||||
itemUpdate[key] = value || undefined;
|
||||
}
|
||||
itemUpdate[key] = value || undefined;
|
||||
} else {
|
||||
// Attempt number coercion for property fields.
|
||||
const num = parseFloat(value);
|
||||
propUpdate[key] = !isNaN(num) && String(num) === value.trim()
|
||||
? num
|
||||
: value || undefined;
|
||||
propUpdate[key] =
|
||||
!isNaN(num) && String(num) === value.trim()
|
||||
? num
|
||||
: value || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +110,10 @@ export function AuditDetailPanel({
|
||||
const payload: Record<string, unknown> = {
|
||||
...itemUpdate,
|
||||
...(hasProps
|
||||
? { properties: { ...currentProps, ...propUpdate }, comment: "Audit field update" }
|
||||
? {
|
||||
properties: { ...currentProps, ...propUpdate },
|
||||
comment: "Audit field update",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -423,9 +413,7 @@ function FieldRow({
|
||||
? String(field.value)
|
||||
: "";
|
||||
|
||||
const borderColor = field.filled
|
||||
? "var(--ctp-green)"
|
||||
: "var(--ctp-red)";
|
||||
const borderColor = field.filled ? "var(--ctp-green)" : "var(--ctp-red)";
|
||||
|
||||
const label = field.key
|
||||
.replace(/_/g, " ")
|
||||
@@ -469,9 +457,7 @@ function FieldRow({
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: "0.8rem",
|
||||
color: field.filled
|
||||
? "var(--ctp-text)"
|
||||
: "var(--ctp-subtext0)",
|
||||
color: field.filled ? "var(--ctp-text)" : "var(--ctp-subtext0)",
|
||||
fontStyle: field.filled ? "normal" : "italic",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { get, post, put, del } from '../../api/client';
|
||||
import type { BOMEntry } from '../../api/types';
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { get, post, put, del } from "../../api/client";
|
||||
import type { BOMEntry } from "../../api/types";
|
||||
|
||||
interface BOMTabProps {
|
||||
partNumber: string;
|
||||
@@ -16,7 +16,14 @@ interface BOMFormData {
|
||||
sourcing_link: string;
|
||||
}
|
||||
|
||||
const emptyForm: BOMFormData = { child_part_number: '', quantity: '1', source: '', seller_description: '', unit_cost: '', sourcing_link: '' };
|
||||
const emptyForm: BOMFormData = {
|
||||
child_part_number: "",
|
||||
quantity: "1",
|
||||
source: "",
|
||||
seller_description: "",
|
||||
unit_cost: "",
|
||||
sourcing_link: "",
|
||||
};
|
||||
|
||||
export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
||||
const [entries, setEntries] = useState<BOMEntry[]>([]);
|
||||
@@ -39,13 +46,14 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
||||
const unitCost = (e: BOMEntry) => Number(meta(e).unit_cost) || 0;
|
||||
const extCost = (e: BOMEntry) => unitCost(e) * (e.quantity ?? 0);
|
||||
const totalCost = entries.reduce((sum, e) => sum + extCost(e), 0);
|
||||
const assemblyCount = entries.filter((e) => e.source === "assembly").length;
|
||||
|
||||
const formToRequest = () => ({
|
||||
child_part_number: form.child_part_number,
|
||||
rel_type: 'component' as const,
|
||||
rel_type: "component" as const,
|
||||
quantity: Number(form.quantity) || 1,
|
||||
source: form.source,
|
||||
metadata: {
|
||||
source: form.source,
|
||||
seller_description: form.seller_description,
|
||||
unit_cost: form.unit_cost,
|
||||
sourcing_link: form.sourcing_link,
|
||||
@@ -54,34 +62,42 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
||||
|
||||
const handleAdd = async () => {
|
||||
try {
|
||||
await post(`/api/items/${encodeURIComponent(partNumber)}/bom`, formToRequest());
|
||||
await post(
|
||||
`/api/items/${encodeURIComponent(partNumber)}/bom`,
|
||||
formToRequest(),
|
||||
);
|
||||
setShowAdd(false);
|
||||
setForm(emptyForm);
|
||||
load();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Failed to add BOM entry');
|
||||
alert(e instanceof Error ? e.message : "Failed to add BOM entry");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async (childPN: string) => {
|
||||
try {
|
||||
const { child_part_number: _, ...req } = formToRequest();
|
||||
await put(`/api/items/${encodeURIComponent(partNumber)}/bom/${encodeURIComponent(childPN)}`, req);
|
||||
await put(
|
||||
`/api/items/${encodeURIComponent(partNumber)}/bom/${encodeURIComponent(childPN)}`,
|
||||
req,
|
||||
);
|
||||
setEditIdx(null);
|
||||
setForm(emptyForm);
|
||||
load();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Failed to update BOM entry');
|
||||
alert(e instanceof Error ? e.message : "Failed to update BOM entry");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (childPN: string) => {
|
||||
if (!confirm(`Remove ${childPN} from BOM?`)) return;
|
||||
try {
|
||||
await del(`/api/items/${encodeURIComponent(partNumber)}/bom/${encodeURIComponent(childPN)}`);
|
||||
await del(
|
||||
`/api/items/${encodeURIComponent(partNumber)}/bom/${encodeURIComponent(childPN)}`,
|
||||
);
|
||||
load();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Failed to delete BOM entry');
|
||||
alert(e instanceof Error ? e.message : "Failed to delete BOM entry");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,71 +107,176 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
||||
setForm({
|
||||
child_part_number: e.child_part_number,
|
||||
quantity: String(e.quantity ?? 1),
|
||||
source: m.source ?? '',
|
||||
seller_description: m.seller_description ?? '',
|
||||
unit_cost: m.unit_cost ?? '',
|
||||
sourcing_link: m.sourcing_link ?? '',
|
||||
source: e.source ?? "",
|
||||
seller_description: m.seller_description ?? "",
|
||||
unit_cost: m.unit_cost ?? "",
|
||||
sourcing_link: m.sourcing_link ?? "",
|
||||
});
|
||||
setEditIdx(idx);
|
||||
setShowAdd(false);
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: '0.2rem 0.4rem', fontSize: '0.8rem',
|
||||
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)',
|
||||
borderRadius: '0.3rem', color: 'var(--ctp-text)', width: '100%',
|
||||
padding: "0.2rem 0.4rem",
|
||||
fontSize: "0.8rem",
|
||||
backgroundColor: "var(--ctp-base)",
|
||||
border: "1px solid var(--ctp-surface1)",
|
||||
borderRadius: "0.3rem",
|
||||
color: "var(--ctp-text)",
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
const formRow = (isEditing: boolean, childPN?: string) => (
|
||||
<tr style={{ backgroundColor: 'var(--ctp-surface0)' }}>
|
||||
<tr style={{ backgroundColor: "var(--ctp-surface0)" }}>
|
||||
<td style={tdStyle}>
|
||||
<input value={form.child_part_number} onChange={(e) => setForm({ ...form, child_part_number: e.target.value })}
|
||||
disabled={isEditing} placeholder="Part number" style={inputStyle} />
|
||||
<input
|
||||
value={form.child_part_number}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, child_part_number: e.target.value })
|
||||
}
|
||||
disabled={isEditing}
|
||||
placeholder="Part number"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<input value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })} placeholder="Source" style={inputStyle} />
|
||||
<select
|
||||
value={form.source}
|
||||
onChange={(e) => setForm({ ...form, source: e.target.value })}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="manual">manual</option>
|
||||
<option value="assembly">assembly</option>
|
||||
</select>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<input value={form.seller_description} onChange={(e) => setForm({ ...form, seller_description: e.target.value })} placeholder="Description" style={inputStyle} />
|
||||
<input
|
||||
value={form.seller_description}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, seller_description: e.target.value })
|
||||
}
|
||||
placeholder="Description"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<input value={form.unit_cost} onChange={(e) => setForm({ ...form, unit_cost: e.target.value })} type="number" step="0.01" placeholder="0.00" style={inputStyle} />
|
||||
<input
|
||||
value={form.unit_cost}
|
||||
onChange={(e) => setForm({ ...form, unit_cost: e.target.value })}
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<input value={form.quantity} onChange={(e) => setForm({ ...form, quantity: e.target.value })} type="number" step="1" placeholder="1" style={{ ...inputStyle, width: 50 }} />
|
||||
<input
|
||||
value={form.quantity}
|
||||
onChange={(e) => setForm({ ...form, quantity: e.target.value })}
|
||||
type="number"
|
||||
step="1"
|
||||
placeholder="1"
|
||||
style={{ ...inputStyle, width: 50 }}
|
||||
/>
|
||||
</td>
|
||||
<td style={tdStyle}>—</td>
|
||||
<td style={tdStyle}>
|
||||
<input value={form.sourcing_link} onChange={(e) => setForm({ ...form, sourcing_link: e.target.value })} placeholder="URL" style={inputStyle} />
|
||||
<input
|
||||
value={form.sourcing_link}
|
||||
onChange={(e) => setForm({ ...form, sourcing_link: e.target.value })}
|
||||
placeholder="URL"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<button onClick={() => isEditing ? void handleEdit(childPN!) : void handleAdd()} style={saveBtnStyle}>Save</button>
|
||||
<button onClick={() => { isEditing ? setEditIdx(null) : setShowAdd(false); setForm(emptyForm); }} style={cancelBtnStyle}>Cancel</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
isEditing ? void handleEdit(childPN!) : void handleAdd()
|
||||
}
|
||||
style={saveBtnStyle}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
isEditing ? setEditIdx(null) : setShowAdd(false);
|
||||
setForm(emptyForm);
|
||||
}}
|
||||
style={cancelBtnStyle}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
if (loading) return <div style={{ color: 'var(--ctp-subtext0)' }}>Loading BOM...</div>;
|
||||
if (loading)
|
||||
return <div style={{ color: "var(--ctp-subtext0)" }}>Loading BOM...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--ctp-subtext1)' }}>{entries.length} entries</span>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
alignItems: "center",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "0.85rem", color: "var(--ctp-subtext1)" }}>
|
||||
{entries.length} entries
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button
|
||||
onClick={() => { window.location.href = `/api/items/${encodeURIComponent(partNumber)}/bom/export.csv`; }}
|
||||
onClick={() => {
|
||||
window.location.href = `/api/items/${encodeURIComponent(partNumber)}/bom/export.csv`;
|
||||
}}
|
||||
style={toolBtnStyle}
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
{isEditor && (
|
||||
<button onClick={() => { setShowAdd(true); setEditIdx(null); setForm(emptyForm); }} style={toolBtnStyle}>+ Add</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAdd(true);
|
||||
setEditIdx(null);
|
||||
setForm(emptyForm);
|
||||
}}
|
||||
style={toolBtnStyle}
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
||||
{isEditor && assemblyCount > 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.35rem 0.6rem",
|
||||
marginBottom: "0.5rem",
|
||||
borderRadius: "0.3rem",
|
||||
backgroundColor: "rgba(148,226,213,0.1)",
|
||||
border: "1px solid rgba(148,226,213,0.3)",
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--ctp-subtext1)",
|
||||
}}
|
||||
>
|
||||
{assemblyCount} assembly-sourced{" "}
|
||||
{assemblyCount === 1 ? "entry" : "entries"}. Entries removed from the
|
||||
FreeCAD assembly will remain here until manually deleted.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ overflow: "auto" }}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={thStyle}>PN</th>
|
||||
@@ -174,20 +295,79 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
||||
if (editIdx === idx) return formRow(true, e.child_part_number);
|
||||
const m = meta(e);
|
||||
return (
|
||||
<tr key={e.id} style={{ backgroundColor: idx % 2 === 0 ? 'var(--ctp-base)' : 'var(--ctp-surface0)' }}>
|
||||
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>{e.child_part_number}</td>
|
||||
<td style={tdStyle}>{m.source ?? ''}</td>
|
||||
<td style={{ ...tdStyle, maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis' }}>{e.child_description || m.seller_description || ''}</td>
|
||||
<td style={{ ...tdStyle, fontFamily: 'monospace' }}>{unitCost(e) ? `$${unitCost(e).toFixed(2)}` : '—'}</td>
|
||||
<td style={tdStyle}>{e.quantity ?? '—'}</td>
|
||||
<td style={{ ...tdStyle, fontFamily: 'monospace' }}>{extCost(e) ? `$${extCost(e).toFixed(2)}` : '—'}</td>
|
||||
<tr
|
||||
key={e.id}
|
||||
style={{
|
||||
backgroundColor:
|
||||
idx % 2 === 0 ? "var(--ctp-base)" : "var(--ctp-surface0)",
|
||||
}}
|
||||
>
|
||||
<td
|
||||
style={{
|
||||
...tdStyle,
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
color: "var(--ctp-peach)",
|
||||
}}
|
||||
>
|
||||
{e.child_part_number}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
{m.sourcing_link ? <a href={m.sourcing_link} target="_blank" rel="noreferrer" style={{ color: 'var(--ctp-sapphire)', fontSize: '0.75rem' }}>Link</a> : '—'}
|
||||
{e.source === "assembly" ? (
|
||||
<span style={assemblyBadge}>assembly</span>
|
||||
) : e.source === "manual" ? (
|
||||
<span style={manualBadge}>manual</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
...tdStyle,
|
||||
maxWidth: 150,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{e.child_description || m.seller_description || ""}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, fontFamily: "monospace" }}>
|
||||
{unitCost(e) ? `$${unitCost(e).toFixed(2)}` : "—"}
|
||||
</td>
|
||||
<td style={tdStyle}>{e.quantity ?? "—"}</td>
|
||||
<td style={{ ...tdStyle, fontFamily: "monospace" }}>
|
||||
{extCost(e) ? `$${extCost(e).toFixed(2)}` : "—"}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
{m.sourcing_link ? (
|
||||
<a
|
||||
href={m.sourcing_link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
color: "var(--ctp-sapphire)",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
Link
|
||||
</a>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
{isEditor && (
|
||||
<td style={tdStyle}>
|
||||
<button onClick={() => startEdit(idx)} style={actionBtnStyle}>Edit</button>
|
||||
<button onClick={() => void handleDelete(e.child_part_number)} style={{ ...actionBtnStyle, color: 'var(--ctp-red)' }}>Del</button>
|
||||
<button
|
||||
onClick={() => startEdit(idx)}
|
||||
style={actionBtnStyle}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleDelete(e.child_part_number)}
|
||||
style={{ ...actionBtnStyle, color: "var(--ctp-red)" }}
|
||||
>
|
||||
Del
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
@@ -196,9 +376,22 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
||||
</tbody>
|
||||
{totalCost > 0 && (
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--ctp-surface1)' }}>
|
||||
<td colSpan={5} style={{ ...tdStyle, textAlign: 'right', fontWeight: 600 }}>Total:</td>
|
||||
<td style={{ ...tdStyle, fontFamily: 'monospace', fontWeight: 600 }}>${totalCost.toFixed(2)}</td>
|
||||
<tr style={{ borderTop: "2px solid var(--ctp-surface1)" }}>
|
||||
<td
|
||||
colSpan={5}
|
||||
style={{ ...tdStyle, textAlign: "right", fontWeight: 600 }}
|
||||
>
|
||||
Total:
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
...tdStyle,
|
||||
fontFamily: "monospace",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
${totalCost.toFixed(2)}
|
||||
</td>
|
||||
<td colSpan={isEditor ? 2 : 1} style={tdStyle} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -210,29 +403,78 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
||||
}
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
padding: '0.3rem 0.5rem', textAlign: 'left', borderBottom: '1px solid var(--ctp-surface1)',
|
||||
color: 'var(--ctp-subtext1)', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em', whiteSpace: 'nowrap',
|
||||
padding: "0.3rem 0.5rem",
|
||||
textAlign: "left",
|
||||
borderBottom: "1px solid var(--ctp-surface1)",
|
||||
color: "var(--ctp-subtext1)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.7rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: '0.25rem 0.5rem', borderBottom: '1px solid var(--ctp-surface0)', whiteSpace: 'nowrap',
|
||||
padding: "0.25rem 0.5rem",
|
||||
borderBottom: "1px solid var(--ctp-surface0)",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const toolBtnStyle: React.CSSProperties = {
|
||||
padding: '0.25rem 0.5rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
|
||||
backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-text)', cursor: 'pointer',
|
||||
padding: "0.25rem 0.5rem",
|
||||
fontSize: "0.8rem",
|
||||
border: "none",
|
||||
borderRadius: "0.3rem",
|
||||
backgroundColor: "var(--ctp-surface1)",
|
||||
color: "var(--ctp-text)",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const actionBtnStyle: React.CSSProperties = {
|
||||
background: 'none', border: 'none', color: 'var(--ctp-subtext1)', cursor: 'pointer', fontSize: '0.75rem', padding: '0.1rem 0.3rem',
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--ctp-subtext1)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.75rem",
|
||||
padding: "0.1rem 0.3rem",
|
||||
};
|
||||
|
||||
const saveBtnStyle: React.CSSProperties = {
|
||||
padding: '0.2rem 0.4rem', fontSize: '0.75rem', border: 'none', borderRadius: '0.25rem',
|
||||
backgroundColor: 'var(--ctp-green)', color: 'var(--ctp-crust)', cursor: 'pointer', marginRight: '0.25rem',
|
||||
padding: "0.2rem 0.4rem",
|
||||
fontSize: "0.75rem",
|
||||
border: "none",
|
||||
borderRadius: "0.25rem",
|
||||
backgroundColor: "var(--ctp-green)",
|
||||
color: "var(--ctp-crust)",
|
||||
cursor: "pointer",
|
||||
marginRight: "0.25rem",
|
||||
};
|
||||
|
||||
const sourceBadgeBase: React.CSSProperties = {
|
||||
padding: "0.1rem 0.4rem",
|
||||
borderRadius: "1rem",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const assemblyBadge: React.CSSProperties = {
|
||||
...sourceBadgeBase,
|
||||
backgroundColor: "rgba(148,226,213,0.2)",
|
||||
color: "var(--ctp-teal)",
|
||||
};
|
||||
|
||||
const manualBadge: React.CSSProperties = {
|
||||
...sourceBadgeBase,
|
||||
backgroundColor: "rgba(137,180,250,0.2)",
|
||||
color: "var(--ctp-blue)",
|
||||
};
|
||||
|
||||
const cancelBtnStyle: React.CSSProperties = {
|
||||
padding: '0.2rem 0.4rem', fontSize: '0.75rem', border: 'none', borderRadius: '0.25rem',
|
||||
backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-subtext1)', cursor: 'pointer',
|
||||
padding: "0.2rem 0.4rem",
|
||||
fontSize: "0.75rem",
|
||||
border: "none",
|
||||
borderRadius: "0.25rem",
|
||||
backgroundColor: "var(--ctp-surface1)",
|
||||
color: "var(--ctp-subtext1)",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
@@ -1,21 +1,48 @@
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import type { CategoryPickerStage } from "../../api/types";
|
||||
|
||||
interface CategoryPickerProps {
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
categories: Record<string, string>;
|
||||
stages?: CategoryPickerStage[];
|
||||
}
|
||||
|
||||
export function CategoryPicker({
|
||||
value,
|
||||
onChange,
|
||||
categories,
|
||||
stages,
|
||||
}: CategoryPickerProps) {
|
||||
const [selectedDomain, setSelectedDomain] = useState<string>("");
|
||||
const [search, setSearch] = useState("");
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Derive domain from current value
|
||||
useEffect(() => {
|
||||
if (value && value.length > 0) {
|
||||
setSelectedDomain(value[0]!);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const isMultiStage = stages && stages.length >= 2;
|
||||
|
||||
// Domain stage (first stage)
|
||||
const domainStage = isMultiStage ? stages[0] : undefined;
|
||||
const subcatStage = isMultiStage
|
||||
? stages.find((s) => s.values_by_domain)
|
||||
: undefined;
|
||||
|
||||
// Filtered categories for current domain in multi-stage mode
|
||||
const filteredCategories = useMemo(() => {
|
||||
if (!isMultiStage || !selectedDomain || !subcatStage?.values_by_domain) {
|
||||
return categories;
|
||||
}
|
||||
return subcatStage.values_by_domain[selectedDomain] ?? {};
|
||||
}, [isMultiStage, selectedDomain, subcatStage, categories]);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const all = Object.entries(categories).sort(([a], [b]) =>
|
||||
const all = Object.entries(filteredCategories).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
if (!search) return all;
|
||||
@@ -24,7 +51,7 @@ export function CategoryPicker({
|
||||
([code, desc]) =>
|
||||
code.toLowerCase().includes(q) || desc.toLowerCase().includes(q),
|
||||
);
|
||||
}, [categories, search]);
|
||||
}, [filteredCategories, search]);
|
||||
|
||||
// Scroll selected into view on mount.
|
||||
useEffect(() => {
|
||||
@@ -40,12 +67,70 @@ export function CategoryPicker({
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Multi-stage domain picker */}
|
||||
{isMultiStage && domainStage?.values && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.25rem",
|
||||
padding: "0.4rem 0.5rem",
|
||||
borderBottom: "1px solid var(--ctp-surface1)",
|
||||
backgroundColor: "var(--ctp-mantle)",
|
||||
}}
|
||||
>
|
||||
{Object.entries(domainStage.values)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([code, label]) => {
|
||||
const isActive = code === selectedDomain;
|
||||
return (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => {
|
||||
setSelectedDomain(code);
|
||||
setSearch("");
|
||||
// Clear selection if switching domain
|
||||
if (value && value[0] !== code) {
|
||||
onChange("");
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: "0.2rem 0.5rem",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
border: "none",
|
||||
borderRadius: "0.25rem",
|
||||
cursor: "pointer",
|
||||
backgroundColor: isActive
|
||||
? "rgba(203,166,247,0.2)"
|
||||
: "transparent",
|
||||
color: isActive
|
||||
? "var(--ctp-mauve)"
|
||||
: "var(--ctp-subtext0)",
|
||||
transition: "background-color 0.1s",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{code}
|
||||
</span>{" "}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search categories..."
|
||||
placeholder={
|
||||
isMultiStage && !selectedDomain
|
||||
? "Select a domain above..."
|
||||
: "Search categories..."
|
||||
}
|
||||
disabled={isMultiStage && !selectedDomain}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.4rem 0.5rem",
|
||||
@@ -61,7 +146,18 @@ export function CategoryPicker({
|
||||
|
||||
{/* Scrollable list */}
|
||||
<div style={{ maxHeight: 200, overflowY: "auto" }}>
|
||||
{entries.length === 0 ? (
|
||||
{isMultiStage && !selectedDomain ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
textAlign: "center",
|
||||
color: "var(--ctp-subtext0)",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
Select a domain to see categories
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
@@ -90,9 +186,7 @@ export function CategoryPicker({
|
||||
backgroundColor: isSelected
|
||||
? "rgba(203,166,247,0.12)"
|
||||
: "transparent",
|
||||
color: isSelected
|
||||
? "var(--ctp-mauve)"
|
||||
: "var(--ctp-text)",
|
||||
color: isSelected ? "var(--ctp-mauve)" : "var(--ctp-text)",
|
||||
fontWeight: isSelected ? 600 : 400,
|
||||
transition: "background-color 0.1s",
|
||||
}}
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { get, post, put } from "../../api/client";
|
||||
import type { Project } from "../../api/types";
|
||||
import type {
|
||||
Project,
|
||||
FormFieldDescriptor,
|
||||
FormFieldGroup,
|
||||
} from "../../api/types";
|
||||
import { TagInput, type TagOption } from "../TagInput";
|
||||
import { CategoryPicker } from "./CategoryPicker";
|
||||
import { FileDropZone } from "./FileDropZone";
|
||||
import { useCategories } from "../../hooks/useCategories";
|
||||
import { useFormDescriptor } from "../../hooks/useFormDescriptor";
|
||||
import {
|
||||
useFileUpload,
|
||||
type PendingAttachment,
|
||||
} from "../../hooks/useFileUpload";
|
||||
import { useAuth } from "../../hooks/useAuth";
|
||||
|
||||
// Item-level field names that are sent as top-level API fields, not properties.
|
||||
const ITEM_LEVEL_FIELDS = new Set([
|
||||
"item_type",
|
||||
"description",
|
||||
"sourcing_type",
|
||||
"long_description",
|
||||
"projects",
|
||||
]);
|
||||
|
||||
interface CreateItemPaneProps {
|
||||
onCreated: (partNumber: string) => void;
|
||||
onCancel: () => void;
|
||||
@@ -18,22 +31,13 @@ interface CreateItemPaneProps {
|
||||
|
||||
export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
const { user } = useAuth();
|
||||
const { categories } = useCategories();
|
||||
const { descriptor, categories } = useFormDescriptor();
|
||||
const { upload } = useFileUpload();
|
||||
|
||||
// Form state.
|
||||
const [itemType, setItemType] = useState("part");
|
||||
// Single form state for all fields (item-level + properties).
|
||||
const [category, setCategory] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [sourcingType, setSourcingType] = useState("manufactured");
|
||||
const [sourcingLink, setSourcingLink] = useState("");
|
||||
const [longDescription, setLongDescription] = useState("");
|
||||
const [standardCost, setStandardCost] = useState("");
|
||||
const [fields, setFields] = useState<Record<string, string>>({});
|
||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||
const [catProps, setCatProps] = useState<Record<string, string>>({});
|
||||
const [catPropDefs, setCatPropDefs] = useState<
|
||||
Record<string, { type: string }>
|
||||
>({});
|
||||
|
||||
// Attachments.
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
@@ -44,27 +48,33 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load category-specific properties.
|
||||
useEffect(() => {
|
||||
if (!category) {
|
||||
setCatPropDefs({});
|
||||
setCatProps({});
|
||||
return;
|
||||
const setField = (name: string, value: string) =>
|
||||
setFields((prev) => ({ ...prev, [name]: value }));
|
||||
|
||||
const getField = (name: string) => fields[name] ?? "";
|
||||
|
||||
// Derive item_type from category using derived_from_category mapping
|
||||
const deriveItemType = (cat: string): string => {
|
||||
if (!cat || !descriptor?.item_fields?.item_type?.derived_from_category) {
|
||||
return getField("item_type") || "part";
|
||||
}
|
||||
get<Record<string, { type: string }>>(
|
||||
`/api/schemas/kindred-rd/properties?category=${encodeURIComponent(category)}`,
|
||||
)
|
||||
.then((defs) => {
|
||||
setCatPropDefs(defs);
|
||||
const defaults: Record<string, string> = {};
|
||||
for (const key of Object.keys(defs)) defaults[key] = "";
|
||||
setCatProps(defaults);
|
||||
})
|
||||
.catch(() => {
|
||||
setCatPropDefs({});
|
||||
setCatProps({});
|
||||
});
|
||||
}, [category]);
|
||||
const mapping = descriptor.item_fields.item_type.derived_from_category;
|
||||
const prefix = cat[0]!;
|
||||
return mapping[prefix] ?? mapping["default"] ?? "part";
|
||||
};
|
||||
|
||||
const handleCategoryChange = (cat: string) => {
|
||||
setCategory(cat);
|
||||
// Auto-derive item_type when category changes
|
||||
if (descriptor?.item_fields?.item_type?.derived_from_category) {
|
||||
const derived = cat
|
||||
? (descriptor.item_fields.item_type.derived_from_category[cat[0]!] ??
|
||||
descriptor.item_fields.item_type.derived_from_category["default"] ??
|
||||
"part")
|
||||
: "part";
|
||||
setField("item_type", derived);
|
||||
}
|
||||
};
|
||||
|
||||
const searchProjects = useCallback(
|
||||
async (query: string): Promise<TagOption[]> => {
|
||||
@@ -90,10 +100,8 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
const startIdx = attachments.length;
|
||||
setAttachments((prev) => [...prev, ...files]);
|
||||
|
||||
// Upload each file.
|
||||
files.forEach((f, i) => {
|
||||
const idx = startIdx + i;
|
||||
// Mark uploading.
|
||||
setAttachments((prev) =>
|
||||
prev.map((a, j) =>
|
||||
j === idx ? { ...a, uploadStatus: "uploading" } : a,
|
||||
@@ -155,12 +163,15 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
// Split fields into item-level and properties
|
||||
const properties: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(catProps)) {
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (!v) continue;
|
||||
const def = catPropDefs[k];
|
||||
if (def?.type === "number") properties[k] = Number(v);
|
||||
else if (def?.type === "boolean") properties[k] = v === "true";
|
||||
if (ITEM_LEVEL_FIELDS.has(k)) continue; // handled separately
|
||||
// Coerce type from descriptor
|
||||
const fieldDef = findFieldDef(k);
|
||||
if (fieldDef?.type === "number") properties[k] = Number(v);
|
||||
else if (fieldDef?.type === "boolean") properties[k] = v === "true";
|
||||
else properties[k] = v;
|
||||
}
|
||||
|
||||
@@ -168,14 +179,12 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
const result = await post<{ part_number: string }>("/api/items", {
|
||||
schema: "kindred-rd",
|
||||
category,
|
||||
description,
|
||||
item_type: itemType,
|
||||
description: getField("description") || undefined,
|
||||
item_type: deriveItemType(category),
|
||||
projects: selectedProjects.length > 0 ? selectedProjects : undefined,
|
||||
properties: Object.keys(properties).length > 0 ? properties : undefined,
|
||||
sourcing_type: sourcingType || undefined,
|
||||
sourcing_link: sourcingLink || undefined,
|
||||
long_description: longDescription || undefined,
|
||||
standard_cost: standardCost ? Number(standardCost) : undefined,
|
||||
sourcing_type: getField("sourcing_type") || undefined,
|
||||
long_description: getField("long_description") || undefined,
|
||||
});
|
||||
|
||||
const pn = result.part_number;
|
||||
@@ -219,6 +228,33 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Find field definition from descriptor (global groups + category groups).
|
||||
function findFieldDef(name: string): FormFieldDescriptor | undefined {
|
||||
if (descriptor?.field_groups) {
|
||||
for (const group of descriptor.field_groups) {
|
||||
const f = group.fields.find((fd) => fd.name === name);
|
||||
if (f) return f;
|
||||
}
|
||||
}
|
||||
if (descriptor?.category_field_groups && category) {
|
||||
const prefix = category[0]!;
|
||||
const catGroups = descriptor.category_field_groups[prefix];
|
||||
if (catGroups) {
|
||||
for (const group of catGroups) {
|
||||
const f = group.fields.find((fd) => fd.name === name);
|
||||
if (f) return f;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get category-specific field groups for the selected category.
|
||||
const catFieldGroups: FormFieldGroup[] =
|
||||
category && descriptor?.category_field_groups
|
||||
? (descriptor.category_field_groups[category[0]!] ?? [])
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* Header */}
|
||||
@@ -262,130 +298,52 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
<div style={{ overflow: "auto", padding: "0.75rem" }}>
|
||||
{error && <div style={errorStyle}>{error}</div>}
|
||||
|
||||
{/* Identity section */}
|
||||
<SectionHeader>Identity</SectionHeader>
|
||||
<div style={fieldGridStyle}>
|
||||
<FormGroup label="Type *">
|
||||
<select
|
||||
value={itemType}
|
||||
onChange={(e) => setItemType(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="part">Part</option>
|
||||
<option value="assembly">Assembly</option>
|
||||
<option value="consumable">Consumable</option>
|
||||
<option value="tool">Tool</option>
|
||||
</select>
|
||||
</FormGroup>
|
||||
<FormGroup label="Description">
|
||||
<input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="Item description"
|
||||
/>
|
||||
</FormGroup>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<FormGroup label="Category *">
|
||||
<CategoryPicker
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
categories={categories}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
</div>
|
||||
{/* Category picker */}
|
||||
<SectionHeader>Category *</SectionHeader>
|
||||
<CategoryPicker
|
||||
value={category}
|
||||
onChange={handleCategoryChange}
|
||||
categories={categories}
|
||||
stages={descriptor?.category_picker?.stages}
|
||||
/>
|
||||
|
||||
{/* Sourcing section */}
|
||||
<SectionHeader>Sourcing</SectionHeader>
|
||||
<div style={fieldGridStyle}>
|
||||
<FormGroup label="Sourcing Type">
|
||||
<select
|
||||
value={sourcingType}
|
||||
onChange={(e) => setSourcingType(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="manufactured">Manufactured</option>
|
||||
<option value="purchased">Purchased</option>
|
||||
</select>
|
||||
</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>
|
||||
|
||||
{/* Details section */}
|
||||
<SectionHeader>Details</SectionHeader>
|
||||
<FormGroup label="Long Description">
|
||||
<textarea
|
||||
value={longDescription}
|
||||
onChange={(e) => setLongDescription(e.target.value)}
|
||||
style={{ ...inputStyle, minHeight: 60, resize: "vertical" }}
|
||||
placeholder="Detailed description..."
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label="Projects">
|
||||
<TagInput
|
||||
value={selectedProjects}
|
||||
onChange={setSelectedProjects}
|
||||
placeholder="Search projects\u2026"
|
||||
searchFn={searchProjects}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{/* Category properties */}
|
||||
{Object.keys(catPropDefs).length > 0 && (
|
||||
<>
|
||||
<SectionHeader>
|
||||
{categories[category] ?? category} Properties
|
||||
</SectionHeader>
|
||||
{/* Dynamic field groups from descriptor */}
|
||||
{descriptor?.field_groups?.map((group) => (
|
||||
<div key={group.key}>
|
||||
<SectionHeader>{group.label}</SectionHeader>
|
||||
<div style={fieldGridStyle}>
|
||||
{Object.entries(catPropDefs).map(([key, def]) => (
|
||||
<FormGroup key={key} label={key}>
|
||||
{def.type === "boolean" ? (
|
||||
<select
|
||||
value={catProps[key] ?? ""}
|
||||
onChange={(e) =>
|
||||
setCatProps({ ...catProps, [key]: e.target.value })
|
||||
}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">---</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={def.type === "number" ? "number" : "text"}
|
||||
value={catProps[key] ?? ""}
|
||||
onChange={(e) =>
|
||||
setCatProps({ ...catProps, [key]: e.target.value })
|
||||
}
|
||||
style={inputStyle}
|
||||
/>
|
||||
)}
|
||||
</FormGroup>
|
||||
))}
|
||||
{group.fields.map((field) =>
|
||||
renderField(
|
||||
field,
|
||||
getField(field.name),
|
||||
(v) => setField(field.name, v),
|
||||
selectedProjects,
|
||||
setSelectedProjects,
|
||||
searchProjects,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Category-specific field groups */}
|
||||
{catFieldGroups.map((group) => (
|
||||
<div key={group.key}>
|
||||
<SectionHeader>{group.label}</SectionHeader>
|
||||
<div style={fieldGridStyle}>
|
||||
{group.fields.map((field) =>
|
||||
renderField(
|
||||
field,
|
||||
getField(field.name),
|
||||
(v) => setField(field.name, v),
|
||||
selectedProjects,
|
||||
setSelectedProjects,
|
||||
searchProjects,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right: sidebar */}
|
||||
@@ -461,6 +419,138 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Field renderer ---
|
||||
|
||||
function renderField(
|
||||
field: FormFieldDescriptor,
|
||||
value: string,
|
||||
onChange: (v: string) => void,
|
||||
selectedProjects: string[],
|
||||
setSelectedProjects: (v: string[]) => void,
|
||||
searchProjects: (q: string) => Promise<{ id: string; label: string }[]>,
|
||||
) {
|
||||
const widget =
|
||||
field.widget ?? (field.type === "boolean" ? "checkbox" : "text");
|
||||
|
||||
// Projects field gets special tag_input treatment
|
||||
if (widget === "tag_input") {
|
||||
return (
|
||||
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
|
||||
<FormGroup label={field.label}>
|
||||
<TagInput
|
||||
value={selectedProjects}
|
||||
onChange={setSelectedProjects}
|
||||
placeholder="Search projects\u2026"
|
||||
searchFn={searchProjects}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (widget === "textarea") {
|
||||
return (
|
||||
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
|
||||
<FormGroup label={field.label}>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ ...inputStyle, minHeight: 60, resize: "vertical" }}
|
||||
placeholder={field.description ?? ""}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (widget === "select" && field.options) {
|
||||
return (
|
||||
<FormGroup key={field.name} label={field.label}>
|
||||
<select
|
||||
value={value || (field.default != null ? String(field.default) : "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
{!field.required && <option value="">---</option>}
|
||||
{field.options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (widget === "checkbox") {
|
||||
return (
|
||||
<FormGroup key={field.name} label={field.label}>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">---</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (widget === "currency") {
|
||||
return (
|
||||
<FormGroup
|
||||
key={field.name}
|
||||
label={`${field.label}${field.currency ? ` (${field.currency})` : ""}`}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (widget === "url") {
|
||||
return (
|
||||
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
|
||||
<FormGroup label={field.label}>
|
||||
<input
|
||||
type="url"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: text or number input
|
||||
const inputType = field.type === "number" ? "number" : "text";
|
||||
const placeholder = field.unit
|
||||
? `${field.description ?? ""} (${field.unit})`
|
||||
: (field.description ?? "");
|
||||
|
||||
return (
|
||||
<FormGroup key={field.name} label={field.label}>
|
||||
<input
|
||||
type={inputType}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { get, put } from '../../api/client';
|
||||
import type { Item } from '../../api/types';
|
||||
import { useState, useEffect } from "react";
|
||||
import { get, put } from "../../api/client";
|
||||
import type { Item } from "../../api/types";
|
||||
|
||||
interface EditItemPaneProps {
|
||||
partNumber: string;
|
||||
@@ -8,17 +8,19 @@ interface EditItemPaneProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProps) {
|
||||
export function EditItemPane({
|
||||
partNumber,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: EditItemPaneProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pn, setPN] = useState('');
|
||||
const [itemType, setItemType] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [sourcingType, setSourcingType] = useState('');
|
||||
const [sourcingLink, setSourcingLink] = useState('');
|
||||
const [longDescription, setLongDescription] = useState('');
|
||||
const [standardCost, setStandardCost] = useState('');
|
||||
const [pn, setPN] = useState("");
|
||||
const [itemType, setItemType] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [sourcingType, setSourcingType] = useState("");
|
||||
const [longDescription, setLongDescription] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@@ -27,12 +29,10 @@ export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProp
|
||||
setPN(item.part_number);
|
||||
setItemType(item.item_type);
|
||||
setDescription(item.description);
|
||||
setSourcingType(item.sourcing_type ?? '');
|
||||
setSourcingLink(item.sourcing_link ?? '');
|
||||
setLongDescription(item.long_description ?? '');
|
||||
setStandardCost(item.standard_cost != null ? String(item.standard_cost) : '');
|
||||
setSourcingType(item.sourcing_type ?? "");
|
||||
setLongDescription(item.long_description ?? "");
|
||||
})
|
||||
.catch(() => setError('Failed to load item'))
|
||||
.catch(() => setError("Failed to load item"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [partNumber]);
|
||||
|
||||
@@ -45,54 +45,97 @@ export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProp
|
||||
item_type: itemType || undefined,
|
||||
description: description || undefined,
|
||||
sourcing_type: sourcingType || undefined,
|
||||
sourcing_link: sourcingLink || undefined,
|
||||
long_description: longDescription || undefined,
|
||||
standard_cost: standardCost ? Number(standardCost) : undefined,
|
||||
});
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to save item');
|
||||
setError(e instanceof Error ? e.message : "Failed to save item");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ padding: '1rem', color: 'var(--ctp-subtext0)' }}>Loading...</div>;
|
||||
if (loading)
|
||||
return (
|
||||
<div style={{ padding: "1rem", color: "var(--ctp-subtext0)" }}>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderBottom: '1px solid var(--ctp-surface1)',
|
||||
backgroundColor: 'var(--ctp-mantle)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ color: 'var(--ctp-blue)', fontWeight: 600, fontSize: '0.9rem' }}>Edit {partNumber}</span>
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderBottom: "1px solid var(--ctp-surface1)",
|
||||
backgroundColor: "var(--ctp-mantle)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--ctp-blue)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
Edit {partNumber}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button onClick={() => void handleSave()} disabled={saving} style={{
|
||||
padding: '0.3rem 0.75rem', 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
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: "0.3rem 0.75rem",
|
||||
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 onClick={onCancel} style={headerBtnStyle}>Cancel</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0.75rem' }}>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "0.75rem" }}>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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 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="assembly">Assembly</option>
|
||||
<option value="document">Document</option>
|
||||
@@ -101,11 +144,19 @@ export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProp
|
||||
</FormGroup>
|
||||
|
||||
<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 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="manufactured">Manufactured</option>
|
||||
<option value="purchased">Purchased</option>
|
||||
@@ -113,38 +164,57 @@ export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProp
|
||||
</select>
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormGroup({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
function FormGroup({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginBottom: '0.6rem' }}>
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--ctp-subtext0)', marginBottom: '0.2rem' }}>{label}</label>
|
||||
<div style={{ marginBottom: "0.6rem" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--ctp-subtext0)",
|
||||
marginBottom: "0.2rem",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '0.35rem 0.5rem', fontSize: '0.85rem',
|
||||
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)',
|
||||
borderRadius: '0.3rem', color: 'var(--ctp-text)',
|
||||
width: "100%",
|
||||
padding: "0.35rem 0.5rem",
|
||||
fontSize: "0.85rem",
|
||||
backgroundColor: "var(--ctp-base)",
|
||||
border: "1px solid var(--ctp-surface1)",
|
||||
borderRadius: "0.3rem",
|
||||
color: "var(--ctp-text)",
|
||||
};
|
||||
|
||||
const headerBtnStyle: React.CSSProperties = {
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--ctp-subtext1)', fontSize: '0.8rem', padding: '0.2rem 0.4rem',
|
||||
background: "none",
|
||||
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 type { CSVImportResult } from '../../api/types';
|
||||
import { useState, useRef } from "react";
|
||||
import type { CSVImportResult } from "../../api/types";
|
||||
|
||||
interface ImportItemsPaneProps {
|
||||
onImported: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ImportItemsPane({ onImported, onCancel }: ImportItemsPaneProps) {
|
||||
export function ImportItemsPane({
|
||||
onImported,
|
||||
onCancel,
|
||||
}: ImportItemsPaneProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [skipExisting, setSkipExisting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
@@ -21,19 +24,22 @@ export function ImportItemsPane({ onImported, onCancel }: ImportItemsPaneProps)
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (dryRun) formData.append('dry_run', 'true');
|
||||
if (skipExisting) formData.append('skip_existing', 'true');
|
||||
formData.append("file", file);
|
||||
if (dryRun) formData.append("dry_run", "true");
|
||||
if (skipExisting) formData.append("skip_existing", "true");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/items/import', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
const res = await fetch("/api/items/import", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json() as CSVImportResult;
|
||||
const data = (await res.json()) as CSVImportResult;
|
||||
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 {
|
||||
setResult(data);
|
||||
if (dryRun) {
|
||||
@@ -43,48 +49,85 @@ export function ImportItemsPane({ onImported, onCancel }: ImportItemsPaneProps)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Import failed');
|
||||
setError(e instanceof Error ? e.message : "Import failed");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderBottom: '1px solid var(--ctp-surface1)',
|
||||
backgroundColor: 'var(--ctp-mantle)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ color: 'var(--ctp-yellow)', fontWeight: 600, fontSize: '0.9rem' }}>Import Items (CSV)</span>
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderBottom: "1px solid var(--ctp-surface1)",
|
||||
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 }} />
|
||||
<button onClick={onCancel} style={headerBtnStyle}>Cancel</button>
|
||||
<button onClick={onCancel} style={headerBtnStyle}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0.75rem' }}>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "0.75rem" }}>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<div style={{ fontSize: '0.8rem', 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, sourcing_link, long_description, standard_cost, + property columns</p>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
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
|
||||
href="/api/items/template.csv"
|
||||
style={{ color: 'var(--ctp-sapphire)', fontSize: '0.8rem' }}
|
||||
style={{ color: "var(--ctp-sapphire)", fontSize: "0.8rem" }}
|
||||
>
|
||||
Download CSV template
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* File input */}
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
@@ -94,76 +137,144 @@ export function ImportItemsPane({ onImported, onCancel }: ImportItemsPaneProps)
|
||||
setResult(null);
|
||||
setValidated(false);
|
||||
}}
|
||||
style={{ display: 'none' }}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem', border: '2px dashed var(--ctp-surface2)',
|
||||
borderRadius: '0.5rem', backgroundColor: 'var(--ctp-surface0)',
|
||||
color: 'var(--ctp-subtext1)', cursor: 'pointer', width: '100%',
|
||||
fontSize: '0.85rem',
|
||||
padding: "0.75rem 1.5rem",
|
||||
border: "2px dashed var(--ctp-surface2)",
|
||||
borderRadius: "0.5rem",
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<label 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)} />
|
||||
<label
|
||||
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
|
||||
</label>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
||||
<div
|
||||
style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem" }}
|
||||
>
|
||||
{!validated ? (
|
||||
<button
|
||||
onClick={() => void doImport(true)}
|
||||
disabled={!file || importing}
|
||||
style={{
|
||||
padding: '0.4rem 0.75rem', fontSize: '0.85rem', border: 'none', borderRadius: '0.3rem',
|
||||
backgroundColor: 'var(--ctp-yellow)', color: 'var(--ctp-crust)', cursor: 'pointer',
|
||||
opacity: (!file || importing) ? 0.5 : 1,
|
||||
padding: "0.4rem 0.75rem",
|
||||
fontSize: "0.85rem",
|
||||
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
|
||||
onClick={() => void doImport(false)}
|
||||
disabled={importing || (result?.error_count ?? 0) > 0}
|
||||
style={{
|
||||
padding: '0.4rem 0.75rem', fontSize: '0.85rem', 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,
|
||||
padding: "0.4rem 0.75rem",
|
||||
fontSize: "0.85rem",
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div style={{ 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>
|
||||
<div
|
||||
style={{
|
||||
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 && (
|
||||
<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 && (
|
||||
<div style={{ marginTop: '0.5rem', maxHeight: 200, overflow: 'auto' }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
maxHeight: 200,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{result.errors.map((err, i) => (
|
||||
<div key={i} style={{ color: 'var(--ctp-red)', fontSize: '0.75rem', padding: '0.1rem 0' }}>
|
||||
Row {err.row}{err.field ? ` [${err.field}]` : ''}: {err.message}
|
||||
<div
|
||||
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>
|
||||
)}
|
||||
{result.created_items && result.created_items.length > 0 && (
|
||||
<div style={{ marginTop: '0.5rem', color: 'var(--ctp-green)', fontSize: '0.75rem' }}>
|
||||
Created: {result.created_items.join(', ')}
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
color: "var(--ctp-green)",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
Created: {result.created_items.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -174,6 +285,10 @@ export function ImportItemsPane({ onImported, onCancel }: ImportItemsPaneProps)
|
||||
}
|
||||
|
||||
const headerBtnStyle: React.CSSProperties = {
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--ctp-subtext1)', fontSize: '0.8rem', padding: '0.2rem 0.4rem',
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--ctp-subtext1)",
|
||||
fontSize: "0.8rem",
|
||||
padding: "0.2rem 0.4rem",
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ export const ALL_COLUMNS: ColumnDef[] = [
|
||||
{ key: "item_type", label: "Type" },
|
||||
{ key: "description", label: "Description" },
|
||||
{ key: "revision", label: "Rev" },
|
||||
{ key: "files", label: "Files" },
|
||||
{ key: "projects", label: "Projects" },
|
||||
{ key: "created", label: "Created" },
|
||||
{ key: "actions", label: "Actions" },
|
||||
@@ -28,6 +29,7 @@ export const DEFAULT_COLUMNS_V = [
|
||||
"item_type",
|
||||
"description",
|
||||
"revision",
|
||||
"files",
|
||||
"created",
|
||||
"actions",
|
||||
];
|
||||
@@ -67,6 +69,12 @@ function copyPN(pn: string) {
|
||||
void navigator.clipboard.writeText(pn);
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function ItemTable({
|
||||
items,
|
||||
loading,
|
||||
@@ -120,6 +128,10 @@ export function ItemTable({
|
||||
av = a.current_revision;
|
||||
bv = b.current_revision;
|
||||
break;
|
||||
case "files":
|
||||
av = a.file_count;
|
||||
bv = b.file_count;
|
||||
break;
|
||||
case "created":
|
||||
av = a.created_at;
|
||||
bv = b.created_at;
|
||||
@@ -271,6 +283,20 @@ export function ItemTable({
|
||||
Rev {item.current_revision}
|
||||
</td>
|
||||
);
|
||||
case "files":
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
style={{ ...tdStyle, textAlign: "center" }}
|
||||
title={
|
||||
item.file_count > 0
|
||||
? `${item.file_count} file${item.file_count !== 1 ? "s" : ""}, ${formatSize(item.files_total_size)}`
|
||||
: "No files"
|
||||
}
|
||||
>
|
||||
{item.file_count > 0 ? item.file_count : "—"}
|
||||
</td>
|
||||
);
|
||||
case "projects":
|
||||
return (
|
||||
<td key={col.key} style={tdStyle}>
|
||||
|
||||
@@ -110,15 +110,19 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
|
||||
{row("Description", item.description)}
|
||||
{row("Type", item.item_type)}
|
||||
{row("Sourcing", item.sourcing_type || "—")}
|
||||
{item.sourcing_link &&
|
||||
{item.properties?.sourcing_link != null &&
|
||||
row(
|
||||
"Source Link",
|
||||
<a href={item.sourcing_link} target="_blank" rel="noreferrer">
|
||||
{item.sourcing_link}
|
||||
<a
|
||||
href={String(item.properties.sourcing_link)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{String(item.properties.sourcing_link)}
|
||||
</a>,
|
||||
)}
|
||||
{item.standard_cost != null &&
|
||||
row("Std Cost", `$${item.standard_cost.toFixed(2)}`)}
|
||||
{item.properties?.standard_cost != null &&
|
||||
row("Std Cost", `$${Number(item.properties.standard_cost).toFixed(2)}`)}
|
||||
{row("Revision", `Rev ${item.current_revision}`)}
|
||||
{row("Created", formatDate(item.created_at))}
|
||||
{row("Updated", formatDate(item.updated_at))}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { get } from "../api/client";
|
||||
import type { Schema } from "../api/types";
|
||||
|
||||
// Module-level cache to avoid refetching across mounts.
|
||||
let cached: Record<string, string> | null = null;
|
||||
|
||||
export function useCategories() {
|
||||
const [categories, setCategories] = useState<Record<string, string>>(
|
||||
cached ?? {},
|
||||
);
|
||||
const [loading, setLoading] = useState(cached === null);
|
||||
|
||||
useEffect(() => {
|
||||
if (cached) return;
|
||||
get<Schema>("/api/schemas/kindred-rd")
|
||||
.then((schema) => {
|
||||
const seg = schema.segments.find((s) => s.name === "category");
|
||||
const vals = seg?.values ?? {};
|
||||
cached = vals;
|
||||
setCategories(vals);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { categories, loading };
|
||||
}
|
||||
37
web/src/hooks/useFormDescriptor.ts
Normal file
37
web/src/hooks/useFormDescriptor.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { get } from "../api/client";
|
||||
import type { FormDescriptor } from "../api/types";
|
||||
|
||||
// Module-level cache to avoid refetching across mounts.
|
||||
let cached: FormDescriptor | null = null;
|
||||
|
||||
export function useFormDescriptor(schemaName = "kindred-rd") {
|
||||
const [descriptor, setDescriptor] = useState<FormDescriptor | null>(cached);
|
||||
const [loading, setLoading] = useState(cached === null);
|
||||
|
||||
useEffect(() => {
|
||||
if (cached) return;
|
||||
get<FormDescriptor>(`/api/schemas/${encodeURIComponent(schemaName)}/form`)
|
||||
.then((desc) => {
|
||||
cached = desc;
|
||||
setDescriptor(desc);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [schemaName]);
|
||||
|
||||
// Derive flat categories map from the category_picker stages
|
||||
const categories: Record<string, string> = {};
|
||||
if (descriptor?.category_picker) {
|
||||
const subcatStage = descriptor.category_picker.stages.find(
|
||||
(s) => s.values_by_domain,
|
||||
);
|
||||
if (subcatStage?.values_by_domain) {
|
||||
for (const domainVals of Object.values(subcatStage.values_by_domain)) {
|
||||
Object.assign(categories, domainVals);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { descriptor, categories, loading };
|
||||
}
|
||||
Reference in New Issue
Block a user