diff --git a/internal/api/audit_handlers.go b/internal/api/audit_handlers.go index 9ca9c9f..825a755 100644 --- a/internal/api/audit_handlers.go +++ b/internal/api/audit_handlers.go @@ -101,6 +101,8 @@ var manufacturedWeights = map[string]float64{ // Weight 1: engineering detail (category-specific default) "sourcing_type": 1, "lifecycle_status": 1, + // Weight 1: engineering detail + "has_files": 1, // Weight 0.5: less relevant for in-house "manufacturer": 0.5, "supplier": 0.5, @@ -207,6 +209,7 @@ func scoreItem( categoryProps map[string]schema.PropertyDefinition, hasBOM bool, bomChildCount int, + hasFiles bool, categoryName string, projects []string, includeFields bool, @@ -276,6 +279,7 @@ func scoreItem( // Score has_bom for manufactured/assembly items. if sourcingType == "manufactured" || isAssembly { processField("has_bom", "computed", "boolean", hasBOM) + processField("has_files", "computed", "boolean", hasFiles) } // Score property fields from schema. @@ -412,6 +416,13 @@ func (s *Server) HandleAuditCompleteness(w http.ResponseWriter, r *http.Request) return } + fileStats, err := s.items.BatchGetFileStats(ctx, itemIDs) + if err != nil { + s.logger.Error().Err(err).Msg("failed to batch get file stats") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load file stats") + return + } + // Look up the schema for category resolution. sch := s.schemas["kindred-rd"] var catSegment *schema.Segment @@ -440,9 +451,10 @@ func (s *Server) HandleAuditCompleteness(w http.ResponseWriter, r *http.Request) bomCount := bomCounts[item.ID] hasBOM := bomCount > 0 + hasFiles := fileStats[item.ID].Count > 0 projects := projectCodes[item.ID] - result := scoreItem(item, categoryProps, hasBOM, bomCount, categoryName, projects, false) + result := scoreItem(item, categoryProps, hasBOM, bomCount, hasFiles, categoryName, projects, false) allResults = append(allResults, *result) } @@ -544,6 +556,15 @@ func (s *Server) HandleAuditItemDetail(w http.ResponseWriter, r *http.Request) { } projects := projectCodes[item.ID] + // Get file stats. + fileStats, err := s.items.BatchGetFileStats(ctx, []string{item.ID}) + if err != nil { + s.logger.Error().Err(err).Str("pn", partNumber).Msg("failed to get file stats for audit") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load file stats") + return + } + hasFiles := fileStats[item.ID].Count > 0 + // Category resolution. cat := extractCategory(item.PartNumber) categoryName := cat @@ -561,7 +582,7 @@ func (s *Server) HandleAuditItemDetail(w http.ResponseWriter, r *http.Request) { categoryProps = sch.PropertySchemas.GetPropertiesForCategory(cat) } - result := scoreItem(iwp, categoryProps, hasBOM, bomCount, categoryName, projects, true) + result := scoreItem(iwp, categoryProps, hasBOM, bomCount, hasFiles, categoryName, projects, true) writeJSON(w, http.StatusOK, result) } diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 1c6a6d5..fb8c3de 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -260,6 +260,8 @@ type ItemResponse struct { LongDescription *string `json:"long_description,omitempty"` StandardCost *float64 `json:"standard_cost,omitempty"` ThumbnailKey *string `json:"thumbnail_key,omitempty"` + FileCount int `json:"file_count"` + FilesTotalSize int64 `json:"files_total_size"` Properties map[string]any `json:"properties,omitempty"` } @@ -304,9 +306,20 @@ func (s *Server) HandleListItems(w http.ResponseWriter, r *http.Request) { return } + // Batch-fetch file attachment stats + ids := make([]string, len(items)) + for i, item := range items { + ids[i] = item.ID + } + fileStats, _ := s.items.BatchGetFileStats(ctx, ids) + response := make([]ItemResponse, len(items)) for i, item := range items { response[i] = itemToResponse(item) + if fs, ok := fileStats[item.ID]; ok { + response[i].FileCount = fs.Count + response[i].FilesTotalSize = fs.TotalSize + } } writeJSON(w, http.StatusOK, response) @@ -482,7 +495,15 @@ func (s *Server) HandleGetItemByUUID(w http.ResponseWriter, r *http.Request) { 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. @@ -504,6 +525,14 @@ func (s *Server) HandleGetItem(w http.ResponseWriter, r *http.Request) { response := itemToResponse(item) + // File attachment stats + if fileStats, err := s.items.BatchGetFileStats(ctx, []string{item.ID}); err == nil { + if fs, ok := fileStats[item.ID]; ok { + response.FileCount = fs.Count + response.FilesTotalSize = fs.TotalSize + } + } + // Include properties from current revision if requested if r.URL.Query().Get("include") == "properties" { revisions, err := s.items.GetRevisions(ctx, item.ID) diff --git a/internal/db/audit_queries.go b/internal/db/audit_queries.go index 2e57aa2..211bd55 100644 --- a/internal/db/audit_queries.go +++ b/internal/db/audit_queries.go @@ -134,6 +134,43 @@ func (r *ItemRepository) BatchCheckBOM(ctx context.Context, itemIDs []string) (m return result, nil } +// FileStats holds aggregated file attachment statistics for an item. +type FileStats struct { + Count int + TotalSize int64 +} + +// BatchGetFileStats returns a map of item ID to file attachment statistics +// for the given item IDs. Items not in the map have no files. +func (r *ItemRepository) BatchGetFileStats(ctx context.Context, itemIDs []string) (map[string]FileStats, error) { + if len(itemIDs) == 0 { + return map[string]FileStats{}, nil + } + + rows, err := r.db.pool.Query(ctx, ` + SELECT item_id, COUNT(*), COALESCE(SUM(size), 0) + FROM item_files + WHERE item_id = ANY($1) + GROUP BY item_id + `, itemIDs) + if err != nil { + return nil, fmt.Errorf("batch getting file stats: %w", err) + } + defer rows.Close() + + result := make(map[string]FileStats) + for rows.Next() { + var itemID string + var fs FileStats + if err := rows.Scan(&itemID, &fs.Count, &fs.TotalSize); err != nil { + return nil, fmt.Errorf("scanning file stats: %w", err) + } + result[itemID] = fs + } + + return result, nil +} + // BatchGetProjectCodes returns a map of item ID to project code list for // the given item IDs. func (r *ItemRepository) BatchGetProjectCodes(ctx context.Context, itemIDs []string) (map[string][]string, error) { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 0df3662..7dfb081 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -19,6 +19,8 @@ export interface Item { sourcing_link?: string; long_description?: string; standard_cost?: number; + file_count: number; + files_total_size: number; properties?: Record; } diff --git a/web/src/components/items/ItemTable.tsx b/web/src/components/items/ItemTable.tsx index cdc109c..504f7bb 100644 --- a/web/src/components/items/ItemTable.tsx +++ b/web/src/components/items/ItemTable.tsx @@ -12,6 +12,7 @@ export const ALL_COLUMNS: ColumnDef[] = [ { key: "item_type", label: "Type" }, { key: "description", label: "Description" }, { key: "revision", label: "Rev" }, + { key: "files", label: "Files" }, { key: "projects", label: "Projects" }, { key: "created", label: "Created" }, { key: "actions", label: "Actions" }, @@ -28,6 +29,7 @@ export const DEFAULT_COLUMNS_V = [ "item_type", "description", "revision", + "files", "created", "actions", ]; @@ -67,6 +69,12 @@ function copyPN(pn: string) { void navigator.clipboard.writeText(pn); } +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + export function ItemTable({ items, loading, @@ -120,6 +128,10 @@ export function ItemTable({ av = a.current_revision; bv = b.current_revision; break; + case "files": + av = a.file_count; + bv = b.file_count; + break; case "created": av = a.created_at; bv = b.created_at; @@ -271,6 +283,20 @@ export function ItemTable({ Rev {item.current_revision} ); + case "files": + return ( + 0 + ? `${item.file_count} file${item.file_count !== 1 ? "s" : ""}, ${formatSize(item.files_total_size)}` + : "No files" + } + > + {item.file_count > 0 ? item.file_count : "—"} + + ); case "projects": return (