Compare commits
11 Commits
issue-43-u
...
issue-37-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50985ed805 | ||
| 9be6f45f09 | |||
| ef05aec619 | |||
| 64075d88b5 | |||
| eac64f863b | |||
| aa414adc43 | |||
| 9ce9468474 | |||
| 2dad658e91 | |||
|
|
08e84703d5 | ||
|
|
fbe4f3a36c | ||
|
|
163dc9f0f0 |
@@ -101,6 +101,8 @@ var manufacturedWeights = map[string]float64{
|
|||||||
// Weight 1: engineering detail (category-specific default)
|
// Weight 1: engineering detail (category-specific default)
|
||||||
"sourcing_type": 1,
|
"sourcing_type": 1,
|
||||||
"lifecycle_status": 1,
|
"lifecycle_status": 1,
|
||||||
|
// Weight 1: engineering detail
|
||||||
|
"has_files": 1,
|
||||||
// Weight 0.5: less relevant for in-house
|
// Weight 0.5: less relevant for in-house
|
||||||
"manufacturer": 0.5,
|
"manufacturer": 0.5,
|
||||||
"supplier": 0.5,
|
"supplier": 0.5,
|
||||||
@@ -207,6 +209,7 @@ func scoreItem(
|
|||||||
categoryProps map[string]schema.PropertyDefinition,
|
categoryProps map[string]schema.PropertyDefinition,
|
||||||
hasBOM bool,
|
hasBOM bool,
|
||||||
bomChildCount int,
|
bomChildCount int,
|
||||||
|
hasFiles bool,
|
||||||
categoryName string,
|
categoryName string,
|
||||||
projects []string,
|
projects []string,
|
||||||
includeFields bool,
|
includeFields bool,
|
||||||
@@ -276,6 +279,7 @@ func scoreItem(
|
|||||||
// Score has_bom for manufactured/assembly items.
|
// Score has_bom for manufactured/assembly items.
|
||||||
if sourcingType == "manufactured" || isAssembly {
|
if sourcingType == "manufactured" || isAssembly {
|
||||||
processField("has_bom", "computed", "boolean", hasBOM)
|
processField("has_bom", "computed", "boolean", hasBOM)
|
||||||
|
processField("has_files", "computed", "boolean", hasFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score property fields from schema.
|
// Score property fields from schema.
|
||||||
@@ -412,6 +416,13 @@ func (s *Server) HandleAuditCompleteness(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
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.
|
// Look up the schema for category resolution.
|
||||||
sch := s.schemas["kindred-rd"]
|
sch := s.schemas["kindred-rd"]
|
||||||
var catSegment *schema.Segment
|
var catSegment *schema.Segment
|
||||||
@@ -440,9 +451,10 @@ func (s *Server) HandleAuditCompleteness(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
bomCount := bomCounts[item.ID]
|
bomCount := bomCounts[item.ID]
|
||||||
hasBOM := bomCount > 0
|
hasBOM := bomCount > 0
|
||||||
|
hasFiles := fileStats[item.ID].Count > 0
|
||||||
projects := projectCodes[item.ID]
|
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)
|
allResults = append(allResults, *result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,6 +556,15 @@ func (s *Server) HandleAuditItemDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
projects := projectCodes[item.ID]
|
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.
|
// Category resolution.
|
||||||
cat := extractCategory(item.PartNumber)
|
cat := extractCategory(item.PartNumber)
|
||||||
categoryName := cat
|
categoryName := cat
|
||||||
@@ -561,7 +582,7 @@ func (s *Server) HandleAuditItemDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
categoryProps = sch.PropertySchemas.GetPropertiesForCategory(cat)
|
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)
|
writeJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type BOMEntryResponse struct {
|
|||||||
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"`
|
||||||
|
Source string `json:"source"`
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ type AddBOMEntryRequest struct {
|
|||||||
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"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
Metadata map[string]any `json:"metadata,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,
|
Unit: req.Unit,
|
||||||
ReferenceDesignators: req.ReferenceDesignators,
|
ReferenceDesignators: req.ReferenceDesignators,
|
||||||
ChildRevision: req.ChildRevision,
|
ChildRevision: req.ChildRevision,
|
||||||
|
Source: req.Source,
|
||||||
Metadata: req.Metadata,
|
Metadata: req.Metadata,
|
||||||
}
|
}
|
||||||
if user := auth.UserFromContext(ctx); user != nil {
|
if user := auth.UserFromContext(ctx); user != nil {
|
||||||
@@ -273,6 +276,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,
|
||||||
|
Source: rel.Source,
|
||||||
Metadata: req.Metadata,
|
Metadata: req.Metadata,
|
||||||
}
|
}
|
||||||
if req.ChildRevision != nil {
|
if req.ChildRevision != nil {
|
||||||
@@ -434,6 +438,7 @@ func bomEntryToResponse(e *db.BOMEntry) BOMEntryResponse {
|
|||||||
ReferenceDesignators: refDes,
|
ReferenceDesignators: refDes,
|
||||||
ChildRevision: e.ChildRevision,
|
ChildRevision: e.ChildRevision,
|
||||||
EffectiveRevision: e.EffectiveRevision,
|
EffectiveRevision: e.EffectiveRevision,
|
||||||
|
Source: e.Source,
|
||||||
Metadata: e.Metadata,
|
Metadata: e.Metadata,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -589,6 +594,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.
|
// BOM CSV headers matching the user-specified format.
|
||||||
var bomCSVHeaders = []string{
|
var bomCSVHeaders = []string{
|
||||||
"Item", "Level", "Source", "PN", "Seller Description",
|
"Item", "Level", "Source", "PN", "Seller Description",
|
||||||
@@ -686,14 +741,14 @@ func (s *Server) HandleExportBOMCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
row := []string{
|
row := []string{
|
||||||
strconv.Itoa(i + 1), // Item
|
strconv.Itoa(i + 1), // Item
|
||||||
strconv.Itoa(e.Depth), // Level
|
strconv.Itoa(e.Depth), // Level
|
||||||
getMetaString(e.Metadata, "source"), // Source
|
e.Source, // Source
|
||||||
e.ChildPartNumber, // PN
|
e.ChildPartNumber, // PN
|
||||||
getMetaString(e.Metadata, "seller_description"), // Seller Description
|
getMetaString(e.Metadata, "seller_description"), // Seller Description
|
||||||
unitCostStr, // Unit Cost
|
unitCostStr, // Unit Cost
|
||||||
qtyStr, // QTY
|
qtyStr, // QTY
|
||||||
extCost, // Ext Cost
|
extCost, // Ext Cost
|
||||||
getMetaString(e.Metadata, "sourcing_link"), // Sourcing Link
|
getMetaString(e.Metadata, "sourcing_link"), // Sourcing Link
|
||||||
}
|
}
|
||||||
if err := writer.Write(row); err != nil {
|
if err := writer.Write(row); err != nil {
|
||||||
@@ -853,12 +908,11 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build metadata from CSV columns
|
// Build metadata from CSV columns
|
||||||
metadata := make(map[string]any)
|
source := ""
|
||||||
if idx, ok := headerIdx["source"]; ok && idx < len(record) {
|
if idx, ok := headerIdx["source"]; ok && idx < len(record) {
|
||||||
if v := strings.TrimSpace(record[idx]); v != "" {
|
source = strings.TrimSpace(record[idx])
|
||||||
metadata["source"] = v
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
metadata := make(map[string]any)
|
||||||
if idx, ok := headerIdx["seller description"]; ok && idx < len(record) {
|
if idx, ok := headerIdx["seller description"]; ok && idx < len(record) {
|
||||||
if v := strings.TrimSpace(record[idx]); v != "" {
|
if v := strings.TrimSpace(record[idx]); v != "" {
|
||||||
metadata["seller_description"] = v
|
metadata["seller_description"] = v
|
||||||
@@ -942,6 +996,7 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
ChildItemID: child.ID,
|
ChildItemID: child.ID,
|
||||||
RelType: "component",
|
RelType: "component",
|
||||||
Quantity: quantity,
|
Quantity: quantity,
|
||||||
|
Source: source,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
CreatedBy: importUsername,
|
CreatedBy: importUsername,
|
||||||
}
|
}
|
||||||
@@ -971,3 +1026,197 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
writeJSON(w, http.StatusOK, result)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -260,6 +260,8 @@ type ItemResponse struct {
|
|||||||
LongDescription *string `json:"long_description,omitempty"`
|
LongDescription *string `json:"long_description,omitempty"`
|
||||||
StandardCost *float64 `json:"standard_cost,omitempty"`
|
StandardCost *float64 `json:"standard_cost,omitempty"`
|
||||||
ThumbnailKey *string `json:"thumbnail_key,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"`
|
Properties map[string]any `json:"properties,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,9 +306,20 @@ func (s *Server) HandleListItems(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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))
|
response := make([]ItemResponse, len(items))
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
response[i] = itemToResponse(item)
|
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)
|
writeJSON(w, http.StatusOK, response)
|
||||||
@@ -482,7 +495,15 @@ func (s *Server) HandleGetItemByUUID(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, itemToResponse(item))
|
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.
|
// HandleGetItem retrieves an item by part number.
|
||||||
@@ -504,6 +525,14 @@ func (s *Server) HandleGetItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
response := itemToResponse(item)
|
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
|
// Include properties from current revision if requested
|
||||||
if r.URL.Query().Get("include") == "properties" {
|
if r.URL.Query().Get("include") == "properties" {
|
||||||
revisions, err := s.items.GetRevisions(ctx, item.ID)
|
revisions, err := s.items.GetRevisions(ctx, item.ID)
|
||||||
|
|||||||
@@ -599,7 +599,7 @@ func (s *Server) HandleExportBOMODS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
source := getMetaString(e.Metadata, "source")
|
source := e.Source
|
||||||
if source == "" && childItem != nil {
|
if source == "" && childItem != nil {
|
||||||
st := childItem.SourcingType
|
st := childItem.SourcingType
|
||||||
if st == "manufactured" {
|
if st == "manufactured" {
|
||||||
@@ -754,7 +754,7 @@ func (s *Server) HandleProjectSheetODS(w http.ResponseWriter, r *http.Request) {
|
|||||||
if e.Quantity != nil {
|
if e.Quantity != nil {
|
||||||
qty = *e.Quantity
|
qty = *e.Quantity
|
||||||
}
|
}
|
||||||
source := getMetaString(e.Metadata, "source")
|
source := e.Source
|
||||||
if source == "" && childItem != nil {
|
if source == "" && childItem != nil {
|
||||||
if childItem.SourcingType == "manufactured" {
|
if childItem.SourcingType == "manufactured" {
|
||||||
source = "M"
|
source = "M"
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
r.Put("/thumbnail", server.HandleSetItemThumbnail)
|
r.Put("/thumbnail", server.HandleSetItemThumbnail)
|
||||||
r.Post("/bom", server.HandleAddBOMEntry)
|
r.Post("/bom", server.HandleAddBOMEntry)
|
||||||
r.Post("/bom/import", server.HandleImportBOMCSV)
|
r.Post("/bom/import", server.HandleImportBOMCSV)
|
||||||
|
r.Post("/bom/merge", server.HandleMergeBOM)
|
||||||
r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry)
|
r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry)
|
||||||
r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry)
|
r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -134,6 +134,43 @@ func (r *ItemRepository) BatchCheckBOM(ctx context.Context, itemIDs []string) (m
|
|||||||
return result, nil
|
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
|
// BatchGetProjectCodes returns a map of item ID to project code list for
|
||||||
// the given item IDs.
|
// the given item IDs.
|
||||||
func (r *ItemRepository) BatchGetProjectCodes(ctx context.Context, itemIDs []string) (map[string][]string, error) {
|
func (r *ItemRepository) BatchGetProjectCodes(ctx context.Context, itemIDs []string) (map[string][]string, error) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type Relationship struct {
|
|||||||
ChildRevision *int
|
ChildRevision *int
|
||||||
Metadata map[string]any
|
Metadata map[string]any
|
||||||
ParentRevisionID *string
|
ParentRevisionID *string
|
||||||
|
Source string // "manual" or "assembly"
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
CreatedBy *string
|
CreatedBy *string
|
||||||
@@ -46,6 +47,7 @@ type BOMEntry struct {
|
|||||||
ChildRevision *int
|
ChildRevision *int
|
||||||
EffectiveRevision int
|
EffectiveRevision int
|
||||||
Metadata map[string]any
|
Metadata map[string]any
|
||||||
|
Source string
|
||||||
}
|
}
|
||||||
|
|
||||||
// BOMTreeEntry extends BOMEntry with depth for multi-level BOM expansion.
|
// 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, `
|
err = r.db.pool.QueryRow(ctx, `
|
||||||
INSERT INTO relationships (
|
INSERT INTO relationships (
|
||||||
parent_item_id, child_item_id, rel_type, quantity, unit,
|
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
|
RETURNING id, created_at, updated_at
|
||||||
`, rel.ParentItemID, rel.ChildItemID, rel.RelType, rel.Quantity, rel.Unit,
|
`, rel.ParentItemID, rel.ChildItemID, rel.RelType, rel.Quantity, rel.Unit,
|
||||||
rel.ReferenceDesignators, rel.ChildRevision, metadataJSON, rel.ParentRevisionID,
|
rel.ReferenceDesignators, rel.ChildRevision, metadataJSON, rel.ParentRevisionID,
|
||||||
rel.CreatedBy,
|
rel.CreatedBy, source,
|
||||||
).Scan(&rel.ID, &rel.CreatedAt, &rel.UpdatedAt)
|
).Scan(&rel.ID, &rel.CreatedAt, &rel.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("inserting relationship: %w", err)
|
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.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
|
rel.metadata, rel.source
|
||||||
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
|
||||||
@@ -281,7 +288,7 @@ func (r *RelationshipRepository) GetWhereUsed(ctx context.Context, childItemID s
|
|||||||
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
|
rel.metadata, rel.source
|
||||||
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
|
||||||
@@ -315,7 +322,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,
|
rel.metadata, rel.source,
|
||||||
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
|
||||||
@@ -334,7 +341,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,
|
rel.metadata, rel.source,
|
||||||
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
|
||||||
@@ -347,7 +354,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, metadata, depth
|
child_revision, effective_revision, metadata, source, depth
|
||||||
FROM bom_tree
|
FROM bom_tree
|
||||||
ORDER BY depth, child_part_number
|
ORDER BY depth, child_part_number
|
||||||
`, parentItemID, maxDepth)
|
`, parentItemID, maxDepth)
|
||||||
@@ -366,7 +373,7 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI
|
|||||||
&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, &metadataJSON, &e.Depth,
|
&e.EffectiveRevision, &metadataJSON, &e.Source, &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)
|
||||||
@@ -553,7 +560,7 @@ func scanBOMEntries(rows pgx.Rows) ([]*BOMEntry, error) {
|
|||||||
&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,
|
&metadataJSON, &e.Source,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("scanning BOM entry: %w", err)
|
return nil, fmt.Errorf("scanning BOM entry: %w", err)
|
||||||
|
|||||||
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';
|
||||||
@@ -19,6 +19,8 @@ export interface Item {
|
|||||||
sourcing_link?: string;
|
sourcing_link?: string;
|
||||||
long_description?: string;
|
long_description?: string;
|
||||||
standard_cost?: number;
|
standard_cost?: number;
|
||||||
|
file_count: number;
|
||||||
|
files_total_size: number;
|
||||||
properties?: Record<string, unknown>;
|
properties?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ export interface BOMEntry {
|
|||||||
child_revision?: number;
|
child_revision?: number;
|
||||||
effective_revision: number;
|
effective_revision: number;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
|
source: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +199,7 @@ export interface AddBOMEntryRequest {
|
|||||||
unit?: string;
|
unit?: string;
|
||||||
reference_designators?: string[];
|
reference_designators?: string[];
|
||||||
child_revision?: number;
|
child_revision?: number;
|
||||||
|
source?: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { get, post, put, del } from '../../api/client';
|
import { get, post, put, del } from "../../api/client";
|
||||||
import type { BOMEntry } from '../../api/types';
|
import type { BOMEntry } from "../../api/types";
|
||||||
|
|
||||||
interface BOMTabProps {
|
interface BOMTabProps {
|
||||||
partNumber: string;
|
partNumber: string;
|
||||||
@@ -16,7 +16,14 @@ interface BOMFormData {
|
|||||||
sourcing_link: string;
|
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) {
|
export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
||||||
const [entries, setEntries] = useState<BOMEntry[]>([]);
|
const [entries, setEntries] = useState<BOMEntry[]>([]);
|
||||||
@@ -42,10 +49,10 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
|||||||
|
|
||||||
const formToRequest = () => ({
|
const formToRequest = () => ({
|
||||||
child_part_number: form.child_part_number,
|
child_part_number: form.child_part_number,
|
||||||
rel_type: 'component' as const,
|
rel_type: "component" as const,
|
||||||
quantity: Number(form.quantity) || 1,
|
quantity: Number(form.quantity) || 1,
|
||||||
|
source: form.source,
|
||||||
metadata: {
|
metadata: {
|
||||||
source: form.source,
|
|
||||||
seller_description: form.seller_description,
|
seller_description: form.seller_description,
|
||||||
unit_cost: form.unit_cost,
|
unit_cost: form.unit_cost,
|
||||||
sourcing_link: form.sourcing_link,
|
sourcing_link: form.sourcing_link,
|
||||||
@@ -54,34 +61,42 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
|||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
try {
|
try {
|
||||||
await post(`/api/items/${encodeURIComponent(partNumber)}/bom`, formToRequest());
|
await post(
|
||||||
|
`/api/items/${encodeURIComponent(partNumber)}/bom`,
|
||||||
|
formToRequest(),
|
||||||
|
);
|
||||||
setShowAdd(false);
|
setShowAdd(false);
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
load();
|
load();
|
||||||
} catch (e) {
|
} 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) => {
|
const handleEdit = async (childPN: string) => {
|
||||||
try {
|
try {
|
||||||
const { child_part_number: _, ...req } = formToRequest();
|
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);
|
setEditIdx(null);
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
load();
|
load();
|
||||||
} catch (e) {
|
} 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) => {
|
const handleDelete = async (childPN: string) => {
|
||||||
if (!confirm(`Remove ${childPN} from BOM?`)) return;
|
if (!confirm(`Remove ${childPN} from BOM?`)) return;
|
||||||
try {
|
try {
|
||||||
await del(`/api/items/${encodeURIComponent(partNumber)}/bom/${encodeURIComponent(childPN)}`);
|
await del(
|
||||||
|
`/api/items/${encodeURIComponent(partNumber)}/bom/${encodeURIComponent(childPN)}`,
|
||||||
|
);
|
||||||
load();
|
load();
|
||||||
} catch (e) {
|
} 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 +106,155 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
|||||||
setForm({
|
setForm({
|
||||||
child_part_number: e.child_part_number,
|
child_part_number: e.child_part_number,
|
||||||
quantity: String(e.quantity ?? 1),
|
quantity: String(e.quantity ?? 1),
|
||||||
source: m.source ?? '',
|
source: e.source ?? "",
|
||||||
seller_description: m.seller_description ?? '',
|
seller_description: m.seller_description ?? "",
|
||||||
unit_cost: m.unit_cost ?? '',
|
unit_cost: m.unit_cost ?? "",
|
||||||
sourcing_link: m.sourcing_link ?? '',
|
sourcing_link: m.sourcing_link ?? "",
|
||||||
});
|
});
|
||||||
setEditIdx(idx);
|
setEditIdx(idx);
|
||||||
setShowAdd(false);
|
setShowAdd(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
padding: '0.2rem 0.4rem', fontSize: '0.8rem',
|
padding: "0.2rem 0.4rem",
|
||||||
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)',
|
fontSize: "0.8rem",
|
||||||
borderRadius: '0.3rem', color: 'var(--ctp-text)', width: '100%',
|
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) => (
|
const formRow = (isEditing: boolean, childPN?: string) => (
|
||||||
<tr style={{ backgroundColor: 'var(--ctp-surface0)' }}>
|
<tr style={{ backgroundColor: "var(--ctp-surface0)" }}>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<input value={form.child_part_number} onChange={(e) => setForm({ ...form, child_part_number: e.target.value })}
|
<input
|
||||||
disabled={isEditing} placeholder="Part number" style={inputStyle} />
|
value={form.child_part_number}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, child_part_number: e.target.value })
|
||||||
|
}
|
||||||
|
disabled={isEditing}
|
||||||
|
placeholder="Part number"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<input value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })} placeholder="Source" style={inputStyle} />
|
<input
|
||||||
|
value={form.source}
|
||||||
|
onChange={(e) => setForm({ ...form, source: e.target.value })}
|
||||||
|
placeholder="Source"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>
|
<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>
|
||||||
<td style={tdStyle}>
|
<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>
|
||||||
<td style={tdStyle}>
|
<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>
|
||||||
<td style={tdStyle}>—</td>
|
<td style={tdStyle}>—</td>
|
||||||
<td style={tdStyle}>
|
<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>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<button onClick={() => isEditing ? void handleEdit(childPN!) : void handleAdd()} style={saveBtnStyle}>Save</button>
|
<button
|
||||||
<button onClick={() => { isEditing ? setEditIdx(null) : setShowAdd(false); setForm(emptyForm); }} style={cancelBtnStyle}>Cancel</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>
|
</td>
|
||||||
</tr>
|
</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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.5rem' }}>
|
<div
|
||||||
<span style={{ fontSize: '0.85rem', color: 'var(--ctp-subtext1)' }}>{entries.length} entries</span>
|
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 }} />
|
<span style={{ flex: 1 }} />
|
||||||
<button
|
<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}
|
style={toolBtnStyle}
|
||||||
>
|
>
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</button>
|
||||||
{isEditor && (
|
{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>
|
||||||
|
|
||||||
<div style={{ overflow: 'auto' }}>
|
<div style={{ overflow: "auto" }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={thStyle}>PN</th>
|
<th style={thStyle}>PN</th>
|
||||||
@@ -174,20 +273,71 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
|||||||
if (editIdx === idx) return formRow(true, e.child_part_number);
|
if (editIdx === idx) return formRow(true, e.child_part_number);
|
||||||
const m = meta(e);
|
const m = meta(e);
|
||||||
return (
|
return (
|
||||||
<tr key={e.id} style={{ backgroundColor: idx % 2 === 0 ? 'var(--ctp-base)' : 'var(--ctp-surface0)' }}>
|
<tr
|
||||||
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>{e.child_part_number}</td>
|
key={e.id}
|
||||||
<td style={tdStyle}>{m.source ?? ''}</td>
|
style={{
|
||||||
<td style={{ ...tdStyle, maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis' }}>{e.child_description || m.seller_description || ''}</td>
|
backgroundColor:
|
||||||
<td style={{ ...tdStyle, fontFamily: 'monospace' }}>{unitCost(e) ? `$${unitCost(e).toFixed(2)}` : '—'}</td>
|
idx % 2 === 0 ? "var(--ctp-base)" : "var(--ctp-surface0)",
|
||||||
<td style={tdStyle}>{e.quantity ?? '—'}</td>
|
}}
|
||||||
<td style={{ ...tdStyle, fontFamily: 'monospace' }}>{extCost(e) ? `$${extCost(e).toFixed(2)}` : '—'}</td>
|
>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
...tdStyle,
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
color: "var(--ctp-peach)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{e.child_part_number}
|
||||||
|
</td>
|
||||||
|
<td style={tdStyle}>{e.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>
|
||||||
<td style={tdStyle}>
|
<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> : '—'}
|
{m.sourcing_link ? (
|
||||||
|
<a
|
||||||
|
href={m.sourcing_link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{
|
||||||
|
color: "var(--ctp-sapphire)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Link
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
{isEditor && (
|
{isEditor && (
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<button onClick={() => startEdit(idx)} style={actionBtnStyle}>Edit</button>
|
<button
|
||||||
<button onClick={() => void handleDelete(e.child_part_number)} style={{ ...actionBtnStyle, color: 'var(--ctp-red)' }}>Del</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>
|
</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -196,9 +346,22 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
{totalCost > 0 && (
|
{totalCost > 0 && (
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr style={{ borderTop: '2px solid var(--ctp-surface1)' }}>
|
<tr style={{ borderTop: "2px solid var(--ctp-surface1)" }}>
|
||||||
<td colSpan={5} style={{ ...tdStyle, textAlign: 'right', fontWeight: 600 }}>Total:</td>
|
<td
|
||||||
<td style={{ ...tdStyle, fontFamily: 'monospace', fontWeight: 600 }}>${totalCost.toFixed(2)}</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} />
|
<td colSpan={isEditor ? 2 : 1} style={tdStyle} />
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
@@ -210,29 +373,59 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const thStyle: React.CSSProperties = {
|
const thStyle: React.CSSProperties = {
|
||||||
padding: '0.3rem 0.5rem', textAlign: 'left', borderBottom: '1px solid var(--ctp-surface1)',
|
padding: "0.3rem 0.5rem",
|
||||||
color: 'var(--ctp-subtext1)', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em', whiteSpace: 'nowrap',
|
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 = {
|
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 = {
|
const toolBtnStyle: React.CSSProperties = {
|
||||||
padding: '0.25rem 0.5rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
|
padding: "0.25rem 0.5rem",
|
||||||
backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-text)', cursor: 'pointer',
|
fontSize: "0.8rem",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.3rem",
|
||||||
|
backgroundColor: "var(--ctp-surface1)",
|
||||||
|
color: "var(--ctp-text)",
|
||||||
|
cursor: "pointer",
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionBtnStyle: React.CSSProperties = {
|
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 = {
|
const saveBtnStyle: React.CSSProperties = {
|
||||||
padding: '0.2rem 0.4rem', fontSize: '0.75rem', border: 'none', borderRadius: '0.25rem',
|
padding: "0.2rem 0.4rem",
|
||||||
backgroundColor: 'var(--ctp-green)', color: 'var(--ctp-crust)', cursor: 'pointer', marginRight: '0.25rem',
|
fontSize: "0.75rem",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
backgroundColor: "var(--ctp-green)",
|
||||||
|
color: "var(--ctp-crust)",
|
||||||
|
cursor: "pointer",
|
||||||
|
marginRight: "0.25rem",
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelBtnStyle: React.CSSProperties = {
|
const cancelBtnStyle: React.CSSProperties = {
|
||||||
padding: '0.2rem 0.4rem', fontSize: '0.75rem', border: 'none', borderRadius: '0.25rem',
|
padding: "0.2rem 0.4rem",
|
||||||
backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-subtext1)', cursor: 'pointer',
|
fontSize: "0.75rem",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
backgroundColor: "var(--ctp-surface1)",
|
||||||
|
color: "var(--ctp-subtext1)",
|
||||||
|
cursor: "pointer",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const ALL_COLUMNS: ColumnDef[] = [
|
|||||||
{ key: "item_type", label: "Type" },
|
{ key: "item_type", label: "Type" },
|
||||||
{ key: "description", label: "Description" },
|
{ key: "description", label: "Description" },
|
||||||
{ key: "revision", label: "Rev" },
|
{ key: "revision", label: "Rev" },
|
||||||
|
{ key: "files", label: "Files" },
|
||||||
{ key: "projects", label: "Projects" },
|
{ key: "projects", label: "Projects" },
|
||||||
{ key: "created", label: "Created" },
|
{ key: "created", label: "Created" },
|
||||||
{ key: "actions", label: "Actions" },
|
{ key: "actions", label: "Actions" },
|
||||||
@@ -28,6 +29,7 @@ export const DEFAULT_COLUMNS_V = [
|
|||||||
"item_type",
|
"item_type",
|
||||||
"description",
|
"description",
|
||||||
"revision",
|
"revision",
|
||||||
|
"files",
|
||||||
"created",
|
"created",
|
||||||
"actions",
|
"actions",
|
||||||
];
|
];
|
||||||
@@ -67,6 +69,12 @@ function copyPN(pn: string) {
|
|||||||
void navigator.clipboard.writeText(pn);
|
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({
|
export function ItemTable({
|
||||||
items,
|
items,
|
||||||
loading,
|
loading,
|
||||||
@@ -120,6 +128,10 @@ export function ItemTable({
|
|||||||
av = a.current_revision;
|
av = a.current_revision;
|
||||||
bv = b.current_revision;
|
bv = b.current_revision;
|
||||||
break;
|
break;
|
||||||
|
case "files":
|
||||||
|
av = a.file_count;
|
||||||
|
bv = b.file_count;
|
||||||
|
break;
|
||||||
case "created":
|
case "created":
|
||||||
av = a.created_at;
|
av = a.created_at;
|
||||||
bv = b.created_at;
|
bv = b.created_at;
|
||||||
@@ -271,6 +283,20 @@ export function ItemTable({
|
|||||||
Rev {item.current_revision}
|
Rev {item.current_revision}
|
||||||
</td>
|
</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":
|
case "projects":
|
||||||
return (
|
return (
|
||||||
<td key={col.key} style={tdStyle}>
|
<td key={col.key} style={tdStyle}>
|
||||||
|
|||||||
Reference in New Issue
Block a user