Files
silo/internal/api/templates/items.html

2431 lines
83 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>
<div class="card">
<div class="card-header">
<h2 class="card-title">Items</h2>
<div class="header-actions">
<!-- Layout Toggle -->
<div class="layout-toggle">
<button
class="layout-btn active"
data-layout="vertical"
onclick="setLayout('vertical')"
title="Vertical layout"
>
<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>
<button
class="layout-btn"
data-layout="horizontal"
onclick="setLayout('horizontal')"
title="Horizontal layout"
>
<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>
</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 by part number or description..."
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>
<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 class="table-container">
<table>
<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>
<!-- Create Item Modal -->
<div class="modal-overlay" id="create-modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Create New Item</h3>
<button class="modal-close" onclick="closeCreateModal()">
&times;
</button>
</div>
<form id="create-form" onsubmit="createItem(event)">
<!-- Item Templates -->
<div class="form-group">
<label class="form-label">Quick Start Template</label>
<select
class="form-input"
id="template"
onchange="applyTemplate()"
>
<option value="">Start from scratch...</option>
<option value="machined-part">
Machined Part (X-category)
</option>
<option value="printed-part">
3D Printed Part (X-category)
</option>
<option value="fastener">Fastener (F-category)</option>
<option value="electronics">
Electronics (E-category)
</option>
<option value="assembly">Assembly (A-category)</option>
<option value="purchased">
Purchased/COTS (P-category)
</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Category</label>
<select class="form-input" id="category" required>
<option value="">Select category...</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Description</label>
<input
type="text"
class="form-input"
id="description"
placeholder="Item description"
/>
</div>
<div class="form-group">
<label class="form-label">Project Tags (optional)</label>
<div class="project-tags-container" id="project-tags-container">
<select
class="form-input"
id="project-select"
onchange="addProjectTag()"
>
<option value="">Add project tag...</option>
</select>
<div class="selected-tags" id="selected-tags"></div>
</div>
</div>
<div class="form-actions">
<button
type="button"
class="btn btn-secondary"
onclick="closeCreateModal()"
>
Cancel
</button>
<button type="submit" class="btn btn-primary">
Create Item
</button>
</div>
</form>
</div>
</div>
<!-- Edit Item Modal -->
<div class="modal-overlay" id="edit-modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Edit Item</h3>
<button class="modal-close" onclick="closeEditModal()">
&times;
</button>
</div>
<form id="edit-form" onsubmit="saveItem(event)">
<input type="hidden" id="edit-original-pn" />
<div class="form-group">
<label class="form-label">Part Number</label>
<input
type="text"
class="form-input"
id="edit-part-number"
required
/>
</div>
<div class="form-group">
<label class="form-label">Type</label>
<select class="form-input" id="edit-type" required>
<option value="part">Part</option>
<option value="assembly">Assembly</option>
<option value="document">Document</option>
<option value="tooling">Tooling</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Description</label>
<input
type="text"
class="form-input"
id="edit-description"
placeholder="Item description"
/>
</div>
<div class="form-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>
<!-- Item Detail Modal -->
<div class="modal-overlay" id="detail-modal">
<div class="modal" style="max-width: 800px">
<div class="modal-header">
<h3 class="modal-title" id="detail-title">Item Details</h3>
<button class="modal-close" onclick="closeDetailModal()">
&times;
</button>
</div>
<!-- Tab Navigation -->
<div class="detail-tabs">
<button
class="tab-btn active"
data-tab="info"
onclick="switchDetailTab('info')"
>
Info
</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>
</div>
<!-- Tab Content -->
<div class="tab-content" id="tab-info"></div>
<div
class="tab-content"
id="tab-properties"
style="display: none"
></div>
<div class="tab-content" id="tab-revisions" style="display: none"></div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal-overlay" id="delete-modal">
<div class="modal" style="max-width: 400px">
<div class="modal-header">
<h3 class="modal-title">Delete Item</h3>
<button class="modal-close" onclick="closeDeleteModal()">
&times;
</button>
</div>
<div style="margin-bottom: 1.5rem">
<p>
Are you sure you want to permanently delete
<strong id="delete-part-number"></strong>?
</p>
<p style="color: var(--ctp-red); margin-top: 0.5rem">
This action cannot be undone.
</p>
</div>
<div class="form-actions">
<button
type="button"
class="btn btn-secondary"
onclick="closeDeleteModal()"
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
style="background-color: var(--ctp-red)"
onclick="confirmDelete()"
>
Delete
</button>
</div>
</div>
</div>
<!-- CSV Import Modal -->
<div class="modal-overlay" id="import-modal">
<div class="modal" style="max-width: 600px">
<div class="modal-header">
<h3 class="modal-title">Import Items from CSV</h3>
<button class="modal-close" onclick="closeImportModal()">
&times;
</button>
</div>
<form id="import-form" onsubmit="importCSV(event)">
<div class="import-instructions">
<p>Upload a CSV file to bulk import items. Required columns:</p>
<ul>
<li>
<code>category</code> - Category code (e.g., F01, A01)
</li>
</ul>
<p>
Optional columns: <code>description</code>,
<code>projects</code> (comma-separated project codes),
<code>part_number</code>, and any property columns.
</p>
<a href="/api/items/template.csv" class="template-link"
>Download CSV Template</a
>
</div>
<div class="form-group">
<label class="form-label">CSV File</label>
<div class="file-input-container">
<input
type="file"
class="form-input"
id="import-file"
accept=".csv,text/csv"
required
onchange="updateFileName(this)"
/>
<div class="file-input-label" id="file-label">
Choose a file or drag it here
</div>
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="import-dry-run" checked />
<span>Dry run (validate without creating items)</span>
</label>
</div>
<div
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 */
.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);
}
@media (max-width: 1024px) {
.layout-toggle {
display: none;
}
}
/* Detail Modal Tabs */
.detail-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--ctp-surface1);
margin-bottom: 1rem;
padding-bottom: 0;
}
.tab-btn {
background: transparent;
border: none;
color: var(--ctp-subtext0);
padding: 0.75rem 1.25rem;
cursor: pointer;
border-radius: 0.5rem 0.5rem 0 0;
transition: all 0.2s;
font-weight: 500;
font-size: 0.9rem;
}
.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: 200px;
}
/* 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;
}
</style>
<script>
let currentPage = 1;
let pageSize = 20;
let searchTimeout = null;
let schema = null;
let itemToDelete = null;
let projectCodes = [];
// SVG Icons (Catppuccin-style)
const icons = {
clipboard: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="2" width="6" height="4" rx="1" ry="1"></rect>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
</svg>`,
check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>`,
file: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>`,
download: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>`,
};
// Item templates for quick creation
const itemTemplates = {
"machined-part": { category: "X01", descPrefix: "MACHINED " },
"printed-part": { category: "X03", descPrefix: "3DP " },
fastener: { category: "F01", descPrefix: "" },
electronics: { category: "E01", descPrefix: "" },
assembly: { category: "A01", descPrefix: "" },
purchased: { category: "P01", descPrefix: "COTS " },
};
// Selected project tags for new item creation
let selectedProjectTags = [];
// Load schema for create form
async function loadSchema() {
try {
const response = await fetch("/api/schemas/kindred-rd");
schema = await response.json();
// Populate category dropdown
const categorySelect = document.getElementById("category");
const categories = schema.segments.find(
(s) => s.name === "category",
);
if (categories && categories.values) {
const sorted = Object.entries(categories.values).sort((a, b) =>
a[0].localeCompare(b[0]),
);
sorted.forEach(([code, desc]) => {
const option = document.createElement("option");
option.value = code;
option.textContent = `${code} - ${desc}`;
categorySelect.appendChild(option);
});
}
} catch (error) {
console.error("Failed to load schema:", error);
}
}
// Load project codes from existing items
async function loadProjectCodes() {
try {
const response = await fetch("/api/projects");
const projects = await response.json();
// Extract codes from project objects
projectCodes = projects.map((p) => p.code || p);
// Populate project filter dropdown
const projectFilter = document.getElementById("project-filter");
projectCodes.forEach((code) => {
const option = document.createElement("option");
option.value = code;
option.textContent = code;
projectFilter.appendChild(option);
});
// Populate create form project tag dropdown
const projectSelect = document.getElementById("project-select");
if (projectSelect) {
projectCodes.forEach((code) => {
const option = document.createElement("option");
option.value = code;
option.textContent = code;
projectSelect.appendChild(option);
});
}
} catch (error) {
console.error("Failed to load project codes:", error);
}
}
// Project tag management for create form
function addProjectTag() {
const select = document.getElementById("project-select");
const code = select.value;
if (!code || selectedProjectTags.includes(code)) {
select.value = "";
return;
}
selectedProjectTags.push(code);
renderSelectedTags();
select.value = "";
}
function removeProjectTag(code) {
selectedProjectTags = selectedProjectTags.filter((c) => c !== code);
renderSelectedTags();
}
function renderSelectedTags() {
const container = document.getElementById("selected-tags");
if (!container) return;
container.innerHTML = selectedProjectTags
.map(
(code) => `
<span class="project-tag">
${code}
<button type="button" class="tag-remove" onclick="removeProjectTag('${code}')">&times;</button>
</span>
`,
)
.join("");
}
// Apply template to create form
function applyTemplate() {
const templateId = document.getElementById("template").value;
if (!templateId) return;
const template = itemTemplates[templateId];
if (!template) return;
// Set category
const categorySelect = document.getElementById("category");
categorySelect.value = template.category;
// Set description prefix
const descInput = document.getElementById("description");
if (template.descPrefix && !descInput.value) {
descInput.value = template.descPrefix;
descInput.focus();
}
}
// Search help functions
function showSearchHelp() {
setTimeout(() => {
document.getElementById("search-help").classList.add("visible");
}, 200);
}
function hideSearchHelp() {
setTimeout(() => {
document.getElementById("search-help").classList.remove("visible");
}, 300);
}
// Load items from API
async function loadItems() {
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("limit", pageSize);
params.set("offset", (currentPage - 1) * pageSize);
const tbody = document.getElementById("items-table");
tbody.innerHTML =
'<tr><td colspan="6"><div class="loading"><div class="spinner"></div></div></td></tr>';
try {
const response = await fetch(`/api/items?${params}`);
const items = await response.json();
if (!items || items.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6">
<div class="empty-state">
<h3>No items found</h3>
<p>Create your first item or adjust your search filters.</p>
</div>
</td>
</tr>
`;
updateStats([]);
return;
}
tbody.innerHTML = items
.map(
(item) => `
<tr>
<td>
<span class="part-number-container">
<span class="part-number" style="cursor: pointer;" onclick="showItemDetail('${item.part_number}')">${item.part_number}</span>
<button class="copy-btn" onclick="event.stopPropagation(); copyPartNumber('${item.part_number}', this)" title="Copy part number">${icons.clipboard}</button>
</span>
</td>
<td><span class="item-type item-type-${item.item_type}">${item.item_type}</span></td>
<td>${item.description || "-"}</td>
<td>Rev ${item.current_revision}</td>
<td>${formatDate(item.created_at)}</td>
<td>
<button class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem;" onclick="openEditModal('${item.part_number}')">Edit</button>
<button class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem; background-color: var(--ctp-surface2);" onclick="openDeleteModal('${item.part_number}')">Delete</button>
</td>
</tr>
`,
)
.join("");
updateStats(items);
} catch (error) {
console.error("Failed to load items:", error);
tbody.innerHTML = `
<tr>
<td colspan="6">
<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,
};
// 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 || "";
} 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 data = {
part_number: document.getElementById("edit-part-number").value,
item_type: document.getElementById("edit-type").value,
description: document.getElementById("edit-description").value,
};
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 closeDetailModal() {
document.getElementById("detail-modal").classList.remove("active");
currentDetailItem = null;
currentDetailRevisions = null;
currentItemPartNumber = null;
}
function switchDetailTab(tab) {
document.querySelectorAll(".detail-tabs .tab-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === tab);
});
document.querySelectorAll(".tab-content").forEach((content) => {
content.style.display = "none";
});
document.getElementById(`tab-${tab}`).style.display = "block";
}
// 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) {
document.getElementById("detail-modal").classList.add("active");
document.getElementById("detail-title").textContent = partNumber;
currentItemPartNumber = partNumber;
// Reset to info tab
switchDetailTab("info");
document.getElementById("tab-info").innerHTML =
'<div class="loading"><div class="spinner"></div></div>';
document.getElementById("tab-properties").innerHTML = "";
document.getElementById("tab-revisions").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 projectTagsHtml =
'<span class="item-projects"><em style="color: var(--ctp-subtext0);">None</em></span>';
try {
const projectsRes = await fetch(
`/api/items/${partNumber}/projects`,
);
if (projectsRes.ok) {
const itemProjects = await projectsRes.json();
if (itemProjects && itemProjects.length > 0) {
projectTagsHtml = `<span class="item-projects">${itemProjects
.map(
(p) =>
`<span class="item-project-tag">${p.code || p}</span>`,
)
.join("")}</span>`;
}
}
} catch (e) {
console.warn("Failed to load project tags:", e);
}
// Info tab
document.getElementById("tab-info").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>Description:</strong> ${item.description || "-"}</p>
<p><strong>Projects:</strong> ${projectTagsHtml}</p>
<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-info").innerHTML =
`<p>Error loading item: ${error.message}</p>`;
}
}
// Properties Editor Functions
function renderPropertiesTab(properties, revNum) {
const html = `
<div class="properties-editor">
<div class="properties-header">
<h4>Properties (Rev ${revNum})</h4>
<div class="properties-mode-toggle">
<button class="mode-btn ${propsMode === "form" ? "active" : ""}" data-mode="form" onclick="switchPropsMode('form')">Form</button>
<button class="mode-btn ${propsMode === "json" ? "active" : ""}" data-mode="json" onclick="switchPropsMode('json')">JSON</button>
</div>
</div>
<div class="props-form-mode" id="props-form-mode" style="${propsMode === "json" ? "display:none" : ""}">
<div class="props-list" id="props-list"></div>
<button class="add-prop-btn" onclick="addPropertyRow()">+ Add Property</button>
</div>
<div class="props-json-mode" id="props-json-mode" style="${propsMode === "form" ? "display:none" : ""}">
<textarea class="json-editor" id="json-editor">${JSON.stringify(properties, null, 2)}</textarea>
<div class="json-validation" id="json-validation"></div>
</div>
<div class="props-actions">
<span class="props-notice">Saving creates a new revision</span>
<button class="btn btn-primary" onclick="saveProperties()">Save Properties</button>
</div>
</div>
`;
document.getElementById("tab-properties").innerHTML = html;
// Populate form mode
const container = document.getElementById("props-list");
Object.entries(properties).forEach(([key, value]) => {
addPropertyRow(key, value);
});
}
function switchPropsMode(mode) {
propsMode = mode;
document
.querySelectorAll(".properties-mode-toggle .mode-btn")
.forEach((btn) => {
btn.classList.toggle("active", btn.dataset.mode === mode);
});
const formMode = document.getElementById("props-form-mode");
const jsonMode = document.getElementById("props-json-mode");
if (mode === "form") {
formMode.style.display = "block";
jsonMode.style.display = "none";
syncJsonToForm();
} else {
formMode.style.display = "none";
jsonMode.style.display = "block";
syncFormToJson();
}
}
function detectType(value) {
if (typeof value === "boolean") return "boolean";
if (typeof value === "number") return "number";
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value))
return "date";
return "string";
}
function escapeHtml(str) {
if (str === null || str === undefined) return "";
const div = document.createElement("div");
div.textContent = String(str);
return div.innerHTML;
}
function addPropertyRow(key = "", value = "") {
const container = document.getElementById("props-list");
if (!container) return;
const type = detectType(value);
const row = document.createElement("div");
row.className = "prop-row";
let valueInput;
if (type === "boolean") {
valueInput = `<select class="prop-value">
<option value="true" ${value === true ? "selected" : ""}>true</option>
<option value="false" ${value === false ? "selected" : ""}>false</option>
</select>`;
} else if (type === "number") {
valueInput = `<input type="number" class="prop-value" step="any" value="${value || ""}" />`;
} else if (type === "date") {
const dateVal = value ? value.split("T")[0] : "";
valueInput = `<input type="date" class="prop-value" value="${dateVal}" />`;
} else {
valueInput = `<input type="text" class="prop-value" value="${escapeHtml(value)}" />`;
}
row.innerHTML = `
<input type="text" class="prop-key" placeholder="Key" value="${escapeHtml(key)}" />
<select class="prop-type" onchange="updatePropInput(this)">
<option value="string" ${type === "string" ? "selected" : ""}>Text</option>
<option value="number" ${type === "number" ? "selected" : ""}>Number</option>
<option value="boolean" ${type === "boolean" ? "selected" : ""}>Boolean</option>
<option value="date" ${type === "date" ? "selected" : ""}>Date</option>
</select>
${valueInput}
<button class="prop-remove" onclick="this.closest('.prop-row').remove()">&times;</button>
`;
container.appendChild(row);
}
function updatePropInput(select) {
const row = select.closest(".prop-row");
const type = select.value;
const oldInput = row.querySelector(".prop-value");
const oldValue = oldInput.value;
let newInput;
if (type === "boolean") {
newInput = document.createElement("select");
newInput.className = "prop-value";
newInput.innerHTML = `<option value="true">true</option><option value="false">false</option>`;
} else if (type === "number") {
newInput = document.createElement("input");
newInput.type = "number";
newInput.step = "any";
newInput.className = "prop-value";
newInput.value = parseFloat(oldValue) || "";
} else if (type === "date") {
newInput = document.createElement("input");
newInput.type = "date";
newInput.className = "prop-value";
} else {
newInput = document.createElement("input");
newInput.type = "text";
newInput.className = "prop-value";
newInput.value = oldValue;
}
oldInput.replaceWith(newInput);
}
function collectFormProperties() {
const props = {};
document.querySelectorAll(".prop-row").forEach((row) => {
const key = row.querySelector(".prop-key").value.trim();
const type = row.querySelector(".prop-type").value;
const valueEl = row.querySelector(".prop-value");
let value = valueEl.value;
if (key) {
switch (type) {
case "number":
props[key] = parseFloat(value) || 0;
break;
case "boolean":
props[key] = value === "true";
break;
default:
props[key] = value;
}
}
});
return props;
}
function syncFormToJson() {
const props = collectFormProperties();
const editor = document.getElementById("json-editor");
if (editor) {
editor.value = JSON.stringify(props, null, 2);
}
}
function syncJsonToForm() {
const editor = document.getElementById("json-editor");
const validation = document.getElementById("json-validation");
if (!editor) return;
try {
const props = JSON.parse(editor.value);
const container = document.getElementById("props-list");
if (container) {
container.innerHTML = "";
Object.entries(props).forEach(([key, value]) => {
addPropertyRow(key, value);
});
}
if (validation) {
validation.textContent = "";
validation.classList.remove("valid");
}
} catch (e) {
if (validation) {
validation.textContent = "Invalid JSON: " + e.message;
validation.classList.remove("valid");
}
}
}
async function saveProperties() {
const isJsonMode =
document.getElementById("props-json-mode").style.display !== "none";
let properties;
if (isJsonMode) {
try {
properties = JSON.parse(
document.getElementById("json-editor").value,
);
} catch (e) {
alert("Invalid JSON: " + e.message);
return;
}
} else {
properties = collectFormProperties();
}
const comment = prompt("Revision comment (optional):");
try {
const response = await fetch(
`/api/items/${currentItemPartNumber}/revisions`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
properties,
comment: comment || "",
}),
},
);
if (!response.ok) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
return;
}
alert("Properties saved as new revision");
showItemDetail(currentItemPartNumber);
loadItems();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Layout Management
const LAYOUT_KEY = "silo-items-layout";
function initLayout() {
const saved = localStorage.getItem(LAYOUT_KEY) || "vertical";
setLayout(saved, false);
}
function setLayout(mode, save = true) {
document.querySelectorAll(".layout-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.layout === mode);
});
if (save) {
localStorage.setItem(LAYOUT_KEY, mode);
}
// For now, both modes use modal - horizontal panel view would be a larger change
// This establishes the toggle infrastructure for future enhancement
}
// 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");
}
});
});
// 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 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("schema", "kindred-rd");
importBtn.textContent = dryRun ? "Validating..." : "Importing...";
importBtn.disabled = true;
try {
const response = await fetch("/api/items/import", {
method: "POST",
body: formData,
});
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}`);
}
}
// Initialize
loadSchema();
loadProjectCodes();
loadItems();
initLayout();
</script>
{{end}}