Files
claudetools/projects/dataforth-dos/datasheet-pipeline/implementation-upload/public/index.html
Mike Swanson 733d87f20e Dataforth UI push + dedup + refactor, GuruRMM roadmap evolution, Azure signing setup
Dataforth (projects/dataforth-dos/):
- UI feature: row coloring + PUSH/RE-PUSH buttons + Website Status filter
- Database dedup to one row per SN (2.89M -> 469K rows, UNIQUE constraint added)
- Import logic handles FAIL -> PASS retest transition
- Refactored upload-to-api.js to render datasheets in-memory (dropped For_Web filesystem dep)
- Bulk pushed 170,984 records to Hoffman API
- Statistical sanity check: 100/100 stamped SNs verified on Hoffman

GuruRMM (projects/msp-tools/guru-rmm/):
- ROADMAP.md: added Terminology (5-tier hierarchy), Tunnel Channels Phase 2,
  Logging/Audit/Observability, Multi-tenancy, Modular Architecture,
  Protocol Versioning, Certificates sections + Decisions Log
- CONTEXT.md: hierarchy table, new anti-patterns (bootstrap sacred,
  no cross-module imports), revised next-steps priorities

Session logs for both projects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:39:32 -07:00

1919 lines
70 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DATAFORTH Test Data System</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
/* Core palette - industrial amber on deep blue-black */
--bg-primary: #0a0e14;
--bg-secondary: #101820;
--bg-tertiary: #151d28;
--bg-elevated: #1a2433;
--accent-primary: #f0a500;
--accent-secondary: #d4920a;
--accent-glow: rgba(240, 165, 0, 0.15);
--accent-glow-strong: rgba(240, 165, 0, 0.3);
--text-primary: #e8eaed;
--text-secondary: #9aa5b1;
--text-muted: #5f6b7a;
--text-inverse: #0a0e14;
--success: #00d26a;
--success-bg: rgba(0, 210, 106, 0.1);
--danger: #ff4757;
--danger-bg: rgba(255, 71, 87, 0.1);
--border-subtle: rgba(255, 255, 255, 0.06);
--border-medium: rgba(255, 255, 255, 0.1);
--border-accent: rgba(240, 165, 0, 0.3);
/* Typography */
--font-display: 'Outfit', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
/* Spacing */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
/* Effects */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow-glow: 0 0 30px var(--accent-glow);
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4);
--transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1);
--transition-medium: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Base */
html {
font-size: 15px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-display);
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.5;
position: relative;
overflow-x: hidden;
}
/* Scanline texture overlay */
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.03) 2px,
rgba(0, 0, 0, 0.03) 4px
);
pointer-events: none;
z-index: 9999;
}
/* Grid pattern background */
body::after {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(var(--border-subtle) 1px, transparent 1px),
linear-gradient(90deg, var(--border-subtle) 1px, transparent 1px);
background-size: 60px 60px;
pointer-events: none;
z-index: -1;
}
/* Layout */
.app-container {
max-width: 1800px;
margin: 0 auto;
padding: var(--space-lg);
display: grid;
grid-template-columns: 1fr;
gap: var(--space-lg);
}
/* Header */
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md) 0;
border-bottom: 1px solid var(--border-medium);
margin-bottom: var(--space-md);
}
.brand {
display: flex;
align-items: center;
gap: var(--space-md);
}
.brand-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-glow);
position: relative;
}
.brand-icon::before {
content: 'DF';
font-family: var(--font-mono);
font-weight: 700;
font-size: 16px;
color: var(--text-inverse);
letter-spacing: -1px;
}
.brand-text h1 {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.5px;
background: linear-gradient(135deg, var(--text-primary), var(--text-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.brand-text span {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 400;
letter-spacing: 2px;
text-transform: uppercase;
}
.system-status {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--bg-tertiary);
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
}
.status-indicator {
width: 8px;
height: 8px;
background: var(--success);
border-radius: 50%;
box-shadow: 0 0 8px var(--success);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.status-text {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-secondary);
}
/* Stats Dashboard */
.stats-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: var(--space-lg);
position: relative;
overflow: hidden;
transition: var(--transition-medium);
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--accent-primary), transparent);
opacity: 0;
transition: var(--transition-fast);
}
.stat-card:hover {
border-color: var(--border-accent);
transform: translateY(-2px);
}
.stat-card:hover::before {
opacity: 1;
}
.stat-card.highlight {
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary));
border-color: var(--border-accent);
}
.stat-card.highlight::before {
opacity: 1;
}
.stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-muted);
margin-bottom: var(--space-sm);
font-weight: 500;
}
.stat-value {
font-family: var(--font-mono);
font-size: 2rem;
font-weight: 700;
color: var(--accent-primary);
line-height: 1;
}
.stat-value.success { color: var(--success); }
.stat-value.danger { color: var(--danger); }
.stat-value.small { font-size: 1rem; }
.stat-meta {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: var(--space-sm);
font-family: var(--font-mono);
}
/* Main Content Grid */
.main-grid {
display: grid;
grid-template-columns: 380px 1fr;
gap: var(--space-lg);
align-items: start;
}
/* Search Panel */
.search-panel {
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
overflow: hidden;
position: sticky;
top: var(--space-lg);
}
.panel-header {
padding: var(--space-md) var(--space-lg);
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-subtle);
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-title {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--accent-primary);
font-weight: 600;
}
.panel-toggle {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: var(--space-xs);
transition: var(--transition-fast);
font-size: 0.75rem;
font-family: var(--font-mono);
}
.panel-toggle:hover {
color: var(--accent-primary);
}
.panel-body {
padding: var(--space-lg);
}
/* Form Elements */
.form-group {
margin-bottom: var(--space-md);
}
.form-label {
display: block;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-secondary);
margin-bottom: var(--space-xs);
font-weight: 500;
}
.form-input,
.form-select {
width: 100%;
padding: var(--space-sm) var(--space-md);
background: var(--bg-primary);
border: 1px solid var(--border-medium);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.9rem;
transition: var(--transition-fast);
}
.form-input::placeholder {
color: var(--text-muted);
}
.form-input:focus,
.form-select:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.form-select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%239aa5b1' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-sm);
}
/* Quick Filters */
.quick-filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
margin-bottom: var(--space-lg);
padding-bottom: var(--space-md);
border-bottom: 1px solid var(--border-subtle);
}
.quick-filter {
padding: var(--space-xs) var(--space-sm);
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 0.7rem;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
transition: var(--transition-fast);
}
.quick-filter:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.quick-filter.active {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: var(--text-inverse);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-family: var(--font-display);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: var(--transition-fast);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
color: var(--text-inverse);
box-shadow: 0 4px 12px rgba(240, 165, 0, 0.3);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(240, 165, 0, 0.4);
}
.btn-secondary {
background: var(--bg-tertiary);
border-color: var(--border-medium);
color: var(--text-secondary);
}
.btn-secondary:hover {
border-color: var(--text-muted);
color: var(--text-primary);
}
.btn-success {
background: var(--success);
color: var(--text-inverse);
}
.btn-success:hover {
box-shadow: 0 4px 12px rgba(0, 210, 106, 0.3);
}
.btn-block {
width: 100%;
}
.btn-group {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-md);
}
.btn-group .btn {
flex: 1;
}
/* Results Panel */
.results-panel {
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 600px;
}
.results-header {
padding: var(--space-md) var(--space-lg);
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-subtle);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--space-md);
}
.results-count {
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--text-secondary);
}
.results-count strong {
color: var(--accent-primary);
font-weight: 600;
}
.results-actions {
display: flex;
gap: var(--space-sm);
align-items: center;
}
.batch-indicator {
font-family: var(--font-mono);
font-size: 0.75rem;
padding: var(--space-xs) var(--space-sm);
background: var(--accent-glow);
border: 1px solid var(--border-accent);
border-radius: var(--radius-sm);
color: var(--accent-primary);
}
/* Table */
.table-wrapper {
flex: 1;
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
position: sticky;
top: 0;
z-index: 10;
}
th {
background: var(--bg-tertiary);
padding: var(--space-sm) var(--space-md);
text-align: left;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
font-weight: 600;
border-bottom: 1px solid var(--border-medium);
white-space: nowrap;
}
th.sortable {
cursor: pointer;
transition: var(--transition-fast);
}
th.sortable:hover {
color: var(--accent-primary);
}
th.sortable::after {
content: '↕';
margin-left: var(--space-xs);
opacity: 0.3;
}
td {
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border-subtle);
font-size: 0.85rem;
vertical-align: middle;
}
tr {
transition: var(--transition-fast);
}
tbody tr:hover {
background: var(--bg-tertiary);
}
.cell-serial {
font-family: var(--font-mono);
font-weight: 600;
color: var(--accent-primary);
}
.cell-model {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-secondary);
}
.cell-date {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-muted);
}
.cell-station {
font-family: var(--font-mono);
font-size: 0.75rem;
padding: var(--space-xs) var(--space-sm);
background: var(--bg-primary);
border-radius: var(--radius-sm);
display: inline-block;
}
.cell-product {
font-size: 0.75rem;
color: var(--text-muted);
}
/* Result Badges */
.result-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.result-badge.pass {
background: var(--success-bg);
color: var(--success);
border: 1px solid rgba(0, 210, 106, 0.2);
}
.result-badge.pass::before {
content: '●';
font-size: 6px;
}
.result-badge.fail {
background: var(--danger-bg);
color: var(--danger);
border: 1px solid rgba(255, 71, 87, 0.2);
}
.result-badge.fail::before {
content: '●';
font-size: 6px;
}
/* Action Links */
.action-links {
display: flex;
gap: var(--space-xs);
}
.action-link {
background: none;
border: 1px solid var(--border-subtle);
color: var(--text-muted);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
font-size: 0.7rem;
font-family: var(--font-mono);
cursor: pointer;
transition: var(--transition-fast);
text-transform: uppercase;
}
.action-link:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
/* Not-on-website records: pink row tint + accent PUSH button */
tbody tr.not-on-web {
background: rgba(255, 99, 132, 0.06);
}
tbody tr.not-on-web:hover {
background: rgba(255, 99, 132, 0.12);
}
.action-link.push {
border-color: rgba(255, 99, 132, 0.5);
color: #ff6384;
}
.action-link.push:hover {
border-color: #ff6384;
color: #ff6384;
background: rgba(255, 99, 132, 0.08);
}
.action-link.push:disabled {
border-color: var(--border-subtle);
color: var(--text-muted);
opacity: 0.4;
cursor: not-allowed;
}
/* Checkbox */
.checkbox-cell {
width: 40px;
text-align: center;
}
input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent-primary);
cursor: pointer;
}
/* Pagination */
.pagination {
padding: var(--space-md) var(--space-lg);
border-top: 1px solid var(--border-subtle);
display: flex;
justify-content: center;
align-items: center;
gap: var(--space-md);
background: var(--bg-tertiary);
}
.pagination-info {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-muted);
}
.pagination-buttons {
display: flex;
gap: var(--space-xs);
}
.page-btn {
min-width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 0.8rem;
cursor: pointer;
transition: var(--transition-fast);
}
.page-btn:hover:not(:disabled) {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.page-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.page-btn.active {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: var(--text-inverse);
}
/* Loading & Empty States */
.state-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-2xl);
color: var(--text-muted);
text-align: center;
min-height: 300px;
}
.state-icon {
width: 64px;
height: 64px;
margin-bottom: var(--space-md);
opacity: 0.3;
}
.state-title {
font-size: 1rem;
color: var(--text-secondary);
margin-bottom: var(--space-xs);
}
.state-subtitle {
font-size: 0.85rem;
color: var(--text-muted);
}
/* Spinner */
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-subtle);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: var(--space-md);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: var(--transition-medium);
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border-medium);
border-radius: var(--radius-lg);
max-width: 900px;
width: 95%;
max-height: 90vh;
overflow: hidden;
transform: translateY(20px) scale(0.95);
transition: var(--transition-medium);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.modal-overlay.active .modal {
transform: translateY(0) scale(1);
}
.modal-header {
padding: var(--space-md) var(--space-lg);
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-subtle);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-size: 1rem;
font-weight: 600;
color: var(--accent-primary);
text-transform: uppercase;
letter-spacing: 1px;
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.5rem;
cursor: pointer;
padding: var(--space-xs);
line-height: 1;
transition: var(--transition-fast);
}
.modal-close:hover {
color: var(--danger);
}
.modal-body {
padding: var(--space-lg);
max-height: calc(90vh - 80px);
overflow-y: auto;
}
/* Record Detail Grid */
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.detail-item {
background: var(--bg-tertiary);
padding: var(--space-md);
border-radius: var(--radius-md);
border-left: 3px solid var(--accent-primary);
}
.detail-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
margin-bottom: var(--space-xs);
}
.detail-value {
font-family: var(--font-mono);
font-size: 1rem;
color: var(--text-primary);
font-weight: 500;
}
/* Raw Data Display */
.raw-data-section h4 {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
margin-bottom: var(--space-sm);
}
.raw-data {
background: var(--bg-primary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: var(--space-md);
font-family: var(--font-mono);
font-size: 0.8rem;
line-height: 1.6;
color: var(--success);
white-space: pre-wrap;
word-break: break-all;
max-height: 350px;
overflow: auto;
}
.modal-actions {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-lg);
padding-top: var(--space-md);
border-top: 1px solid var(--border-subtle);
}
/* Responsive */
@media (max-width: 1200px) {
.main-grid {
grid-template-columns: 1fr;
}
.search-panel {
position: static;
}
.stats-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.app-container {
padding: var(--space-md);
}
.app-header {
flex-direction: column;
text-align: center;
gap: var(--space-md);
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.form-row {
grid-template-columns: 1fr;
}
.results-header {
flex-direction: column;
align-items: stretch;
}
.results-actions {
justify-content: center;
}
}
/* Animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-in {
animation: fadeInUp 0.4s ease-out forwards;
}
/* Stagger delays for rows */
tbody tr {
opacity: 0;
animation: fadeInUp 0.3s ease-out forwards;
}
tbody tr:nth-child(1) { animation-delay: 0.02s; }
tbody tr:nth-child(2) { animation-delay: 0.04s; }
tbody tr:nth-child(3) { animation-delay: 0.06s; }
tbody tr:nth-child(4) { animation-delay: 0.08s; }
tbody tr:nth-child(5) { animation-delay: 0.10s; }
tbody tr:nth-child(6) { animation-delay: 0.12s; }
tbody tr:nth-child(7) { animation-delay: 0.14s; }
tbody tr:nth-child(8) { animation-delay: 0.16s; }
tbody tr:nth-child(9) { animation-delay: 0.18s; }
tbody tr:nth-child(10) { animation-delay: 0.20s; }
tbody tr:nth-child(n+11) { animation-delay: 0.22s; }
</style>
</head>
<body>
<div class="app-container">
<!-- Header -->
<header class="app-header">
<div class="brand">
<div class="brand-icon"></div>
<div class="brand-text">
<h1>Test Data System</h1>
<span>Production Database</span>
</div>
</div>
<div class="system-status">
<div class="status-indicator"></div>
<span class="status-text" id="dbStatus">CONNECTING...</span>
</div>
</header>
<!-- Stats Dashboard -->
<div class="stats-grid">
<div class="stat-card highlight">
<div class="stat-label">Total Records</div>
<div class="stat-value" id="stat-total"></div>
<div class="stat-meta">indexed entries</div>
</div>
<div class="stat-card">
<div class="stat-label">Passed Tests</div>
<div class="stat-value success" id="stat-pass"></div>
<div class="stat-meta">verified units</div>
</div>
<div class="stat-card">
<div class="stat-label">Failed Tests</div>
<div class="stat-value danger" id="stat-fail"></div>
<div class="stat-meta">flagged units</div>
</div>
<div class="stat-card">
<div class="stat-label">Oldest Record</div>
<div class="stat-value small" id="stat-oldest"></div>
<div class="stat-meta">first entry</div>
</div>
<div class="stat-card">
<div class="stat-label">Latest Record</div>
<div class="stat-value small" id="stat-newest"></div>
<div class="stat-meta">most recent</div>
</div>
</div>
<!-- Main Grid -->
<div class="main-grid">
<!-- Search Panel -->
<aside class="search-panel">
<div class="panel-header">
<span class="panel-title">Search Filters</span>
<button class="panel-toggle" onclick="toggleAdvanced()" id="advancedToggle">[+] ADVANCED</button>
</div>
<div class="panel-body">
<!-- Quick Filters -->
<div class="quick-filters">
<button class="quick-filter active" onclick="setQuickFilter('all')">ALL</button>
<button class="quick-filter" onclick="setQuickFilter('today')">TODAY</button>
<button class="quick-filter" onclick="setQuickFilter('week')">7 DAYS</button>
<button class="quick-filter" onclick="setQuickFilter('month')">30 DAYS</button>
<button class="quick-filter" onclick="setQuickFilter('fail')">FAILS</button>
<button class="quick-filter" onclick="setQuickFilter('pass')">PASS</button>
</div>
<form id="searchForm">
<div class="form-group">
<label class="form-label">Serial Number</label>
<input type="text" id="serial" class="form-input" placeholder="e.g., 176923 or 176923-1">
</div>
<div class="form-group">
<label class="form-label">Work Order #</label>
<input type="text" id="workorder" class="form-input" placeholder="e.g., 179257">
</div>
<div class="form-group">
<label class="form-label">Model Number</label>
<input type="text" id="model" class="form-input" placeholder="e.g., DSCA38-1793" list="modelList">
<datalist id="modelList"></datalist>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Result</label>
<select id="result" class="form-select">
<option value="">All Results</option>
<option value="PASS">Pass Only</option>
<option value="FAIL">Fail Only</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Product Line</label>
<select id="logtype" class="form-select">
<option value="">All Products</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Website Status</label>
<select id="webStatus" class="form-select">
<option value="">Any</option>
<option value="off">Not on Website</option>
<option value="on">On Website</option>
</select>
</div>
</div>
<!-- Advanced Fields -->
<div id="advancedFields" style="display: none;">
<div class="form-group">
<label class="form-label">Test Station</label>
<select id="station" class="form-select">
<option value="">All Stations</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">From Date</label>
<input type="date" id="fromDate" class="form-input">
</div>
<div class="form-group">
<label class="form-label">To Date</label>
<input type="date" id="toDate" class="form-input">
</div>
</div>
<div class="form-group">
<label class="form-label">Full-Text Search</label>
<input type="text" id="fulltext" class="form-input" placeholder="Search in raw data...">
</div>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary">SEARCH</button>
<button type="button" class="btn btn-secondary" onclick="clearForm()">CLEAR</button>
</div>
<button type="button" class="btn btn-success btn-block" style="margin-top: var(--space-sm);" onclick="exportCsv()">
EXPORT CSV
</button>
</form>
</div>
</aside>
<!-- Results Panel -->
<section class="results-panel">
<div class="results-header">
<span class="results-count" id="resultsCount">
<strong>0</strong> records found
</span>
<div class="results-actions">
<span class="batch-indicator" id="batchIndicator" style="display: none;">
<span id="selectedCount">0</span> selected
</span>
<button class="btn btn-secondary" onclick="selectAll()" id="selectAllBtn">SELECT PAGE</button>
<button class="btn btn-primary" onclick="generateBatchDatasheets()" id="batchBtn" disabled>DATASHEETS</button>
<button class="btn btn-secondary" onclick="pushSelectedToWebsite()" id="pushBatchBtn" disabled>PUSH TO WEB</button>
</div>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th class="checkbox-cell">
<input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll()">
</th>
<th class="sortable" onclick="sortBy('serial_number')">Serial</th>
<th class="sortable" onclick="sortBy('model_number')">Model</th>
<th class="sortable" onclick="sortBy('test_date')">Date</th>
<th>Station</th>
<th>Product</th>
<th class="sortable" onclick="sortBy('overall_result')">Result</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="resultsTable">
<tr>
<td colspan="8">
<div class="state-message">
<svg class="state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
<div class="state-title">Ready to Search</div>
<div class="state-subtitle">Enter criteria and click SEARCH to query the database</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="pagination"></div>
</section>
</div>
</div>
<!-- Record Detail Modal -->
<div class="modal-overlay" id="recordModal">
<div class="modal">
<div class="modal-header">
<span class="modal-title">Test Record Details</span>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body" id="modalBody">
<!-- Content loaded dynamically -->
</div>
</div>
</div>
<script>
// State
let currentOffset = 0;
let currentTotal = 0;
const limit = 50;
let selectedIds = new Set();
let allRecords = [];
let filters = { stations: [], log_types: [], models: [] };
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadStats();
loadFilters();
// Auto-search with no filters on load (shows most recent records)
searchRecords();
});
// Load statistics
async function loadStats() {
try {
const res = await fetch('/api/stats');
const stats = await res.json();
document.getElementById('stat-total').textContent = stats.total_records?.toLocaleString() || '0';
const passCount = stats.by_result?.find(r => r.overall_result === 'PASS')?.count || 0;
const failCount = stats.by_result?.find(r => r.overall_result === 'FAIL')?.count || 0;
document.getElementById('stat-pass').textContent = passCount.toLocaleString();
document.getElementById('stat-fail').textContent = failCount.toLocaleString();
document.getElementById('stat-oldest').textContent = stats.date_range?.oldest || '—';
document.getElementById('stat-newest').textContent = stats.date_range?.newest || '—';
document.getElementById('dbStatus').textContent = `ONLINE • ${stats.total_records?.toLocaleString() || 0} RECORDS`;
} catch (err) {
console.error('Failed to load stats:', err);
document.getElementById('dbStatus').textContent = 'OFFLINE';
document.querySelector('.status-indicator').style.background = 'var(--danger)';
document.querySelector('.status-indicator').style.boxShadow = '0 0 8px var(--danger)';
}
}
// Load filter options
async function loadFilters() {
try {
const res = await fetch('/api/filters');
filters = await res.json();
// Populate station dropdown
const stationSelect = document.getElementById('station');
stationSelect.innerHTML = '<option value="">All Stations</option>';
filters.stations.forEach(s => {
stationSelect.innerHTML += `<option value="${escapeHtml(s)}">${escapeHtml(s)}</option>`;
});
// Populate log type dropdown
const logtypeSelect = document.getElementById('logtype');
logtypeSelect.innerHTML = '<option value="">All Products</option>';
filters.log_types.forEach(lt => {
const label = getProductLabel(lt);
logtypeSelect.innerHTML += `<option value="${escapeHtml(lt)}">${escapeHtml(label)}</option>`;
});
// Populate model datalist
const modelList = document.getElementById('modelList');
modelList.innerHTML = '';
filters.models.slice(0, 200).forEach(m => {
modelList.innerHTML += `<option value="${escapeHtml(m.model_number)}">`;
});
} catch (err) {
console.error('Failed to load filters:', err);
}
}
// Get friendly product label
function getProductLabel(logType) {
const labels = {
'DSCLOG': 'DSC Series',
'5BLOG': '5B Series',
'7BLOG': '7B Series',
'8BLOG': '8B Series',
'PWRLOG': 'Power Tests',
'SCTLOG': 'SCT Series',
'VASLOG': 'VAS Tests',
'SHT': 'Test Sheets'
};
return labels[logType] || logType;
}
// Toggle advanced fields
function toggleAdvanced() {
const fields = document.getElementById('advancedFields');
const btn = document.getElementById('advancedToggle');
if (fields.style.display === 'none') {
fields.style.display = 'block';
btn.textContent = '[] ADVANCED';
} else {
fields.style.display = 'none';
btn.textContent = '[+] ADVANCED';
}
}
// Quick filters
function setQuickFilter(filter) {
// Clear existing quick filter classes
document.querySelectorAll('.quick-filter').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
const today = new Date().toISOString().split('T')[0];
const fromDate = document.getElementById('fromDate');
const toDate = document.getElementById('toDate');
const result = document.getElementById('result');
// Reset
fromDate.value = '';
toDate.value = '';
result.value = '';
switch (filter) {
case 'all':
// No date filter — show everything
break;
case 'today':
fromDate.value = today;
toDate.value = today;
break;
case 'week':
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
fromDate.value = weekAgo;
toDate.value = today;
break;
case 'month':
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
fromDate.value = monthAgo;
toDate.value = today;
break;
case 'fail':
result.value = 'FAIL';
break;
case 'pass':
result.value = 'PASS';
break;
}
// Show advanced fields if date filters are used
if (['today', 'week', 'month'].includes(filter)) {
document.getElementById('advancedFields').style.display = 'block';
document.getElementById('advancedToggle').textContent = '[] ADVANCED';
}
search(0);
}
// Search
async function search(offset = 0) {
currentOffset = offset;
selectedIds.clear();
updateSelectedCount();
const params = new URLSearchParams();
const serial = document.getElementById('serial').value.trim();
const workorder = document.getElementById('workorder').value.trim();
const model = document.getElementById('model').value.trim();
const result = document.getElementById('result').value;
const logtype = document.getElementById('logtype').value;
const station = document.getElementById('station').value;
const fromDate = document.getElementById('fromDate').value;
const toDate = document.getElementById('toDate').value;
const fulltext = document.getElementById('fulltext').value.trim();
const webStatus = document.getElementById('webStatus').value;
if (serial) params.append('serial', serial);
if (workorder) params.append('workorder', workorder);
if (model) params.append('model', model);
if (result) params.append('result', result);
if (logtype) params.append('logtype', logtype);
if (station) params.append('station', station);
if (fromDate) params.append('from', fromDate);
if (toDate) params.append('to', toDate);
if (fulltext) params.append('q', fulltext);
if (webStatus) params.append('web_status', webStatus);
params.append('limit', limit);
params.append('offset', offset);
// Show loading
document.getElementById('resultsTable').innerHTML = `
<tr>
<td colspan="8">
<div class="state-message">
<div class="spinner"></div>
<div class="state-title">Searching Database</div>
<div class="state-subtitle">Querying ${document.getElementById('stat-total').textContent} records...</div>
</div>
</td>
</tr>`;
try {
const res = await fetch(`/api/search?${params}`);
const data = await res.json();
allRecords = data.records;
currentTotal = data.total;
displayResults(data);
} catch (err) {
console.error('Search failed:', err);
document.getElementById('resultsTable').innerHTML = `
<tr>
<td colspan="8">
<div class="state-message">
<div class="state-title" style="color: var(--danger);">Search Failed</div>
<div class="state-subtitle">Unable to query database. Please try again.</div>
</div>
</td>
</tr>`;
}
}
// Display results
function displayResults(data) {
const tbody = document.getElementById('resultsTable');
const countEl = document.getElementById('resultsCount');
countEl.innerHTML = `<strong>${data.total.toLocaleString()}</strong> records found`;
if (data.records.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="8">
<div class="state-message">
<svg class="state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9.172 14.828a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div class="state-title">No Records Found</div>
<div class="state-subtitle">Try adjusting your search criteria</div>
</div>
</td>
</tr>`;
document.getElementById('pagination').innerHTML = '';
return;
}
tbody.innerHTML = data.records.map(r => {
const onWeb = !!r.api_uploaded_at;
const pushable = r.overall_result === 'PASS';
const title = onWeb ? `On website (uploaded ${String(r.api_uploaded_at).slice(0, 10)})` :
(pushable ? 'Not on website yet — click to push' :
'Only PASS records can be pushed');
return `
<tr class="${onWeb ? '' : 'not-on-web'}">
<td class="checkbox-cell">
<input type="checkbox" value="${r.id}" onchange="toggleSelect(${r.id})" ${selectedIds.has(r.id) ? 'checked' : ''}>
</td>
<td class="cell-serial">${escapeHtml(r.serial_number)}</td>
<td class="cell-model">${escapeHtml(r.model_number)}</td>
<td class="cell-date">${r.test_date || '—'}</td>
<td><span class="cell-station">${r.test_station || '—'}</span></td>
<td class="cell-product">${getProductLabel(r.log_type)}</td>
<td>
<span class="result-badge ${r.overall_result === 'PASS' ? 'pass' : r.overall_result === 'FAIL' ? 'fail' : ''}">
${r.overall_result || 'N/A'}
</span>
</td>
<td>
<div class="action-links">
<button class="action-link" onclick="viewRecord(${r.id})">VIEW</button>
<button class="action-link" onclick="viewDatasheet(${r.id})">SHEET</button>
<button class="action-link push" onclick="pushOneToWebsite(${r.id})" ${pushable ? '' : 'disabled'} title="${escapeHtml(title)}">${onWeb ? 'RE-PUSH' : 'PUSH'}</button>
</div>
</td>
</tr>`;
}).join('');
// Pagination
renderPagination(data.total);
}
// Pagination
function renderPagination(total) {
const pagination = document.getElementById('pagination');
const totalPages = Math.ceil(total / limit);
const currentPage = Math.floor(currentOffset / limit) + 1;
if (totalPages <= 1) {
pagination.innerHTML = '';
return;
}
let html = `<span class="pagination-info">Page ${currentPage} of ${totalPages.toLocaleString()}</span>`;
html += '<div class="pagination-buttons">';
// Previous
html += `<button class="page-btn" onclick="search(${Math.max(0, currentOffset - limit)})" ${currentPage === 1 ? 'disabled' : ''}>◀</button>`;
// Page numbers
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, currentPage + 2);
if (startPage > 1) {
html += `<button class="page-btn" onclick="search(0)">1</button>`;
if (startPage > 2) html += '<span style="padding: 0 8px; color: var(--text-muted);">···</span>';
}
for (let i = startPage; i <= endPage; i++) {
html += `<button class="page-btn ${i === currentPage ? 'active' : ''}" onclick="search(${(i-1) * limit})">${i}</button>`;
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) html += '<span style="padding: 0 8px; color: var(--text-muted);">···</span>';
html += `<button class="page-btn" onclick="search(${(totalPages-1) * limit})">${totalPages}</button>`;
}
// Next
html += `<button class="page-btn" onclick="search(${currentOffset + limit})" ${currentPage === totalPages ? 'disabled' : ''}>▶</button>`;
html += '</div>';
pagination.innerHTML = html;
}
// Selection functions
function toggleSelect(id) {
if (selectedIds.has(id)) {
selectedIds.delete(id);
} else {
selectedIds.add(id);
}
updateSelectedCount();
}
function toggleSelectAll() {
const checkbox = document.getElementById('selectAllCheckbox');
if (checkbox.checked) {
allRecords.forEach(r => selectedIds.add(r.id));
} else {
allRecords.forEach(r => selectedIds.delete(r.id));
}
// Update individual checkboxes
document.querySelectorAll('#resultsTable input[type="checkbox"]').forEach(cb => {
cb.checked = checkbox.checked;
});
updateSelectedCount();
}
function selectAll() {
const checkbox = document.getElementById('selectAllCheckbox');
checkbox.checked = !checkbox.checked;
toggleSelectAll();
}
function updateSelectedCount() {
const count = selectedIds.size;
document.getElementById('selectedCount').textContent = count;
document.getElementById('batchBtn').disabled = count === 0;
const pushBatch = document.getElementById('pushBatchBtn');
if (pushBatch) pushBatch.disabled = count === 0;
document.getElementById('batchIndicator').style.display = count > 0 ? 'inline-block' : 'none';
}
// Push to website
async function pushOneToWebsite(id) {
const btn = event.target;
const original = btn.textContent;
btn.disabled = true; btn.textContent = '...';
let didRerender = false;
try {
const resp = await fetch('/api/upload', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ ids: [id] }),
});
const data = await resp.json();
if (!resp.ok) { alert('Push failed: ' + (data.error || resp.status)); return; }
const confirmed = (data.created || 0) + (data.updated || 0) + (data.unchanged || 0);
if (confirmed === 0) {
const parts = [];
if (data.skipped) parts.push(`skipped ${data.skipped} (file missing in For_Web)`);
if (data.errors) parts.push(`errors ${data.errors}`);
if (data.processed === 0) parts.push('not exported to For_Web yet');
alert('Push did not apply: ' + (parts.join('; ') || 'no record processed'));
return;
}
search(currentOffset);
didRerender = true;
} catch (e) {
alert('Push error: ' + e.message);
} finally {
if (!didRerender) { btn.disabled = false; btn.textContent = original; }
}
}
async function pushSelectedToWebsite() {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
if (!confirm(`Push ${ids.length} selected record${ids.length > 1 ? 's' : ''} to the Dataforth website?`)) return;
const btn = document.getElementById('pushBatchBtn');
const original = btn.textContent;
btn.disabled = true; btn.textContent = 'PUSHING...';
try {
const resp = await fetch('/api/upload', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ ids }),
});
const data = await resp.json();
if (!resp.ok) { alert('Push failed: ' + (data.error || resp.status)); return; }
alert(`Push complete:\n processed: ${data.processed}\n created: ${data.created}\n updated: ${data.updated}\n unchanged: ${data.unchanged}\n skipped: ${data.skipped}\n errors: ${data.errors}`);
selectedIds.clear();
search(currentOffset);
} catch (e) {
alert('Push error: ' + e.message);
} finally {
btn.disabled = selectedIds.size === 0;
btn.textContent = original;
}
}
// View record detail
async function viewRecord(id) {
try {
const res = await fetch(`/api/record/${id}`);
const record = await res.json();
const modal = document.getElementById('recordModal');
const body = document.getElementById('modalBody');
body.innerHTML = `
<div class="detail-grid">
<div class="detail-item">
<div class="detail-label">Serial Number</div>
<div class="detail-value" style="color: var(--accent-primary);">${escapeHtml(record.serial_number)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Model Number</div>
<div class="detail-value">${escapeHtml(record.model_number)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Test Date</div>
<div class="detail-value">${record.test_date || '—'}</div>
</div>
<div class="detail-item">
<div class="detail-label">Test Station</div>
<div class="detail-value">${record.test_station || '—'}</div>
</div>
<div class="detail-item">
<div class="detail-label">Work Order</div>
<div class="detail-value">${record.work_order ? '<a href="#" onclick="viewWorkOrder(\'' + record.work_order + '\'); return false;" style="color: var(--accent-primary); text-decoration: underline;">' + record.work_order + '</a>' : '—'}</div>
</div>
<div class="detail-item">
<div class="detail-label">Product Line</div>
<div class="detail-value">${getProductLabel(record.log_type)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Result</div>
<div class="detail-value">
<span class="result-badge ${record.overall_result === 'PASS' ? 'pass' : record.overall_result === 'FAIL' ? 'fail' : ''}">
${record.overall_result || 'N/A'}
</span>
</div>
</div>
</div>
<div class="detail-item" style="margin-bottom: var(--space-lg);">
<div class="detail-label">Source File</div>
<div class="detail-value" style="font-size: 0.75rem; word-break: break-all; color: var(--text-muted);">${escapeHtml(record.source_file || '—')}</div>
<div class="detail-label">ForWeb Export</div>
<div class="detail-value">${record.forweb_exported_at ? '<span style="color: var(--success);">Exported ' + record.forweb_exported_at.split('T')[0] + '</span>' : '<span style="color: var(--warning);">Pending</span>'}</div>
<div class="detail-label">Datasheet Generated</div>
<div class="detail-value">${record.datasheet_exported_at ? '<span style="color: var(--success);">Yes ' + record.datasheet_exported_at.split('T')[0] + '</span>' : '<span style="color: var(--text-muted);">No</span>'}</div>
</div>
<div class="raw-data-section">
<h4>Raw Test Data</h4>
<div class="raw-data">${escapeHtml(record.raw_data || 'No raw data available')}</div>
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="viewDatasheet(${record.id})">GENERATE DATASHEET</button>
<button class="btn btn-primary" onclick="downloadPdf(${record.id})" style="margin-left:8px">DOWNLOAD PDF</button>
<button class="btn btn-secondary" onclick="closeModal()">CLOSE</button>
</div>
`;
modal.classList.add('active');
} catch (err) {
console.error('Failed to load record:', err);
alert('Failed to load record details');
}
}
// Close modal
function closeModal() {
document.getElementById('recordModal').classList.remove('active');
}
// Close modal on overlay click
document.getElementById('recordModal').addEventListener('click', (e) => {
if (e.target.classList.contains('modal-overlay')) {
closeModal();
}
});
// View datasheet
function viewDatasheet(id) {
window.open(`/api/datasheet/${id}`, '_blank');
}
// Download PDF datasheet
// View work order details
async function viewWorkOrder(woNumber) {
try {
const res = await fetch(`/api/workorder/${woNumber}`);
const data = await res.json();
const wo = data.work_order;
const lines = data.lines || [];
const testRecs = data.test_records || [];
let linesHtml = '';
for (const l of lines) {
const statusColor = l.status === 'PASS' ? 'var(--success)' : 'var(--danger)';
const snLink = `<a href="#" onclick="document.getElementById('serial').value='${l.serial_number}'; search(); return false;" style="color: var(--accent-primary);">${l.serial_number}</a>`;
linesHtml += `<tr>
<td style="color: ${statusColor}; font-weight: bold;">${l.status}</td>
<td>${snLink}</td>
<td>${l.model_number || ''}</td>
<td>${l.test_date || ''}</td>
<td>${l.test_time || ''}</td>
<td style="font-size: 0.75rem; color: var(--text-muted);">${l.ds_filename || ''}</td>
</tr>`;
}
const modalContent = `
<h3 style="margin-top:0;">Work Order: ${woNumber}</h3>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:16px;">
<div><strong>Date:</strong> ${wo.wo_date || '—'}</div>
<div><strong>Station:</strong> ${wo.test_station || '—'}</div>
<div><strong>Program:</strong> ${wo.program || '—'}</div>
<div><strong>Version:</strong> ${wo.version || '—'}</div>
</div>
<div style="margin-bottom:8px;"><strong>Test Lines (${lines.length}):</strong></div>
<div style="max-height:400px; overflow-y:auto;">
<table style="width:100%; font-size:0.85rem; border-collapse:collapse;">
<tr style="border-bottom:1px solid var(--border);">
<th style="text-align:left; padding:4px;">Status</th>
<th style="text-align:left; padding:4px;">Serial #</th>
<th style="text-align:left; padding:4px;">Model</th>
<th style="text-align:left; padding:4px;">Date</th>
<th style="text-align:left; padding:4px;">Time</th>
<th style="text-align:left; padding:4px;">DS File</th>
</tr>
${linesHtml}
</table>
</div>
<div style="margin-top:12px;"><strong>Linked Test Records:</strong> ${testRecs.length}</div>
`;
// Reuse the detail modal
document.getElementById('modalBody').innerHTML = modalContent;
document.getElementById('recordModal').classList.add('active');
} catch (err) {
console.error('Failed to load work order:', err);
}
}
function downloadPdf(id) {
window.open(`/api/datasheet/${id}/pdf`, '_blank');
}
// Generate batch datasheets
function generateBatchDatasheets() {
if (selectedIds.size === 0) return;
// Open each datasheet in a new tab
selectedIds.forEach(id => {
window.open(`/api/datasheet/${id}`, '_blank');
});
}
// Export CSV
function exportCsv() {
const params = new URLSearchParams();
const serial = document.getElementById('serial').value.trim();
const model = document.getElementById('model').value.trim();
const result = document.getElementById('result').value;
const logtype = document.getElementById('logtype').value;
const station = document.getElementById('station').value;
const fromDate = document.getElementById('fromDate').value;
const toDate = document.getElementById('toDate').value;
const webStatus = document.getElementById('webStatus').value;
if (serial) params.append('serial', serial);
if (model) params.append('model', model);
if (result) params.append('result', result);
if (logtype) params.append('logtype', logtype);
if (station) params.append('station', station);
if (fromDate) params.append('from', fromDate);
if (toDate) params.append('to', toDate);
if (webStatus) params.append('web_status', webStatus);
params.append('format', 'csv');
window.location.href = `/api/export?${params}`;
}
// Clear form
function clearForm() {
document.getElementById('searchForm').reset();
document.querySelectorAll('.quick-filter').forEach(btn => btn.classList.remove('active'));
document.getElementById('advancedFields').style.display = 'none';
document.getElementById('advancedToggle').textContent = '[+] ADVANCED';
selectedIds.clear();
updateSelectedCount();
document.getElementById('resultsTable').innerHTML = `
<tr>
<td colspan="8">
<div class="state-message">
<svg class="state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
<div class="state-title">Ready to Search</div>
<div class="state-subtitle">Enter criteria and click SEARCH to query the database</div>
</div>
</td>
</tr>`;
document.getElementById('resultsCount').innerHTML = '<strong>0</strong> records found';
document.getElementById('pagination').innerHTML = '';
}
// Escape HTML
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
})[c]);
}
// Sort (placeholder)
function sortBy(column) {
console.log('Sort by:', column);
// TODO: Implement sorting
}
// Form submit
document.getElementById('searchForm').addEventListener('submit', (e) => {
e.preventDefault();
search(0);
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
}
});
</script>
</body>
</html>