Files
silo/internal/api/templates/audit.html
Zoe Forbes 8f6e956fde 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
2026-02-01 10:41:57 -06:00

1015 lines
36 KiB
HTML

{{define "audit_content"}}
<!-- Summary Bar -->
<div class="audit-summary-bar" id="audit-summary-bar">
<div class="tier-segment tier-critical" data-tier="critical" onclick="filterByTier('critical')">
<span class="tier-count" id="tier-critical-count">0</span>
<span class="tier-label">Critical</span>
</div>
<div class="tier-segment tier-low" data-tier="low" onclick="filterByTier('low')">
<span class="tier-count" id="tier-low-count">0</span>
<span class="tier-label">Low</span>
</div>
<div class="tier-segment tier-partial" data-tier="partial" onclick="filterByTier('partial')">
<span class="tier-count" id="tier-partial-count">0</span>
<span class="tier-label">Partial</span>
</div>
<div class="tier-segment tier-good" data-tier="good" onclick="filterByTier('good')">
<span class="tier-count" id="tier-good-count">0</span>
<span class="tier-label">Good</span>
</div>
<div class="tier-segment tier-complete" data-tier="complete" onclick="filterByTier('complete')">
<span class="tier-count" id="tier-complete-count">0</span>
<span class="tier-label">Complete</span>
</div>
</div>
<!-- Stats Row -->
<div class="audit-stats-row">
<div class="audit-stat">
<span class="audit-stat-value" id="stat-total">-</span>
<span class="audit-stat-label">Total Items</span>
</div>
<div class="audit-stat">
<span class="audit-stat-value" id="stat-avg-score">-</span>
<span class="audit-stat-label">Avg Score</span>
</div>
<div class="audit-stat">
<span class="audit-stat-value" id="stat-mfg-no-bom">-</span>
<span class="audit-stat-label">Mfg w/o BOM</span>
</div>
</div>
<!-- Toolbar -->
<div class="card audit-toolbar">
<div class="card-header">
<h2 class="card-title">Component Audit</h2>
<div class="header-actions">
<button class="btn btn-secondary" onclick="clearFilters()">Clear Filters</button>
</div>
</div>
<div class="search-bar">
<input type="text" class="search-input" id="audit-search" placeholder="Search part number or description..." onkeyup="debounceAuditSearch()" />
<select id="audit-project-filter" onchange="loadAuditData()">
<option value="">All Projects</option>
</select>
<select id="audit-category-filter" onchange="loadAuditData()">
<option value="">All Categories</option>
</select>
<select id="audit-sort" onchange="loadAuditData()">
<option value="score_asc">Score (Low to High)</option>
<option value="score_desc">Score (High to Low)</option>
<option value="part_number">Part Number</option>
<option value="updated_at">Recently Updated</option>
</select>
</div>
</div>
<!-- Workspace: table + detail -->
<div class="audit-workspace" id="audit-workspace">
<div class="audit-list-panel" id="audit-list-panel">
<div class="table-container">
<table>
<thead>
<tr>
<th style="width:80px">Score</th>
<th>Part Number</th>
<th>Description</th>
<th>Category</th>
<th>Sourcing</th>
<th style="width:70px">Missing</th>
</tr>
</thead>
<tbody id="audit-table">
<tr><td colspan="6"><div class="loading"><div class="spinner"></div></div></td></tr>
</tbody>
</table>
</div>
<div class="pagination" id="audit-pagination"></div>
</div>
<div class="audit-detail-panel" id="audit-detail-panel">
<div class="detail-empty-state" id="audit-detail-empty">
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1" style="color:var(--ctp-surface2);margin-bottom:1rem;">
<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/>
<rect x="9" y="3" width="6" height="4" rx="1"/>
<path d="M9 14l2 2 4-4"/>
</svg>
<p>Select an item to audit</p>
<p class="detail-empty-hint">Click a row to see field-by-field breakdown</p>
</div>
<div class="audit-detail-content" id="audit-detail-content" style="display:none;"></div>
</div>
</div>
<style>
/* Audit Summary Bar */
.audit-summary-bar {
display: flex;
border-radius: 0.75rem;
overflow: hidden;
margin-bottom: 1rem;
height: 52px;
cursor: pointer;
}
.tier-segment {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.25rem 0.5rem;
transition: all 0.2s;
min-width: 60px;
}
.tier-segment:hover {
filter: brightness(1.2);
}
.tier-segment.active-filter {
outline: 2px solid var(--ctp-text);
outline-offset: -2px;
}
.tier-count {
font-size: 1.1rem;
font-weight: 700;
}
.tier-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.8;
}
.tier-critical {
background: rgba(243, 139, 168, 0.3);
color: var(--ctp-red);
flex: 1;
}
.tier-low {
background: rgba(250, 179, 135, 0.3);
color: var(--ctp-peach);
flex: 1;
}
.tier-partial {
background: rgba(249, 226, 175, 0.3);
color: var(--ctp-yellow);
flex: 1;
}
.tier-good {
background: rgba(166, 227, 161, 0.25);
color: var(--ctp-green);
flex: 1;
}
.tier-complete {
background: rgba(166, 227, 161, 0.4);
color: var(--ctp-green);
flex: 1;
}
/* Audit Stats Row */
.audit-stats-row {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.audit-stat {
background: var(--ctp-surface0);
border-radius: 0.75rem;
padding: 0.75rem 1.25rem;
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.audit-stat-value {
font-size: 1.25rem;
font-weight: 700;
color: var(--ctp-text);
}
.audit-stat-label {
font-size: 0.8rem;
color: var(--ctp-subtext0);
}
/* Audit Toolbar */
.audit-toolbar {
margin-bottom: 1rem;
}
/* Audit Workspace */
.audit-workspace {
display: flex;
gap: 1rem;
min-height: calc(100vh - 380px);
}
.audit-list-panel {
flex: 0 0 560px;
min-width: 400px;
overflow-y: auto;
background: var(--ctp-surface0);
border-radius: 0.75rem;
}
.audit-list-panel .table-container {
border: none;
border-radius: 0;
}
.audit-list-panel .pagination {
padding: 0.75rem;
}
.audit-detail-panel {
flex: 1;
min-width: 350px;
overflow-y: auto;
background: var(--ctp-surface0);
border-radius: 0.75rem;
}
/* Score cell */
.score-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.score-value {
font-weight: 600;
font-size: 0.85rem;
}
.score-bar {
height: 4px;
border-radius: 2px;
background: var(--ctp-surface2);
overflow: hidden;
}
.score-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s;
}
/* Tier colors for score text */
.score-critical { color: var(--ctp-red); }
.score-low { color: var(--ctp-peach); }
.score-partial { color: var(--ctp-yellow); }
.score-good { color: var(--ctp-green); }
.score-complete { color: var(--ctp-green); }
.score-bar-critical .score-bar-fill { background: var(--ctp-red); }
.score-bar-low .score-bar-fill { background: var(--ctp-peach); }
.score-bar-partial .score-bar-fill { background: var(--ctp-yellow); }
.score-bar-good .score-bar-fill { background: var(--ctp-green); }
.score-bar-complete .score-bar-fill { background: var(--ctp-green); }
/* Table row styles */
.audit-list-panel tr {
cursor: pointer;
}
.audit-list-panel tr.selected {
background: var(--ctp-surface1);
}
.audit-list-panel td {
padding: 0.6rem 0.75rem;
font-size: 0.9rem;
}
.audit-list-panel th {
padding: 0.6rem 0.75rem;
font-size: 0.8rem;
}
.audit-pn {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
color: var(--ctp-peach);
font-weight: 500;
font-size: 0.85rem;
}
.sourcing-badge {
display: inline-block;
padding: 0.15rem 0.4rem;
border-radius: 0.5rem;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
}
.sourcing-purchased {
background: rgba(137, 180, 250, 0.2);
color: var(--ctp-blue);
}
.sourcing-manufactured {
background: rgba(166, 227, 161, 0.2);
color: var(--ctp-green);
}
.missing-count {
font-weight: 600;
color: var(--ctp-subtext0);
}
.missing-count.high {
color: var(--ctp-red);
}
/* Detail panel */
.detail-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 300px;
color: var(--ctp-subtext0);
text-align: center;
padding: 2rem;
}
.detail-empty-hint {
font-size: 0.85rem;
color: var(--ctp-overlay0);
margin-top: 0.5rem;
}
.audit-detail-content {
padding: 1.25rem;
}
.audit-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--ctp-surface1);
}
.audit-detail-pn {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 1.1rem;
font-weight: 600;
color: var(--ctp-peach);
}
.audit-detail-score {
font-size: 1.5rem;
font-weight: 700;
}
.audit-detail-desc {
color: var(--ctp-subtext1);
font-size: 0.9rem;
margin-bottom: 1rem;
}
.audit-detail-meta {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.audit-meta-tag {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 0.5rem;
font-size: 0.75rem;
background: var(--ctp-surface1);
color: var(--ctp-subtext1);
}
/* Field sections */
.audit-section {
margin-bottom: 1.25rem;
}
.audit-section-header {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ctp-subtext0);
padding-bottom: 0.4rem;
margin-bottom: 0.5rem;
border-bottom: 1px solid var(--ctp-surface1);
}
.audit-field {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0;
border-left: 3px solid transparent;
padding-left: 0.5rem;
margin-bottom: 0.25rem;
}
.audit-field.field-filled {
border-left-color: var(--ctp-green);
}
.audit-field.field-empty {
border-left-color: var(--ctp-red);
}
.audit-field-label {
width: 140px;
flex-shrink: 0;
font-size: 0.85rem;
color: var(--ctp-subtext1);
}
.audit-field-weight {
width: 32px;
flex-shrink: 0;
font-size: 0.7rem;
color: var(--ctp-overlay0);
text-align: center;
}
.audit-field-input {
flex: 1;
padding: 0.35rem 0.5rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.35rem;
color: var(--ctp-text);
font-size: 0.85rem;
}
.audit-field-input:focus {
outline: none;
border-color: var(--ctp-mauve);
}
.audit-field-input.dirty {
border-color: var(--ctp-yellow);
background: rgba(249, 226, 175, 0.05);
}
.audit-field-unit {
font-size: 0.75rem;
color: var(--ctp-overlay0);
flex-shrink: 0;
}
/* Save bar */
.audit-save-bar {
display: flex;
gap: 0.75rem;
padding-top: 1rem;
margin-top: 0.5rem;
border-top: 1px solid var(--ctp-surface1);
}
.audit-save-bar .btn {
flex: 1;
}
/* Responsive */
@media (max-width: 900px) {
.audit-workspace {
flex-direction: column;
}
.audit-list-panel {
flex: 1;
min-width: 0;
}
.audit-detail-panel {
min-width: 0;
}
}
</style>
{{end}}
{{define "audit_scripts"}}
<script>
// State
let auditData = [];
let auditSummary = {};
let currentAuditPN = null;
let currentAuditDetail = null;
let auditPage = 1;
const auditPageSize = 50;
let auditSearchTimeout = null;
let activeTierFilter = null;
let dirtyFields = {};
// Init
document.addEventListener('DOMContentLoaded', () => {
loadProjectsFilter();
loadCategoryFilter();
loadAuditData();
});
async function loadProjectsFilter() {
try {
const resp = await fetch('/api/projects');
if (!resp.ok) return;
const projects = await resp.json();
const sel = document.getElementById('audit-project-filter');
projects.forEach(p => {
const opt = document.createElement('option');
opt.value = p.code;
opt.textContent = p.code + (p.name ? ' - ' + p.name : '');
sel.appendChild(opt);
});
} catch (e) { console.error('Failed to load projects:', e); }
}
async function loadCategoryFilter() {
try {
const resp = await fetch('/api/schemas/kindred-rd');
if (!resp.ok) return;
const schema = await resp.json();
const sel = document.getElementById('audit-category-filter');
const catSeg = schema.segments.find(s => s.name === 'category');
if (catSeg && catSeg.values) {
// Group by prefix
const prefixes = {};
for (const [code, name] of Object.entries(catSeg.values)) {
const prefix = code[0];
if (!prefixes[prefix]) prefixes[prefix] = [];
prefixes[prefix].push({ code, name });
}
// Add prefix groups
for (const [prefix, cats] of Object.entries(prefixes).sort()) {
const optgroup = document.createElement('optgroup');
optgroup.label = prefix + ' - ' + (cats[0] ? getCategoryGroupName(prefix) : prefix);
cats.sort((a, b) => a.code.localeCompare(b.code)).forEach(c => {
const opt = document.createElement('option');
opt.value = c.code;
opt.textContent = c.code + ' - ' + c.name;
optgroup.appendChild(opt);
});
sel.appendChild(optgroup);
}
}
} catch (e) { console.error('Failed to load categories:', e); }
}
function getCategoryGroupName(prefix) {
const names = {
'F': 'Fasteners', 'C': 'Fluid Fittings', 'R': 'Motion Components',
'S': 'Structural Materials', 'E': 'Electrical', 'M': 'Mechanical',
'T': 'Tooling', 'A': 'Assemblies', 'P': 'Purchased', 'X': 'Custom Fabricated'
};
return names[prefix] || prefix;
}
async function loadAuditData() {
const params = new URLSearchParams();
const project = document.getElementById('audit-project-filter').value;
const category = document.getElementById('audit-category-filter').value;
const sort = document.getElementById('audit-sort').value;
const search = document.getElementById('audit-search').value.trim();
if (project) params.set('project', project);
if (category) params.set('category', category);
if (sort) params.set('sort', sort);
// Apply tier filter as score range
if (activeTierFilter) {
const ranges = {
'critical': [0, 0.2499],
'low': [0.25, 0.4999],
'partial': [0.50, 0.7499],
'good': [0.75, 0.9999],
'complete': [1.0, 1.0]
};
const range = ranges[activeTierFilter];
if (range) {
params.set('min_score', range[0]);
params.set('max_score', range[1]);
}
}
params.set('limit', auditPageSize);
params.set('offset', (auditPage - 1) * auditPageSize);
try {
const resp = await fetch('/api/audit/completeness?' + params.toString());
if (!resp.ok) {
const err = await resp.json();
console.error('Audit load error:', err);
return;
}
const data = await resp.json();
auditData = data.items || [];
auditSummary = data.summary || {};
// Client-side search filter
let filtered = auditData;
if (search) {
const q = search.toLowerCase();
filtered = auditData.filter(item =>
item.part_number.toLowerCase().includes(q) ||
(item.description || '').toLowerCase().includes(q)
);
}
renderSummaryBar(auditSummary);
renderStats(auditSummary);
renderAuditTable(filtered);
renderAuditPagination(auditSummary.total_items || 0);
} catch (e) {
console.error('Failed to load audit data:', e);
}
}
function renderSummaryBar(summary) {
const tiers = ['critical', 'low', 'partial', 'good', 'complete'];
const byTier = summary.by_tier || {};
const total = summary.total_items || 1;
tiers.forEach(tier => {
const count = byTier[tier] || 0;
document.getElementById('tier-' + tier + '-count').textContent = count;
const seg = document.querySelector('.tier-segment.tier-' + tier);
// Set flex based on proportion, minimum 60px
const pct = Math.max(count / total * 100, 8);
seg.style.flex = '0 0 ' + pct + '%';
seg.classList.toggle('active-filter', activeTierFilter === tier);
});
}
function renderStats(summary) {
document.getElementById('stat-total').textContent = summary.total_items || 0;
document.getElementById('stat-avg-score').textContent =
summary.avg_score != null ? Math.round(summary.avg_score * 100) + '%' : '-';
document.getElementById('stat-mfg-no-bom').textContent = summary.manufactured_without_bom || 0;
}
function renderAuditTable(items) {
const tbody = document.getElementById('audit-table');
if (!items || items.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:2rem;color:var(--ctp-subtext0);">No items match the current filters</td></tr>';
return;
}
tbody.innerHTML = items.map(item => {
const pct = Math.round(item.score * 100);
const tier = item.tier || tierForScore(item.score);
const isSelected = currentAuditPN === item.part_number;
const missingCount = (item.missing || []).length;
return `<tr class="${isSelected ? 'selected' : ''}" onclick="showAuditDetail('${escapeAttr(item.part_number)}')">
<td>
<div class="score-cell">
<span class="score-value score-${tier}">${pct}%</span>
<div class="score-bar score-bar-${tier}">
<div class="score-bar-fill" style="width:${pct}%"></div>
</div>
</div>
</td>
<td><span class="audit-pn">${item.part_number}</span></td>
<td>${item.description || '-'}</td>
<td>${item.category_name || item.category || '-'}</td>
<td><span class="sourcing-badge sourcing-${item.sourcing_type}">${item.sourcing_type === 'purchased' ? 'P' : 'M'}</span></td>
<td><span class="missing-count ${missingCount > 5 ? 'high' : ''}">${missingCount}</span></td>
</tr>`;
}).join('');
}
function renderAuditPagination(total) {
const totalPages = Math.ceil(total / auditPageSize);
const container = document.getElementById('audit-pagination');
if (totalPages <= 1) {
container.innerHTML = '';
return;
}
let html = '';
html += `<button class="pagination-btn" ${auditPage <= 1 ? 'disabled' : ''} onclick="goAuditPage(${auditPage - 1})">Prev</button>`;
html += `<span style="padding:0.5rem;color:var(--ctp-subtext0);font-size:0.85rem;">${auditPage} / ${totalPages}</span>`;
html += `<button class="pagination-btn" ${auditPage >= totalPages ? 'disabled' : ''} onclick="goAuditPage(${auditPage + 1})">Next</button>`;
container.innerHTML = html;
}
function goAuditPage(page) {
auditPage = page;
loadAuditData();
}
function debounceAuditSearch() {
clearTimeout(auditSearchTimeout);
auditSearchTimeout = setTimeout(() => {
auditPage = 1;
loadAuditData();
}, 300);
}
function filterByTier(tier) {
if (activeTierFilter === tier) {
activeTierFilter = null;
} else {
activeTierFilter = tier;
}
auditPage = 1;
loadAuditData();
}
function clearFilters() {
document.getElementById('audit-project-filter').value = '';
document.getElementById('audit-category-filter').value = '';
document.getElementById('audit-search').value = '';
document.getElementById('audit-sort').value = 'score_asc';
activeTierFilter = null;
auditPage = 1;
loadAuditData();
}
function tierForScore(score) {
if (score >= 1.0) return 'complete';
if (score >= 0.75) return 'good';
if (score >= 0.50) return 'partial';
if (score >= 0.25) return 'low';
return 'critical';
}
function escapeAttr(s) {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function formatFieldLabel(key) {
return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
// --- Detail Panel ---
async function showAuditDetail(partNumber) {
currentAuditPN = partNumber;
dirtyFields = {};
// Highlight selected row
document.querySelectorAll('#audit-table tr').forEach(tr => tr.classList.remove('selected'));
const rows = document.querySelectorAll('#audit-table tr');
rows.forEach(tr => {
if (tr.onclick && tr.onclick.toString().includes(partNumber)) {
tr.classList.add('selected');
}
});
try {
const resp = await fetch('/api/audit/completeness/' + encodeURIComponent(partNumber));
if (!resp.ok) {
console.error('Failed to load audit detail');
return;
}
currentAuditDetail = await resp.json();
renderAuditDetail(currentAuditDetail);
} catch (e) {
console.error('Failed to load audit detail:', e);
}
// Re-render table to update selected row
const search = document.getElementById('audit-search').value.trim();
let filtered = auditData;
if (search) {
const q = search.toLowerCase();
filtered = auditData.filter(item =>
item.part_number.toLowerCase().includes(q) ||
(item.description || '').toLowerCase().includes(q)
);
}
renderAuditTable(filtered);
}
function renderAuditDetail(detail) {
document.getElementById('audit-detail-empty').style.display = 'none';
const content = document.getElementById('audit-detail-content');
content.style.display = 'block';
const pct = Math.round(detail.score * 100);
const tier = detail.tier || tierForScore(detail.score);
// Group fields into sections
const sections = groupFields(detail.fields || [], detail.sourcing_type, detail.category);
let html = `
<div class="audit-detail-header">
<span class="audit-detail-pn">${detail.part_number}</span>
<span class="audit-detail-score score-${tier}">${pct}%</span>
</div>
<div class="audit-detail-desc">${detail.description || 'No description'}</div>
<div class="audit-detail-meta">
<span class="audit-meta-tag">${detail.category_name || detail.category}</span>
<span class="sourcing-badge sourcing-${detail.sourcing_type}">${detail.sourcing_type}</span>
${detail.has_bom ? '<span class="audit-meta-tag" style="background:rgba(166,227,161,0.2);color:var(--ctp-green);">Has BOM (' + detail.bom_children + ')</span>' : ''}
${(detail.projects || []).map(p => '<span class="audit-meta-tag">' + p + '</span>').join('')}
</div>
`;
// Render each section
for (const section of sections) {
html += `<div class="audit-section">
<div class="audit-section-header">${section.title}</div>`;
for (const field of section.fields) {
const filledClass = field.filled ? 'field-filled' : 'field-empty';
const inputId = 'audit-field-' + field.key;
const weightLabel = field.weight >= 3 ? '!!!' : field.weight >= 2 ? '!!' : field.weight >= 1 ? '!' : '';
html += `<div class="audit-field ${filledClass}">
<span class="audit-field-label" title="${field.key}">${formatFieldLabel(field.key)}</span>
<span class="audit-field-weight" title="Weight: ${field.weight}">${weightLabel}</span>
${renderFieldInput(field, inputId)}
</div>`;
}
html += '</div>';
}
// Save bar
html += `<div class="audit-save-bar">
<button class="btn btn-primary" onclick="saveAllAuditFields()">Save Changes</button>
</div>`;
content.innerHTML = html;
// Attach change listeners
content.querySelectorAll('.audit-field-input').forEach(input => {
input.addEventListener('input', () => {
input.classList.add('dirty');
dirtyFields[input.dataset.key] = true;
});
input.addEventListener('change', () => {
input.classList.add('dirty');
dirtyFields[input.dataset.key] = true;
});
});
}
function renderFieldInput(field, inputId) {
const val = field.value != null ? field.value : '';
const source = field.source;
const key = field.key;
// has_bom is read-only computed
if (key === 'has_bom') {
return `<span style="color:${field.filled ? 'var(--ctp-green)' : 'var(--ctp-red)'};font-size:0.85rem;">${field.filled ? 'Yes' : 'No'}</span>`;
}
// sourcing_type is a dropdown
if (key === 'sourcing_type') {
return `<select class="audit-field-input" id="${inputId}" data-key="${key}" data-source="${source}">
<option value="manufactured" ${val === 'manufactured' ? 'selected' : ''}>Manufactured</option>
<option value="purchased" ${val === 'purchased' ? 'selected' : ''}>Purchased</option>
</select>`;
}
// lifecycle_status dropdown
if (key === 'lifecycle_status') {
const opts = ['active', 'deprecated', 'obsolete', 'prototype'];
return `<select class="audit-field-input" id="${inputId}" data-key="${key}" data-source="${source}">
<option value="">--</option>
${opts.map(o => `<option value="${o}" ${val === o ? 'selected' : ''}>${o}</option>`).join('')}
</select>`;
}
// Boolean -> checkbox
if (key === 'rohs_compliant') {
const checked = val === true ? 'checked' : '';
return `<input type="checkbox" class="audit-field-input" id="${inputId}" data-key="${key}" data-source="${source}" data-type="boolean" ${checked} style="width:auto;">`;
}
// Number fields
const numFields = ['standard_cost', 'lead_time_days', 'minimum_order_qty',
'load_capacity', 'speed_rating', 'power_rating', 'voltage_nominal', 'voltage_min',
'voltage_max', 'current_nominal', 'current_stall', 'torque_continuous', 'torque_peak',
'steps_per_rev', 'encoder_resolution', 'bore_diameter', 'outer_diameter', 'width',
'travel', 'stroke', 'bore', 'operating_pressure', 'pressure_rating',
'temperature_min', 'temperature_max', 'length', 'dimension_a', 'dimension_b',
'wall_thickness', 'weight_per_length', 'voltage_rating', 'current_rating',
'pin_count', 'pitch', 'frequency', 'spring_rate', 'free_length', 'solid_length',
'max_load', 'force_extended', 'inner_diameter', 'cross_section', 'cycle_life',
'weight', 'component_count', 'assembly_time', 'quantity_per_unit', 'shelf_life',
'efficiency'];
if (numFields.includes(key)) {
return `<input type="number" step="any" class="audit-field-input" id="${inputId}" data-key="${key}" data-source="${source}" data-type="number" value="${val !== '' && val != null ? val : ''}" placeholder="0">`;
}
// URL fields
if (key === 'sourcing_link') {
return `<input type="url" class="audit-field-input" id="${inputId}" data-key="${key}" data-source="${source}" value="${escapeAttr(String(val || ''))}" placeholder="https://...">`;
}
// Default: text input
return `<input type="text" class="audit-field-input" id="${inputId}" data-key="${key}" data-source="${source}" value="${escapeAttr(String(val || ''))}" placeholder="">`;
}
function groupFields(fields, sourcingType, category) {
const sections = [];
// Item-level required fields
const itemKeys = ['description', 'sourcing_type', 'standard_cost', 'sourcing_link', 'long_description'];
const itemFields = fields.filter(f => itemKeys.includes(f.key));
if (itemFields.length > 0) {
sections.push({ title: 'Core Fields', fields: sortByWeight(itemFields) });
}
// has_bom (computed)
const bomField = fields.find(f => f.key === 'has_bom');
if (bomField) {
sections.push({ title: 'BOM Status', fields: [bomField] });
}
// Procurement (global defaults minus core)
const procurementKeys = ['manufacturer', 'manufacturer_pn', 'supplier', 'supplier_pn',
'lead_time_days', 'minimum_order_qty', 'lifecycle_status',
'rohs_compliant', 'country_of_origin', 'notes'];
const procFields = fields.filter(f => procurementKeys.includes(f.key));
if (procFields.length > 0) {
sections.push({ title: 'Procurement', fields: sortByWeight(procFields) });
}
// Category-specific (everything else)
const allHandled = new Set([...itemKeys, 'has_bom', ...procurementKeys]);
const catFields = fields.filter(f => !allHandled.has(f.key));
if (catFields.length > 0) {
const prefix = category ? category[0] : '?';
const groupName = getCategoryGroupName(prefix);
sections.push({ title: groupName + ' Properties', fields: sortByWeight(catFields) });
}
return sections;
}
function sortByWeight(fields) {
return [...fields].sort((a, b) => b.weight - a.weight);
}
// --- Save ---
async function saveAllAuditFields() {
if (!currentAuditPN || !currentAuditDetail) return;
const dirtyKeys = Object.keys(dirtyFields);
if (dirtyKeys.length === 0) {
return;
}
// Separate item-level vs property fields
const itemLevelKeys = new Set(['description', 'sourcing_type', 'sourcing_link', 'standard_cost', 'long_description']);
const itemUpdates = {};
const propUpdates = {};
dirtyKeys.forEach(key => {
const input = document.querySelector(`[data-key="${key}"]`);
if (!input) return;
let value;
if (input.dataset.type === 'boolean' || input.type === 'checkbox') {
value = input.checked;
} else if (input.dataset.type === 'number' || input.type === 'number') {
value = input.value !== '' ? parseFloat(input.value) : null;
} else {
value = input.value;
}
if (itemLevelKeys.has(key)) {
itemUpdates[key] = value;
} else {
propUpdates[key] = value;
}
});
try {
// Update item-level fields
if (Object.keys(itemUpdates).length > 0) {
const resp = await fetch('/api/items/' + encodeURIComponent(currentAuditPN), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(itemUpdates)
});
if (!resp.ok) {
const err = await resp.json();
alert('Error saving item fields: ' + (err.message || err.error));
return;
}
}
// Update property fields via new revision
if (Object.keys(propUpdates).length > 0) {
// Get current properties, merge updates
const merged = { ...(currentAuditDetail._currentProperties || {}) };
// Rebuild from detail fields
if (currentAuditDetail.fields) {
currentAuditDetail.fields.forEach(f => {
if (f.source === 'property' && f.value != null) {
merged[f.key] = f.value;
}
});
}
Object.assign(merged, propUpdates);
// Remove null/empty values
for (const [k, v] of Object.entries(merged)) {
if (v === null || v === '' || v === undefined) {
delete merged[k];
}
}
const resp = await fetch('/api/items/' + encodeURIComponent(currentAuditPN) + '/revisions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
properties: merged,
comment: 'Updated from audit tool'
})
});
if (!resp.ok) {
const err = await resp.json();
alert('Error saving properties: ' + (err.message || err.error));
return;
}
}
// Reset dirty state and refresh
dirtyFields = {};
document.querySelectorAll('.audit-field-input.dirty').forEach(el => el.classList.remove('dirty'));
// Refresh detail and list
await showAuditDetail(currentAuditPN);
await loadAuditData();
} catch (e) {
alert('Error saving: ' + e.message);
}
}
</script>
{{end}}