- 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
1015 lines
36 KiB
HTML
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, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<');
|
|
}
|
|
|
|
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}}
|