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>
1919 lines
70 KiB
HTML
1919 lines
70 KiB
HTML
<!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()">×</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 => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
})[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>
|