feat: add component audit tool and category properties in create form

- New /audit page with completeness scoring engine
- Weighted scoring by sourcing type (purchased vs manufactured)
- Batch DB queries for items+properties, BOM existence, project codes
- API endpoints: GET /api/audit/completeness, GET /api/audit/completeness/{pn}
- Audit UI: tier summary bar, filterable table, split-panel inline editing
- Create item form now shows category-specific property fields on category select
- Properties collected and submitted with item creation
This commit is contained in:
Zoe Forbes
2026-02-01 10:41:57 -06:00
parent 36a8d9995d
commit 8f6e956fde
7 changed files with 2098 additions and 1 deletions

View File

@@ -0,0 +1,622 @@
package api
import (
"encoding/json"
"fmt"
"math"
"net/http"
"sort"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/kindredsystems/silo/internal/db"
"github.com/kindredsystems/silo/internal/schema"
)
// --- Response types ---
// AuditFieldResult represents one field's audit status.
type AuditFieldResult struct {
Key string `json:"key"`
Source string `json:"source"` // "item", "property", "computed"
Weight float64 `json:"weight"`
Value any `json:"value"`
Filled bool `json:"filled"`
}
// AuditItemResult represents one item's completeness score.
type AuditItemResult struct {
PartNumber string `json:"part_number"`
Description string `json:"description"`
Category string `json:"category"`
CategoryName string `json:"category_name"`
SourcingType string `json:"sourcing_type"`
Projects []string `json:"projects"`
Score float64 `json:"score"`
Tier string `json:"tier"`
WeightedFilled float64 `json:"weighted_filled"`
WeightedTotal float64 `json:"weighted_total"`
HasBOM bool `json:"has_bom"`
BOMChildren int `json:"bom_children"`
MissingCritical []string `json:"missing_critical"`
Missing []string `json:"missing"`
UpdatedAt string `json:"updated_at"`
Fields []AuditFieldResult `json:"fields,omitempty"`
}
// CategorySummary holds per-category audit stats.
type CategorySummary struct {
Count int `json:"count"`
AvgScore float64 `json:"avg_score"`
}
// AuditSummary holds aggregate audit statistics.
type AuditSummary struct {
TotalItems int `json:"total_items"`
AvgScore float64 `json:"avg_score"`
ManufacturedWithoutBOM int `json:"manufactured_without_bom"`
ByTier map[string]int `json:"by_tier"`
ByCategory map[string]CategorySummary `json:"by_category"`
}
// AuditCompletenessResponse is the top-level response for the list endpoint.
type AuditCompletenessResponse struct {
Items []AuditItemResult `json:"items"`
Summary AuditSummary `json:"summary"`
}
// --- Weight tables ---
// Purchased parts field weights.
var purchasedWeights = map[string]float64{
// Weight 3: critical for procurement
"manufacturer_pn": 3,
"sourcing_link": 3,
// Weight 2: core procurement data
"manufacturer": 2,
"supplier": 2,
"supplier_pn": 2,
"standard_cost": 2,
// Weight 1: important but less blocking
"description": 1,
"sourcing_type": 1,
"lead_time_days": 1,
"minimum_order_qty": 1,
"lifecycle_status": 1,
// Weight 0.5: nice to have
"rohs_compliant": 0.5,
"country_of_origin": 0.5,
"notes": 0.5,
"long_description": 0.5,
}
// Manufactured parts field weights.
var manufacturedWeights = map[string]float64{
// Weight 3: critical
"has_bom": 3,
// Weight 2: core identification
"description": 2,
"standard_cost": 2,
// Weight 1: engineering detail (category-specific default)
"sourcing_type": 1,
"lifecycle_status": 1,
// Weight 0.5: less relevant for in-house
"manufacturer": 0.5,
"supplier": 0.5,
"notes": 0.5,
"long_description": 0.5,
}
// Item-level fields that are stored on the items table, not in revision properties.
var itemLevelFields = map[string]bool{
"description": true,
"sourcing_type": true,
"sourcing_link": true,
"standard_cost": true,
"long_description": true,
}
// Fields to skip when scoring (internal metadata).
var skipFields = map[string]bool{
"category": true,
}
// --- Scoring functions ---
// extractCategory parses the category code from a part number (e.g., "F01" from "F01-0042").
func extractCategory(partNumber string) string {
idx := strings.Index(partNumber, "-")
if idx > 0 {
return partNumber[:idx]
}
return ""
}
// tierForScore returns the tier name for a given score.
func tierForScore(score float64) string {
switch {
case score >= 1.0:
return "complete"
case score >= 0.75:
return "good"
case score >= 0.50:
return "partial"
case score >= 0.25:
return "low"
default:
return "critical"
}
}
// isFieldFilled checks whether a field value counts as populated.
func isFieldFilled(value any, fieldType string) bool {
if value == nil {
return false
}
switch fieldType {
case "string":
s, ok := value.(string)
return ok && strings.TrimSpace(s) != ""
case "number":
switch v := value.(type) {
case float64:
return v != 0
case int:
return v != 0
case json.Number:
f, err := v.Float64()
return err == nil && f != 0
default:
return false
}
case "boolean":
// Non-null means filled (false is a valid answer).
_, ok := value.(bool)
return ok
default:
// For unknown types, treat any non-nil, non-empty-string as filled.
s, ok := value.(string)
if ok {
return strings.TrimSpace(s) != ""
}
return true
}
}
// getWeightForField returns the weight for a field given the sourcing type.
// Category-specific properties not in the weight tables default to 1.0.
func getWeightForField(fieldKey string, sourcingType string, isAssembly bool) float64 {
var table map[string]float64
if sourcingType == "purchased" {
table = purchasedWeights
} else {
table = manufacturedWeights
}
if w, ok := table[fieldKey]; ok {
return w
}
// Category-specific properties default to 1.0.
return 1.0
}
// scoreItem computes the completeness score for a single item.
// When includeFields is true, the Fields slice is populated for detail view.
func scoreItem(
item *db.ItemWithProperties,
categoryProps map[string]schema.PropertyDefinition,
hasBOM bool,
bomChildCount int,
categoryName string,
projects []string,
includeFields bool,
) *AuditItemResult {
category := extractCategory(item.PartNumber)
sourcingType := item.SourcingType
if sourcingType == "" {
sourcingType = "manufactured"
}
isAssembly := len(category) > 0 && category[0] == 'A'
var fields []AuditFieldResult
var weightedFilled, weightedTotal float64
var missing, missingCritical []string
// Helper to process a single field.
processField := func(key, source, fieldType string, value any) {
if skipFields[key] {
return
}
weight := getWeightForField(key, sourcingType, isAssembly)
filled := isFieldFilled(value, fieldType)
weightedTotal += weight
if filled {
weightedFilled += weight
} else {
missing = append(missing, key)
if weight >= 3 {
missingCritical = append(missingCritical, key)
}
}
if includeFields {
fields = append(fields, AuditFieldResult{
Key: key,
Source: source,
Weight: weight,
Value: value,
Filled: filled,
})
}
}
// Score item-level fields.
processField("description", "item", "string", item.Description)
processField("sourcing_type", "item", "string", item.SourcingType)
var sourcingLinkVal any
if item.SourcingLink != nil {
sourcingLinkVal = *item.SourcingLink
}
processField("sourcing_link", "item", "string", sourcingLinkVal)
var stdCostVal any
if item.StandardCost != nil {
stdCostVal = *item.StandardCost
}
processField("standard_cost", "item", "number", stdCostVal)
var longDescVal any
if item.LongDescription != nil {
longDescVal = *item.LongDescription
}
processField("long_description", "item", "string", longDescVal)
// Score has_bom for manufactured/assembly items.
if sourcingType == "manufactured" || isAssembly {
processField("has_bom", "computed", "boolean", hasBOM)
}
// Score property fields from schema.
for key, def := range categoryProps {
if skipFields[key] || itemLevelFields[key] {
continue
}
// sourcing_link and standard_cost are already handled at item level.
if key == "sourcing_link" || key == "standard_cost" {
continue
}
value := item.Properties[key]
processField(key, "property", def.Type, value)
}
// Compute score.
var score float64
if weightedTotal > 0 {
score = weightedFilled / weightedTotal
}
score = math.Round(score*10000) / 10000 // 4 decimal places
result := &AuditItemResult{
PartNumber: item.PartNumber,
Description: item.Description,
Category: category,
CategoryName: categoryName,
SourcingType: sourcingType,
Projects: projects,
Score: score,
Tier: tierForScore(score),
WeightedFilled: math.Round(weightedFilled*100) / 100,
WeightedTotal: math.Round(weightedTotal*100) / 100,
HasBOM: hasBOM,
BOMChildren: bomChildCount,
MissingCritical: missingCritical,
Missing: missing,
UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
if includeFields {
result.Fields = fields
}
// Ensure nil slices become empty arrays in JSON.
if result.Projects == nil {
result.Projects = []string{}
}
if result.MissingCritical == nil {
result.MissingCritical = []string{}
}
if result.Missing == nil {
result.Missing = []string{}
}
return result
}
// --- HTTP handlers ---
// HandleAuditCompleteness returns completeness scores for items.
func (s *Server) HandleAuditCompleteness(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Parse query parameters.
project := r.URL.Query().Get("project")
category := r.URL.Query().Get("category")
sortParam := r.URL.Query().Get("sort")
if sortParam == "" {
sortParam = "score_asc"
}
var minScore, maxScore float64
minScore = -1 // sentinel: no filter
maxScore = 2 // sentinel: no filter
if v := r.URL.Query().Get("min_score"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
minScore = f
}
}
if v := r.URL.Query().Get("max_score"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
maxScore = f
}
}
limit := 100
if v := r.URL.Query().Get("limit"); v != "" {
if l, err := strconv.Atoi(v); err == nil && l > 0 {
limit = l
}
}
offset := 0
if v := r.URL.Query().Get("offset"); v != "" {
if o, err := strconv.Atoi(v); err == nil && o >= 0 {
offset = o
}
}
// Fetch items with properties. Don't apply limit/offset here because
// we need to score and filter in Go first.
items, err := s.items.ListItemsWithProperties(ctx, db.AuditListOptions{
Project: project,
Category: category,
})
if err != nil {
s.logger.Error().Err(err).Msg("failed to list items for audit")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load items")
return
}
if items == nil {
items = []*db.ItemWithProperties{}
}
// Collect item IDs for batch queries.
itemIDs := make([]string, len(items))
for i, item := range items {
itemIDs[i] = item.ID
}
// Batch fetch BOM counts and project codes.
bomCounts, err := s.items.BatchCheckBOM(ctx, itemIDs)
if err != nil {
s.logger.Error().Err(err).Msg("failed to batch check BOM")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check BOMs")
return
}
projectCodes, err := s.items.BatchGetProjectCodes(ctx, itemIDs)
if err != nil {
s.logger.Error().Err(err).Msg("failed to batch get project codes")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load project codes")
return
}
// Look up the schema for category resolution.
sch := s.schemas["kindred-rd"]
var catSegment *schema.Segment
if sch != nil {
catSegment = sch.GetSegment("category")
}
// Score each item.
var allResults []AuditItemResult
for _, item := range items {
cat := extractCategory(item.PartNumber)
// Get category name from schema segment values.
categoryName := cat
if catSegment != nil {
if name, ok := catSegment.Values[cat]; ok {
categoryName = name
}
}
// Get applicable properties for this category.
var categoryProps map[string]schema.PropertyDefinition
if sch != nil && sch.PropertySchemas != nil {
categoryProps = sch.PropertySchemas.GetPropertiesForCategory(cat)
}
bomCount := bomCounts[item.ID]
hasBOM := bomCount > 0
projects := projectCodes[item.ID]
result := scoreItem(item, categoryProps, hasBOM, bomCount, categoryName, projects, false)
allResults = append(allResults, *result)
}
// Filter by score range.
var filtered []AuditItemResult
for _, r := range allResults {
if r.Score >= minScore && r.Score <= maxScore {
filtered = append(filtered, r)
}
}
// Sort.
switch sortParam {
case "score_desc":
sort.Slice(filtered, func(i, j int) bool { return filtered[i].Score > filtered[j].Score })
case "part_number":
sort.Slice(filtered, func(i, j int) bool { return filtered[i].PartNumber < filtered[j].PartNumber })
case "updated_at":
sort.Slice(filtered, func(i, j int) bool { return filtered[i].UpdatedAt > filtered[j].UpdatedAt })
default: // score_asc
sort.Slice(filtered, func(i, j int) bool { return filtered[i].Score < filtered[j].Score })
}
// Compute summary from all results (before pagination).
summary := computeSummary(allResults)
// Paginate.
total := len(filtered)
if offset > total {
offset = total
}
end := offset + limit
if end > total {
end = total
}
page := filtered[offset:end]
if page == nil {
page = []AuditItemResult{}
}
writeJSON(w, http.StatusOK, AuditCompletenessResponse{
Items: page,
Summary: summary,
})
}
// HandleAuditItemDetail returns a single item's field-by-field audit breakdown.
func (s *Server) HandleAuditItemDetail(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).Str("pn", partNumber).Msg("failed to get item for audit")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item")
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", fmt.Sprintf("Item %s not found", partNumber))
return
}
// Get current revision properties.
properties := make(map[string]any)
if item.CurrentRevision > 0 {
rev, err := s.items.GetRevision(ctx, item.ID, item.CurrentRevision)
if err != nil {
s.logger.Error().Err(err).Str("pn", partNumber).Msg("failed to get revision for audit")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load properties")
return
}
if rev != nil {
properties = rev.Properties
}
}
iwp := &db.ItemWithProperties{
Item: *item,
Properties: properties,
}
// Get BOM count.
bomCounts, err := s.items.BatchCheckBOM(ctx, []string{item.ID})
if err != nil {
s.logger.Error().Err(err).Str("pn", partNumber).Msg("failed to check BOM for audit")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check BOM")
return
}
bomCount := bomCounts[item.ID]
hasBOM := bomCount > 0
// Get project codes.
projectCodes, err := s.items.BatchGetProjectCodes(ctx, []string{item.ID})
if err != nil {
s.logger.Error().Err(err).Str("pn", partNumber).Msg("failed to get projects for audit")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load projects")
return
}
projects := projectCodes[item.ID]
// Category resolution.
cat := extractCategory(item.PartNumber)
categoryName := cat
sch := s.schemas["kindred-rd"]
if sch != nil {
if seg := sch.GetSegment("category"); seg != nil {
if name, ok := seg.Values[cat]; ok {
categoryName = name
}
}
}
var categoryProps map[string]schema.PropertyDefinition
if sch != nil && sch.PropertySchemas != nil {
categoryProps = sch.PropertySchemas.GetPropertiesForCategory(cat)
}
result := scoreItem(iwp, categoryProps, hasBOM, bomCount, categoryName, projects, true)
writeJSON(w, http.StatusOK, result)
}
// computeSummary builds aggregate statistics from scored items.
func computeSummary(results []AuditItemResult) AuditSummary {
summary := AuditSummary{
TotalItems: len(results),
ByTier: map[string]int{
"critical": 0,
"low": 0,
"partial": 0,
"good": 0,
"complete": 0,
},
ByCategory: make(map[string]CategorySummary),
}
if len(results) == 0 {
return summary
}
var totalScore float64
mfgWithoutBOM := 0
// Per-category accumulators.
catCounts := make(map[string]int)
catScoreSums := make(map[string]float64)
for _, r := range results {
totalScore += r.Score
summary.ByTier[r.Tier]++
if r.SourcingType == "manufactured" && !r.HasBOM {
mfgWithoutBOM++
}
// Category prefix (first character).
catPrefix := ""
if len(r.Category) > 0 {
catPrefix = string(r.Category[0])
}
catCounts[catPrefix]++
catScoreSums[catPrefix] += r.Score
}
summary.AvgScore = math.Round(totalScore/float64(len(results))*10000) / 10000
summary.ManufacturedWithoutBOM = mfgWithoutBOM
for prefix, count := range catCounts {
summary.ByCategory[prefix] = CategorySummary{
Count: count,
AvgScore: math.Round(catScoreSums[prefix]/float64(count)*10000) / 10000,
}
}
return summary
}

