Add BOM handling and routes to API and web UI
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/csv"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,16 +17,17 @@ import (
|
|||||||
|
|
||||||
// BOMEntryResponse represents a BOM entry in API responses.
|
// BOMEntryResponse represents a BOM entry in API responses.
|
||||||
type BOMEntryResponse struct {
|
type BOMEntryResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ChildPartNumber string `json:"child_part_number"`
|
ChildPartNumber string `json:"child_part_number"`
|
||||||
ChildDescription string `json:"child_description"`
|
ChildDescription string `json:"child_description"`
|
||||||
RelType string `json:"rel_type"`
|
RelType string `json:"rel_type"`
|
||||||
Quantity *float64 `json:"quantity"`
|
Quantity *float64 `json:"quantity"`
|
||||||
Unit *string `json:"unit,omitempty"`
|
Unit *string `json:"unit,omitempty"`
|
||||||
ReferenceDesignators []string `json:"reference_designators,omitempty"`
|
ReferenceDesignators []string `json:"reference_designators,omitempty"`
|
||||||
ChildRevision *int `json:"child_revision,omitempty"`
|
ChildRevision *int `json:"child_revision,omitempty"`
|
||||||
EffectiveRevision int `json:"effective_revision"`
|
EffectiveRevision int `json:"effective_revision"`
|
||||||
Depth *int `json:"depth,omitempty"`
|
Depth *int `json:"depth,omitempty"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WhereUsedResponse represents a where-used entry in API responses.
|
// WhereUsedResponse represents a where-used entry in API responses.
|
||||||
@@ -264,6 +268,7 @@ func (s *Server) HandleAddBOMEntry(w http.ResponseWriter, r *http.Request) {
|
|||||||
ReferenceDesignators: req.ReferenceDesignators,
|
ReferenceDesignators: req.ReferenceDesignators,
|
||||||
ChildRevision: req.ChildRevision,
|
ChildRevision: req.ChildRevision,
|
||||||
EffectiveRevision: child.CurrentRevision,
|
EffectiveRevision: child.CurrentRevision,
|
||||||
|
Metadata: req.Metadata,
|
||||||
}
|
}
|
||||||
if req.ChildRevision != nil {
|
if req.ChildRevision != nil {
|
||||||
entry.EffectiveRevision = *req.ChildRevision
|
entry.EffectiveRevision = *req.ChildRevision
|
||||||
@@ -419,6 +424,7 @@ func bomEntryToResponse(e *db.BOMEntry) BOMEntryResponse {
|
|||||||
ReferenceDesignators: refDes,
|
ReferenceDesignators: refDes,
|
||||||
ChildRevision: e.ChildRevision,
|
ChildRevision: e.ChildRevision,
|
||||||
EffectiveRevision: e.EffectiveRevision,
|
EffectiveRevision: e.EffectiveRevision,
|
||||||
|
Metadata: e.Metadata,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,3 +444,380 @@ func whereUsedToResponse(e *db.BOMEntry) WhereUsedResponse {
|
|||||||
ReferenceDesignators: refDes,
|
ReferenceDesignators: refDes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BOM CSV headers matching the user-specified format.
|
||||||
|
var bomCSVHeaders = []string{
|
||||||
|
"Item", "Level", "Source", "PN", "Seller Description",
|
||||||
|
"Unit Cost", "QTY", "Ext Cost", "Sourcing Link",
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMetaString extracts a string value from metadata.
|
||||||
|
func getMetaString(m map[string]any, key string) string {
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v, ok := m[key]; ok {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMetaFloat extracts a float64 value from metadata.
|
||||||
|
func getMetaFloat(m map[string]any, key string) (float64, bool) {
|
||||||
|
if m == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
v, ok := m[key]
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return n, true
|
||||||
|
case json.Number:
|
||||||
|
f, err := n.Float64()
|
||||||
|
return f, err == nil
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleExportBOMCSV exports the expanded BOM as a CSV file.
|
||||||
|
func (s *Server) HandleExportBOMCSV(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
partNumber := chi.URLParam(r, "partNumber")
|
||||||
|
|
||||||
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get item")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := s.relationships.GetExpandedBOM(ctx, item.ID, 10)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get expanded BOM")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get BOM")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/csv")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-bom.csv"`, partNumber))
|
||||||
|
|
||||||
|
writer := csv.NewWriter(w)
|
||||||
|
defer writer.Flush()
|
||||||
|
|
||||||
|
// Write header
|
||||||
|
if err := writer.Write(bomCSVHeaders); err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to write CSV header")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write rows
|
||||||
|
for i, e := range entries {
|
||||||
|
unitCost, hasUnitCost := getMetaFloat(e.Metadata, "unit_cost")
|
||||||
|
qty := 0.0
|
||||||
|
if e.Quantity != nil {
|
||||||
|
qty = *e.Quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
extCost := ""
|
||||||
|
if hasUnitCost && qty > 0 {
|
||||||
|
extCost = fmt.Sprintf("%.2f", unitCost*qty)
|
||||||
|
}
|
||||||
|
|
||||||
|
unitCostStr := ""
|
||||||
|
if hasUnitCost {
|
||||||
|
unitCostStr = fmt.Sprintf("%.2f", unitCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
qtyStr := ""
|
||||||
|
if e.Quantity != nil {
|
||||||
|
qtyStr = strconv.FormatFloat(*e.Quantity, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := []string{
|
||||||
|
strconv.Itoa(i + 1), // Item
|
||||||
|
strconv.Itoa(e.Depth), // Level
|
||||||
|
getMetaString(e.Metadata, "source"), // Source
|
||||||
|
e.ChildPartNumber, // PN
|
||||||
|
getMetaString(e.Metadata, "seller_description"), // Seller Description
|
||||||
|
unitCostStr, // Unit Cost
|
||||||
|
qtyStr, // QTY
|
||||||
|
extCost, // Ext Cost
|
||||||
|
getMetaString(e.Metadata, "sourcing_link"), // Sourcing Link
|
||||||
|
}
|
||||||
|
if err := writer.Write(row); err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to write CSV row")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleImportBOMCSV imports BOM entries from a CSV file.
|
||||||
|
func (s *Server) HandleImportBOMCSV(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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse multipart form (32MB max)
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_form", "Failed to parse multipart form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, _, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing_file", "CSV file is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
dryRun := r.FormValue("dry_run") == "true"
|
||||||
|
clearExisting := r.FormValue("clear_existing") == "true"
|
||||||
|
|
||||||
|
// Read CSV
|
||||||
|
reader := csv.NewReader(file)
|
||||||
|
reader.TrimLeadingSpace = true
|
||||||
|
|
||||||
|
headers, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_csv", "Failed to read CSV headers")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build case-insensitive header index
|
||||||
|
headerIdx := make(map[string]int)
|
||||||
|
for i, h := range headers {
|
||||||
|
headerIdx[strings.ToLower(strings.TrimSpace(h))] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require PN column
|
||||||
|
pnIdx, hasPn := headerIdx["pn"]
|
||||||
|
if !hasPn {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing_column", "CSV must have a 'PN' column")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing BOM if requested (only on real import)
|
||||||
|
if clearExisting && !dryRun {
|
||||||
|
existing, err := s.relationships.GetBOM(ctx, parent.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get existing BOM for clearing")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear existing BOM")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, e := range existing {
|
||||||
|
if err := s.relationships.Delete(ctx, e.RelationshipID); err != nil {
|
||||||
|
s.logger.Error().Err(err).Str("id", e.RelationshipID).Msg("failed to delete BOM entry during clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := CSVImportResult{}
|
||||||
|
var createdItems []string
|
||||||
|
|
||||||
|
for {
|
||||||
|
record, err := reader.Read()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
result.TotalRows++
|
||||||
|
result.ErrorCount++
|
||||||
|
result.Errors = append(result.Errors, CSVImportErr{
|
||||||
|
Row: result.TotalRows + 1, // +1 for header
|
||||||
|
Message: fmt.Sprintf("Failed to read row: %s", err.Error()),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result.TotalRows++
|
||||||
|
rowNum := result.TotalRows + 1 // +1 for header
|
||||||
|
|
||||||
|
// Get part number
|
||||||
|
if pnIdx >= len(record) {
|
||||||
|
result.ErrorCount++
|
||||||
|
result.Errors = append(result.Errors, CSVImportErr{
|
||||||
|
Row: rowNum,
|
||||||
|
Field: "PN",
|
||||||
|
Message: "Row has fewer columns than expected",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
childPN := strings.TrimSpace(record[pnIdx])
|
||||||
|
if childPN == "" {
|
||||||
|
// Skip blank PN rows silently
|
||||||
|
result.TotalRows--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up child item
|
||||||
|
child, err := s.items.GetByPartNumber(ctx, childPN)
|
||||||
|
if err != nil {
|
||||||
|
result.ErrorCount++
|
||||||
|
result.Errors = append(result.Errors, CSVImportErr{
|
||||||
|
Row: rowNum,
|
||||||
|
Field: "PN",
|
||||||
|
Message: fmt.Sprintf("Error looking up item: %s", err.Error()),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if child == nil {
|
||||||
|
result.ErrorCount++
|
||||||
|
result.Errors = append(result.Errors, CSVImportErr{
|
||||||
|
Row: rowNum,
|
||||||
|
Field: "PN",
|
||||||
|
Message: fmt.Sprintf("Item '%s' not found", childPN),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse quantity
|
||||||
|
var quantity *float64
|
||||||
|
if idx, ok := headerIdx["qty"]; ok && idx < len(record) {
|
||||||
|
qtyStr := strings.TrimSpace(record[idx])
|
||||||
|
if qtyStr != "" {
|
||||||
|
q, err := strconv.ParseFloat(qtyStr, 64)
|
||||||
|
if err != nil {
|
||||||
|
result.ErrorCount++
|
||||||
|
result.Errors = append(result.Errors, CSVImportErr{
|
||||||
|
Row: rowNum,
|
||||||
|
Field: "QTY",
|
||||||
|
Message: fmt.Sprintf("Invalid quantity '%s'", qtyStr),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
quantity = &q
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build metadata from CSV columns
|
||||||
|
metadata := make(map[string]any)
|
||||||
|
if idx, ok := headerIdx["source"]; ok && idx < len(record) {
|
||||||
|
if v := strings.TrimSpace(record[idx]); v != "" {
|
||||||
|
metadata["source"] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx, ok := headerIdx["seller description"]; ok && idx < len(record) {
|
||||||
|
if v := strings.TrimSpace(record[idx]); v != "" {
|
||||||
|
metadata["seller_description"] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx, ok := headerIdx["unit cost"]; ok && idx < len(record) {
|
||||||
|
if v := strings.TrimSpace(record[idx]); v != "" {
|
||||||
|
// Strip leading $ or currency symbols
|
||||||
|
v = strings.TrimLeft(v, "$£€ ")
|
||||||
|
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||||
|
metadata["unit_cost"] = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx, ok := headerIdx["sourcing link"]; ok && idx < len(record) {
|
||||||
|
if v := strings.TrimSpace(record[idx]); v != "" {
|
||||||
|
metadata["sourcing_link"] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(metadata) == 0 {
|
||||||
|
metadata = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle check
|
||||||
|
hasCycle, err := s.relationships.HasCycle(ctx, parent.ID, child.ID)
|
||||||
|
if err != nil {
|
||||||
|
result.ErrorCount++
|
||||||
|
result.Errors = append(result.Errors, CSVImportErr{
|
||||||
|
Row: rowNum,
|
||||||
|
Field: "PN",
|
||||||
|
Message: fmt.Sprintf("Error checking for cycles: %s", err.Error()),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hasCycle {
|
||||||
|
result.ErrorCount++
|
||||||
|
result.Errors = append(result.Errors, CSVImportErr{
|
||||||
|
Row: rowNum,
|
||||||
|
Field: "PN",
|
||||||
|
Message: fmt.Sprintf("Adding '%s' would create a cycle", childPN),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
result.SuccessCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if relationship already exists (upsert)
|
||||||
|
existing, err := s.relationships.GetByParentAndChild(ctx, parent.ID, child.ID)
|
||||||
|
if err != nil {
|
||||||
|
result.ErrorCount++
|
||||||
|
result.Errors = append(result.Errors, CSVImportErr{
|
||||||
|
Row: rowNum,
|
||||||
|
Message: fmt.Sprintf("Error checking existing relationship: %s", err.Error()),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing != nil {
|
||||||
|
// Update existing
|
||||||
|
if err := s.relationships.Update(ctx, existing.ID, nil, quantity, nil, nil, nil, metadata); err != nil {
|
||||||
|
result.ErrorCount++
|
||||||
|
result.Errors = append(result.Errors, CSVImportErr{
|
||||||
|
Row: rowNum,
|
||||||
|
Message: fmt.Sprintf("Failed to update: %s", err.Error()),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
rel := &db.Relationship{
|
||||||
|
ParentItemID: parent.ID,
|
||||||
|
ChildItemID: child.ID,
|
||||||
|
RelType: "component",
|
||||||
|
Quantity: quantity,
|
||||||
|
Metadata: metadata,
|
||||||
|
}
|
||||||
|
if err := s.relationships.Create(ctx, rel); err != nil {
|
||||||
|
result.ErrorCount++
|
||||||
|
result.Errors = append(result.Errors, CSVImportErr{
|
||||||
|
Row: rowNum,
|
||||||
|
Message: fmt.Sprintf("Failed to create: %s", err.Error()),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
createdItems = append(createdItems, childPN)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.SuccessCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
result.CreatedItems = createdItems
|
||||||
|
|
||||||
|
s.logger.Info().
|
||||||
|
Str("parent", partNumber).
|
||||||
|
Bool("dry_run", dryRun).
|
||||||
|
Int("total", result.TotalRows).
|
||||||
|
Int("success", result.SuccessCount).
|
||||||
|
Int("errors", result.ErrorCount).
|
||||||
|
Msg("BOM CSV import completed")
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
r.Post("/bom", server.HandleAddBOMEntry)
|
r.Post("/bom", server.HandleAddBOMEntry)
|
||||||
r.Get("/bom/expanded", server.HandleGetExpandedBOM)
|
r.Get("/bom/expanded", server.HandleGetExpandedBOM)
|
||||||
r.Get("/bom/where-used", server.HandleGetWhereUsed)
|
r.Get("/bom/where-used", server.HandleGetWhereUsed)
|
||||||
|
r.Get("/bom/export.csv", server.HandleExportBOMCSV)
|
||||||
|
r.Post("/bom/import", server.HandleImportBOMCSV)
|
||||||
r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry)
|
r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry)
|
||||||
r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry)
|
r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -345,6 +345,20 @@
|
|||||||
>
|
>
|
||||||
Revisions
|
Revisions
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
data-tab="bom"
|
||||||
|
onclick="switchDetailTab('bom')"
|
||||||
|
>
|
||||||
|
BOM
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
data-tab="where-used"
|
||||||
|
onclick="switchDetailTab('where-used')"
|
||||||
|
>
|
||||||
|
Where Used
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab Content -->
|
<!-- Tab Content -->
|
||||||
@@ -355,6 +369,12 @@
|
|||||||
style="display: none"
|
style="display: none"
|
||||||
></div>
|
></div>
|
||||||
<div class="tab-content" id="tab-revisions" style="display: none"></div>
|
<div class="tab-content" id="tab-revisions" style="display: none"></div>
|
||||||
|
<div class="tab-content" id="tab-bom" style="display: none"></div>
|
||||||
|
<div
|
||||||
|
class="tab-content"
|
||||||
|
id="tab-where-used"
|
||||||
|
style="display: none"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -855,6 +875,127 @@
|
|||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* BOM Tab */
|
||||||
|
.bom-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.bom-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.bom-toolbar .btn {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.bom-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.bom-table th {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--ctp-base);
|
||||||
|
color: var(--ctp-subtext1);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--ctp-surface1);
|
||||||
|
}
|
||||||
|
.bom-table td {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--ctp-surface1);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
.bom-table tr:hover {
|
||||||
|
background: var(--ctp-surface1);
|
||||||
|
}
|
||||||
|
.bom-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.bom-table .pn-link {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ctp-peach);
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.bom-table .pn-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.bom-cost {
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.bom-summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-top: 2px solid var(--ctp-surface2);
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
color: var(--ctp-green);
|
||||||
|
}
|
||||||
|
.bom-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
}
|
||||||
|
.bom-add-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--ctp-base);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.bom-add-form .form-group {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.bom-add-form .full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.bom-import-area {
|
||||||
|
border: 2px dashed var(--ctp-surface2);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.bom-import-area:hover {
|
||||||
|
border-color: var(--ctp-mauve);
|
||||||
|
}
|
||||||
|
.bom-import-results {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--ctp-base);
|
||||||
|
}
|
||||||
|
.bom-import-results.success {
|
||||||
|
border-left: 3px solid var(--ctp-green);
|
||||||
|
}
|
||||||
|
.bom-import-results.error {
|
||||||
|
border-left: 3px solid var(--ctp-red);
|
||||||
|
}
|
||||||
|
.bom-import-results.warning {
|
||||||
|
border-left: 3px solid var(--ctp-yellow);
|
||||||
|
}
|
||||||
|
.bom-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.bom-actions .btn {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Properties Editor */
|
/* Properties Editor */
|
||||||
.properties-editor {
|
.properties-editor {
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
@@ -1592,6 +1733,14 @@
|
|||||||
content.style.display = "none";
|
content.style.display = "none";
|
||||||
});
|
});
|
||||||
document.getElementById(`tab-${tab}`).style.display = "block";
|
document.getElementById(`tab-${tab}`).style.display = "block";
|
||||||
|
|
||||||
|
// Lazy-load BOM and Where Used data
|
||||||
|
if (tab === "bom" && currentItemPartNumber) {
|
||||||
|
loadBOMTab(currentItemPartNumber);
|
||||||
|
}
|
||||||
|
if (tab === "where-used" && currentItemPartNumber) {
|
||||||
|
loadWhereUsedTab(currentItemPartNumber);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format file size
|
// Format file size
|
||||||
@@ -1618,6 +1767,8 @@
|
|||||||
'<div class="loading"><div class="spinner"></div></div>';
|
'<div class="loading"><div class="spinner"></div></div>';
|
||||||
document.getElementById("tab-properties").innerHTML = "";
|
document.getElementById("tab-properties").innerHTML = "";
|
||||||
document.getElementById("tab-revisions").innerHTML = "";
|
document.getElementById("tab-revisions").innerHTML = "";
|
||||||
|
document.getElementById("tab-bom").innerHTML = "";
|
||||||
|
document.getElementById("tab-where-used").innerHTML = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [itemRes, revsRes] = await Promise.all([
|
const [itemRes, revsRes] = await Promise.all([
|
||||||
@@ -2445,6 +2596,493 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// BOM Tab Functions
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
let bomData = [];
|
||||||
|
|
||||||
|
function escapeAttr(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBOMTab(partNumber) {
|
||||||
|
const container = document.getElementById("tab-bom");
|
||||||
|
container.innerHTML =
|
||||||
|
'<div class="loading"><div class="spinner"></div></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/items/${partNumber}/bom`);
|
||||||
|
if (!response.ok) throw new Error("Failed to load BOM");
|
||||||
|
bomData = await response.json();
|
||||||
|
renderBOMTab(partNumber, bomData);
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = `<p style="color: var(--ctp-red);">Error: ${error.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBOMTab(partNumber, entries) {
|
||||||
|
const container = document.getElementById("tab-bom");
|
||||||
|
const escapedPN = escapeAttr(partNumber);
|
||||||
|
|
||||||
|
let totalExtCost = 0;
|
||||||
|
entries.forEach((e) => {
|
||||||
|
const unitCost = e.metadata?.unit_cost || 0;
|
||||||
|
const qty = e.quantity || 0;
|
||||||
|
totalExtCost += unitCost * qty;
|
||||||
|
});
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="bom-toolbar">
|
||||||
|
<span style="color: var(--ctp-subtext0); font-size: 0.85rem;">
|
||||||
|
${entries.length} component${entries.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
<div class="bom-toolbar-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="exportBOMCSV('${escapedPN}')">Export CSV</button>
|
||||||
|
<button class="btn btn-secondary" onclick="showBOMImport('${escapedPN}')">Import CSV</button>
|
||||||
|
<button class="btn btn-primary" onclick="showAddBOMForm('${escapedPN}')">+ Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="bom-add-container"></div>
|
||||||
|
<div id="bom-import-container"></div>`;
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
html +=
|
||||||
|
'<div class="bom-empty"><p>No BOM entries.</p><p style="font-size:0.85rem;">Add components or import a CSV.</p></div>';
|
||||||
|
} else {
|
||||||
|
html += `
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="bom-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>PN</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Seller Description</th>
|
||||||
|
<th style="text-align:right">Unit Cost</th>
|
||||||
|
<th style="text-align:right">QTY</th>
|
||||||
|
<th style="text-align:right">Ext Cost</th>
|
||||||
|
<th>Link</th>
|
||||||
|
<th style="width:90px">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>`;
|
||||||
|
|
||||||
|
entries.forEach((e, idx) => {
|
||||||
|
const unitCost = e.metadata?.unit_cost || 0;
|
||||||
|
const qty = e.quantity || 0;
|
||||||
|
const extCost = unitCost * qty;
|
||||||
|
const source = escapeHtml(e.metadata?.source || "");
|
||||||
|
const sellerDesc = escapeHtml(
|
||||||
|
e.metadata?.seller_description || e.child_description || "",
|
||||||
|
);
|
||||||
|
const sourcingLink = e.metadata?.sourcing_link || "";
|
||||||
|
const childPN = escapeAttr(e.child_part_number);
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<tr>
|
||||||
|
<td>${idx + 1}</td>
|
||||||
|
<td><span class="pn-link" onclick="showItemDetail('${childPN}')">${escapeHtml(e.child_part_number)}</span></td>
|
||||||
|
<td>${source}</td>
|
||||||
|
<td>${sellerDesc}</td>
|
||||||
|
<td class="bom-cost">${unitCost ? "$" + unitCost.toFixed(2) : ""}</td>
|
||||||
|
<td class="bom-cost">${qty || ""}</td>
|
||||||
|
<td class="bom-cost">${extCost ? "$" + extCost.toFixed(2) : ""}</td>
|
||||||
|
<td>${sourcingLink ? `<a href="${escapeAttr(sourcingLink)}" target="_blank" rel="noopener" style="color:var(--ctp-blue);font-size:0.8rem;">Link</a>` : ""}</td>
|
||||||
|
<td>
|
||||||
|
<div class="bom-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="editBOMEntry('${escapedPN}', ${idx})">Edit</button>
|
||||||
|
<button class="btn btn-secondary" style="color:var(--ctp-red)" onclick="deleteBOMEntry('${escapedPN}', '${childPN}')">Del</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `</tbody></table></div>`;
|
||||||
|
if (totalExtCost > 0) {
|
||||||
|
html += `<div class="bom-summary">Total: $${totalExtCost.toFixed(2)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddBOMForm(partNumber) {
|
||||||
|
const container = document.getElementById("bom-add-container");
|
||||||
|
document.getElementById("bom-import-container").innerHTML = "";
|
||||||
|
const escapedPN = escapeAttr(partNumber);
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="bom-add-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Part Number *</label>
|
||||||
|
<input type="text" class="form-input" id="bom-add-pn" placeholder="e.g. F01-0001">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Quantity</label>
|
||||||
|
<input type="number" class="form-input" id="bom-add-qty" step="any" value="1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Source</label>
|
||||||
|
<input type="text" class="form-input" id="bom-add-source" placeholder="e.g. DigiKey">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Seller Description</label>
|
||||||
|
<input type="text" class="form-input" id="bom-add-seller-desc">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Unit Cost</label>
|
||||||
|
<input type="number" class="form-input" id="bom-add-unit-cost" step="0.01" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Sourcing Link</label>
|
||||||
|
<input type="url" class="form-input" id="bom-add-sourcing-link" placeholder="https://...">
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width" style="display:flex;gap:0.5rem;justify-content:flex-end;">
|
||||||
|
<button class="btn btn-secondary" onclick="document.getElementById('bom-add-container').innerHTML=''">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="submitAddBOM('${escapedPN}')">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.getElementById("bom-add-pn").focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAddBOM(partNumber) {
|
||||||
|
const pn = document.getElementById("bom-add-pn").value.trim();
|
||||||
|
if (!pn) {
|
||||||
|
alert("Part number is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qty =
|
||||||
|
parseFloat(document.getElementById("bom-add-qty").value) || null;
|
||||||
|
const metadata = {};
|
||||||
|
const source = document.getElementById("bom-add-source").value.trim();
|
||||||
|
const sellerDesc = document
|
||||||
|
.getElementById("bom-add-seller-desc")
|
||||||
|
.value.trim();
|
||||||
|
const unitCost = parseFloat(
|
||||||
|
document.getElementById("bom-add-unit-cost").value,
|
||||||
|
);
|
||||||
|
const sourcingLink = document
|
||||||
|
.getElementById("bom-add-sourcing-link")
|
||||||
|
.value.trim();
|
||||||
|
|
||||||
|
if (source) metadata.source = source;
|
||||||
|
if (sellerDesc) metadata.seller_description = sellerDesc;
|
||||||
|
if (!isNaN(unitCost)) metadata.unit_cost = unitCost;
|
||||||
|
if (sourcingLink) metadata.sourcing_link = sourcingLink;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/items/${partNumber}/bom`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
child_part_number: pn,
|
||||||
|
rel_type: "component",
|
||||||
|
quantity: qty,
|
||||||
|
metadata:
|
||||||
|
Object.keys(metadata).length > 0 ? metadata : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
alert(err.message || err.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById("bom-add-container").innerHTML = "";
|
||||||
|
loadBOMTab(partNumber);
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editBOMEntry(partNumber, idx) {
|
||||||
|
const e = bomData[idx];
|
||||||
|
if (!e) return;
|
||||||
|
const container = document.getElementById("bom-add-container");
|
||||||
|
document.getElementById("bom-import-container").innerHTML = "";
|
||||||
|
const escapedPN = escapeAttr(partNumber);
|
||||||
|
const childPN = escapeAttr(e.child_part_number);
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="bom-add-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Part Number</label>
|
||||||
|
<input type="text" class="form-input" value="${childPN}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Quantity</label>
|
||||||
|
<input type="number" class="form-input" id="bom-edit-qty" step="any" value="${e.quantity || ""}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Source</label>
|
||||||
|
<input type="text" class="form-input" id="bom-edit-source" value="${escapeAttr(e.metadata?.source || "")}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Seller Description</label>
|
||||||
|
<input type="text" class="form-input" id="bom-edit-seller-desc" value="${escapeAttr(e.metadata?.seller_description || "")}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Unit Cost</label>
|
||||||
|
<input type="number" class="form-input" id="bom-edit-unit-cost" step="0.01" value="${e.metadata?.unit_cost || ""}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Sourcing Link</label>
|
||||||
|
<input type="url" class="form-input" id="bom-edit-sourcing-link" value="${escapeAttr(e.metadata?.sourcing_link || "")}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width" style="display:flex;gap:0.5rem;justify-content:flex-end;">
|
||||||
|
<button class="btn btn-secondary" onclick="document.getElementById('bom-add-container').innerHTML=''">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="submitEditBOM('${escapedPN}', '${childPN}')">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEditBOM(partNumber, childPN) {
|
||||||
|
const qty = parseFloat(document.getElementById("bom-edit-qty").value);
|
||||||
|
const metadata = {};
|
||||||
|
const source = document.getElementById("bom-edit-source").value.trim();
|
||||||
|
const sellerDesc = document
|
||||||
|
.getElementById("bom-edit-seller-desc")
|
||||||
|
.value.trim();
|
||||||
|
const unitCost = parseFloat(
|
||||||
|
document.getElementById("bom-edit-unit-cost").value,
|
||||||
|
);
|
||||||
|
const sourcingLink = document
|
||||||
|
.getElementById("bom-edit-sourcing-link")
|
||||||
|
.value.trim();
|
||||||
|
|
||||||
|
if (source) metadata.source = source;
|
||||||
|
if (sellerDesc) metadata.seller_description = sellerDesc;
|
||||||
|
if (!isNaN(unitCost)) metadata.unit_cost = unitCost;
|
||||||
|
if (sourcingLink) metadata.sourcing_link = sourcingLink;
|
||||||
|
|
||||||
|
const body = {};
|
||||||
|
if (!isNaN(qty)) body.quantity = qty;
|
||||||
|
if (Object.keys(metadata).length > 0) body.metadata = metadata;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/items/${partNumber}/bom/${childPN}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
alert(err.message || err.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById("bom-add-container").innerHTML = "";
|
||||||
|
loadBOMTab(partNumber);
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBOMEntry(partNumber, childPN) {
|
||||||
|
if (!confirm(`Remove ${childPN} from BOM?`)) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/items/${partNumber}/bom/${childPN}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
if (!response.ok && response.status !== 204) {
|
||||||
|
const err = await response.json();
|
||||||
|
alert(err.message || err.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadBOMTab(partNumber);
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportBOMCSV(partNumber) {
|
||||||
|
window.location.href = `/api/items/${partNumber}/bom/export.csv`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBOMImport(partNumber) {
|
||||||
|
const container = document.getElementById("bom-import-container");
|
||||||
|
document.getElementById("bom-add-container").innerHTML = "";
|
||||||
|
const escapedPN = escapeAttr(partNumber);
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="bom-import-area">
|
||||||
|
<div style="margin-bottom:0.75rem;">
|
||||||
|
<input type="file" id="bom-import-file" accept=".csv,text/csv" style="display:none"
|
||||||
|
onchange="document.getElementById('bom-file-label').textContent = this.files[0]?.name || 'Choose a CSV file'">
|
||||||
|
<button class="btn btn-secondary" onclick="document.getElementById('bom-import-file').click()">Choose File</button>
|
||||||
|
<span id="bom-file-label" style="margin-left:0.5rem;color:var(--ctp-subtext0);font-size:0.85rem;">No file selected</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:1rem;justify-content:center;align-items:center;margin-bottom:0.75rem;">
|
||||||
|
<label style="display:flex;align-items:center;gap:0.4rem;color:var(--ctp-subtext1);font-size:0.85rem;">
|
||||||
|
<input type="checkbox" id="bom-import-dry-run" checked> Dry run (validate only)
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:0.4rem;color:var(--ctp-subtext1);font-size:0.85rem;">
|
||||||
|
<input type="checkbox" id="bom-import-clear"> Replace existing BOM
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="bom-import-results"></div>
|
||||||
|
<div style="display:flex;gap:0.5rem;justify-content:center;margin-top:0.75rem;">
|
||||||
|
<button class="btn btn-secondary" onclick="document.getElementById('bom-import-container').innerHTML=''">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="bom-import-btn" onclick="submitBOMImport('${escapedPN}')">Validate</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBOMImport(partNumber) {
|
||||||
|
const fileInput = document.getElementById("bom-import-file");
|
||||||
|
if (!fileInput.files || !fileInput.files[0]) {
|
||||||
|
alert("Select a CSV file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dryRun = document.getElementById("bom-import-dry-run").checked;
|
||||||
|
const clearExisting =
|
||||||
|
document.getElementById("bom-import-clear").checked;
|
||||||
|
const btn = document.getElementById("bom-import-btn");
|
||||||
|
const resultsDiv = document.getElementById("bom-import-results");
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", fileInput.files[0]);
|
||||||
|
formData.append("dry_run", dryRun.toString());
|
||||||
|
formData.append("clear_existing", clearExisting.toString());
|
||||||
|
|
||||||
|
btn.textContent = dryRun ? "Validating..." : "Importing...";
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/items/${partNumber}/bom/import`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
if (!contentType || !contentType.includes("application/json")) {
|
||||||
|
const text = await response.text();
|
||||||
|
resultsDiv.innerHTML = `<div class="bom-import-results error"><strong>Error</strong><p>Server returned non-JSON response (status ${response.status}).</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
resultsDiv.innerHTML = `<div class="bom-import-results error"><strong>Error</strong><p>${result.message || result.error}</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let statusClass = "success";
|
||||||
|
if (result.error_count > 0 && result.success_count > 0)
|
||||||
|
statusClass = "warning";
|
||||||
|
else if (result.error_count > 0) statusClass = "error";
|
||||||
|
|
||||||
|
let rhtml = `<div class="bom-import-results ${statusClass}">`;
|
||||||
|
rhtml += `<strong>${dryRun ? "Validation" : "Import"} Complete</strong>`;
|
||||||
|
rhtml += `<div style="display:flex;gap:1.5rem;margin:0.5rem 0;">`;
|
||||||
|
rhtml += `<span>Total: ${result.total_rows}</span>`;
|
||||||
|
rhtml += `<span style="color:var(--ctp-green)">${dryRun ? "Valid" : "Success"}: ${result.success_count}</span>`;
|
||||||
|
rhtml += `<span style="color:var(--ctp-red)">Errors: ${result.error_count}</span>`;
|
||||||
|
rhtml += `</div>`;
|
||||||
|
|
||||||
|
if (result.errors && result.errors.length > 0) {
|
||||||
|
rhtml += `<div style="margin-top:0.5rem;font-size:0.85rem;">`;
|
||||||
|
result.errors.slice(0, 20).forEach((err) => {
|
||||||
|
rhtml += `<div style="color:var(--ctp-red);margin:0.2rem 0;">Row ${err.row}: ${err.field ? `[${err.field}] ` : ""}${escapeHtml(err.message)}</div>`;
|
||||||
|
});
|
||||||
|
if (result.errors.length > 20) {
|
||||||
|
rhtml += `<div style="color:var(--ctp-subtext0);">...and ${result.errors.length - 20} more errors</div>`;
|
||||||
|
}
|
||||||
|
rhtml += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.created_items && result.created_items.length > 0) {
|
||||||
|
rhtml += `<div style="margin-top:0.5rem;font-size:0.85rem;"><strong>Added:</strong> ${result.created_items.map(escapeHtml).join(", ")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
rhtml += `</div>`;
|
||||||
|
resultsDiv.innerHTML = rhtml;
|
||||||
|
|
||||||
|
if (
|
||||||
|
dryRun &&
|
||||||
|
result.success_count > 0 &&
|
||||||
|
result.error_count === 0
|
||||||
|
) {
|
||||||
|
btn.textContent = "Import Now";
|
||||||
|
document.getElementById("bom-import-dry-run").checked = false;
|
||||||
|
} else if (!dryRun && result.success_count > 0) {
|
||||||
|
loadBOMTab(partNumber);
|
||||||
|
btn.textContent = "Done";
|
||||||
|
} else {
|
||||||
|
btn.textContent = "Validate";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
resultsDiv.innerHTML = `<div class="bom-import-results error"><strong>Error</strong><p>${error.message}</p></div>`;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Where Used Tab
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
async function loadWhereUsedTab(partNumber) {
|
||||||
|
const container = document.getElementById("tab-where-used");
|
||||||
|
container.innerHTML =
|
||||||
|
'<div class="loading"><div class="spinner"></div></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/items/${partNumber}/bom/where-used`,
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error("Failed to load where-used data");
|
||||||
|
const entries = await response.json();
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
container.innerHTML =
|
||||||
|
'<div class="bom-empty"><p>This item is not used in any assemblies.</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="bom-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Parent PN</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th style="text-align:right">QTY</th>
|
||||||
|
<th>Type</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>`;
|
||||||
|
|
||||||
|
entries.forEach((e) => {
|
||||||
|
const parentPN = escapeAttr(e.parent_part_number);
|
||||||
|
html += `
|
||||||
|
<tr>
|
||||||
|
<td><span class="pn-link" onclick="showItemDetail('${parentPN}')">${escapeHtml(e.parent_part_number)}</span></td>
|
||||||
|
<td>${escapeHtml(e.parent_description || "")}</td>
|
||||||
|
<td class="bom-cost">${e.quantity || ""}</td>
|
||||||
|
<td><span class="item-type item-type-part">${escapeHtml(e.rel_type)}</span></td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `</tbody></table></div>`;
|
||||||
|
container.innerHTML = html;
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = `<p style="color: var(--ctp-red);">Error: ${error.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
loadSchema();
|
loadSchema();
|
||||||
loadProjectCodes();
|
loadProjectCodes();
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type BOMEntry struct {
|
|||||||
ReferenceDesignators []string
|
ReferenceDesignators []string
|
||||||
ChildRevision *int
|
ChildRevision *int
|
||||||
EffectiveRevision int
|
EffectiveRevision int
|
||||||
|
Metadata map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
// BOMTreeEntry extends BOMEntry with depth for multi-level BOM expansion.
|
// BOMTreeEntry extends BOMEntry with depth for multi-level BOM expansion.
|
||||||
@@ -243,7 +244,8 @@ func (r *RelationshipRepository) GetBOM(ctx context.Context, parentItemID string
|
|||||||
rel.child_item_id, child.part_number, child.description,
|
rel.child_item_id, child.part_number, child.description,
|
||||||
rel.rel_type, rel.quantity, rel.unit,
|
rel.rel_type, rel.quantity, rel.unit,
|
||||||
rel.reference_designators, rel.child_revision,
|
rel.reference_designators, rel.child_revision,
|
||||||
COALESCE(rel.child_revision, child.current_revision) AS effective_revision
|
COALESCE(rel.child_revision, child.current_revision) AS effective_revision,
|
||||||
|
rel.metadata
|
||||||
FROM relationships rel
|
FROM relationships rel
|
||||||
JOIN items parent ON parent.id = rel.parent_item_id
|
JOIN items parent ON parent.id = rel.parent_item_id
|
||||||
JOIN items child ON child.id = rel.child_item_id
|
JOIN items child ON child.id = rel.child_item_id
|
||||||
@@ -267,7 +269,8 @@ func (r *RelationshipRepository) GetWhereUsed(ctx context.Context, childItemID s
|
|||||||
rel.child_item_id, child.part_number, child.description,
|
rel.child_item_id, child.part_number, child.description,
|
||||||
rel.rel_type, rel.quantity, rel.unit,
|
rel.rel_type, rel.quantity, rel.unit,
|
||||||
rel.reference_designators, rel.child_revision,
|
rel.reference_designators, rel.child_revision,
|
||||||
COALESCE(rel.child_revision, child.current_revision) AS effective_revision
|
COALESCE(rel.child_revision, child.current_revision) AS effective_revision,
|
||||||
|
rel.metadata
|
||||||
FROM relationships rel
|
FROM relationships rel
|
||||||
JOIN items parent ON parent.id = rel.parent_item_id
|
JOIN items parent ON parent.id = rel.parent_item_id
|
||||||
JOIN items child ON child.id = rel.child_item_id
|
JOIN items child ON child.id = rel.child_item_id
|
||||||
@@ -301,6 +304,7 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI
|
|||||||
rel.rel_type, rel.quantity, rel.unit,
|
rel.rel_type, rel.quantity, rel.unit,
|
||||||
rel.reference_designators, rel.child_revision,
|
rel.reference_designators, rel.child_revision,
|
||||||
COALESCE(rel.child_revision, child.current_revision) AS effective_revision,
|
COALESCE(rel.child_revision, child.current_revision) AS effective_revision,
|
||||||
|
rel.metadata,
|
||||||
1 AS depth
|
1 AS depth
|
||||||
FROM relationships rel
|
FROM relationships rel
|
||||||
JOIN items parent ON parent.id = rel.parent_item_id
|
JOIN items parent ON parent.id = rel.parent_item_id
|
||||||
@@ -319,6 +323,7 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI
|
|||||||
rel.rel_type, rel.quantity, rel.unit,
|
rel.rel_type, rel.quantity, rel.unit,
|
||||||
rel.reference_designators, rel.child_revision,
|
rel.reference_designators, rel.child_revision,
|
||||||
COALESCE(rel.child_revision, child.current_revision),
|
COALESCE(rel.child_revision, child.current_revision),
|
||||||
|
rel.metadata,
|
||||||
bt.depth + 1
|
bt.depth + 1
|
||||||
FROM relationships rel
|
FROM relationships rel
|
||||||
JOIN items parent ON parent.id = rel.parent_item_id
|
JOIN items parent ON parent.id = rel.parent_item_id
|
||||||
@@ -331,7 +336,7 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI
|
|||||||
SELECT id, parent_item_id, parent_part_number, parent_description,
|
SELECT id, parent_item_id, parent_part_number, parent_description,
|
||||||
child_item_id, child_part_number, child_description,
|
child_item_id, child_part_number, child_description,
|
||||||
rel_type, quantity, unit, reference_designators,
|
rel_type, quantity, unit, reference_designators,
|
||||||
child_revision, effective_revision, depth
|
child_revision, effective_revision, metadata, depth
|
||||||
FROM bom_tree
|
FROM bom_tree
|
||||||
ORDER BY depth, child_part_number
|
ORDER BY depth, child_part_number
|
||||||
`, parentItemID, maxDepth)
|
`, parentItemID, maxDepth)
|
||||||
@@ -344,12 +349,13 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
e := &BOMTreeEntry{}
|
e := &BOMTreeEntry{}
|
||||||
var parentDesc, childDesc *string
|
var parentDesc, childDesc *string
|
||||||
|
var metadataJSON []byte
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&e.RelationshipID, &e.ParentItemID, &e.ParentPartNumber, &parentDesc,
|
&e.RelationshipID, &e.ParentItemID, &e.ParentPartNumber, &parentDesc,
|
||||||
&e.ChildItemID, &e.ChildPartNumber, &childDesc,
|
&e.ChildItemID, &e.ChildPartNumber, &childDesc,
|
||||||
&e.RelType, &e.Quantity, &e.Unit,
|
&e.RelType, &e.Quantity, &e.Unit,
|
||||||
&e.ReferenceDesignators, &e.ChildRevision,
|
&e.ReferenceDesignators, &e.ChildRevision,
|
||||||
&e.EffectiveRevision, &e.Depth,
|
&e.EffectiveRevision, &metadataJSON, &e.Depth,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("scanning BOM tree entry: %w", err)
|
return nil, fmt.Errorf("scanning BOM tree entry: %w", err)
|
||||||
@@ -360,6 +366,11 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI
|
|||||||
if childDesc != nil {
|
if childDesc != nil {
|
||||||
e.ChildDescription = *childDesc
|
e.ChildDescription = *childDesc
|
||||||
}
|
}
|
||||||
|
if metadataJSON != nil {
|
||||||
|
if err := json.Unmarshal(metadataJSON, &e.Metadata); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshaling BOM entry metadata: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,12 +418,14 @@ func scanBOMEntries(rows pgx.Rows) ([]*BOMEntry, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
e := &BOMEntry{}
|
e := &BOMEntry{}
|
||||||
var parentDesc, childDesc *string
|
var parentDesc, childDesc *string
|
||||||
|
var metadataJSON []byte
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&e.RelationshipID, &e.ParentItemID, &e.ParentPartNumber, &parentDesc,
|
&e.RelationshipID, &e.ParentItemID, &e.ParentPartNumber, &parentDesc,
|
||||||
&e.ChildItemID, &e.ChildPartNumber, &childDesc,
|
&e.ChildItemID, &e.ChildPartNumber, &childDesc,
|
||||||
&e.RelType, &e.Quantity, &e.Unit,
|
&e.RelType, &e.Quantity, &e.Unit,
|
||||||
&e.ReferenceDesignators, &e.ChildRevision,
|
&e.ReferenceDesignators, &e.ChildRevision,
|
||||||
&e.EffectiveRevision,
|
&e.EffectiveRevision,
|
||||||
|
&metadataJSON,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("scanning BOM entry: %w", err)
|
return nil, fmt.Errorf("scanning BOM entry: %w", err)
|
||||||
@@ -423,6 +436,11 @@ func scanBOMEntries(rows pgx.Rows) ([]*BOMEntry, error) {
|
|||||||
if childDesc != nil {
|
if childDesc != nil {
|
||||||
e.ChildDescription = *childDesc
|
e.ChildDescription = *childDesc
|
||||||
}
|
}
|
||||||
|
if metadataJSON != nil {
|
||||||
|
if err := json.Unmarshal(metadataJSON, &e.Metadata); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshaling BOM entry metadata: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user