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