diff --git a/FabWorks.Api/wwwroot/css/styles.css b/FabWorks.Api/wwwroot/css/styles.css
new file mode 100644
index 0000000..9dd9643
--- /dev/null
+++ b/FabWorks.Api/wwwroot/css/styles.css
@@ -0,0 +1,737 @@
+:root {
+ --bg-deep: #0a0e14;
+ --bg: #0d1117;
+ --surface: #151b23;
+ --surface-raised: #1c2128;
+ --border: #2a313a;
+ --border-subtle: #21262d;
+ --text: #e6edf3;
+ --text-secondary: #7d8590;
+ --text-dim: #484f58;
+ --cyan: #00d4ff;
+ --cyan-dim: rgba(0, 212, 255, 0.15);
+ --cyan-glow: rgba(0, 212, 255, 0.3);
+ --amber: #f0883e;
+ --amber-dim: rgba(240, 136, 62, 0.15);
+ --green: #3fb950;
+ --green-dim: rgba(63, 185, 80, 0.12);
+ --red: #f85149;
+ --sidebar-w: 64px;
+ --font-display: 'Outfit', sans-serif;
+ --font-body: 'IBM Plex Sans', sans-serif;
+ --font-mono: 'IBM Plex Mono', monospace;
+}
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: var(--font-body);
+ background: var(--bg);
+ color: var(--text);
+ display: flex;
+ min-height: 100vh;
+ overflow-x: hidden;
+}
+
+/* Blueprint grid background */
+body::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ background-image:
+ linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px);
+ background-size: 48px 48px;
+ pointer-events: none;
+ z-index: 0;
+}
+
+/* ─── Sidebar ─── */
+.sidebar {
+ width: var(--sidebar-w);
+ background: var(--bg-deep);
+ border-right: 1px solid var(--border-subtle);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ position: fixed;
+ top: 0; left: 0; bottom: 0;
+ z-index: 50;
+ padding-top: 8px;
+}
+
+.sidebar-brand {
+ width: 40px; height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 24px;
+ position: relative;
+}
+
+.sidebar-brand::after {
+ content: '';
+ position: absolute;
+ bottom: -12px;
+ left: 8px; right: 8px;
+ height: 1px;
+ background: var(--border);
+}
+
+.sidebar-brand svg {
+ width: 26px; height: 26px;
+ color: var(--cyan);
+ filter: drop-shadow(0 0 6px rgba(0, 212, 255, 0.4));
+}
+
+.sidebar-nav {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding-top: 16px;
+ width: 100%;
+}
+
+.nav-item {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px; height: 44px;
+ margin: 0 auto;
+ color: var(--text-dim);
+ text-decoration: none;
+ cursor: pointer;
+ border-radius: 8px;
+ transition: all 0.2s;
+ position: relative;
+}
+
+.nav-item:hover {
+ color: var(--text-secondary);
+ background: var(--surface);
+}
+
+.nav-item.active {
+ color: var(--cyan);
+ background: var(--cyan-dim);
+}
+
+.nav-item.active::before {
+ content: '';
+ position: absolute;
+ left: -10px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 3px;
+ height: 20px;
+ background: var(--cyan);
+ border-radius: 0 2px 2px 0;
+ box-shadow: 0 0 8px var(--cyan-glow);
+}
+
+.nav-item svg { width: 20px; height: 20px; }
+
+.nav-tooltip {
+ position: absolute;
+ left: calc(100% + 12px);
+ top: 50%;
+ transform: translateY(-50%);
+ background: var(--surface-raised);
+ border: 1px solid var(--border);
+ color: var(--text);
+ padding: 4px 10px;
+ border-radius: 4px;
+ font-size: 12px;
+ font-family: var(--font-body);
+ white-space: nowrap;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.15s;
+ z-index: 100;
+}
+
+.nav-item:hover .nav-tooltip { opacity: 1; }
+
+/* ─── Main ─── */
+.main {
+ margin-left: var(--sidebar-w);
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ position: relative;
+ z-index: 1;
+}
+
+.topbar {
+ background: rgba(13, 17, 23, 0.85);
+ backdrop-filter: blur(12px);
+ border-bottom: 1px solid var(--border-subtle);
+ padding: 0 32px;
+ height: 56px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ position: sticky;
+ top: 0;
+ z-index: 40;
+}
+
+.topbar-left {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.topbar h2 {
+ font-family: var(--font-display);
+ font-size: 16px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+
+.topbar-tag {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--cyan);
+ background: var(--cyan-dim);
+ padding: 2px 8px;
+ border-radius: 3px;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+}
+
+.page-content {
+ padding: 28px 32px;
+ flex: 1;
+}
+
+/* ─── Animations ─── */
+@keyframes fadeSlideIn {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes pulseGlow {
+ 0%, 100% { box-shadow: 0 0 4px var(--cyan-glow); }
+ 50% { box-shadow: 0 0 12px var(--cyan-glow); }
+}
+
+.animate-in {
+ animation: fadeSlideIn 0.3s ease forwards;
+ opacity: 0;
+}
+
+.animate-in:nth-child(1) { animation-delay: 0.04s; }
+.animate-in:nth-child(2) { animation-delay: 0.08s; }
+.animate-in:nth-child(3) { animation-delay: 0.12s; }
+.animate-in:nth-child(4) { animation-delay: 0.16s; }
+
+/* ─── Cards ─── */
+.card {
+ background: var(--surface);
+ border: 1px solid var(--border-subtle);
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.card-header {
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--border-subtle);
+ font-family: var(--font-display);
+ font-weight: 600;
+ font-size: 13px;
+ letter-spacing: 0.02em;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+}
+
+.card-body { padding: 18px; }
+
+/* ─── Stats ─── */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 12px;
+ margin-bottom: 24px;
+}
+
+.stat-card {
+ background: var(--surface);
+ border: 1px solid var(--border-subtle);
+ border-radius: 6px;
+ padding: 18px 20px;
+ position: relative;
+ overflow: hidden;
+}
+
+.stat-card::before {
+ content: '';
+ position: absolute;
+ top: 0; left: 0;
+ width: 100%; height: 2px;
+ background: linear-gradient(90deg, var(--cyan), transparent);
+ opacity: 0.6;
+}
+
+.stat-label {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 1.5px;
+}
+
+.stat-value {
+ font-family: var(--font-display);
+ font-size: 32px;
+ font-weight: 700;
+ margin-top: 4px;
+ color: var(--text);
+ letter-spacing: -0.02em;
+}
+
+.stat-value.stat-sm {
+ font-size: 14px;
+ font-weight: 500;
+ font-family: var(--font-mono);
+}
+
+/* ─── Tables ─── */
+table { width: 100%; border-collapse: collapse; }
+
+th {
+ text-align: left;
+ padding: 10px 16px;
+ background: var(--bg);
+ border-bottom: 1px solid var(--border);
+ font-family: var(--font-mono);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 1.2px;
+ color: var(--text-dim);
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+td {
+ padding: 11px 16px;
+ border-bottom: 1px solid var(--border-subtle);
+ font-size: 13px;
+}
+
+tbody tr { transition: background 0.1s; }
+tbody tr:hover td { background: rgba(0, 212, 255, 0.03); }
+tbody tr:last-child td { border-bottom: none; }
+
+/* ─── Badges ─── */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px;
+ border-radius: 3px;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+}
+
+.badge svg { width: 12px; height: 12px; flex-shrink: 0; }
+.badge-cyan { background: var(--cyan-dim); color: var(--cyan); }
+.badge-amber { background: var(--amber-dim); color: var(--amber); }
+.badge-green { background: var(--green-dim); color: var(--green); }
+.badge-count {
+ background: var(--surface-raised);
+ color: var(--text-secondary);
+ border: 1px solid var(--border);
+}
+
+/* ─── Buttons ─── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ border-radius: 4px;
+ font-family: var(--font-body);
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ text-decoration: none;
+ border: 1px solid var(--border);
+ background: var(--surface-raised);
+ color: var(--text-secondary);
+ transition: all 0.15s;
+ white-space: nowrap;
+}
+
+.btn:hover {
+ background: var(--surface);
+ color: var(--text);
+ border-color: var(--text-dim);
+}
+
+.btn svg { width: 13px; height: 13px; }
+
+.btn-cyan {
+ background: var(--cyan-dim);
+ color: var(--cyan);
+ border-color: rgba(0, 212, 255, 0.2);
+}
+
+.btn-cyan:hover {
+ background: rgba(0, 212, 255, 0.2);
+ border-color: rgba(0, 212, 255, 0.4);
+ color: var(--cyan);
+}
+
+.btn-sm { padding: 3px 8px; font-size: 11px; }
+
+/* ─── Search ─── */
+.search-box {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 0 12px;
+ height: 34px;
+ width: 300px;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.search-box:focus-within {
+ border-color: var(--cyan);
+ box-shadow: 0 0 0 1px var(--cyan-dim);
+}
+
+.search-box svg {
+ width: 14px; height: 14px;
+ color: var(--text-dim);
+ flex-shrink: 0;
+}
+
+.search-box input {
+ border: none;
+ outline: none;
+ font-family: var(--font-body);
+ font-size: 13px;
+ width: 100%;
+ background: transparent;
+ color: var(--text);
+}
+
+.search-box input::placeholder { color: var(--text-dim); }
+
+/* ─── Clickable ─── */
+.clickable { cursor: pointer; }
+
+/* ─── Detail sections ─── */
+.detail-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 20px;
+}
+
+.detail-field label {
+ display: block;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 1.2px;
+ color: var(--text-dim);
+ margin-bottom: 4px;
+}
+
+.detail-field .value {
+ font-size: 14px;
+ font-weight: 500;
+ word-break: break-all;
+}
+
+.detail-field .value.mono {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+/* ─── Back link ─── */
+.back-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--text-dim);
+ text-decoration: none;
+ font-size: 12px;
+ cursor: pointer;
+ margin-bottom: 20px;
+ font-family: var(--font-mono);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ transition: color 0.15s;
+}
+
+.back-link:hover { color: var(--cyan); }
+.back-link svg { width: 14px; height: 14px; }
+
+/* ─── BOM Expansion ─── */
+.bom-expand-row td {
+ padding: 0 !important;
+ background: var(--bg) !important;
+}
+
+.bom-expand-content {
+ padding: 16px 16px 16px 48px;
+ border-left: 2px solid var(--cyan-dim);
+ margin-left: 16px;
+}
+
+.bom-expand-content .info-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 6px 24px;
+}
+
+.bom-expand-content .info-item {
+ font-size: 12px;
+ padding: 2px 0;
+}
+
+.bom-expand-content .info-item .lbl {
+ color: var(--text-dim);
+ font-family: var(--font-mono);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-right: 6px;
+}
+
+.bom-expand-content .info-item .val {
+ font-family: var(--font-mono);
+ color: var(--text);
+}
+
+.bom-section-title {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 1.5px;
+ color: var(--cyan);
+ margin: 14px 0 8px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.bom-section-title svg { width: 14px; height: 14px; flex-shrink: 0; }
+.bom-section-title:first-child { margin-top: 0; }
+
+.bom-section-title::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: var(--border-subtle);
+}
+
+/* ─── File Browser ─── */
+.breadcrumb {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ margin-bottom: 16px;
+ flex-wrap: wrap;
+ padding: 8px 14px;
+ background: var(--surface);
+ border: 1px solid var(--border-subtle);
+ border-radius: 4px;
+}
+
+.breadcrumb a {
+ color: var(--cyan);
+ text-decoration: none;
+ cursor: pointer;
+ transition: opacity 0.15s;
+}
+
+.breadcrumb a:hover { opacity: 0.7; }
+.breadcrumb .sep { color: var(--text-dim); font-size: 10px; }
+.breadcrumb .current { color: var(--text); font-weight: 500; }
+
+.file-name-cell {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.file-name-cell svg { width: 18px; height: 18px; flex-shrink: 0; }
+
+.file-name-cell a {
+ color: var(--text);
+ text-decoration: none;
+ cursor: pointer;
+ transition: color 0.15s;
+}
+
+.file-name-cell a:hover { color: var(--cyan); }
+
+/* ─── Loading / Empty ─── */
+.loading, .empty {
+ text-align: center;
+ padding: 60px 24px;
+ color: var(--text-dim);
+ font-size: 13px;
+ font-family: var(--font-mono);
+}
+
+.loading::before {
+ content: '';
+ display: block;
+ width: 24px;
+ height: 24px;
+ border: 2px solid var(--border);
+ border-top-color: var(--cyan);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ margin: 0 auto 12px;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* ─── Chevron toggle ─── */
+.chevron-toggle {
+ display: inline-flex;
+ width: 18px; height: 18px;
+ align-items: center;
+ justify-content: center;
+ transition: transform 0.2s;
+ color: var(--text-dim);
+}
+
+.chevron-toggle.open {
+ transform: rotate(90deg);
+ color: var(--cyan);
+}
+
+/* ─── Drawing cards grid ─── */
+.drawings-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 12px;
+}
+
+.drawing-card {
+ background: var(--surface);
+ border: 1px solid var(--border-subtle);
+ border-radius: 6px;
+ padding: 18px 20px;
+ cursor: pointer;
+ transition: all 0.2s;
+ position: relative;
+}
+
+.drawing-card:hover {
+ border-color: var(--cyan);
+ background: rgba(0, 212, 255, 0.03);
+ box-shadow: 0 0 0 1px var(--cyan-dim);
+}
+
+.drawing-card-title {
+ font-family: var(--font-display);
+ font-size: 15px;
+ font-weight: 600;
+ margin-bottom: 2px;
+}
+
+.drawing-card-sub {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+/* ─── Equipment Groups ─── */
+.equip-group {
+ margin-bottom: 16px;
+}
+
+.equip-group:last-child { margin-bottom: 0; }
+
+.equip-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 16px;
+ background: var(--surface);
+ border: 1px solid var(--border-subtle);
+ border-radius: 6px 6px 0 0;
+ cursor: pointer;
+ transition: all 0.15s;
+ user-select: none;
+}
+
+.equip-header:hover { background: rgba(0, 212, 255, 0.03); }
+
+.equip-header .chevron-toggle { flex-shrink: 0; }
+
+.equip-header-title {
+ font-family: var(--font-display);
+ font-size: 15px;
+ font-weight: 600;
+}
+
+.equip-header-number {
+ font-family: var(--font-mono);
+ font-size: 13px;
+ color: var(--cyan);
+ font-weight: 600;
+}
+
+.equip-header-meta {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.equip-header-stat {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--text-dim);
+}
+
+.equip-header-stat strong {
+ color: var(--text-secondary);
+}
+
+.equip-body {
+ border: 1px solid var(--border-subtle);
+ border-top: none;
+ border-radius: 0 0 6px 6px;
+ overflow: hidden;
+}
+
+.equip-body table { margin: 0; }
+
+.equip-group.collapsed .equip-body { display: none; }
+.equip-group.collapsed .equip-header { border-radius: 6px; }
+
+/* ─── Responsive ─── */
+@media (max-width: 768px) {
+ .sidebar { display: none; }
+ .main { margin-left: 0; }
+ .search-box { width: 100%; }
+ .topbar { padding: 0 16px; }
+ .page-content { padding: 16px; }
+}
diff --git a/FabWorks.Api/wwwroot/index.html b/FabWorks.Api/wwwroot/index.html
new file mode 100644
index 0000000..0d6c7e3
--- /dev/null
+++ b/FabWorks.Api/wwwroot/index.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+ FabWorks
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FabWorks.Api/wwwroot/js/components.js b/FabWorks.Api/wwwroot/js/components.js
new file mode 100644
index 0000000..de03a79
--- /dev/null
+++ b/FabWorks.Api/wwwroot/js/components.js
@@ -0,0 +1,60 @@
+/* ─── BOM Detail Expansion ─── */
+function renderBomDetails(b) {
+ let html = '';
+
+ if (b.cutTemplate) {
+ const ct = b.cutTemplate;
+ const displayName = ct.dxfFilePath?.split(/[/\\]/).pop() || '';
+ html += `
+
${icons.laser} Cut Template
+
+
File${esc(displayName)}
+
Thickness${fmtThickness(ct.thickness)}
+
K-Factor${ct.kFactor != null ? ct.kFactor : '\u2014'}
+
Bend Radius${ct.defaultBendRadius != null ? ct.defaultBendRadius.toFixed(4) + '"' : '\u2014'}
+
`;
+
+ if (ct.contentHash) {
+ html += `
`;
+ }
+ }
+
+ if (b.formProgram) {
+ const fp = b.formProgram;
+ html += `
+
${icons.bend} Form Program
+
+
Program${esc(fp.programName)}
+
Thickness${fmtThickness(fp.thickness)}
+
Material${esc(fp.materialType)}
+
K-Factor${fp.kFactor != null ? fp.kFactor : '\u2014'}
+
Bends${fp.bendCount}
+
Upper Tools${esc(fp.upperToolNames) || '\u2014'}
+
Lower Tools${esc(fp.lowerToolNames) || '\u2014'}
+
+ ${fp.setupNotes ? `
Setup Notes${esc(fp.setupNotes)}
` : ''}`;
+ }
+
+ html += '
';
+ return html;
+}
+
+function toggleEquipGroup(id) {
+ const group = document.getElementById(id);
+ const icon = document.getElementById(id + '-icon');
+ if (!group) return;
+ group.classList.toggle('collapsed');
+ if (icon) icon.classList.toggle('open', !group.classList.contains('collapsed'));
+}
+
+function toggleBomRow(id) {
+ const row = document.getElementById(id);
+ const icon = document.getElementById(id + '-icon');
+ if (!row) return;
+ const visible = row.style.display !== 'none';
+ row.style.display = visible ? 'none' : '';
+ if (icon) icon.classList.toggle('open', !visible);
+}
diff --git a/FabWorks.Api/wwwroot/js/helpers.js b/FabWorks.Api/wwwroot/js/helpers.js
new file mode 100644
index 0000000..c0792e4
--- /dev/null
+++ b/FabWorks.Api/wwwroot/js/helpers.js
@@ -0,0 +1,36 @@
+function fmtSize(b) {
+ if (!b) return '0 B';
+ const k = 1024, s = ['B','KB','MB','GB'];
+ const i = Math.floor(Math.log(b) / Math.log(k));
+ return parseFloat((b / Math.pow(k, i)).toFixed(1)) + ' ' + s[i];
+}
+
+function fmtDate(d) {
+ if (!d) return '';
+ const dt = new Date(d);
+ return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +
+ ' ' + dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+}
+
+function fmtThickness(t) {
+ if (t == null) return '\u2014';
+ return `${t.toFixed(4)}"`;
+}
+
+function esc(s) {
+ return s ? s.replace(//g,'>').replace(/"/g,'"').replace(/'/g,''') : '';
+}
+
+function setPage(title, tag = '') {
+ document.getElementById('page-title').textContent = title;
+ document.getElementById('page-tag').textContent = tag;
+ document.getElementById('page-tag').style.display = tag ? '' : 'none';
+}
+
+const api = {
+ async get(url) {
+ const r = await fetch(url);
+ if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
+ return r.json();
+ }
+};
diff --git a/FabWorks.Api/wwwroot/js/icons.js b/FabWorks.Api/wwwroot/js/icons.js
new file mode 100644
index 0000000..97a1bda
--- /dev/null
+++ b/FabWorks.Api/wwwroot/js/icons.js
@@ -0,0 +1,19 @@
+const icons = {
+ search: ``,
+ folder: ``,
+ fileDxf: ``,
+ filePdf: ``,
+ fileGeneric: ``,
+ download: ``,
+ back: ``,
+ chevron: ``,
+ laser: ``,
+ bend: ``,
+};
+
+function fileIcon(name) {
+ const ext = name.split('.').pop().toLowerCase();
+ if (ext === 'dxf') return icons.fileDxf;
+ if (ext === 'pdf') return icons.filePdf;
+ return icons.fileGeneric;
+}
diff --git a/FabWorks.Api/wwwroot/js/pages.js b/FabWorks.Api/wwwroot/js/pages.js
new file mode 100644
index 0000000..f4c99d6
--- /dev/null
+++ b/FabWorks.Api/wwwroot/js/pages.js
@@ -0,0 +1,396 @@
+const pages = {
+ async exports(params) {
+ const actions = document.getElementById('topbar-actions');
+ const content = document.getElementById('page-content');
+ setPage('Exports');
+
+ const searchVal = params.q || '';
+ actions.innerHTML = `
+
+ ${icons.search}
+
+
`;
+
+ content.innerHTML = `Loading exports
`;
+
+ const searchInput = document.getElementById('export-search');
+ let debounce;
+ searchInput.addEventListener('input', () => {
+ clearTimeout(debounce);
+ debounce = setTimeout(() => router.go('exports', { q: searchInput.value }), 400);
+ });
+
+ try {
+ const searchQ = searchVal ? `&search=${encodeURIComponent(searchVal)}` : '';
+ const data = await api.get(`/api/exports?take=500${searchQ}`);
+
+ if (data.items.length === 0) {
+ content.innerHTML = `No exports found.
`;
+ return;
+ }
+
+ // Deduplicate: keep only the latest export per drawing number
+ const seen = new Set();
+ const unique = data.items.filter(e => {
+ const dn = e.drawingNumber || '';
+ if (seen.has(dn)) return false;
+ seen.add(dn);
+ return true;
+ });
+
+ // Group by equipment number (first token of drawing number)
+ const groups = new Map();
+ unique.forEach(e => {
+ const dn = e.drawingNumber || '';
+ const spaceIdx = dn.indexOf(' ');
+ const equip = spaceIdx > 0 ? dn.substring(0, spaceIdx) : (dn || 'Other');
+ if (!groups.has(equip)) groups.set(equip, []);
+ groups.get(equip).push(e);
+ });
+
+ // Sort equipment groups by number descending (most recent equipment first)
+ const sortedGroups = [...groups.entries()].sort((a, b) => {
+ const numA = parseInt(a[0]) || 0;
+ const numB = parseInt(b[0]) || 0;
+ return numB - numA;
+ });
+
+ const uniqueEquip = sortedGroups.length;
+ const uniqueDrawings = unique.length;
+ setPage('Exports', `${uniqueDrawings} drawings / ${uniqueEquip} equipment`);
+
+ const groupsHtml = sortedGroups.map(([equip, items], gi) => {
+ const totalBom = items.reduce((s, e) => s + e.bomItemCount, 0);
+
+ const rows = items.map((e, i) => {
+ const dn = e.drawingNumber || '';
+ const spaceIdx = dn.indexOf(' ');
+ const drawingPart = spaceIdx > 0 ? dn.substring(spaceIdx + 1) : dn;
+
+ return `
+
+ | ${e.id} |
+ ${esc(drawingPart) || '\u2014'} |
+ ${esc(e.title) || ''} |
+ ${e.bomItemCount} |
+ ${esc(e.exportedBy)} |
+ ${fmtDate(e.exportedAt)} |
+
`;
+ }).join('');
+
+ return `
+
+
+
+
+
+ | # |
+ Drawing |
+ Title |
+ Items |
+ Exported By |
+ Date |
+
+ ${rows}
+
+
+
`;
+ }).join('');
+
+ content.innerHTML = `
+
+
Drawings
${uniqueDrawings}
+
+
+ ${groupsHtml}`;
+ } catch (err) {
+ content.innerHTML = `Error: ${esc(err.message)}
`;
+ }
+ },
+
+ async exportDetail(id) {
+ const actions = document.getElementById('topbar-actions');
+ const content = document.getElementById('page-content');
+ setPage('Loading...');
+ actions.innerHTML = '';
+ content.innerHTML = `Loading export
`;
+
+ try {
+ const exp = await api.get(`/api/exports/${id}`);
+ setPage(exp.drawingNumber || `Export #${exp.id}`, 'export detail');
+
+ const dxfCount = (exp.bomItems || []).filter(b => b.cutTemplate?.contentHash).length;
+
+ const bomRows = (exp.bomItems || []).map((b, i) => {
+ const hasDetails = b.cutTemplate || b.formProgram;
+ const toggleId = `bom-${b.id}`;
+ return `
+
+ | ${hasDetails ? `${icons.chevron}` : ''} |
+ ${esc(b.itemNo)} |
+ ${esc(b.partName)} |
+ ${esc(b.description)} |
+ ${esc(b.material)} |
+ ${b.qty ?? ''} |
+ ${b.totalQty ?? ''} |
+
+ ${b.cutTemplate ? `${icons.laser} DXF` : ''}
+ ${b.formProgram ? `${icons.bend} Form` : ''}
+ |
+
+ ${hasDetails ? `| ${renderBomDetails(b)} |
` : ''}`;
+ }).join('');
+
+ content.innerHTML = `
+ ${icons.back} Back to exports
+
+
+
+
+
+
${esc(exp.drawingNumber) || '\u2014'}
+ ${exp.title ? `
` : ''}
+
${esc(exp.exportedBy)}
+
${fmtDate(exp.exportedAt)}
+
${esc(exp.sourceFilePath)}
+
+
+
+
+
+
+ ${exp.bomItems?.length ? `
+
+
+ |
+ Item |
+ Part Name |
+ Description |
+ Material |
+ Qty |
+ Total |
+ Data |
+
+ ${bomRows}
+
` : '
No BOM items for this export.
'}
+
`;
+ } catch (err) {
+ content.innerHTML = `Error: ${esc(err.message)}
`;
+ }
+ },
+
+ async drawings() {
+ const actions = document.getElementById('topbar-actions');
+ const content = document.getElementById('page-content');
+ setPage('Drawings');
+ actions.innerHTML = '';
+ content.innerHTML = `Loading drawings
`;
+
+ try {
+ const numbers = await api.get('/api/exports/drawing-numbers');
+ if (numbers.length === 0) {
+ content.innerHTML = `No drawings found.
`;
+ return;
+ }
+
+ numbers.sort();
+ setPage('Drawings', `${numbers.length} drawings`);
+
+ const cards = numbers.map((d, i) => `
+ `).join('');
+
+ content.innerHTML = `
+
+
Total Drawings
${numbers.length}
+
+ ${cards}
`;
+ } catch (err) {
+ content.innerHTML = `Error: ${esc(err.message)}
`;
+ }
+ },
+
+ async drawingDetail(drawingEncoded) {
+ const drawingNumber = decodeURIComponent(drawingEncoded);
+ const actions = document.getElementById('topbar-actions');
+ const content = document.getElementById('page-content');
+ setPage(drawingNumber, 'drawing');
+ actions.innerHTML = '';
+ content.innerHTML = `Loading drawing
`;
+
+ try {
+ const exports = await api.get(`/api/exports/by-drawing?drawingNumber=${encodeURIComponent(drawingNumber)}`);
+
+ if (exports.length === 0) {
+ content.innerHTML = `
+ ${icons.back} Back to drawings
+ No exports found for this drawing.
`;
+ return;
+ }
+
+ const allBom = [];
+ exports.forEach(exp => {
+ (exp.bomItems || []).forEach(b => {
+ allBom.push({ ...b, exportId: exp.id, exportedAt: exp.exportedAt });
+ });
+ });
+
+ const bomRows = allBom.map((b, i) => {
+ const hasDetails = b.cutTemplate || b.formProgram;
+ const toggleId = `dbom-${b.id}`;
+ return `
+
+ | ${hasDetails ? `${icons.chevron}` : ''} |
+ ${esc(b.itemNo)} |
+ ${esc(b.partName)} |
+ ${esc(b.description)} |
+ ${esc(b.material)} |
+ ${b.qty ?? ''} |
+ ${b.totalQty ?? ''} |
+
+ ${b.cutTemplate ? `${icons.laser} DXF` : ''}
+ ${b.formProgram ? `${icons.bend} Form` : ''}
+ |
+
+ ${hasDetails ? `| ${renderBomDetails(b)} |
` : ''}`;
+ }).join('');
+
+ content.innerHTML = `
+ ${icons.back} Back to drawings
+
+
+
+
BOM Items
${allBom.length}
+
Latest Export
${fmtDate(exports[0].exportedAt)}
+
+
+
+
+ ${allBom.length ? `
+
+
+ |
+ Item |
+ Part Name |
+ Description |
+ Material |
+ Qty |
+ Total |
+ Data |
+
+ ${bomRows}
+
` : '
No BOM items.
'}
+
`;
+ } catch (err) {
+ content.innerHTML = `Error: ${esc(err.message)}
`;
+ }
+ },
+
+ async files(params) {
+ const actions = document.getElementById('topbar-actions');
+ const content = document.getElementById('page-content');
+ setPage('Files');
+
+ const searchVal = params.q || '';
+ actions.innerHTML = `
+
+
+ ${icons.search}
+
+
+
+
`;
+
+ content.innerHTML = `Loading files
`;
+
+ const searchInput = document.getElementById('file-search');
+ const typeFilter = document.getElementById('file-type-filter');
+ let debounce;
+ const refresh = () => {
+ clearTimeout(debounce);
+ debounce = setTimeout(() => router.go('files', { q: searchInput.value + (typeFilter.value ? '&type=' + typeFilter.value : '') }), 400);
+ };
+ searchInput.addEventListener('input', refresh);
+ typeFilter.addEventListener('change', refresh);
+
+ // Parse search and type from combined param
+ let searchQ = searchVal;
+ let typeQ = '';
+ if (searchVal.includes('&type=')) {
+ const parts = searchVal.split('&type=');
+ searchQ = parts[0];
+ typeQ = parts[1] || '';
+ searchInput.value = searchQ;
+ typeFilter.value = typeQ;
+ }
+
+ try {
+ let url = '/api/filebrowser/files?';
+ if (searchQ) url += `search=${encodeURIComponent(searchQ)}&`;
+ if (typeQ) url += `type=${encodeURIComponent(typeQ)}&`;
+ const data = await api.get(url);
+
+ setPage('Files', `${data.total} files`);
+
+ if (data.files.length === 0) {
+ content.innerHTML = `No files found.
`;
+ return;
+ }
+
+ const rows = data.files.map((f, i) => {
+ const ext = f.fileType || f.fileName.split('.').pop().toLowerCase();
+ const hashShort = f.contentHash ? f.contentHash.substring(0, 12) : '';
+ return `
+
+ |
+ ${ext.toUpperCase()} |
+ ${esc(f.drawingNumber)} |
+ ${f.thickness != null ? f.thickness.toFixed(4) + '"' : '\u2014'} |
+ ${fmtDate(f.createdAt)} |
+ ${esc(hashShort)} |
+
+ ${icons.download}
+ |
+
`;
+ }).join('');
+
+ content.innerHTML = `
+
+
+
+ | Name |
+ Type |
+ Drawing |
+ Thickness |
+ Date |
+ Hash |
+ Actions |
+
+ ${rows}
+
+
`;
+ } catch (err) {
+ content.innerHTML = `Error: ${esc(err.message)}
`;
+ }
+ }
+};
diff --git a/FabWorks.Api/wwwroot/js/router.js b/FabWorks.Api/wwwroot/js/router.js
new file mode 100644
index 0000000..5b5a311
--- /dev/null
+++ b/FabWorks.Api/wwwroot/js/router.js
@@ -0,0 +1,35 @@
+const router = {
+ go(page, params = {}) {
+ const hash = page + (params.id ? '/' + params.id : '') + (params.q ? '?q=' + encodeURIComponent(params.q) : '');
+ location.hash = hash;
+ },
+ parse() {
+ const h = location.hash.slice(1) || 'exports';
+ const [path, qs] = h.split('?');
+ const parts = path.split('/');
+ const params = {};
+ if (qs) qs.split('&').forEach(p => { const [k,v] = p.split('='); params[k] = decodeURIComponent(v); });
+ return { page: parts[0], id: parts[1], params };
+ },
+ init() {
+ window.addEventListener('hashchange', () => this.dispatch());
+ this.dispatch();
+ },
+ dispatch() {
+ const { page, id, params } = this.parse();
+ document.querySelectorAll('.nav-item').forEach(el => {
+ el.classList.toggle('active',
+ el.dataset.page === page ||
+ (page === 'export-detail' && el.dataset.page === 'exports') ||
+ (page === 'drawing-detail' && el.dataset.page === 'drawings'));
+ });
+ switch(page) {
+ case 'exports': pages.exports(params); break;
+ case 'export-detail': pages.exportDetail(id); break;
+ case 'drawings': pages.drawings(); break;
+ case 'drawing-detail': pages.drawingDetail(id, params); break;
+ case 'files': pages.files(params); break;
+ default: pages.exports(params);
+ }
+ }
+};