2455 lines
84 KiB
HTML
2455 lines
84 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()">
|
|
×
|
|
</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()">
|
|
×
|
|
</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()">
|
|
×
|
|
</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()">
|
|
×
|
|
</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 */
|
|
.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}')">×</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()">×</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 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}`);
|
|
}
|
|
}
|
|
|
|
// Initialize
|
|
loadSchema();
|
|
loadProjectCodes();
|
|
loadItems();
|
|
initLayout();
|
|
</script>
|
|
{{end}}
|