View File

@@ -68,6 +68,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Get("/", webHandler.HandleIndex)
r.Get("/projects", webHandler.HandleProjectsPage)
r.Get("/schemas", webHandler.HandleSchemasPage)
r.Get("/audit", webHandler.HandleAuditPage)
r.Get("/settings", server.HandleSettingsPage)
r.Post("/settings/tokens", server.HandleCreateTokenWeb)
r.Post("/settings/tokens/{id}/revoke", server.HandleRevokeTokenWeb)
@@ -164,6 +165,12 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
})
})
// Audit (read-only, viewer role)
r.Route("/audit", func(r chi.Router) {
r.Get("/completeness", server.HandleAuditCompleteness)
r.Get("/completeness/{partNumber}", server.HandleAuditItemDetail)
})
// Integrations (read: viewer, write: editor)
r.Route("/integrations/odoo", func(r chi.Router) {
r.Get("/config", server.HandleGetOdooConfig)

File diff suppressed because it is too large Load Diff

View File

@@ -482,6 +482,7 @@
<a href="/" class="{{if eq .Page "items"}}active{{end}}">Items</a>
<a href="/projects" class="{{if eq .Page "projects"}}active{{end}}">Projects</a>
<a href="/schemas" class="{{if eq .Page "schemas"}}active{{end}}">Schemas</a>
<a href="/audit" class="{{if eq .Page "audit"}}active{{end}}">Audit</a>
<a href="/settings" class="{{if eq .Page "settings"}}active{{end}}">Settings</a>
</nav>
{{if .User}}
@@ -505,6 +506,8 @@
{{template "projects_content" .}}
{{else if eq .Page "schemas"}}
{{template "schemas_content" .}}
{{else if eq .Page "audit"}}
{{template "audit_content" .}}
{{else if eq .Page "settings"}}
{{template "settings_content" .}}
{{end}}
@@ -516,6 +519,8 @@
{{template "projects_scripts" .}}
{{else if eq .Page "schemas"}}
{{template "schemas_scripts" .}}
{{else if eq .Page "audit"}}
{{template "audit_scripts" .}}
{{else if eq .Page "settings"}}
{{template "settings_scripts" .}}
{{end}}

View File

@@ -434,7 +434,12 @@
</div>
<div class="form-group">
<label class="form-label">Category</label>
<select class="form-input" id="category" required>
<select
class="form-input"
id="category"
required
onchange="onCategoryChange()"
>
<option value="">Select category...</option>
</select>
</div>
@@ -496,6 +501,51 @@
<div class="selected-tags" id="selected-tags"></div>
</div>
</div>
<!-- Category Properties (dynamically populated) -->
<div id="category-properties-section" style="display: none">
<div
style="
border-top: 1px solid var(--ctp-surface1);
margin: 1rem 0 0.75rem;
padding-top: 0.75rem;
"
>
<div
style="
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
"
>
<span
style="
font-size: 0.85rem;
font-weight: 600;
color: var(--ctp-subtext0);
text-transform: uppercase;
letter-spacing: 0.05em;
"
>Category Properties</span
>
<button
type="button"
style="
background: none;
border: none;
color: var(--ctp-overlay0);
cursor: pointer;
font-size: 0.8rem;
"
onclick="toggleCategoryProps()"
>
collapse
</button>
</div>
</div>
<div id="category-properties-fields"></div>
</div>
<div class="form-actions">
<button
type="button"
@@ -1955,6 +2005,196 @@
}
}
// --- Category Properties in Create Form ---
// Fields already handled by the main create form
const createFormHandledFields = new Set([
"category",
"description",
"sourcing_type",
"sourcing_link",
"standard_cost",
"long_description",
]);
// Global default property keys for grouping
const globalPropKeys = new Set([
"manufacturer",
"manufacturer_pn",
"supplier",
"supplier_pn",
"sourcing_link",
"standard_cost",
"lead_time_days",
"minimum_order_qty",
"lifecycle_status",
"rohs_compliant",
"country_of_origin",
"notes",
]);
let categoryPropsCache = {};
let categoryPropsCollapsed = false;
async function onCategoryChange() {
const category = document.getElementById("category").value;
const section = document.getElementById("category-properties-section");
const container = document.getElementById("category-properties-fields");
if (!category) {
section.style.display = "none";
container.innerHTML = "";
return;
}
// Fetch schema properties for this category
try {
const resp = await fetch(
"/api/schemas/kindred-rd/properties?category=" +
encodeURIComponent(category),
);
if (!resp.ok) {
section.style.display = "none";
return;
}
const data = await resp.json();
const props = data.properties || {};
categoryPropsCache = props;
// Filter out fields handled by the main form
const entries = Object.entries(props).filter(
([key]) => !createFormHandledFields.has(key),
);
if (entries.length === 0) {
section.style.display = "none";
return;
}
// Group into procurement and category-specific
const procurementFields = entries.filter(([key]) =>
globalPropKeys.has(key),
);
const categoryFields = entries.filter(
([key]) => !globalPropKeys.has(key),
);
let html = "";
if (procurementFields.length > 0) {
html +=
'<div style="font-size:0.8rem;font-weight:600;color:var(--ctp-subtext0);margin-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.04em;">Procurement</div>';
procurementFields
.sort((a, b) => a[0].localeCompare(b[0]))
.forEach(([key, def]) => {
html += renderCreatePropertyField(key, def);
});
}
if (categoryFields.length > 0) {
if (procurementFields.length > 0) {
html +=
'<div style="border-top:1px solid var(--ctp-surface1);margin:0.75rem 0;"></div>';
}
const prefix = category[0];
const groupNames = {
F: "Fastener",
C: "Fitting",
R: "Motion",
S: "Structural",
E: "Electrical",
M: "Mechanical",
T: "Tooling",
A: "Assembly",
P: "Purchased",
X: "Fabrication",
};
const groupName = groupNames[prefix] || "Category";
html +=
'<div style="font-size:0.8rem;font-weight:600;color:var(--ctp-subtext0);margin-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.04em;">' +
groupName +
" Properties</div>";
categoryFields
.sort((a, b) => a[0].localeCompare(b[0]))
.forEach(([key, def]) => {
html += renderCreatePropertyField(key, def);
});
}
container.innerHTML = html;
section.style.display = "block";
categoryPropsCollapsed = false;
} catch (e) {
console.error("Failed to load category properties:", e);
section.style.display = "none";
}
}
function renderCreatePropertyField(key, def) {
const label = key
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
const unit = def.unit
? ' <span style="font-size:0.75rem;color:var(--ctp-overlay0);">' +
def.unit +
"</span>"
: "";
const defaultVal = def.default != null ? def.default : "";
let input = "";
if (def.type === "boolean") {
const checked = defaultVal === true ? "checked" : "";
input =
'<input type="checkbox" class="prop-field" data-key="' +
key +
'" data-type="boolean" ' +
checked +
' style="width:auto;margin-top:0.35rem;">';
} else if (def.type === "number") {
input =
'<input type="number" step="any" class="form-input prop-field" data-key="' +
key +
'" data-type="number" value="' +
(defaultVal || "") +
'" placeholder="0" style="font-size:0.9rem;">';
} else if (key === "lifecycle_status") {
const opts = ["active", "deprecated", "obsolete", "prototype"];
input =
'<select class="form-input prop-field" data-key="' +
key +
'" data-type="string" style="font-size:0.9rem;">';
input += '<option value="">--</option>';
opts.forEach((o) => {
const sel = defaultVal === o ? " selected" : "";
input +=
'<option value="' + o + '"' + sel + ">" + o + "</option>";
});
input += "</select>";
} else {
input =
'<input type="text" class="form-input prop-field" data-key="' +
key +
'" data-type="string" value="' +
(defaultVal || "") +
'" placeholder="" style="font-size:0.9rem;">';
}
return (
'<div class="form-group" style="margin-bottom:0.75rem;">' +
'<label class="form-label" style="font-size:0.85rem;">' +
label +
unit +
"</label>" +
input +
"</div>"
);
}
function toggleCategoryProps() {
const container = document.getElementById("category-properties-fields");
categoryPropsCollapsed = !categoryPropsCollapsed;
container.style.display = categoryPropsCollapsed ? "none" : "block";
}
// Project tag management for create form
function addProjectTag() {
const select = document.getElementById("project-select");
@@ -2317,6 +2557,10 @@
document.getElementById("create-form").reset();
selectedProjectTags = [];
renderSelectedTags();
// Reset category properties section
document.getElementById("category-properties-section").style.display =
"none";
document.getElementById("category-properties-fields").innerHTML = "";
}
async function createItem(event) {
@@ -2349,6 +2593,29 @@
data.projects = selectedProjectTags;
}
// Collect category properties from dynamic fields
const properties = {};
document
.querySelectorAll("#category-properties-fields .prop-field")
.forEach((field) => {
const key = field.dataset.key;
const type = field.dataset.type;
let value;
if (type === "boolean") {
value = field.checked;
} else if (type === "number") {
value = field.value !== "" ? parseFloat(field.value) : null;
} else {
value = field.value.trim();
}
if (value !== null && value !== "" && value !== undefined) {
properties[key] = value;
}
});
if (Object.keys(properties).length > 0) {
data.properties = properties;
}
try {
const response = await fetch("/api/items", {
method: "POST",

View File

@@ -100,3 +100,19 @@ func (h *WebHandler) HandleSchemasPage(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
// HandleAuditPage serves the component audit page.
func (h *WebHandler) HandleAuditPage(w http.ResponseWriter, r *http.Request) {
data := PageData{
Title: "Audit",
Page: "audit",
User: auth.UserFromContext(r.Context()),
CSRFToken: nosurf.Token(r),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.templates.ExecuteTemplate(w, "base.html", data); err != nil {
h.logger.Error().Err(err).Msg("failed to render template")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,166 @@
package db
import (
"context"
"encoding/json"
"fmt"
)
// AuditListOptions controls filtering for the batch audit item query.
type AuditListOptions struct {
Project string // filter by project code
Category string // filter by category prefix ("F", "F01")
Limit int
Offset int
}
// ItemWithProperties combines an Item with its current revision properties.
type ItemWithProperties struct {
Item
Properties map[string]any
}
// ListItemsWithProperties returns items joined with their current revision
// properties in a single query, avoiding the N+1 pattern.
func (r *ItemRepository) ListItemsWithProperties(ctx context.Context, opts AuditListOptions) ([]*ItemWithProperties, error) {
args := []any{}
argNum := 1
var query string
if opts.Project != "" {
query = `
SELECT DISTINCT i.id, i.part_number, i.schema_id, i.item_type, i.description,
i.created_at, i.updated_at, i.archived_at, i.current_revision,
i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost,
COALESCE(r.properties, '{}'::jsonb) as properties
FROM items i
LEFT JOIN revisions r ON r.item_id = i.id AND r.revision_number = i.current_revision
JOIN item_projects ip ON ip.item_id = i.id
JOIN projects p ON p.id = ip.project_id
WHERE i.archived_at IS NULL AND p.code = $1
`
args = append(args, opts.Project)
argNum++
} else {
query = `
SELECT i.id, i.part_number, i.schema_id, i.item_type, i.description,
i.created_at, i.updated_at, i.archived_at, i.current_revision,
i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost,
COALESCE(r.properties, '{}'::jsonb) as properties
FROM items i
LEFT JOIN revisions r ON r.item_id = i.id AND r.revision_number = i.current_revision
WHERE i.archived_at IS NULL
`
}
if opts.Category != "" {
query += fmt.Sprintf(" AND i.part_number LIKE $%d", argNum)
args = append(args, opts.Category+"%")
argNum++
}
query += " ORDER BY i.part_number"
if opts.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argNum)
args = append(args, opts.Limit)
argNum++
}
if opts.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argNum)
args = append(args, opts.Offset)
}
rows, err := r.db.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying items with properties: %w", err)
}
defer rows.Close()
var items []*ItemWithProperties
for rows.Next() {
iwp := &ItemWithProperties{}
var propsJSON []byte
err := rows.Scan(
&iwp.ID, &iwp.PartNumber, &iwp.SchemaID, &iwp.ItemType, &iwp.Description,
&iwp.CreatedAt, &iwp.UpdatedAt, &iwp.ArchivedAt, &iwp.CurrentRevision,
&iwp.SourcingType, &iwp.SourcingLink, &iwp.LongDescription, &iwp.StandardCost,
&propsJSON,
)
if err != nil {
return nil, fmt.Errorf("scanning item with properties: %w", err)
}
iwp.Properties = make(map[string]any)
if len(propsJSON) > 0 {
if err := json.Unmarshal(propsJSON, &iwp.Properties); err != nil {
return nil, fmt.Errorf("unmarshaling properties for %s: %w", iwp.PartNumber, err)
}
}
items = append(items, iwp)
}
return items, nil
}
// BatchCheckBOM returns a map of item ID to BOM child count for the given
// item IDs. Items not in the map have zero children.
func (r *ItemRepository) BatchCheckBOM(ctx context.Context, itemIDs []string) (map[string]int, error) {
if len(itemIDs) == 0 {
return map[string]int{}, nil
}
rows, err := r.db.pool.Query(ctx, `
SELECT parent_item_id, COUNT(*) as child_count
FROM relationships
WHERE parent_item_id = ANY($1) AND rel_type = 'component'
GROUP BY parent_item_id
`, itemIDs)
if err != nil {
return nil, fmt.Errorf("batch checking BOM: %w", err)
}
defer rows.Close()
result := make(map[string]int)
for rows.Next() {
var itemID string
var count int
if err := rows.Scan(&itemID, &count); err != nil {
return nil, fmt.Errorf("scanning BOM count: %w", err)
}
result[itemID] = count
}
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) {
if len(itemIDs) == 0 {
return map[string][]string{}, nil
}
rows, err := r.db.pool.Query(ctx, `
SELECT ip.item_id, p.code
FROM item_projects ip
JOIN projects p ON p.id = ip.project_id
WHERE ip.item_id = ANY($1)
ORDER BY p.code
`, itemIDs)
if err != nil {
return nil, fmt.Errorf("batch getting project codes: %w", err)
}
defer rows.Close()
result := make(map[string][]string)
for rows.Next() {
var itemID, code string
if err := rows.Scan(&itemID, &code); err != nil {
return nil, fmt.Errorf("scanning project code: %w", err)
}
result[itemID] = append(result[itemID], code)
}
return result, nil
}