Files
silo/internal/api/templates/items.html
Zoe Forbes 1bd29e6a6a feat: add sourcing type, extended fields, and inline project tagging
- Add migration 009: sourcing_type (manufactured/purchased), sourcing_link,
  long_description, and standard_cost columns on items table
- Update Item struct, repository queries, and API handlers for new fields
- Add sourcing badge, long description block, standard cost, and sourcing
  link display to item detail panel
- Add inline project tag editor in detail panel (add/remove via dropdown)
- Add new fields to create and edit modals
- Update CSV import/export for new columns
- Merge with auth CreatedBy/UpdatedBy changes from stash
2026-01-31 14:27:11 -06:00

3977 lines
140 KiB
HTML

{{define "items_content"}}
<div class="stats-grid" id="stats">
<div class="stat-card">
<div class="stat-value" id="total-count">-</div>
<div class="stat-label">Total Items</div>
</div>
<div class="stat-card">
<div class="stat-value" id="parts-count">-</div>
<div class="stat-label">Parts</div>
</div>
<div class="stat-card">
<div class="stat-value" id="assemblies-count">-</div>
<div class="stat-label">Assemblies</div>
</div>
<div class="stat-card">
<div class="stat-value" id="documents-count">-</div>
<div class="stat-label">Documents</div>
</div>
</div>
<!-- Toolbar -->
<div class="card items-toolbar">
<div class="card-header">
<h2 class="card-title">Items</h2>
<div class="header-actions">
<!-- Layout Toggle -->
<div class="layout-toggle">
<button
class="layout-btn"
data-layout="horizontal"
onclick="setLayout('horizontal')"
title="Horizontal layout (list + detail side by side)"
>
<svg
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="8" height="18" rx="1" />
<rect x="13" y="3" width="8" height="18" rx="1" />
</svg>
</button>
<button
class="layout-btn active"
data-layout="vertical"
onclick="setLayout('vertical')"
title="Vertical layout (detail above list)"
>
<svg
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="7" rx="1" />
<rect x="3" y="14" width="18" height="7" rx="1" />
</svg>
</button>
</div>
<!-- Column Config -->
<div class="column-config-container">
<button
class="layout-btn"
onclick="toggleColumnConfig()"
title="Configure columns"
>
<svg
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
></path>
</svg>
</button>
<div
class="column-config-popover"
id="column-config-popover"
style="display: none"
></div>
</div>
<!-- CSV Import/Export -->
<div class="csv-actions">
<button
class="btn btn-secondary"
onclick="exportCSV()"
title="Export to CSV"
>
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Export
</button>
<button
class="btn btn-secondary"
onclick="openImportModal()"
title="Import from CSV"
>
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
Import
</button>
</div>
<button class="btn btn-primary" onclick="openCreateModal()">
+ New Item
</button>
</div>
</div>
<div class="search-bar">
<div class="search-input-container">
<input
type="text"
class="search-input"
id="search-input"
placeholder="Search items... (Ctrl+F)"
onkeyup="debounceSearch()"
onfocus="showSearchHelp()"
onblur="hideSearchHelp()"
/>
<div class="search-help" id="search-help">
<div class="search-help-title">Search Tips</div>
<div class="search-help-item">
<code>F01</code> - Search by category code
</div>
<div class="search-help-item">
<code>F01-0001</code> - Full part number
</div>
<div class="search-help-item">
<code>0001</code> - Search by sequence
</div>
<div class="search-help-item">
<code>bearing</code> - Search description
</div>
</div>
</div>
<div class="search-scope-toggle" id="search-scope-toggle">
<button
class="scope-btn active"
data-scope="all"
onclick="setSearchScope('all')"
>
All
</button>
<button
class="scope-btn"
data-scope="part_number"
onclick="setSearchScope('part_number')"
>
PN
</button>
<button
class="scope-btn"
data-scope="description"
onclick="setSearchScope('description')"
>
Desc
</button>
</div>
<select id="project-filter" onchange="loadItems()">
<option value="">All Projects</option>
</select>
<select id="type-filter" onchange="loadItems()">
<option value="">All Types</option>
<option value="part">Parts</option>
<option value="assembly">Assemblies</option>
<option value="document">Documents</option>
<option value="tooling">Tooling</option>
</select>
<button class="btn btn-secondary" onclick="loadItems()">Refresh</button>
</div>
</div>
<!-- Filter Overlay (Ctrl+F) -->
<div class="filter-overlay" id="filter-overlay" style="display: none">
<div class="filter-overlay-bar">
<input
type="text"
class="search-input"
id="filter-input"
placeholder="Filter items..."
oninput="debounceFilter()"
/>
<div class="search-scope-toggle">
<button
class="scope-btn active"
data-scope="all"
onclick="setFilterScope('all')"
>
All
</button>
<button
class="scope-btn"
data-scope="part_number"
onclick="setFilterScope('part_number')"
>
PN
</button>
<button
class="scope-btn"
data-scope="description"
onclick="setFilterScope('description')"
>
Desc
</button>
</div>
<select id="filter-type" onchange="applyFilter()">
<option value="">All Types</option>
<option value="part">Parts</option>
<option value="assembly">Assemblies</option>
<option value="document">Documents</option>
<option value="tooling">Tooling</option>
</select>
<select id="filter-project" onchange="applyFilter()">
<option value="">All Projects</option>
</select>
<button
class="btn btn-secondary filter-close-btn"
onclick="closeFilterOverlay()"
>
Esc
</button>
</div>
</div>
<!-- Split-Panel Workspace -->
<div class="items-workspace" id="items-workspace">
<!-- List Panel -->
<div class="items-list-panel" id="list-panel">
<div class="table-container">
<table>
<thead id="items-thead">
<tr>
<th>Part Number</th>
<th>Type</th>
<th>Description</th>
<th>Revision</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="items-table">
<tr>
<td colspan="6">
<div class="loading">
<div class="spinner"></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="pagination"></div>
</div>
<!-- Detail Panel -->
<div class="items-detail-panel" id="detail-panel">
<div class="detail-empty-state" id="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"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="3" x2="9" y2="21"></line>
</svg>
<p>Select an item to view details</p>
<p class="detail-empty-hint">
Click a row in the item list, or use Ctrl+F to search
</p>
</div>
<div class="detail-content" id="detail-content" style="display: none">
<div class="detail-header">
<h3 class="detail-title" id="detail-title">Item Details</h3>
<div class="detail-header-actions">
<button
class="btn btn-secondary"
style="padding: 0.4rem 0.75rem; font-size: 0.85rem"
onclick="openEditModalFromDetail()"
title="Edit item"
>
Edit
</button>
<button
class="btn btn-secondary"
style="
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
color: var(--ctp-red);
"
onclick="openDeleteModalFromDetail()"
title="Delete item"
>
Delete
</button>
<button
class="detail-close-btn"
onclick="closeDetailPanel()"
title="Close detail panel"
>
&times;
</button>
</div>
</div>
<div class="detail-tabs">
<button
class="tab-btn active"
data-tab="main"
onclick="switchDetailTab('main')"
>
Main
</button>
<button
class="tab-btn"
data-tab="properties"
onclick="switchDetailTab('properties')"
>
Properties
</button>
<button
class="tab-btn"
data-tab="revisions"
onclick="switchDetailTab('revisions')"
>
Revisions
</button>
<button
class="tab-btn"
data-tab="bom"
onclick="switchDetailTab('bom')"
>
BOM
</button>
<button
class="tab-btn"
data-tab="where-used"
onclick="switchDetailTab('where-used')"
>
Where Used
</button>
</div>
<div class="tab-content" id="tab-main"></div>
<div
class="tab-content"
id="tab-properties"
style="display: none"
></div>
<div
class="tab-content"
id="tab-revisions"
style="display: none"
></div>
<div class="tab-content" id="tab-bom" style="display: none"></div>
<div
class="tab-content"
id="tab-where-used"
style="display: none"
></div>
</div>
</div>
</div>
<!-- Create Item Modal -->
<div class="modal-overlay" id="create-modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Create New Item</h3>
<button class="modal-close" onclick="closeCreateModal()">
&times;
</button>
</div>
<form id="create-form" onsubmit="createItem(event)">
<!-- Item Templates -->
<div class="form-group">
<label class="form-label">Quick Start Template</label>
<select
class="form-input"
id="template"
onchange="applyTemplate()"
>
<option value="">Start from scratch...</option>
<option value="machined-part">
Machined Part (X-category)
</option>
<option value="printed-part">
3D Printed Part (X-category)
</option>
<option value="fastener">Fastener (F-category)</option>
<option value="electronics">
Electronics (E-category)
</option>
<option value="assembly">Assembly (A-category)</option>
<option value="purchased">
Purchased/COTS (P-category)
</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Category</label>
<select class="form-input" id="category" required>
<option value="">Select category...</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Description</label>
<input
type="text"
class="form-input"
id="description"
placeholder="Item description"
/>
</div>
<div class="form-group">
<label class="form-label">Sourcing Type</label>
<select class="form-input" id="sourcing-type">
<option value="manufactured">Manufactured</option>
<option value="purchased">Purchased</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Long Description (optional)</label>
<textarea
class="form-input"
id="long-description"
rows="3"
placeholder="Extended description, specifications, notes..."
style="resize: vertical"
></textarea>
</div>
<div class="form-group">
<label class="form-label">Standard Cost (optional)</label>
<input
type="number"
class="form-input"
id="standard-cost"
step="0.01"
placeholder="0.00"
/>
</div>
<div class="form-group">
<label class="form-label">Sourcing Link (optional)</label>
<input
type="url"
class="form-input"
id="sourcing-link"
placeholder="https://..."
/>
</div>
<div class="form-group">
<label class="form-label">Project Tags (optional)</label>
<div class="project-tags-container" id="project-tags-container">
<select
class="form-input"
id="project-select"
onchange="addProjectTag()"
>
<option value="">Add project tag...</option>
</select>
<div class="selected-tags" id="selected-tags"></div>
</div>
</div>
<div class="form-actions">
<button
type="button"
class="btn btn-secondary"
onclick="closeCreateModal()"
>
Cancel
</button>
<button type="submit" class="btn btn-primary">
Create Item
</button>
</div>
</form>
</div>
</div>
<!-- Edit Item Modal -->
<div class="modal-overlay" id="edit-modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Edit Item</h3>
<button class="modal-close" onclick="closeEditModal()">
&times;
</button>
</div>
<form id="edit-form" onsubmit="saveItem(event)">
<input type="hidden" id="edit-original-pn" />
<div class="form-group">
<label class="form-label">Part Number</label>
<input
type="text"
class="form-input"
id="edit-part-number"
required
/>
</div>
<div class="form-group">
<label class="form-label">Type</label>
<select class="form-input" id="edit-type" required>
<option value="part">Part</option>
<option value="assembly">Assembly</option>
<option value="document">Document</option>
<option value="tooling">Tooling</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Description</label>
<input
type="text"
class="form-input"
id="edit-description"
placeholder="Item description"
/>
</div>
<div class="form-group">
<label class="form-label">Sourcing Type</label>
<select class="form-input" id="edit-sourcing-type">
<option value="manufactured">Manufactured</option>
<option value="purchased">Purchased</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Long Description</label>
<textarea
class="form-input"
id="edit-long-description"
rows="3"
placeholder="Extended description, specifications, notes..."
style="resize: vertical"
></textarea>
</div>
<div class="form-group">
<label class="form-label">Standard Cost</label>
<input
type="number"
class="form-input"
id="edit-standard-cost"
step="0.01"
placeholder="0.00"
/>
</div>
<div class="form-group">
<label class="form-label">Sourcing Link</label>
<input
type="url"
class="form-input"
id="edit-sourcing-link"
placeholder="https://..."
/>
</div>
<div class="form-actions">
<button
type="button"
class="btn btn-secondary"
onclick="closeEditModal()"
>
Cancel
</button>
<button type="submit" class="btn btn-primary">
Save Changes
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal-overlay" id="delete-modal">
<div class="modal" style="max-width: 400px">
<div class="modal-header">
<h3 class="modal-title">Delete Item</h3>
<button class="modal-close" onclick="closeDeleteModal()">
&times;
</button>
</div>
<div style="margin-bottom: 1.5rem">
<p>
Are you sure you want to permanently delete
<strong id="delete-part-number"></strong>?
</p>
<p style="color: var(--ctp-red); margin-top: 0.5rem">
This action cannot be undone.
</p>
</div>
<div class="form-actions">
<button
type="button"
class="btn btn-secondary"
onclick="closeDeleteModal()"
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
style="background-color: var(--ctp-red)"
onclick="confirmDelete()"
>
Delete
</button>
</div>
</div>
</div>
<!-- CSV Import Modal -->
<div class="modal-overlay" id="import-modal">
<div class="modal" style="max-width: 600px">
<div class="modal-header">
<h3 class="modal-title">Import Items from CSV</h3>
<button class="modal-close" onclick="closeImportModal()">
&times;
</button>
</div>
<form id="import-form" onsubmit="importCSV(event)">
<div class="import-instructions">
<p>Upload a CSV file to bulk import items. Required columns:</p>
<ul>
<li>
<code>category</code> - Category code (e.g., F01, A01)
</li>
</ul>
<p>
Optional columns: <code>description</code>,
<code>projects</code> (comma-separated project codes),
<code>part_number</code>, and any property columns.
</p>
<a href="/api/items/template.csv" class="template-link"
>Download CSV Template</a
>
</div>
<div class="form-group">
<label class="form-label">CSV File</label>
<div class="file-input-container">
<input
type="file"
class="form-input"
id="import-file"
accept=".csv,text/csv"
required
onchange="updateFileName(this)"
/>
<div class="file-input-label" id="file-label">
Choose a file or drag it here
</div>
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="import-dry-run" checked />
<span>Dry run (validate without creating items)</span>
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="import-skip-existing" checked />
<span
>Skip existing part numbers (don't report as
errors)</span
>
</label>
</div>
<div
id="import-results"
class="import-results"
style="display: none"
></div>
<div class="form-actions">
<button
type="button"
class="btn btn-secondary"
onclick="closeImportModal()"
>
Cancel
</button>
<button type="submit" class="btn btn-primary" id="import-btn">
Validate
</button>
</div>
</form>
</div>
</div>
{{end}} {{define "items_scripts"}}
<style>
/* CSV Import/Export Styles */
.csv-actions {
display: flex;
gap: 0.5rem;
}
.csv-actions .btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
}
.csv-actions .btn svg {
flex-shrink: 0;
}
.import-instructions {
background: var(--ctp-surface0);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
.import-instructions p {
margin: 0 0 0.5rem 0;
color: var(--ctp-subtext1);
}
.import-instructions ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.import-instructions li {
color: var(--ctp-subtext0);
margin: 0.25rem 0;
}
.import-instructions code {
background: var(--ctp-surface1);
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
color: var(--ctp-peach);
font-family: "JetBrains Mono", monospace;
font-size: 0.85rem;
}
.template-link {
display: inline-block;
margin-top: 0.75rem;
color: var(--ctp-blue);
text-decoration: none;
font-weight: 500;
}
.template-link:hover {
text-decoration: underline;
}
.file-input-container {
position: relative;
border: 2px dashed var(--ctp-surface2);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.file-input-container:hover {
border-color: var(--ctp-mauve);
background: var(--ctp-surface0);
}
.file-input-container input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.file-input-label {
color: var(--ctp-subtext0);
font-size: 0.9rem;
}
.file-input-label.has-file {
color: var(--ctp-green);
font-weight: 500;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
color: var(--ctp-subtext1);
}
.checkbox-label input[type="checkbox"] {
width: 1rem;
height: 1rem;
accent-color: var(--ctp-mauve);
}
.import-results {
background: var(--ctp-surface0);
border-radius: 0.5rem;
padding: 1rem;
margin: 1rem 0;
max-height: 300px;
overflow-y: auto;
}
.import-results h4 {
margin: 0 0 0.75rem 0;
color: var(--ctp-text);
font-size: 0.95rem;
}
.import-results.success h4 {
color: var(--ctp-green);
}
.import-results.error h4 {
color: var(--ctp-red);
}
.import-results.warning h4 {
color: var(--ctp-yellow);
}
.import-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin-bottom: 1rem;
}
.import-stat {
background: var(--ctp-base);
padding: 0.75rem;
border-radius: 0.375rem;
text-align: center;
}
.import-stat-value {
font-size: 1.5rem;
font-weight: 600;
color: var(--ctp-text);
}
.import-stat-value.success {
color: var(--ctp-green);
}
.import-stat-value.error {
color: var(--ctp-red);
}
.import-stat-label {
font-size: 0.75rem;
color: var(--ctp-subtext0);
margin-top: 0.25rem;
}
.import-errors {
margin-top: 1rem;
}
.import-error-item {
display: flex;
gap: 0.5rem;
padding: 0.5rem;
background: var(--ctp-base);
border-radius: 0.25rem;
margin-bottom: 0.25rem;
font-size: 0.85rem;
}
.import-error-row {
color: var(--ctp-red);
font-weight: 600;
min-width: 3rem;
}
.import-error-msg {
color: var(--ctp-subtext1);
}
.import-created {
margin-top: 1rem;
}
.import-created-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.import-created-item {
background: var(--ctp-surface1);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-family: "JetBrains Mono", monospace;
font-size: 0.8rem;
color: var(--ctp-green);
}
.part-number-container {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.copy-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
color: var(--ctp-subtext0);
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
}
.copy-btn:hover {
background: var(--ctp-surface1);
color: var(--ctp-text);
}
.copy-btn.copied {
color: var(--ctp-green);
}
.copy-btn svg {
width: 1rem;
height: 1rem;
}
/* Search help dropdown */
.search-input-container {
position: relative;
flex: 1;
}
.search-input-container .search-input {
width: 100%;
}
.search-help {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface2);
border-radius: 0.5rem;
padding: 0.75rem;
margin-top: 0.25rem;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.search-help.visible {
display: block;
}
.search-help-title {
font-weight: 600;
color: var(--ctp-mauve);
margin-bottom: 0.5rem;
font-size: 0.85rem;
}
.search-help-item {
font-size: 0.8rem;
color: var(--ctp-subtext1);
padding: 0.25rem 0;
}
.search-help-item code {
background: var(--ctp-surface1);
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
color: var(--ctp-peach);
font-family: "JetBrains Mono", monospace;
}
/* File info section in detail modal */
.file-info {
background: var(--ctp-surface1);
border-radius: 0.5rem;
padding: 1rem;
margin: 1rem 0;
}
.file-info-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
color: var(--ctp-mauve);
font-weight: 600;
}
.file-info-header svg {
width: 1.25rem;
height: 1.25rem;
}
.file-info-row {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
font-size: 0.9rem;
}
.file-info-label {
color: var(--ctp-subtext0);
}
.file-info-value {
color: var(--ctp-text);
font-family: "JetBrains Mono", monospace;
}
.download-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.5rem 1rem;
background: var(--ctp-surface2);
color: var(--ctp-text);
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s ease;
}
.download-btn:hover {
background: var(--ctp-overlay0);
}
.download-btn svg {
width: 1rem;
height: 1rem;
}
.no-file {
color: var(--ctp-subtext0);
font-style: italic;
font-size: 0.9rem;
}
/* Layout Toggle & Toolbar */
.items-toolbar {
margin-bottom: 1rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.layout-toggle {
display: flex;
background: var(--ctp-surface1);
border-radius: 0.5rem;
padding: 0.25rem;
}
.layout-btn {
background: transparent;
border: none;
color: var(--ctp-subtext0);
padding: 0.4rem;
cursor: pointer;
border-radius: 0.375rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.layout-btn:hover {
color: var(--ctp-text);
}
.layout-btn.active {
background: var(--ctp-mauve);
color: var(--ctp-crust);
}
/* Column Config */
.column-config-container {
position: relative;
}
.column-config-popover {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface2);
border-radius: 0.5rem;
padding: 0.75rem;
min-width: 180px;
z-index: 200;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.column-config-popover label {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0;
font-size: 0.85rem;
color: var(--ctp-text);
cursor: pointer;
}
.column-config-popover input[type="checkbox"] {
accent-color: var(--ctp-mauve);
}
.column-config-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--ctp-subtext0);
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
/* Search Scope Toggle */
.search-scope-toggle {
display: flex;
background: var(--ctp-surface1);
border-radius: 0.5rem;
padding: 0.2rem;
}
.scope-btn {
background: transparent;
border: none;
color: var(--ctp-subtext0);
padding: 0.4rem 0.75rem;
cursor: pointer;
border-radius: 0.375rem;
font-size: 0.8rem;
font-weight: 500;
transition: all 0.2s;
}
.scope-btn:hover {
color: var(--ctp-text);
}
.scope-btn.active {
background: var(--ctp-mauve);
color: var(--ctp-crust);
}
/* Filter Overlay (Ctrl+F) */
.filter-overlay {
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
animation: slideDown 0.15s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-0.5rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.filter-overlay-bar {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.filter-overlay-bar .search-input {
flex: 1;
min-width: 200px;
}
.filter-close-btn {
padding: 0.5rem 0.75rem !important;
font-size: 0.8rem !important;
}
/* Split-Panel Workspace */
.items-workspace {
display: flex;
gap: 1rem;
min-height: calc(100vh - 300px);
}
.items-list-panel {
flex: 0 0 500px;
min-width: 350px;
overflow-y: auto;
background: var(--ctp-surface0);
border-radius: 0.75rem;
padding: 0;
}
.items-list-panel .table-container {
border: none;
border-radius: 0;
}
.items-list-panel .pagination {
padding: 0.75rem;
}
.items-detail-panel {
flex: 1;
min-width: 400px;
overflow-y: auto;
background: var(--ctp-surface0);
border-radius: 0.75rem;
padding: 0;
}
/* Vertical Layout */
.items-workspace.vertical {
flex-direction: column;
}
.items-workspace.vertical .items-list-panel {
flex: 1;
min-width: 0;
overflow-y: auto;
}
.items-workspace.vertical .items-detail-panel {
flex: 0 0 auto;
max-height: 55vh;
min-width: 0;
overflow-y: auto;
order: -1;
}
/* Detail Panel: Empty State */
.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;
}
/* Detail Panel: Content */
.detail-content {
padding: 1.25rem;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.detail-title {
font-size: 1.15rem;
font-weight: 600;
color: var(--ctp-peach);
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.detail-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.detail-close-btn {
background: none;
border: none;
color: var(--ctp-subtext0);
cursor: pointer;
font-size: 1.5rem;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
transition: all 0.2s;
line-height: 1;
}
.detail-close-btn:hover {
color: var(--ctp-text);
background: var(--ctp-surface1);
}
/* Detail Panel: Tabs */
.detail-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--ctp-surface1);
margin-bottom: 1rem;
padding-bottom: 0;
position: sticky;
top: 0;
background: var(--ctp-surface0);
z-index: 10;
}
.tab-btn {
background: transparent;
border: none;
color: var(--ctp-subtext0);
padding: 0.6rem 1rem;
cursor: pointer;
border-radius: 0.5rem 0.5rem 0 0;
transition: all 0.2s;
font-weight: 500;
font-size: 0.85rem;
white-space: nowrap;
}
.tab-btn:hover {
color: var(--ctp-text);
background: var(--ctp-surface1);
}
.tab-btn.active {
color: var(--ctp-mauve);
background: var(--ctp-surface1);
}
.tab-content {
min-height: 150px;
}
/* Selected Row Highlight */
#items-table tr {
cursor: pointer;
transition: background 0.1s;
}
#items-table tr.selected {
background: var(--ctp-surface1) !important;
border-left: 3px solid var(--ctp-mauve);
}
#items-table tr.selected .part-number {
color: var(--ctp-mauve);
}
/* Responsive */
@media (max-width: 900px) {
.items-workspace {
flex-direction: column;
}
.items-list-panel {
flex: 1;
min-width: 0;
}
.items-detail-panel {
flex: 0 0 auto;
max-height: 50vh;
min-width: 0;
order: -1;
}
.layout-toggle {
display: none;
}
}
/* BOM Tab */
.bom-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
gap: 0.5rem;
flex-wrap: wrap;
}
.bom-toolbar-actions {
display: flex;
gap: 0.5rem;
}
.bom-toolbar .btn {
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
}
.bom-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.bom-table th {
padding: 0.5rem 0.75rem;
background: var(--ctp-base);
color: var(--ctp-subtext1);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
text-align: left;
border-bottom: 1px solid var(--ctp-surface1);
}
.bom-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--ctp-surface1);
color: var(--ctp-text);
}
.bom-table tr:hover {
background: var(--ctp-surface1);
}
.bom-table tr:last-child td {
border-bottom: none;
}
.bom-table .pn-link {
cursor: pointer;
color: var(--ctp-peach);
font-family: "JetBrains Mono", "Fira Code", monospace;
font-weight: 500;
}
.bom-table .pn-link:hover {
text-decoration: underline;
}
.bom-cost {
font-family: "JetBrains Mono", "Fira Code", monospace;
text-align: right;
}
.bom-summary {
display: flex;
justify-content: flex-end;
padding: 0.75rem;
font-weight: 600;
border-top: 2px solid var(--ctp-surface2);
font-family: "JetBrains Mono", "Fira Code", monospace;
color: var(--ctp-green);
}
.bom-empty {
text-align: center;
padding: 2rem;
color: var(--ctp-subtext0);
}
.bom-add-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
padding: 1rem;
background: var(--ctp-base);
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.bom-add-form .form-group {
margin-bottom: 0;
}
.bom-add-form .full-width {
grid-column: 1 / -1;
}
.bom-import-area {
border: 2px dashed var(--ctp-surface2);
border-radius: 0.5rem;
padding: 1.5rem;
text-align: center;
margin-bottom: 1rem;
transition: border-color 0.2s;
}
.bom-import-area:hover {
border-color: var(--ctp-mauve);
}
.bom-import-results {
margin-top: 1rem;
padding: 1rem;
border-radius: 0.5rem;
background: var(--ctp-base);
}
.bom-import-results.success {
border-left: 3px solid var(--ctp-green);
}
.bom-import-results.error {
border-left: 3px solid var(--ctp-red);
}
.bom-import-results.warning {
border-left: 3px solid var(--ctp-yellow);
}
.bom-actions {
display: flex;
gap: 0.25rem;
}
.bom-actions .btn {
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
}
/* Properties Editor */
.properties-editor {
padding: 0.5rem 0;
}
.properties-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.properties-header h4 {
color: var(--ctp-subtext1);
font-size: 0.9rem;
font-weight: 600;
}
.properties-mode-toggle {
display: flex;
background: var(--ctp-base);
border-radius: 0.5rem;
padding: 0.2rem;
}
.mode-btn {
background: transparent;
border: none;
color: var(--ctp-subtext0);
padding: 0.35rem 0.75rem;
cursor: pointer;
border-radius: 0.375rem;
font-size: 0.8rem;
font-weight: 500;
transition: all 0.2s;
}
.mode-btn:hover {
color: var(--ctp-text);
}
.mode-btn.active {
background: var(--ctp-mauve);
color: var(--ctp-crust);
}
.props-list {
margin-bottom: 0.75rem;
}
.prop-row {
display: grid;
grid-template-columns: 1fr 100px 2fr 36px;
gap: 0.5rem;
margin-bottom: 0.5rem;
align-items: center;
}
.prop-key,
.prop-value,
.prop-type {
padding: 0.5rem 0.75rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
color: var(--ctp-text);
font-size: 0.9rem;
}
.prop-key:focus,
.prop-value:focus,
.prop-type:focus {
outline: none;
border-color: var(--ctp-mauve);
}
.prop-type {
padding: 0.5rem;
}
.prop-remove {
background: var(--ctp-surface1);
border: none;
color: var(--ctp-red);
width: 32px;
height: 32px;
border-radius: 0.375rem;
cursor: pointer;
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.prop-remove:hover {
background: var(--ctp-red);
color: var(--ctp-crust);
}
.add-prop-btn {
background: var(--ctp-surface1);
border: 1px dashed var(--ctp-surface2);
color: var(--ctp-subtext0);
padding: 0.5rem 1rem;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.85rem;
width: 100%;
transition: all 0.2s;
}
.add-prop-btn:hover {
background: var(--ctp-surface2);
color: var(--ctp-text);
border-color: var(--ctp-overlay0);
}
.json-editor {
width: 100%;
min-height: 250px;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.85rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
color: var(--ctp-text);
padding: 1rem;
resize: vertical;
line-height: 1.5;
}
.json-editor:focus {
outline: none;
border-color: var(--ctp-mauve);
}
.json-validation {
margin-top: 0.5rem;
font-size: 0.8rem;
color: var(--ctp-red);
}
.json-validation.valid {
color: var(--ctp-green);
}
.props-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--ctp-surface1);
}
.props-notice {
font-size: 0.8rem;
color: var(--ctp-subtext0);
font-style: italic;
}
/* Project Tags Styles */
.project-tags-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
min-height: 1.5rem;
}
.project-tag {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: var(--ctp-surface1);
border: 1px solid var(--ctp-surface2);
border-radius: 1rem;
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
font-size: 0.85rem;
color: var(--ctp-text);
}
.project-tag .tag-remove {
background: transparent;
border: none;
color: var(--ctp-subtext0);
cursor: pointer;
padding: 0;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
line-height: 1;
transition: all 0.2s;
}
.project-tag .tag-remove:hover {
background: var(--ctp-red);
color: var(--ctp-crust);
}
.item-projects {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.25rem;
}
.item-project-tag {
display: inline-block;
background: var(--ctp-surface1);
border: 1px solid var(--ctp-mauve);
border-radius: 0.75rem;
padding: 0.15rem 0.5rem;
font-size: 0.75rem;
color: var(--ctp-mauve);
font-weight: 500;
}
/* Revision Status Badges */
.status-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-draft {
background: var(--ctp-surface1);
color: var(--ctp-subtext0);
}
.status-review {
background: var(--ctp-yellow);
color: var(--ctp-crust);
}
.status-released {
background: var(--ctp-green);
color: var(--ctp-crust);
}
.status-obsolete {
background: var(--ctp-red);
color: var(--ctp-crust);
}
/* Revision Actions */
.revision-actions {
display: flex;
gap: 0.25rem;
}
.revision-actions .btn {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
/* Revision Compare UI */
.compare-controls {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--ctp-surface0);
border-radius: 0.5rem;
}
.compare-controls select {
padding: 0.4rem 0.75rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface2);
border-radius: 0.375rem;
color: var(--ctp-text);
font-size: 0.85rem;
}
.compare-result {
margin-top: 1rem;
padding: 1rem;
background: var(--ctp-surface0);
border-radius: 0.5rem;
}
.compare-result h4 {
margin: 0 0 0.75rem 0;
color: var(--ctp-subtext1);
font-size: 0.9rem;
}
.diff-section {
margin-bottom: 1rem;
}
.diff-section h5 {
margin: 0 0 0.5rem 0;
font-size: 0.85rem;
}
.diff-added {
color: var(--ctp-green);
}
.diff-removed {
color: var(--ctp-red);
}
.diff-changed {
color: var(--ctp-yellow);
}
.diff-item {
font-family: "JetBrains Mono", monospace;
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
background: var(--ctp-base);
border-radius: 0.25rem;
margin-bottom: 0.25rem;
}
/* Status Select Dropdown */
.status-select {
padding: 0.2rem 0.4rem;
font-size: 0.75rem;
background: var(--ctp-surface1);
border: 1px solid var(--ctp-surface2);
border-radius: 0.375rem;
color: var(--ctp-text);
cursor: pointer;
}
/* Sourcing Type Badges */
.sourcing-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.sourcing-manufactured {
background: var(--ctp-blue);
color: var(--ctp-crust);
}
.sourcing-purchased {
background: var(--ctp-peach);
color: var(--ctp-crust);
}
/* Long Description Block */
.long-desc-block {
background: var(--ctp-surface1);
border-radius: 0.375rem;
padding: 0.75rem;
margin: 0.25rem 0 0.75rem 0;
font-size: 0.9rem;
color: var(--ctp-subtext1);
white-space: pre-wrap;
line-height: 1.5;
}
/* Inline Project Tag Editor */
.project-tags-editor {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
vertical-align: middle;
}
.project-tags-editor .item-project-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.project-tags-editor .tag-remove-inline {
background: transparent;
border: none;
color: var(--ctp-overlay0);
cursor: pointer;
padding: 0;
font-size: 0.85rem;
line-height: 1;
border-radius: 50%;
width: 1rem;
height: 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.project-tags-editor .tag-remove-inline:hover {
background: var(--ctp-red);
color: var(--ctp-crust);
}
.project-add-inline {
display: inline-flex;
align-items: center;
}
.project-add-inline select {
padding: 0.15rem 0.4rem;
font-size: 0.75rem;
background: var(--ctp-surface1);
border: 1px solid var(--ctp-surface2);
border-radius: 0.375rem;
color: var(--ctp-subtext0);
cursor: pointer;
}
</style>
<script>
let currentPage = 1;
let pageSize = 20;
let searchTimeout = null;
let schema = null;
let itemToDelete = null;
let projectCodes = [];
let currentItems = [];
let searchScope = "all";
let filterTimeout = null;
// Column definitions
const ALL_COLUMNS = [
{ key: "part_number", label: "Part Number" },
{ key: "item_type", label: "Type" },
{ key: "description", label: "Description" },
{ key: "revision", label: "Revision" },
{ key: "projects", label: "Projects" },
{ key: "created", label: "Created" },
{ key: "actions", label: "Actions" },
];
const DEFAULT_COLUMNS_H = [
"part_number",
"item_type",
"description",
"revision",
];
const DEFAULT_COLUMNS_V = [
"part_number",
"item_type",
"description",
"revision",
"created",
"actions",
];
// SVG Icons (Catppuccin-style)
const icons = {
clipboard: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="2" width="6" height="4" rx="1" ry="1"></rect>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
</svg>`,
check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>`,
file: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>`,
download: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>`,
};
// Item templates for quick creation
const itemTemplates = {
"machined-part": { category: "X01", descPrefix: "MACHINED " },
"printed-part": { category: "X03", descPrefix: "3DP " },
fastener: { category: "F01", descPrefix: "" },
electronics: { category: "E01", descPrefix: "" },
assembly: { category: "A01", descPrefix: "" },
purchased: { category: "P01", descPrefix: "COTS " },
};
// Selected project tags for new item creation
let selectedProjectTags = [];
// Load schema for create form
async function loadSchema() {
try {
const response = await fetch("/api/schemas/kindred-rd");
schema = await response.json();
// Populate category dropdown
const categorySelect = document.getElementById("category");
const categories = schema.segments.find(
(s) => s.name === "category",
);
if (categories && categories.values) {
const sorted = Object.entries(categories.values).sort((a, b) =>
a[0].localeCompare(b[0]),
);
sorted.forEach(([code, desc]) => {
const option = document.createElement("option");
option.value = code;
option.textContent = `${code} - ${desc}`;
categorySelect.appendChild(option);
});
}
} catch (error) {
console.error("Failed to load schema:", error);
}
}
// Load project codes from existing items
async function loadProjectCodes() {
try {
const response = await fetch("/api/projects");
const projects = await response.json();
// Extract codes from project objects
projectCodes = projects.map((p) => p.code || p);
// Populate project filter dropdown
const projectFilter = document.getElementById("project-filter");
projectCodes.forEach((code) => {
const option = document.createElement("option");
option.value = code;
option.textContent = code;
projectFilter.appendChild(option);
});
// Populate create form project tag dropdown
const projectSelect = document.getElementById("project-select");
if (projectSelect) {
projectCodes.forEach((code) => {
const option = document.createElement("option");
option.value = code;
option.textContent = code;
projectSelect.appendChild(option);
});
}
} catch (error) {
console.error("Failed to load project codes:", error);
}
}
// Project tag management for create form
function addProjectTag() {
const select = document.getElementById("project-select");
const code = select.value;
if (!code || selectedProjectTags.includes(code)) {
select.value = "";
return;
}
selectedProjectTags.push(code);
renderSelectedTags();
select.value = "";
}
function removeProjectTag(code) {
selectedProjectTags = selectedProjectTags.filter((c) => c !== code);
renderSelectedTags();
}
function renderSelectedTags() {
const container = document.getElementById("selected-tags");
if (!container) return;
container.innerHTML = selectedProjectTags
.map(
(code) => `
<span class="project-tag">
${code}
<button type="button" class="tag-remove" onclick="removeProjectTag('${code}')">&times;</button>
</span>
`,
)
.join("");
}
// Apply template to create form
function applyTemplate() {
const templateId = document.getElementById("template").value;
if (!templateId) return;
const template = itemTemplates[templateId];
if (!template) return;
// Set category
const categorySelect = document.getElementById("category");
categorySelect.value = template.category;
// Set description prefix
const descInput = document.getElementById("description");
if (template.descPrefix && !descInput.value) {
descInput.value = template.descPrefix;
descInput.focus();
}
}
// Search help functions
function showSearchHelp() {
setTimeout(() => {
document.getElementById("search-help").classList.add("visible");
}, 200);
}
function hideSearchHelp() {
setTimeout(() => {
document.getElementById("search-help").classList.remove("visible");
}, 300);
}
// Load items from API
// Column config helpers
function getVisibleColumns() {
const layout =
localStorage.getItem("silo-items-layout") || "horizontal";
const key =
layout === "vertical"
? "silo-items-columns-v"
: "silo-items-columns-h";
const saved = localStorage.getItem(key);
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {}
}
return layout === "vertical" ? DEFAULT_COLUMNS_V : DEFAULT_COLUMNS_H;
}
function saveVisibleColumns(cols) {
const layout =
localStorage.getItem("silo-items-layout") || "horizontal";
const key =
layout === "vertical"
? "silo-items-columns-v"
: "silo-items-columns-h";
localStorage.setItem(key, JSON.stringify(cols));
}
function renderTableHeader() {
const cols = getVisibleColumns();
const thead = document.getElementById("items-thead");
thead.innerHTML =
"<tr>" +
cols
.map((key) => {
const col = ALL_COLUMNS.find((c) => c.key === key);
return col ? `<th>${col.label}</th>` : "";
})
.join("") +
"</tr>";
}
function renderTableRow(item) {
const cols = getVisibleColumns();
const pn = escapeAttr(item.part_number);
const isSelected = currentItemPartNumber === item.part_number;
const cellRenderers = {
part_number: () =>
`<td><span class="part-number-container"><span class="part-number">${item.part_number}</span><button class="copy-btn" onclick="event.stopPropagation(); copyPartNumber('${pn}', this)" title="Copy">${icons.clipboard}</button></span></td>`,
item_type: () =>
`<td><span class="item-type item-type-${item.item_type}">${item.item_type}</span></td>`,
description: () => `<td>${item.description || "-"}</td>`,
revision: () => `<td>Rev ${item.current_revision}</td>`,
projects: () =>
`<td>${(item.projects || []).map((p) => `<span class="item-project-tag">${p}</span>`).join(" ") || "-"}</td>`,
created: () => `<td>${formatDate(item.created_at)}</td>`,
actions: () =>
`<td><button class="btn btn-secondary" style="padding:0.3rem 0.6rem;font-size:0.8rem;" onclick="event.stopPropagation(); openEditModal('${pn}')">Edit</button> <button class="btn btn-secondary" style="padding:0.3rem 0.6rem;font-size:0.8rem;background:var(--ctp-surface2);" onclick="event.stopPropagation(); openDeleteModal('${pn}')">Del</button></td>`,
};
const cells = cols
.map((key) => (cellRenderers[key] || (() => "<td></td>"))())
.join("");
return `<tr class="${isSelected ? "selected" : ""}" onclick="selectItem('${pn}')">${cells}</tr>`;
}
function selectItem(partNumber) {
// Highlight selected row
document
.querySelectorAll("#items-table tr")
.forEach((tr) => tr.classList.remove("selected"));
const rows = document.querySelectorAll("#items-table tr");
rows.forEach((tr) => {
const pnEl = tr.querySelector(".part-number");
if (pnEl && pnEl.textContent === partNumber)
tr.classList.add("selected");
});
showItemDetail(partNumber);
}
// Column config popover
function toggleColumnConfig() {
const popover = document.getElementById("column-config-popover");
if (popover.style.display === "none") {
const visible = getVisibleColumns();
popover.innerHTML =
'<div class="column-config-title">Visible Columns</div>' +
ALL_COLUMNS.map(
(col) =>
`<label><input type="checkbox" value="${col.key}" ${visible.includes(col.key) ? "checked" : ""} onchange="updateColumnConfig()"> ${col.label}</label>`,
).join("");
popover.style.display = "block";
} else {
popover.style.display = "none";
}
}
function updateColumnConfig() {
const checks = document.querySelectorAll(
'#column-config-popover input[type="checkbox"]',
);
const cols = [];
checks.forEach((cb) => {
if (cb.checked) cols.push(cb.value);
});
if (cols.length === 0) cols.push("part_number");
saveVisibleColumns(cols);
renderTableHeader();
renderItemsList();
}
// Search scope
function setSearchScope(scope) {
searchScope = scope;
document
.querySelectorAll("#search-scope-toggle .scope-btn")
.forEach((btn) => {
btn.classList.toggle("active", btn.dataset.scope === scope);
});
if (document.getElementById("search-input").value) loadItems();
}
// Filter overlay (Ctrl+F)
function showFilterOverlay() {
const overlay = document.getElementById("filter-overlay");
overlay.style.display = "block";
const input = document.getElementById("filter-input");
input.focus();
// Populate filter-project dropdown
const fp = document.getElementById("filter-project");
if (fp.options.length <= 1) {
projectCodes.forEach((code) => {
const opt = document.createElement("option");
opt.value = code;
opt.textContent = code;
fp.appendChild(opt);
});
}
}
function closeFilterOverlay() {
document.getElementById("filter-overlay").style.display = "none";
document.getElementById("filter-input").value = "";
}
function debounceFilter() {
clearTimeout(filterTimeout);
filterTimeout = setTimeout(applyFilter, 300);
}
function setFilterScope(scope) {
const overlay = document.getElementById("filter-overlay");
overlay.querySelectorAll(".scope-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.scope === scope);
});
applyFilter();
}
function applyFilter() {
// Sync filter overlay values to main search bar and reload
const query = document.getElementById("filter-input").value;
const type = document.getElementById("filter-type").value;
const project = document.getElementById("filter-project").value;
document.getElementById("search-input").value = query;
document.getElementById("type-filter").value = type;
document.getElementById("project-filter").value = project;
// Get scope from filter overlay
const activeScope = document.querySelector(
"#filter-overlay .scope-btn.active",
);
if (activeScope) setSearchScope(activeScope.dataset.scope);
currentPage = 1;
loadItems();
}
// Ctrl+F keyboard shortcut
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "f") {
e.preventDefault();
showFilterOverlay();
}
if (e.key === "Escape") {
const overlay = document.getElementById("filter-overlay");
if (overlay.style.display !== "none") {
closeFilterOverlay();
}
}
});
function renderItemsList() {
const tbody = document.getElementById("items-table");
const cols = getVisibleColumns();
if (!currentItems || currentItems.length === 0) {
tbody.innerHTML = `<tr><td colspan="${cols.length}"><div class="empty-state"><h3>No items found</h3><p>Create your first item or adjust your search filters.</p></div></td></tr>`;
return;
}
tbody.innerHTML = currentItems
.map((item) => renderTableRow(item))
.join("");
}
async function loadItems() {
const search = document.getElementById("search-input").value.trim();
const type = document.getElementById("type-filter").value;
const project = document.getElementById("project-filter").value;
const tbody = document.getElementById("items-table");
const cols = getVisibleColumns();
tbody.innerHTML = `<tr><td colspan="${cols.length}"><div class="loading"><div class="spinner"></div></div></td></tr>`;
try {
let url;
if (search) {
// Use fuzzy search endpoint
const params = new URLSearchParams();
params.set("q", search);
if (type) params.set("type", type);
if (project) params.set("project", project);
params.set("limit", "50");
// Map scope to fields param
if (searchScope !== "all") {
params.set("fields", searchScope);
}
url = `/api/items/search?${params}`;
} else {
// Use standard list endpoint with pagination
const params = new URLSearchParams();
if (type) params.set("type", type);
if (project) params.set("project", project);
params.set("limit", pageSize);
params.set("offset", (currentPage - 1) * pageSize);
url = `/api/items?${params}`;
}
const response = await fetch(url);
const items = await response.json();
currentItems = items || [];
renderTableHeader();
renderItemsList();
updateStats(currentItems);
} catch (error) {
console.error("Failed to load items:", error);
tbody.innerHTML = `<tr><td colspan="${cols.length}"><div class="empty-state"><h3>Error loading items</h3><p>${error.message}</p></div></td></tr>`;
}
}
function updateStats(items) {
const total = items.length;
const parts = items.filter((i) => i.item_type === "part").length;
const assemblies = items.filter(
(i) => i.item_type === "assembly",
).length;
const documents = items.filter(
(i) => i.item_type === "document",
).length;
document.getElementById("total-count").textContent = total;
document.getElementById("parts-count").textContent = parts;
document.getElementById("assemblies-count").textContent = assemblies;
document.getElementById("documents-count").textContent = documents;
}
function formatDate(dateStr) {
if (!dateStr) return "-";
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function debounceSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentPage = 1;
loadItems();
}, 300);
}
// Create Modal functions
function openCreateModal() {
document.getElementById("create-modal").classList.add("active");
selectedProjectTags = [];
renderSelectedTags();
}
function closeCreateModal() {
document.getElementById("create-modal").classList.remove("active");
document.getElementById("create-form").reset();
selectedProjectTags = [];
renderSelectedTags();
}
async function createItem(event) {
event.preventDefault();
const data = {
schema: "kindred-rd",
category: document.getElementById("category").value,
description: document.getElementById("description").value,
sourcing_type: document.getElementById("sourcing-type").value,
};
const longDesc = document
.getElementById("long-description")
.value.trim();
if (longDesc) data.long_description = longDesc;
const stdCost = parseFloat(
document.getElementById("standard-cost").value,
);
if (!isNaN(stdCost)) data.standard_cost = stdCost;
const sourcingLink = document
.getElementById("sourcing-link")
.value.trim();
if (sourcingLink) data.sourcing_link = sourcingLink;
// Add project tags if any selected
if (selectedProjectTags.length > 0) {
data.projects = selectedProjectTags;
}
try {
const response = await fetch("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
return;
}
const item = await response.json();
let msg = `Created: ${item.part_number}`;
if (selectedProjectTags.length > 0) {
msg += `\nTagged with: ${selectedProjectTags.join(", ")}`;
}
alert(msg);
closeCreateModal();
loadItems();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Edit Modal functions
async function openEditModal(partNumber) {
document.getElementById("edit-modal").classList.add("active");
try {
const response = await fetch(`/api/items/${partNumber}`);
const item = await response.json();
document.getElementById("edit-original-pn").value = partNumber;
document.getElementById("edit-part-number").value =
item.part_number;
document.getElementById("edit-type").value = item.item_type;
document.getElementById("edit-description").value =
item.description || "";
document.getElementById("edit-sourcing-type").value =
item.sourcing_type || "manufactured";
document.getElementById("edit-long-description").value =
item.long_description || "";
document.getElementById("edit-standard-cost").value =
item.standard_cost != null ? item.standard_cost : "";
document.getElementById("edit-sourcing-link").value =
item.sourcing_link || "";
} catch (error) {
alert(`Error loading item: ${error.message}`);
closeEditModal();
}
}
function closeEditModal() {
document.getElementById("edit-modal").classList.remove("active");
document.getElementById("edit-form").reset();
}
async function saveItem(event) {
event.preventDefault();
const originalPN = document.getElementById("edit-original-pn").value;
const stdCostVal = parseFloat(
document.getElementById("edit-standard-cost").value,
);
const data = {
part_number: document.getElementById("edit-part-number").value,
item_type: document.getElementById("edit-type").value,
description: document.getElementById("edit-description").value,
sourcing_type: document.getElementById("edit-sourcing-type").value,
long_description:
document.getElementById("edit-long-description").value || null,
standard_cost: !isNaN(stdCostVal) ? stdCostVal : null,
sourcing_link:
document.getElementById("edit-sourcing-link").value || null,
};
try {
const response = await fetch(`/api/items/${originalPN}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
return;
}
closeEditModal();
loadItems();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Delete Modal functions
function openDeleteModal(partNumber) {
itemToDelete = partNumber;
document.getElementById("delete-part-number").textContent = partNumber;
document.getElementById("delete-modal").classList.add("active");
}
function closeDeleteModal() {
document.getElementById("delete-modal").classList.remove("active");
itemToDelete = null;
}
async function confirmDelete() {
if (!itemToDelete) return;
try {
const response = await fetch(`/api/items/${itemToDelete}`, {
method: "DELETE",
});
if (!response.ok && response.status !== 204) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
return;
}
closeDeleteModal();
loadItems();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Detail Modal functions
let currentDetailItem = null;
let currentDetailRevisions = null;
let currentItemPartNumber = null;
let propsMode = "form"; // 'form' or 'json'
function closeDetailPanel() {
document.getElementById("detail-content").style.display = "none";
document.getElementById("detail-empty").style.display = "flex";
document
.querySelectorAll("#items-table tr")
.forEach((tr) => tr.classList.remove("selected"));
currentDetailItem = null;
currentDetailRevisions = null;
currentItemPartNumber = null;
}
// Legacy alias for any remaining references
function closeDetailModal() {
closeDetailPanel();
}
function openEditModalFromDetail() {
if (currentItemPartNumber) openEditModal(currentItemPartNumber);
}
function openDeleteModalFromDetail() {
if (currentItemPartNumber) openDeleteModal(currentItemPartNumber);
}
function switchDetailTab(tab) {
const panel = document.getElementById("detail-content");
panel.querySelectorAll(".detail-tabs .tab-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === tab);
});
panel.querySelectorAll(".tab-content").forEach((content) => {
content.style.display = "none";
});
document.getElementById(`tab-${tab}`).style.display = "block";
// Lazy-load BOM and Where Used data
if (tab === "bom" && currentItemPartNumber) {
loadBOMTab(currentItemPartNumber);
}
if (tab === "where-used" && currentItemPartNumber) {
loadWhereUsedTab(currentItemPartNumber);
}
}
// Format file size
function formatFileSize(bytes) {
if (!bytes) return "-";
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
async function showItemDetail(partNumber) {
// Show detail panel
document.getElementById("detail-empty").style.display = "none";
document.getElementById("detail-content").style.display = "block";
document.getElementById("detail-title").textContent = partNumber;
currentItemPartNumber = partNumber;
// Reset to main tab
switchDetailTab("main");
document.getElementById("tab-main").innerHTML =
'<div class="loading"><div class="spinner"></div></div>';
document.getElementById("tab-properties").innerHTML = "";
document.getElementById("tab-revisions").innerHTML = "";
document.getElementById("tab-bom").innerHTML = "";
document.getElementById("tab-where-used").innerHTML = "";
try {
const [itemRes, revsRes] = await Promise.all([
fetch(`/api/items/${partNumber}?include=properties`),
fetch(`/api/items/${partNumber}/revisions`),
]);
if (!itemRes.ok) {
const error = await itemRes.json();
throw new Error(
error.message || error.error || "Failed to load item",
);
}
if (!revsRes.ok) {
const error = await revsRes.json();
throw new Error(
error.message || error.error || "Failed to load revisions",
);
}
const item = await itemRes.json();
const revisions = await revsRes.json();
// Ensure revisions is an array
if (!Array.isArray(revisions)) {
throw new Error("Invalid revisions response");
}
currentDetailItem = item;
currentDetailRevisions = revisions;
// Find the latest revision with file info
const latestRevWithFile = revisions.find((rev) => rev.file_key);
// Build file info section
let fileInfoHtml = "";
if (latestRevWithFile) {
fileInfoHtml = `
<div class="file-info">
<div class="file-info-header">
${icons.file}
<span>File Attachment (Rev ${latestRevWithFile.revision_number})</span>
</div>
<div class="file-info-row">
<span class="file-info-label">Size</span>
<span class="file-info-value">${formatFileSize(latestRevWithFile.file_size)}</span>
</div>
${
latestRevWithFile.file_checksum
? `
<div class="file-info-row">
<span class="file-info-label">Checksum</span>
<span class="file-info-value" title="${latestRevWithFile.file_checksum}">${latestRevWithFile.file_checksum.substring(0, 16)}...</span>
</div>
`
: ""
}
<button class="download-btn" onclick="downloadFile('${partNumber}', ${latestRevWithFile.revision_number})">
${icons.download}
<span>Download Latest</span>
</button>
</div>
`;
} else {
fileInfoHtml = `
<div class="file-info">
<div class="file-info-header">
${icons.file}
<span>File Attachment</span>
</div>
<p class="no-file">No file attached to any revision</p>
</div>
`;
}
// Fetch project tags for this item
let itemProjectsList = [];
try {
const projectsRes = await fetch(
`/api/items/${partNumber}/projects`,
);
if (projectsRes.ok) {
itemProjectsList = await projectsRes.json();
}
} catch (e) {
console.warn("Failed to load project tags:", e);
}
const projectTagsHtml = buildProjectTagsEditor(
partNumber,
itemProjectsList,
);
// Build sourcing info
const sourcingType = item.sourcing_type || "manufactured";
const sourcingLink = item.sourcing_link || "";
const longDesc = item.long_description || "";
const stdCost =
item.standard_cost != null
? `$${Number(item.standard_cost).toFixed(2)}`
: "-";
// Info tab
document.getElementById("tab-main").innerHTML = `
<div style="margin-bottom: 1.5rem;">
<p><strong>ID:</strong> <code style="background: var(--ctp-surface1); padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.85rem;">${item.id}</code></p>
<p><strong>Part Number:</strong> <span class="part-number-container"><span class="part-number">${item.part_number}</span><button class="copy-btn" onclick="copyPartNumber('${item.part_number}', this)" title="Copy part number">${icons.clipboard}</button></span></p>
<p><strong>Type:</strong> <span class="item-type item-type-${item.item_type}">${item.item_type}</span></p>
<p><strong>Sourcing:</strong> <span class="sourcing-badge sourcing-${sourcingType}">${sourcingType}</span></p>
<p><strong>Description:</strong> ${item.description || "-"}</p>
${longDesc ? `<p><strong>Long Description:</strong></p><div class="long-desc-block">${escapeHtml(longDesc)}</div>` : ""}
<p><strong>Standard Cost:</strong> <span style="font-family: 'JetBrains Mono', monospace;">${stdCost}</span></p>
${sourcingLink ? `<p><strong>Sourcing Link:</strong> <a href="${escapeAttr(sourcingLink)}" target="_blank" rel="noopener" style="color: var(--ctp-blue);">${escapeHtml(sourcingLink.length > 60 ? sourcingLink.substring(0, 60) + "..." : sourcingLink)}</a></p>` : ""}
<div style="margin: 0.75rem 0;"><strong>Projects:</strong> ${projectTagsHtml}</div>
<p><strong>Current Revision:</strong> ${item.current_revision}</p>
<p><strong>Created:</strong> ${formatDate(item.created_at)}</p>
<p><strong>Updated:</strong> ${formatDate(item.updated_at)}</p>
</div>
${fileInfoHtml}
`;
// Properties tab
renderPropertiesTab(item.properties || {}, item.current_revision);
// Revisions tab with compare and rollback
const revisionOptions = revisions
.map(
(r) =>
`<option value="${r.revision_number}">Rev ${r.revision_number}</option>`,
)
.join("");
document.getElementById("tab-revisions").innerHTML = `
<div class="compare-controls">
<span>Compare:</span>
<select id="compare-from">${revisionOptions}</select>
<span>to</span>
<select id="compare-to">${revisionOptions}</select>
<button class="btn btn-secondary" onclick="compareRevisions('${partNumber}')">Compare</button>
</div>
<div id="compare-result"></div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Rev</th>
<th>Status</th>
<th>Date</th>
<th>File</th>
<th>Comment</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${revisions
.map(
(rev, idx) => `
<tr>
<td>${rev.revision_number}</td>
<td>
<select class="status-select" onchange="updateRevisionStatus('${partNumber}', ${rev.revision_number}, this.value)">
<option value="draft" ${rev.status === "draft" ? "selected" : ""}>Draft</option>
<option value="review" ${rev.status === "review" ? "selected" : ""}>Review</option>
<option value="released" ${rev.status === "released" ? "selected" : ""}>Released</option>
<option value="obsolete" ${rev.status === "obsolete" ? "selected" : ""}>Obsolete</option>
</select>
</td>
<td>${formatDate(rev.created_at)}</td>
<td>${rev.file_key ? `<button class="copy-btn" onclick="downloadFile('${partNumber}', ${rev.revision_number})" title="Download">${icons.download}</button>` : "-"}</td>
<td>${rev.comment || "-"}</td>
<td class="revision-actions">
${idx > 0 ? `<button class="btn btn-secondary" onclick="rollbackToRevision('${partNumber}', ${rev.revision_number})" title="Rollback to this revision">Rollback</button>` : '<span style="color: var(--ctp-subtext0); font-size: 0.75rem;">Current</span>'}
</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
`;
// Set default compare selection (latest vs previous)
if (revisions.length >= 2) {
document.getElementById("compare-from").value =
revisions[1].revision_number;
document.getElementById("compare-to").value =
revisions[0].revision_number;
}
} catch (error) {
document.getElementById("tab-main").innerHTML =
`<p>Error loading item: ${error.message}</p>`;
}
}
// Properties Editor Functions
function renderPropertiesTab(properties, revNum) {
const html = `
<div class="properties-editor">
<div class="properties-header">
<h4>Properties (Rev ${revNum})</h4>
<div class="properties-mode-toggle">
<button class="mode-btn ${propsMode === "form" ? "active" : ""}" data-mode="form" onclick="switchPropsMode('form')">Form</button>
<button class="mode-btn ${propsMode === "json" ? "active" : ""}" data-mode="json" onclick="switchPropsMode('json')">JSON</button>
</div>
</div>
<div class="props-form-mode" id="props-form-mode" style="${propsMode === "json" ? "display:none" : ""}">
<div class="props-list" id="props-list"></div>
<button class="add-prop-btn" onclick="addPropertyRow()">+ Add Property</button>
</div>
<div class="props-json-mode" id="props-json-mode" style="${propsMode === "form" ? "display:none" : ""}">
<textarea class="json-editor" id="json-editor">${JSON.stringify(properties, null, 2)}</textarea>
<div class="json-validation" id="json-validation"></div>
</div>
<div class="props-actions">
<span class="props-notice">Saving creates a new revision</span>
<button class="btn btn-primary" onclick="saveProperties()">Save Properties</button>
</div>
</div>
`;
document.getElementById("tab-properties").innerHTML = html;
// Populate form mode
const container = document.getElementById("props-list");
Object.entries(properties).forEach(([key, value]) => {
addPropertyRow(key, value);
});
}
function switchPropsMode(mode) {
propsMode = mode;
document
.querySelectorAll(".properties-mode-toggle .mode-btn")
.forEach((btn) => {
btn.classList.toggle("active", btn.dataset.mode === mode);
});
const formMode = document.getElementById("props-form-mode");
const jsonMode = document.getElementById("props-json-mode");
if (mode === "form") {
formMode.style.display = "block";
jsonMode.style.display = "none";
syncJsonToForm();
} else {
formMode.style.display = "none";
jsonMode.style.display = "block";
syncFormToJson();
}
}
function detectType(value) {
if (typeof value === "boolean") return "boolean";
if (typeof value === "number") return "number";
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value))
return "date";
return "string";
}
function escapeHtml(str) {
if (str === null || str === undefined) return "";
const div = document.createElement("div");
div.textContent = String(str);
return div.innerHTML;
}
function addPropertyRow(key = "", value = "") {
const container = document.getElementById("props-list");
if (!container) return;
const type = detectType(value);
const row = document.createElement("div");
row.className = "prop-row";
let valueInput;
if (type === "boolean") {
valueInput = `<select class="prop-value">
<option value="true" ${value === true ? "selected" : ""}>true</option>
<option value="false" ${value === false ? "selected" : ""}>false</option>
</select>`;
} else if (type === "number") {
valueInput = `<input type="number" class="prop-value" step="any" value="${value || ""}" />`;
} else if (type === "date") {
const dateVal = value ? value.split("T")[0] : "";
valueInput = `<input type="date" class="prop-value" value="${dateVal}" />`;
} else {
valueInput = `<input type="text" class="prop-value" value="${escapeHtml(value)}" />`;
}
row.innerHTML = `
<input type="text" class="prop-key" placeholder="Key" value="${escapeHtml(key)}" />
<select class="prop-type" onchange="updatePropInput(this)">
<option value="string" ${type === "string" ? "selected" : ""}>Text</option>
<option value="number" ${type === "number" ? "selected" : ""}>Number</option>
<option value="boolean" ${type === "boolean" ? "selected" : ""}>Boolean</option>
<option value="date" ${type === "date" ? "selected" : ""}>Date</option>
</select>
${valueInput}
<button class="prop-remove" onclick="this.closest('.prop-row').remove()">&times;</button>
`;
container.appendChild(row);
}
function updatePropInput(select) {
const row = select.closest(".prop-row");
const type = select.value;
const oldInput = row.querySelector(".prop-value");
const oldValue = oldInput.value;
let newInput;
if (type === "boolean") {
newInput = document.createElement("select");
newInput.className = "prop-value";
newInput.innerHTML = `<option value="true">true</option><option value="false">false</option>`;
} else if (type === "number") {
newInput = document.createElement("input");
newInput.type = "number";
newInput.step = "any";
newInput.className = "prop-value";
newInput.value = parseFloat(oldValue) || "";
} else if (type === "date") {
newInput = document.createElement("input");
newInput.type = "date";
newInput.className = "prop-value";
} else {
newInput = document.createElement("input");
newInput.type = "text";
newInput.className = "prop-value";
newInput.value = oldValue;
}
oldInput.replaceWith(newInput);
}
function collectFormProperties() {
const props = {};
document.querySelectorAll(".prop-row").forEach((row) => {
const key = row.querySelector(".prop-key").value.trim();
const type = row.querySelector(".prop-type").value;
const valueEl = row.querySelector(".prop-value");
let value = valueEl.value;
if (key) {
switch (type) {
case "number":
props[key] = parseFloat(value) || 0;
break;
case "boolean":
props[key] = value === "true";
break;
default:
props[key] = value;
}
}
});
return props;
}
function syncFormToJson() {
const props = collectFormProperties();
const editor = document.getElementById("json-editor");
if (editor) {
editor.value = JSON.stringify(props, null, 2);
}
}
function syncJsonToForm() {
const editor = document.getElementById("json-editor");
const validation = document.getElementById("json-validation");
if (!editor) return;
try {
const props = JSON.parse(editor.value);
const container = document.getElementById("props-list");
if (container) {
container.innerHTML = "";
Object.entries(props).forEach(([key, value]) => {
addPropertyRow(key, value);
});
}
if (validation) {
validation.textContent = "";
validation.classList.remove("valid");
}
} catch (e) {
if (validation) {
validation.textContent = "Invalid JSON: " + e.message;
validation.classList.remove("valid");
}
}
}
async function saveProperties() {
const isJsonMode =
document.getElementById("props-json-mode").style.display !== "none";
let properties;
if (isJsonMode) {
try {
properties = JSON.parse(
document.getElementById("json-editor").value,
);
} catch (e) {
alert("Invalid JSON: " + e.message);
return;
}
} else {
properties = collectFormProperties();
}
const comment = prompt("Revision comment (optional):");
try {
const response = await fetch(
`/api/items/${currentItemPartNumber}/revisions`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
properties,
comment: comment || "",
}),
},
);
if (!response.ok) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
return;
}
alert("Properties saved as new revision");
showItemDetail(currentItemPartNumber);
loadItems();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Layout Management
const LAYOUT_KEY = "silo-items-layout";
function initLayout() {
const saved = localStorage.getItem(LAYOUT_KEY) || "horizontal";
setLayout(saved, false);
}
function setLayout(mode, save = true) {
document.querySelectorAll(".layout-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.layout === mode);
});
const workspace = document.getElementById("items-workspace");
if (mode === "vertical") {
workspace.classList.add("vertical");
} else {
workspace.classList.remove("vertical");
}
if (save) {
localStorage.setItem(LAYOUT_KEY, mode);
}
// Re-render table with layout-appropriate columns
renderTableHeader();
renderItemsList();
}
// Download file for a specific revision
function downloadFile(partNumber, revision) {
window.location.href = `/api/items/${partNumber}/file/${revision}`;
}
// Close modals on overlay click
document.querySelectorAll(".modal-overlay").forEach((overlay) => {
overlay.addEventListener("click", (e) => {
if (e.target === overlay) {
overlay.classList.remove("active");
}
});
});
// Close column config popover on outside click
document.addEventListener("click", (e) => {
const container = document.querySelector(".column-config-container");
const popover = document.getElementById("column-config-popover");
if (container && popover && !container.contains(e.target)) {
popover.style.display = "none";
}
});
// Copy part number to clipboard
async function copyPartNumber(partNumber, btn) {
try {
await navigator.clipboard.writeText(partNumber);
btn.innerHTML = icons.check;
btn.classList.add("copied");
setTimeout(() => {
btn.innerHTML = icons.clipboard;
btn.classList.remove("copied");
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}
// CSV Export/Import Functions
function exportCSV() {
const search = document.getElementById("search-input").value;
const type = document.getElementById("type-filter").value;
const project = document.getElementById("project-filter").value;
const params = new URLSearchParams();
if (search) params.set("search", search);
if (type) params.set("type", type);
if (project) params.set("project", project);
params.set("include_properties", "true");
window.location.href = `/api/items/export.csv?${params}`;
}
function openImportModal() {
document.getElementById("import-modal").classList.add("active");
document.getElementById("import-results").style.display = "none";
document.getElementById("import-form").reset();
document.getElementById("file-label").textContent =
"Choose a file or drag it here";
document.getElementById("file-label").classList.remove("has-file");
document.getElementById("import-btn").textContent = "Validate";
document.getElementById("import-dry-run").checked = true;
}
function closeImportModal() {
document.getElementById("import-modal").classList.remove("active");
}
function updateFileName(input) {
const label = document.getElementById("file-label");
if (input.files && input.files[0]) {
label.textContent = input.files[0].name;
label.classList.add("has-file");
} else {
label.textContent = "Choose a file or drag it here";
label.classList.remove("has-file");
}
}
async function importCSV(event) {
event.preventDefault();
const fileInput = document.getElementById("import-file");
const dryRun = document.getElementById("import-dry-run").checked;
const skipExisting = document.getElementById(
"import-skip-existing",
).checked;
const resultsDiv = document.getElementById("import-results");
const importBtn = document.getElementById("import-btn");
if (!fileInput.files || !fileInput.files[0]) {
alert("Please select a CSV file");
return;
}
const formData = new FormData();
formData.append("file", fileInput.files[0]);
formData.append("dry_run", dryRun.toString());
formData.append("skip_existing", skipExisting.toString());
formData.append("schema", "kindred-rd");
importBtn.textContent = dryRun ? "Validating..." : "Importing...";
importBtn.disabled = true;
try {
const response = await fetch("/api/items/import", {
method: "POST",
body: formData,
});
// Check content type to handle non-JSON errors (e.g., nginx error pages)
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
const text = await response.text();
console.error("Non-JSON response:", text);
resultsDiv.innerHTML = `<h4>Error</h4><p>Server returned non-JSON response (status ${response.status}). Check server logs.</p><pre style="max-height:200px;overflow:auto;font-size:12px;">${text.substring(0, 500)}</pre>`;
resultsDiv.className = "import-results error";
resultsDiv.style.display = "block";
return;
}
const result = await response.json();
if (!response.ok) {
resultsDiv.innerHTML = `<h4>Error</h4><p>${result.message || result.error}</p>`;
resultsDiv.className = "import-results error";
resultsDiv.style.display = "block";
return;
}
// Display results
let statusClass = "success";
let statusTitle = dryRun
? "Validation Complete"
: "Import Complete";
if (result.error_count > 0 && result.success_count > 0) {
statusClass = "warning";
} else if (result.error_count > 0) {
statusClass = "error";
}
let html = `<h4>${statusTitle}</h4>`;
html += `<div class="import-stats">
<div class="import-stat">
<div class="import-stat-value">${result.total_rows}</div>
<div class="import-stat-label">Total Rows</div>
</div>
<div class="import-stat">
<div class="import-stat-value success">${result.success_count}</div>
<div class="import-stat-label">${dryRun ? "Valid" : "Created"}</div>
</div>
<div class="import-stat">
<div class="import-stat-value error">${result.error_count}</div>
<div class="import-stat-label">Errors</div>
</div>
</div>`;
if (result.errors && result.errors.length > 0) {
html += `<div class="import-errors"><strong>Errors:</strong>`;
result.errors.slice(0, 20).forEach((err) => {
html += `<div class="import-error-item">
<span class="import-error-row">Row ${err.row}:</span>
<span class="import-error-msg">${err.field ? `[${err.field}] ` : ""}${err.message}</span>
</div>`;
});
if (result.errors.length > 20) {
html += `<div class="import-error-item"><em>...and ${result.errors.length - 20} more errors</em></div>`;
}
html += `</div>`;
}
if (
!dryRun &&
result.created_items &&
result.created_items.length > 0
) {
html += `<div class="import-created"><strong>Created Items:</strong>
<div class="import-created-list">`;
result.created_items.slice(0, 50).forEach((pn) => {
html += `<span class="import-created-item">${pn}</span>`;
});
if (result.created_items.length > 50) {
html += `<span class="import-created-item">...+${result.created_items.length - 50} more</span>`;
}
html += `</div></div>`;
}
resultsDiv.innerHTML = html;
resultsDiv.className = `import-results ${statusClass}`;
resultsDiv.style.display = "block";
// Update button based on mode
if (
dryRun &&
result.success_count > 0 &&
result.error_count === 0
) {
importBtn.textContent = "Import Now";
document.getElementById("import-dry-run").checked = false;
} else if (!dryRun && result.success_count > 0) {
loadItems(); // Refresh the list
importBtn.textContent = "Done";
} else {
importBtn.textContent = "Validate";
}
} catch (error) {
resultsDiv.innerHTML = `<h4>Error</h4><p>${error.message}</p>`;
resultsDiv.className = "import-results error";
resultsDiv.style.display = "block";
} finally {
importBtn.disabled = false;
}
}
// Revision Control Functions
// Compare two revisions and display the diff
async function compareRevisions(partNumber) {
const fromRev = document.getElementById("compare-from").value;
const toRev = document.getElementById("compare-to").value;
const resultDiv = document.getElementById("compare-result");
if (fromRev === toRev) {
resultDiv.innerHTML =
'<p style="color: var(--ctp-subtext0);">Select two different revisions to compare.</p>';
return;
}
resultDiv.innerHTML =
'<div class="loading"><div class="spinner"></div></div>';
try {
const response = await fetch(
`/api/items/${partNumber}/revisions/compare?from=${fromRev}&to=${toRev}`,
);
if (!response.ok) {
const error = await response.json();
resultDiv.innerHTML = `<p style="color: var(--ctp-red);">Error: ${error.message || error.error}</p>`;
return;
}
const diff = await response.json();
let html = `<div class="compare-result">
<h4>Changes from Rev ${diff.from_revision} to Rev ${diff.to_revision}</h4>`;
// Status change
if (diff.from_status !== diff.to_status) {
html += `<div class="diff-section">
<h5 class="diff-changed">Status Changed</h5>
<div class="diff-item">${diff.from_status}${diff.to_status}</div>
</div>`;
}
// File change
if (diff.file_changed) {
const sizeChange = diff.file_size_diff
? diff.file_size_diff > 0
? `+${formatFileSize(diff.file_size_diff)}`
: formatFileSize(diff.file_size_diff)
: "";
html += `<div class="diff-section">
<h5 class="diff-changed">File Changed</h5>
<div class="diff-item">File was modified ${sizeChange ? `(${sizeChange})` : ""}</div>
</div>`;
}
// Added properties
if (diff.added && Object.keys(diff.added).length > 0) {
html += `<div class="diff-section">
<h5 class="diff-added">+ Added Properties</h5>`;
for (const [key, value] of Object.entries(diff.added)) {
html += `<div class="diff-item diff-added">+ ${escapeHtml(key)}: ${escapeHtml(JSON.stringify(value))}</div>`;
}
html += `</div>`;
}
// Removed properties
if (diff.removed && Object.keys(diff.removed).length > 0) {
html += `<div class="diff-section">
<h5 class="diff-removed">- Removed Properties</h5>`;
for (const [key, value] of Object.entries(diff.removed)) {
html += `<div class="diff-item diff-removed">- ${escapeHtml(key)}: ${escapeHtml(JSON.stringify(value))}</div>`;
}
html += `</div>`;
}
// Changed properties
if (diff.changed && Object.keys(diff.changed).length > 0) {
html += `<div class="diff-section">
<h5 class="diff-changed">~ Changed Properties</h5>`;
for (const [key, change] of Object.entries(diff.changed)) {
html += `<div class="diff-item diff-changed">~ ${escapeHtml(key)}: ${escapeHtml(JSON.stringify(change.old_value))}${escapeHtml(JSON.stringify(change.new_value))}</div>`;
}
html += `</div>`;
}
// No changes
if (
(!diff.added || Object.keys(diff.added).length === 0) &&
(!diff.removed || Object.keys(diff.removed).length === 0) &&
(!diff.changed || Object.keys(diff.changed).length === 0) &&
!diff.file_changed &&
diff.from_status === diff.to_status
) {
html += `<p style="color: var(--ctp-subtext0);">No property changes between these revisions.</p>`;
}
html += `</div>`;
resultDiv.innerHTML = html;
} catch (error) {
resultDiv.innerHTML = `<p style="color: var(--ctp-red);">Error: ${error.message}</p>`;
}
}
// Update revision status
async function updateRevisionStatus(partNumber, revision, status) {
try {
const response = await fetch(
`/api/items/${partNumber}/revisions/${revision}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
},
);
if (!response.ok) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
// Reload to restore the original status
showItemDetail(partNumber);
return;
}
// Brief visual feedback - the select already shows the new value
} catch (error) {
alert(`Error: ${error.message}`);
showItemDetail(partNumber);
}
}
// Rollback to a previous revision
async function rollbackToRevision(partNumber, revision) {
const confirmed = confirm(
`Rollback to Revision ${revision}?\n\nThis will create a NEW revision with the properties and file from Rev ${revision}. The revision history is preserved.`,
);
if (!confirmed) return;
const comment = prompt(
"Rollback comment (optional):",
`Rollback to revision ${revision}`,
);
if (comment === null) return; // User cancelled
try {
const response = await fetch(
`/api/items/${partNumber}/revisions/${revision}/rollback`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
comment: comment || `Rollback to revision ${revision}`,
}),
},
);
if (!response.ok) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
return;
}
const result = await response.json();
alert(`Created revision ${result.revision_number} from rollback`);
showItemDetail(partNumber);
loadItems();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// ========================================
// BOM Tab Functions
// ========================================
let bomData = [];
function escapeAttr(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
async function loadBOMTab(partNumber) {
const container = document.getElementById("tab-bom");
container.innerHTML =
'<div class="loading"><div class="spinner"></div></div>';
try {
const response = await fetch(`/api/items/${partNumber}/bom`);
if (!response.ok) throw new Error("Failed to load BOM");
bomData = await response.json();
renderBOMTab(partNumber, bomData);
} catch (error) {
container.innerHTML = `<p style="color: var(--ctp-red);">Error: ${error.message}</p>`;
}
}
function renderBOMTab(partNumber, entries) {
const container = document.getElementById("tab-bom");
const escapedPN = escapeAttr(partNumber);
let totalExtCost = 0;
entries.forEach((e) => {
const unitCost = e.metadata?.unit_cost || 0;
const qty = e.quantity || 0;
totalExtCost += unitCost * qty;
});
let html = `
<div class="bom-toolbar">
<span style="color: var(--ctp-subtext0); font-size: 0.85rem;">
${entries.length} component${entries.length !== 1 ? "s" : ""}
</span>
<div class="bom-toolbar-actions">
<button class="btn btn-secondary" onclick="exportBOMCSV('${escapedPN}')">Export CSV</button>
<button class="btn btn-secondary" onclick="showBOMImport('${escapedPN}')">Import CSV</button>
<button class="btn btn-primary" onclick="showAddBOMForm('${escapedPN}')">+ Add</button>
</div>
</div>
<div id="bom-add-container"></div>
<div id="bom-import-container"></div>`;
if (entries.length === 0) {
html +=
'<div class="bom-empty"><p>No BOM entries.</p><p style="font-size:0.85rem;">Add components or import a CSV.</p></div>';
} else {
html += `
<div class="table-container">
<table class="bom-table">
<thead>
<tr>
<th>#</th>
<th>PN</th>
<th>Source</th>
<th>Seller Description</th>
<th style="text-align:right">Unit Cost</th>
<th style="text-align:right">QTY</th>
<th style="text-align:right">Ext Cost</th>
<th>Link</th>
<th style="width:90px">Actions</th>
</tr>
</thead>
<tbody>`;
entries.forEach((e, idx) => {
const unitCost = e.metadata?.unit_cost || 0;
const qty = e.quantity || 0;
const extCost = unitCost * qty;
const source = escapeHtml(e.metadata?.source || "");
const sellerDesc = escapeHtml(
e.metadata?.seller_description || e.child_description || "",
);
const sourcingLink = e.metadata?.sourcing_link || "";
const childPN = escapeAttr(e.child_part_number);
html += `
<tr>
<td>${idx + 1}</td>
<td><span class="pn-link" onclick="showItemDetail('${childPN}')">${escapeHtml(e.child_part_number)}</span></td>
<td>${source}</td>
<td>${sellerDesc}</td>
<td class="bom-cost">${unitCost ? "$" + unitCost.toFixed(2) : ""}</td>
<td class="bom-cost">${qty || ""}</td>
<td class="bom-cost">${extCost ? "$" + extCost.toFixed(2) : ""}</td>
<td>${sourcingLink ? `<a href="${escapeAttr(sourcingLink)}" target="_blank" rel="noopener" style="color:var(--ctp-blue);font-size:0.8rem;">Link</a>` : ""}</td>
<td>
<div class="bom-actions">
<button class="btn btn-secondary" onclick="editBOMEntry('${escapedPN}', ${idx})">Edit</button>
<button class="btn btn-secondary" style="color:var(--ctp-red)" onclick="deleteBOMEntry('${escapedPN}', '${childPN}')">Del</button>
</div>
</td>
</tr>`;
});
html += `</tbody></table></div>`;
if (totalExtCost > 0) {
html += `<div class="bom-summary">Total: $${totalExtCost.toFixed(2)}</div>`;
}
}
container.innerHTML = html;
}
function showAddBOMForm(partNumber) {
const container = document.getElementById("bom-add-container");
document.getElementById("bom-import-container").innerHTML = "";
const escapedPN = escapeAttr(partNumber);
container.innerHTML = `
<div class="bom-add-form">
<div class="form-group">
<label class="form-label">Part Number *</label>
<input type="text" class="form-input" id="bom-add-pn" placeholder="e.g. F01-0001">
</div>
<div class="form-group">
<label class="form-label">Quantity</label>
<input type="number" class="form-input" id="bom-add-qty" step="any" value="1">
</div>
<div class="form-group">
<label class="form-label">Source</label>
<input type="text" class="form-input" id="bom-add-source" placeholder="e.g. DigiKey">
</div>
<div class="form-group">
<label class="form-label">Seller Description</label>
<input type="text" class="form-input" id="bom-add-seller-desc">
</div>
<div class="form-group">
<label class="form-label">Unit Cost</label>
<input type="number" class="form-input" id="bom-add-unit-cost" step="0.01" placeholder="0.00">
</div>
<div class="form-group">
<label class="form-label">Sourcing Link</label>
<input type="url" class="form-input" id="bom-add-sourcing-link" placeholder="https://...">
</div>
<div class="form-group full-width" style="display:flex;gap:0.5rem;justify-content:flex-end;">
<button class="btn btn-secondary" onclick="document.getElementById('bom-add-container').innerHTML=''">Cancel</button>
<button class="btn btn-primary" onclick="submitAddBOM('${escapedPN}')">Add</button>
</div>
</div>`;
document.getElementById("bom-add-pn").focus();
}
async function submitAddBOM(partNumber) {
const pn = document.getElementById("bom-add-pn").value.trim();
if (!pn) {
alert("Part number is required");
return;
}
const qty =
parseFloat(document.getElementById("bom-add-qty").value) || null;
const metadata = {};
const source = document.getElementById("bom-add-source").value.trim();
const sellerDesc = document
.getElementById("bom-add-seller-desc")
.value.trim();
const unitCost = parseFloat(
document.getElementById("bom-add-unit-cost").value,
);
const sourcingLink = document
.getElementById("bom-add-sourcing-link")
.value.trim();
if (source) metadata.source = source;
if (sellerDesc) metadata.seller_description = sellerDesc;
if (!isNaN(unitCost)) metadata.unit_cost = unitCost;
if (sourcingLink) metadata.sourcing_link = sourcingLink;
try {
const response = await fetch(`/api/items/${partNumber}/bom`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
child_part_number: pn,
rel_type: "component",
quantity: qty,
metadata:
Object.keys(metadata).length > 0 ? metadata : undefined,
}),
});
if (!response.ok) {
const err = await response.json();
alert(err.message || err.error);
return;
}
document.getElementById("bom-add-container").innerHTML = "";
loadBOMTab(partNumber);
} catch (error) {
alert(error.message);
}
}
function editBOMEntry(partNumber, idx) {
const e = bomData[idx];
if (!e) return;
const container = document.getElementById("bom-add-container");
document.getElementById("bom-import-container").innerHTML = "";
const escapedPN = escapeAttr(partNumber);
const childPN = escapeAttr(e.child_part_number);
container.innerHTML = `
<div class="bom-add-form">
<div class="form-group">
<label class="form-label">Part Number</label>
<input type="text" class="form-input" value="${childPN}" disabled>
</div>
<div class="form-group">
<label class="form-label">Quantity</label>
<input type="number" class="form-input" id="bom-edit-qty" step="any" value="${e.quantity || ""}">
</div>
<div class="form-group">
<label class="form-label">Source</label>
<input type="text" class="form-input" id="bom-edit-source" value="${escapeAttr(e.metadata?.source || "")}">
</div>
<div class="form-group">
<label class="form-label">Seller Description</label>
<input type="text" class="form-input" id="bom-edit-seller-desc" value="${escapeAttr(e.metadata?.seller_description || "")}">
</div>
<div class="form-group">
<label class="form-label">Unit Cost</label>
<input type="number" class="form-input" id="bom-edit-unit-cost" step="0.01" value="${e.metadata?.unit_cost || ""}">
</div>
<div class="form-group">
<label class="form-label">Sourcing Link</label>
<input type="url" class="form-input" id="bom-edit-sourcing-link" value="${escapeAttr(e.metadata?.sourcing_link || "")}">
</div>
<div class="form-group full-width" style="display:flex;gap:0.5rem;justify-content:flex-end;">
<button class="btn btn-secondary" onclick="document.getElementById('bom-add-container').innerHTML=''">Cancel</button>
<button class="btn btn-primary" onclick="submitEditBOM('${escapedPN}', '${childPN}')">Save</button>
</div>
</div>`;
}
async function submitEditBOM(partNumber, childPN) {
const qty = parseFloat(document.getElementById("bom-edit-qty").value);
const metadata = {};
const source = document.getElementById("bom-edit-source").value.trim();
const sellerDesc = document
.getElementById("bom-edit-seller-desc")
.value.trim();
const unitCost = parseFloat(
document.getElementById("bom-edit-unit-cost").value,
);
const sourcingLink = document
.getElementById("bom-edit-sourcing-link")
.value.trim();
if (source) metadata.source = source;
if (sellerDesc) metadata.seller_description = sellerDesc;
if (!isNaN(unitCost)) metadata.unit_cost = unitCost;
if (sourcingLink) metadata.sourcing_link = sourcingLink;
const body = {};
if (!isNaN(qty)) body.quantity = qty;
if (Object.keys(metadata).length > 0) body.metadata = metadata;
try {
const response = await fetch(
`/api/items/${partNumber}/bom/${childPN}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const err = await response.json();
alert(err.message || err.error);
return;
}
document.getElementById("bom-add-container").innerHTML = "";
loadBOMTab(partNumber);
} catch (error) {
alert(error.message);
}
}
async function deleteBOMEntry(partNumber, childPN) {
if (!confirm(`Remove ${childPN} from BOM?`)) return;
try {
const response = await fetch(
`/api/items/${partNumber}/bom/${childPN}`,
{ method: "DELETE" },
);
if (!response.ok && response.status !== 204) {
const err = await response.json();
alert(err.message || err.error);
return;
}
loadBOMTab(partNumber);
} catch (error) {
alert(error.message);
}
}
function exportBOMCSV(partNumber) {
window.location.href = `/api/items/${partNumber}/bom/export.csv`;
}
function showBOMImport(partNumber) {
const container = document.getElementById("bom-import-container");
document.getElementById("bom-add-container").innerHTML = "";
const escapedPN = escapeAttr(partNumber);
container.innerHTML = `
<div class="bom-import-area">
<div style="margin-bottom:0.75rem;">
<input type="file" id="bom-import-file" accept=".csv,text/csv" style="display:none"
onchange="document.getElementById('bom-file-label').textContent = this.files[0]?.name || 'Choose a CSV file'">
<button class="btn btn-secondary" onclick="document.getElementById('bom-import-file').click()">Choose File</button>
<span id="bom-file-label" style="margin-left:0.5rem;color:var(--ctp-subtext0);font-size:0.85rem;">No file selected</span>
</div>
<div style="display:flex;gap:1rem;justify-content:center;align-items:center;margin-bottom:0.75rem;">
<label style="display:flex;align-items:center;gap:0.4rem;color:var(--ctp-subtext1);font-size:0.85rem;">
<input type="checkbox" id="bom-import-dry-run" checked> Dry run (validate only)
</label>
<label style="display:flex;align-items:center;gap:0.4rem;color:var(--ctp-subtext1);font-size:0.85rem;">
<input type="checkbox" id="bom-import-clear"> Replace existing BOM
</label>
</div>
<div id="bom-import-results"></div>
<div style="display:flex;gap:0.5rem;justify-content:center;margin-top:0.75rem;">
<button class="btn btn-secondary" onclick="document.getElementById('bom-import-container').innerHTML=''">Cancel</button>
<button class="btn btn-primary" id="bom-import-btn" onclick="submitBOMImport('${escapedPN}')">Validate</button>
</div>
</div>`;
}
async function submitBOMImport(partNumber) {
const fileInput = document.getElementById("bom-import-file");
if (!fileInput.files || !fileInput.files[0]) {
alert("Select a CSV file");
return;
}
const dryRun = document.getElementById("bom-import-dry-run").checked;
const clearExisting =
document.getElementById("bom-import-clear").checked;
const btn = document.getElementById("bom-import-btn");
const resultsDiv = document.getElementById("bom-import-results");
const formData = new FormData();
formData.append("file", fileInput.files[0]);
formData.append("dry_run", dryRun.toString());
formData.append("clear_existing", clearExisting.toString());
btn.textContent = dryRun ? "Validating..." : "Importing...";
btn.disabled = true;
try {
const response = await fetch(
`/api/items/${partNumber}/bom/import`,
{
method: "POST",
body: formData,
},
);
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
const text = await response.text();
resultsDiv.innerHTML = `<div class="bom-import-results error"><strong>Error</strong><p>Server returned non-JSON response (status ${response.status}).</p></div>`;
return;
}
const result = await response.json();
if (!response.ok) {
resultsDiv.innerHTML = `<div class="bom-import-results error"><strong>Error</strong><p>${result.message || result.error}</p></div>`;
return;
}
let statusClass = "success";
if (result.error_count > 0 && result.success_count > 0)
statusClass = "warning";
else if (result.error_count > 0) statusClass = "error";
let rhtml = `<div class="bom-import-results ${statusClass}">`;
rhtml += `<strong>${dryRun ? "Validation" : "Import"} Complete</strong>`;
rhtml += `<div style="display:flex;gap:1.5rem;margin:0.5rem 0;">`;
rhtml += `<span>Total: ${result.total_rows}</span>`;
rhtml += `<span style="color:var(--ctp-green)">${dryRun ? "Valid" : "Success"}: ${result.success_count}</span>`;
rhtml += `<span style="color:var(--ctp-red)">Errors: ${result.error_count}</span>`;
rhtml += `</div>`;
if (result.errors && result.errors.length > 0) {
rhtml += `<div style="margin-top:0.5rem;font-size:0.85rem;">`;
result.errors.slice(0, 20).forEach((err) => {
rhtml += `<div style="color:var(--ctp-red);margin:0.2rem 0;">Row ${err.row}: ${err.field ? `[${err.field}] ` : ""}${escapeHtml(err.message)}</div>`;
});
if (result.errors.length > 20) {
rhtml += `<div style="color:var(--ctp-subtext0);">...and ${result.errors.length - 20} more errors</div>`;
}
rhtml += `</div>`;
}
if (result.created_items && result.created_items.length > 0) {
rhtml += `<div style="margin-top:0.5rem;font-size:0.85rem;"><strong>Added:</strong> ${result.created_items.map(escapeHtml).join(", ")}</div>`;
}
rhtml += `</div>`;
resultsDiv.innerHTML = rhtml;
if (
dryRun &&
result.success_count > 0 &&
result.error_count === 0
) {
btn.textContent = "Import Now";
document.getElementById("bom-import-dry-run").checked = false;
} else if (!dryRun && result.success_count > 0) {
loadBOMTab(partNumber);
btn.textContent = "Done";
} else {
btn.textContent = "Validate";
}
} catch (error) {
resultsDiv.innerHTML = `<div class="bom-import-results error"><strong>Error</strong><p>${error.message}</p></div>`;
} finally {
btn.disabled = false;
}
}
// ========================================
// Where Used Tab
// ========================================
async function loadWhereUsedTab(partNumber) {
const container = document.getElementById("tab-where-used");
container.innerHTML =
'<div class="loading"><div class="spinner"></div></div>';
try {
const response = await fetch(
`/api/items/${partNumber}/bom/where-used`,
);
if (!response.ok) throw new Error("Failed to load where-used data");
const entries = await response.json();
if (entries.length === 0) {
container.innerHTML =
'<div class="bom-empty"><p>This item is not used in any assemblies.</p></div>';
return;
}
let html = `
<div class="table-container">
<table class="bom-table">
<thead>
<tr>
<th>Parent PN</th>
<th>Description</th>
<th style="text-align:right">QTY</th>
<th>Type</th>
</tr>
</thead>
<tbody>`;
entries.forEach((e) => {
const parentPN = escapeAttr(e.parent_part_number);
html += `
<tr>
<td><span class="pn-link" onclick="showItemDetail('${parentPN}')">${escapeHtml(e.parent_part_number)}</span></td>
<td>${escapeHtml(e.parent_description || "")}</td>
<td class="bom-cost">${e.quantity || ""}</td>
<td><span class="item-type item-type-part">${escapeHtml(e.rel_type)}</span></td>
</tr>`;
});
html += `</tbody></table></div>`;
container.innerHTML = html;
} catch (error) {
container.innerHTML = `<p style="color: var(--ctp-red);">Error: ${error.message}</p>`;
}
}
// ========================================
// Inline Project Tag Editor (Detail Panel)
// ========================================
function buildProjectTagsEditor(partNumber, itemProjects) {
const escapedPN = escapeAttr(partNumber);
const tags = (itemProjects || [])
.map((p) => {
const code = p.code || p;
return `<span class="item-project-tag">${escapeHtml(code)}<button class="tag-remove-inline" onclick="removeDetailProjectTag('${escapedPN}', '${escapeAttr(code)}')" title="Remove">&times;</button></span>`;
})
.join("");
// Build dropdown of available projects (exclude already tagged)
const taggedCodes = (itemProjects || []).map((p) => p.code || p);
const available = projectCodes.filter((c) => !taggedCodes.includes(c));
let addDropdown = "";
if (available.length > 0) {
const opts = available
.map(
(c) =>
`<option value="${escapeAttr(c)}">${escapeHtml(c)}</option>`,
)
.join("");
addDropdown = `<span class="project-add-inline"><select onchange="addDetailProjectTag('${escapedPN}', this.value); this.value='';">
<option value="">+ tag</option>${opts}
</select></span>`;
}
return `<span class="project-tags-editor">${tags || '<em style="color: var(--ctp-subtext0); font-size: 0.85rem;">None</em>'}${addDropdown}</span>`;
}
async function addDetailProjectTag(partNumber, projectCode) {
if (!projectCode) return;
try {
const response = await fetch(`/api/items/${partNumber}/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projects: [projectCode] }),
});
if (!response.ok) {
const err = await response.json();
alert(err.message || err.error);
return;
}
showItemDetail(partNumber);
} catch (error) {
alert(error.message);
}
}
async function removeDetailProjectTag(partNumber, projectCode) {
try {
const response = await fetch(
`/api/items/${partNumber}/projects/${projectCode}`,
{
method: "DELETE",
},
);
if (!response.ok && response.status !== 204) {
const err = await response.json();
alert(err.message || err.error);
return;
}
showItemDetail(partNumber);
} catch (error) {
alert(error.message);
}
}
// Initialize
initLayout();
loadSchema();
loadProjectCodes();
loadItems();
</script>
{{end}}