Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> | |
| <meta name="apple-mobile-web-app-capable" content="yes"> | |
| <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> | |
| <meta name="apple-mobile-web-app-title" content="AMA Metals"> | |
| <meta name="theme-color" content="#0a0a0f"> | |
| <title>AMA β Precious Metals</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@600;700;800&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet"> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: #0a0a0f; | |
| --surface: #13131a; | |
| --surface2: #1c1c27; | |
| --border: rgba(255,255,255,0.07); | |
| --text: #e8e8f0; | |
| --muted: #6b6b80; | |
| --ru: #e8614a; | |
| --ru-dim: rgba(232,97,74,0.12); | |
| --ru-border: rgba(232,97,74,0.25); | |
| --ir: #4a8fe8; | |
| --ir-dim: rgba(74,143,232,0.12); | |
| --ir-border: rgba(74,143,232,0.25); | |
| --green: #4ae89b; | |
| --green-dim: rgba(74,232,155,0.12); | |
| --warn: #e8c44a; | |
| --warn-dim: rgba(232,196,74,0.12); | |
| --danger: #e84a4a; | |
| --danger-dim: rgba(232,74,74,0.12); | |
| --nav-h: 64px; | |
| --safe-top: env(safe-area-inset-top, 0px); | |
| --safe-bot: env(safe-area-inset-bottom, 0px); | |
| } | |
| html, body { | |
| height: 100%; background: var(--bg); color: var(--text); | |
| font-family: 'DM Sans', sans-serif; font-weight: 400; | |
| -webkit-font-smoothing: antialiased; overflow: hidden; | |
| } | |
| /* ββ Login ββ */ | |
| #login-screen { | |
| position: fixed; inset: 0; background: var(--bg); | |
| display: flex; flex-direction: column; | |
| align-items: center; justify-content: center; | |
| padding: 40px 32px; z-index: 1000; | |
| transition: opacity 0.4s, transform 0.4s; | |
| } | |
| #login-screen.hidden { opacity: 0; pointer-events: none; transform: translateY(-16px); } | |
| .login-eyebrow { | |
| font-family: 'Syne', sans-serif; font-weight: 600; | |
| font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase; | |
| color: var(--muted); margin-bottom: 10px; | |
| } | |
| .login-title { | |
| font-family: 'Syne', sans-serif; font-weight: 800; | |
| font-size: 30px; color: var(--text); margin-bottom: 4px; text-align: center; | |
| } | |
| .login-sub { font-size: 14px; color: var(--muted); margin-bottom: 52px; text-align: center; } | |
| .login-form { width: 100%; max-width: 300px; display: flex; flex-direction: column; gap: 12px; } | |
| .login-form input { | |
| width: 100%; background: var(--surface); border: 1px solid var(--border); | |
| border-radius: 12px; padding: 15px 18px; | |
| font-family: 'DM Sans', sans-serif; font-size: 16px; color: var(--text); | |
| outline: none; transition: border-color 0.2s; -webkit-appearance: none; | |
| } | |
| .login-form input:focus { border-color: rgba(255,255,255,0.18); } | |
| .login-form input::placeholder { color: var(--muted); } | |
| .login-btn { | |
| background: var(--text); color: var(--bg); border: none; | |
| border-radius: 12px; padding: 15px; | |
| font-family: 'Syne', sans-serif; font-weight: 700; | |
| font-size: 14px; letter-spacing: 0.06em; cursor: pointer; | |
| margin-top: 4px; transition: opacity 0.2s, transform 0.1s; | |
| } | |
| .login-btn:active { transform: scale(0.98); opacity: 0.85; } | |
| .login-error { color: var(--danger); font-size: 12px; text-align: center; min-height: 18px; } | |
| /* ββ App Shell ββ */ | |
| #app { | |
| position: fixed; inset: 0; display: flex; flex-direction: column; | |
| opacity: 0; pointer-events: none; transition: opacity 0.4s; | |
| } | |
| #app.visible { opacity: 1; pointer-events: all; } | |
| /* ββ Header ββ */ | |
| #header { | |
| padding: calc(var(--safe-top) + 14px) 20px 14px; | |
| display: flex; align-items: center; justify-content: space-between; | |
| border-bottom: 1px solid var(--border); background: var(--bg); flex-shrink: 0; | |
| } | |
| .header-brand { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 17px; letter-spacing: 0.04em; } | |
| .header-brand span { color: var(--muted); font-weight: 600; } | |
| .refresh-btn { | |
| width: 36px; height: 36px; background: var(--surface); | |
| border: 1px solid var(--border); border-radius: 10px; | |
| display: flex; align-items: center; justify-content: center; | |
| cursor: pointer; font-size: 16px; transition: background 0.15s; | |
| -webkit-tap-highlight-color: transparent; | |
| } | |
| .refresh-btn:active { background: var(--surface2); } | |
| .refresh-btn.spinning { animation: spin 0.6s linear infinite; } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| /* ββ Scroll Area ββ */ | |
| #scroll-area { | |
| flex: 1; overflow-y: auto; overflow-x: hidden; | |
| -webkit-overflow-scrolling: touch; | |
| padding: 20px 16px calc(var(--nav-h) + var(--safe-bot) + 20px); | |
| } | |
| /* ββ Bottom Nav ββ */ | |
| #bottom-nav { | |
| position: fixed; bottom: 0; left: 0; right: 0; | |
| height: calc(var(--nav-h) + var(--safe-bot)); | |
| padding-bottom: var(--safe-bot); | |
| background: rgba(10,10,15,0.94); | |
| backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); | |
| border-top: 1px solid var(--border); | |
| display: flex; align-items: stretch; z-index: 100; | |
| } | |
| .nav-item { | |
| flex: 1; display: flex; flex-direction: column; | |
| align-items: center; justify-content: center; gap: 4px; | |
| cursor: pointer; padding: 8px 4px; | |
| -webkit-tap-highlight-color: transparent; | |
| } | |
| .nav-icon { font-size: 20px; line-height: 1; transition: transform 0.15s; } | |
| .nav-item:active .nav-icon { transform: scale(0.88); } | |
| .nav-label { | |
| font-size: 10px; font-weight: 500; color: var(--muted); | |
| letter-spacing: 0.02em; transition: color 0.2s; | |
| } | |
| .nav-item.active .nav-label { color: var(--text); } | |
| /* ββ Views ββ */ | |
| .view { display: none; animation: fadeUp 0.25s ease; } | |
| .view.active { display: block; } | |
| @keyframes fadeUp { from { opacity:0; transform: translateY(8px); } to { opacity:1; transform: translateY(0); } } | |
| /* ββ Typography ββ */ | |
| .page-title { | |
| font-family: 'Syne', sans-serif; font-weight: 700; | |
| font-size: 22px; margin-bottom: 20px; color: var(--text); | |
| } | |
| /* ββ Generic Card ββ */ | |
| .card { | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: 16px; padding: 18px; margin-bottom: 12px; | |
| } | |
| /* ββ Overview Metal Block ββ */ | |
| .metal-block { border-radius: 16px; padding: 20px; margin-bottom: 12px; } | |
| .metal-block.ru { background: var(--ru-dim); border: 1px solid var(--ru-border); } | |
| .metal-block.ir { background: var(--ir-dim); border: 1px solid var(--ir-border); } | |
| .metal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; } | |
| .metal-name { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 15px; letter-spacing: 0.06em; } | |
| .metal-block.ru .metal-name { color: var(--ru); } | |
| .metal-block.ir .metal-name { color: var(--ir); } | |
| .metal-badge { | |
| font-family: 'DM Mono', monospace; font-size: 11px; | |
| padding: 3px 10px; border-radius: 20px; font-weight: 500; | |
| } | |
| .metal-block.ru .metal-badge { background: var(--ru-dim); color: var(--ru); border: 1px solid var(--ru-border); } | |
| .metal-block.ir .metal-badge { background: var(--ir-dim); color: var(--ir); border: 1px solid var(--ir-border); } | |
| .stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } | |
| .stat-val { font-family: 'DM Mono', monospace; font-size: 19px; font-weight: 500; color: var(--text); } | |
| .stat-val.green { color: var(--green); } | |
| .stat-lbl { font-size: 11px; color: var(--muted); margin-top: 3px; } | |
| .divider { height: 1px; background: var(--border); margin: 14px 0; } | |
| .breakdown-row { display: flex; justify-content: space-between; align-items: center; padding: 5px 0; } | |
| .breakdown-key { font-size: 13px; color: var(--muted); } | |
| .breakdown-val { font-family: 'DM Mono', monospace; font-size: 13px; color: var(--text); } | |
| /* ββ Section separator ββ */ | |
| .section-sep { display: flex; align-items: center; gap: 10px; margin: 22px 0 14px; } | |
| .section-sep-label { | |
| font-family: 'Syne', sans-serif; font-size: 11px; font-weight: 700; | |
| letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); white-space: nowrap; | |
| } | |
| .section-sep-line { flex: 1; height: 1px; background: var(--border); } | |
| /* ββ Totals bar ββ */ | |
| .totals-bar { display: flex; gap: 8px; margin-bottom: 16px; } | |
| .total-chip { | |
| flex: 1; background: var(--surface); border: 1px solid var(--border); | |
| border-radius: 12px; padding: 11px 12px; text-align: center; | |
| } | |
| .total-chip-val { font-family: 'DM Mono', monospace; font-size: 15px; font-weight: 500; color: var(--text); } | |
| .total-chip-lbl { font-size: 10px; color: var(--muted); margin-top: 3px; letter-spacing: 0.04em; text-transform: uppercase; } | |
| /* ββ Filter tabs ββ */ | |
| .filter-tabs { display: flex; gap: 8px; margin-bottom: 18px; } | |
| .filter-tab { | |
| flex: 1; padding: 10px; border-radius: 10px; | |
| font-family: 'Syne', sans-serif; font-size: 12px; font-weight: 700; | |
| letter-spacing: 0.04em; text-align: center; cursor: pointer; | |
| border: 1px solid var(--border); background: var(--surface); color: var(--muted); | |
| transition: all 0.2s; -webkit-tap-highlight-color: transparent; | |
| } | |
| .filter-tab.active.ru { background: var(--ru-dim); border-color: var(--ru-border); color: var(--ru); } | |
| .filter-tab.active.ir { background: var(--ir-dim); border-color: var(--ir-border); color: var(--ir); } | |
| /* ββ Lot cards ββ */ | |
| .lot-card { | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: 14px; padding: 15px; margin-bottom: 9px; | |
| } | |
| .lot-card.ru { border-left: 3px solid var(--ru); } | |
| .lot-card.ir { border-left: 3px solid var(--ir); } | |
| .lot-card.dim { opacity: 0.65; } | |
| .lot-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 9px; } | |
| .lot-ama { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 14px; } | |
| .lot-qty { font-family: 'DM Mono', monospace; font-size: 14px; font-weight: 500; } | |
| .lot-tags { display: flex; flex-wrap: wrap; gap: 6px; } | |
| .tag { | |
| font-size: 11px; font-weight: 500; padding: 3px 9px; | |
| border-radius: 20px; background: var(--surface2); | |
| color: var(--muted); border: 1px solid var(--border); | |
| } | |
| .tag.hi { color: var(--text); background: rgba(255,255,255,0.06); } | |
| /* ββ Pack status tabs ββ */ | |
| .pack-tabs { display: flex; gap: 6px; margin-bottom: 14px; } | |
| .pack-tab { | |
| flex: 1; padding: 9px 8px; border-radius: 10px; text-align: center; cursor: pointer; | |
| font-family: 'Syne', sans-serif; font-size: 12px; font-weight: 700; letter-spacing: 0.03em; | |
| border: 1px solid var(--border); background: var(--surface); color: var(--muted); | |
| transition: all 0.2s; -webkit-tap-highlight-color: transparent; | |
| } | |
| .pack-tab.active { background: var(--green-dim); border-color: rgba(74,232,155,0.3); color: var(--green); } | |
| /* ββ Brand filter pills ββ */ | |
| .brand-filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; } | |
| .brand-btn { | |
| padding: 6px 14px; border-radius: 20px; cursor: pointer; | |
| font-size: 12px; font-weight: 500; border: 1px solid var(--border); | |
| background: var(--surface); color: var(--muted); | |
| transition: all 0.2s; -webkit-tap-highlight-color: transparent; | |
| } | |
| .brand-btn.active { background: var(--ru-dim); border-color: var(--ru-border); color: var(--ru); } | |
| /* ββ Brand section grouping ββ */ | |
| .brand-section { margin-bottom: 6px; } | |
| .brand-header { | |
| display: flex; justify-content: space-between; align-items: center; | |
| padding: 8px 4px 6px; | |
| } | |
| .brand-name { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 13px; color: var(--muted); letter-spacing: 0.05em; text-transform: uppercase; } | |
| .brand-total { font-family: 'DM Mono', monospace; font-size: 12px; color: var(--muted); } | |
| /* ββ Opened/Unopened tags ββ */ | |
| .tag-unopened { color: var(--green) ; background: var(--green-dim) ; border-color: rgba(74,232,155,0.25) ; } | |
| .tag-opened { color: var(--warn) ; background: var(--warn-dim) ; border-color: rgba(232,196,74,0.25) ; } | |
| /* ββ Commitment cards ββ */ | |
| .commit-card { | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: 14px; padding: 15px; margin-bottom: 9px; | |
| } | |
| .commit-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; } | |
| .commit-customer { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 15px; } | |
| .commit-qty { font-family: 'DM Mono', monospace; font-size: 15px; font-weight: 500; } | |
| .pill { | |
| display: inline-block; font-size: 11px; font-weight: 500; | |
| padding: 3px 9px; border-radius: 20px; | |
| } | |
| .pill.pending { background: var(--warn-dim); color: var(--warn); border: 1px solid rgba(232,196,74,0.25); } | |
| .pill.partial { background: var(--ir-dim); color: var(--ir); border: 1px solid var(--ir-border); } | |
| .pill.fulfilled{ background: var(--green-dim); color: var(--green); border: 1px solid rgba(74,232,155,0.25); } | |
| /* ββ Consignment cards ββ */ | |
| .consign-card { | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: 14px; padding: 15px; margin-bottom: 9px; | |
| } | |
| .consign-card.warn { border-color: rgba(232,196,74,0.35); } | |
| .consign-card.danger { border-color: rgba(232,74,74,0.35); } | |
| .consign-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; } | |
| .consign-name { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 15px; } | |
| .expiry-pill { | |
| font-size: 11px; font-weight: 500; padding: 4px 10px; border-radius: 20px; white-space: nowrap; | |
| } | |
| .expiry-pill.ok { background: var(--green-dim); color: var(--green); border: 1px solid rgba(74,232,155,0.25); } | |
| .expiry-pill.warning { background: var(--warn-dim); color: var(--warn); border: 1px solid rgba(232,196,74,0.25); } | |
| .expiry-pill.expired { background: var(--danger-dim);color: var(--danger);border: 1px solid rgba(232,74,74,0.25); } | |
| .progress-bar { height: 4px; background: var(--surface2); border-radius: 4px; overflow: hidden; margin: 10px 0 5px; } | |
| .progress-fill { height: 100%; background: var(--ru); border-radius: 4px; } | |
| .progress-labels { | |
| display: flex; justify-content: space-between; | |
| font-family: 'DM Mono', monospace; font-size: 11px; color: var(--muted); | |
| } | |
| .consign-lot-row { | |
| display: flex; justify-content: space-between; align-items: center; | |
| padding: 6px 0; border-top: 1px solid var(--border); font-size: 12px; | |
| } | |
| .consign-lot-ama { font-family: 'DM Mono', monospace; color: var(--muted); } | |
| .consign-lot-val { font-family: 'DM Mono', monospace; color: var(--text); } | |
| /* ββ Incoming cards ββ */ | |
| .incoming-card { | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: 14px; padding: 15px; margin-bottom: 9px; | |
| } | |
| .incoming-card.ru { border-left: 3px solid var(--ru); } | |
| .incoming-card.ir { border-left: 3px solid var(--ir); } | |
| /* ββ States ββ */ | |
| .skeleton { | |
| border-radius: 14px; height: 80px; margin-bottom: 9px; | |
| animation: shimmer 1.4s ease-in-out infinite; | |
| background: linear-gradient(90deg, var(--surface) 25%, var(--surface2) 50%, var(--surface) 75%); | |
| background-size: 200% 100%; | |
| } | |
| @keyframes shimmer { to { background-position: -200% 0; } } | |
| .empty-state { text-align: center; padding: 56px 24px; color: var(--muted); font-size: 14px; } | |
| .empty-icon { font-size: 36px; margin-bottom: 14px; opacity: 0.4; } | |
| .error-card { | |
| background: var(--danger-dim); border: 1px solid rgba(232,74,74,0.25); | |
| border-radius: 14px; padding: 16px; color: var(--danger); font-size: 13px; text-align: center; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Login --> | |
| <div id="login-screen"> | |
| <div class="login-eyebrow">Atlantic Metals & Alloys</div> | |
| <div class="login-title">Precious Metals</div> | |
| <div class="login-sub">Inventory System</div> | |
| <div class="login-form"> | |
| <input type="email" id="inp-email" placeholder="Email address" autocomplete="email" autocapitalize="none" spellcheck="false"> | |
| <input type="password" id="inp-pass" placeholder="Password" autocomplete="current-password"> | |
| <div class="login-error" id="login-err"></div> | |
| <button class="login-btn" onclick="doLogin()">Sign In</button> | |
| </div> | |
| </div> | |
| <!-- App --> | |
| <div id="app"> | |
| <div id="header"> | |
| <div class="header-brand">AMA <span>Metals</span></div> | |
| <div class="refresh-btn" id="refresh-btn" onclick="refreshView()">β»</div> | |
| </div> | |
| <div id="scroll-area"> | |
| <div class="view active" id="view-overview"> | |
| <div class="page-title">Overview</div> | |
| <div id="c-overview"><div class="skeleton"></div><div class="skeleton"></div></div> | |
| </div> | |
| <div class="view" id="view-inventory"> | |
| <div class="page-title">Inventory</div> | |
| <div class="filter-tabs"> | |
| <div class="filter-tab active ru" id="tab-ru" onclick="setFilter('ru')">Ruthenium</div> | |
| <div class="filter-tab ir" id="tab-ir" onclick="setFilter('ir')">Iridium</div> | |
| </div> | |
| <div id="c-inventory"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div> | |
| </div> | |
| <div class="view" id="view-commitments"> | |
| <div class="page-title">Commitments</div> | |
| <div id="c-commitments"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div> | |
| </div> | |
| <div class="view" id="view-consignment"> | |
| <div class="page-title">Consignment</div> | |
| <div id="c-consignment"><div class="skeleton"></div><div class="skeleton"></div></div> | |
| </div> | |
| <div class="view" id="view-incoming"> | |
| <div class="page-title">Incoming</div> | |
| <div id="c-incoming"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div> | |
| </div> | |
| </div> | |
| <nav id="bottom-nav"> | |
| <div class="nav-item active" onclick="nav('overview',this)"> | |
| <div class="nav-icon">β</div><div class="nav-label">Overview</div> | |
| </div> | |
| <div class="nav-item" onclick="nav('inventory',this)"> | |
| <div class="nav-icon">β«</div><div class="nav-label">Inventory</div> | |
| </div> | |
| <div class="nav-item" onclick="nav('commitments',this)"> | |
| <div class="nav-icon">β·</div><div class="nav-label">Commits</div> | |
| </div> | |
| <div class="nav-item" onclick="nav('consignment',this)"> | |
| <div class="nav-icon">β</div><div class="nav-label">Consign</div> | |
| </div> | |
| <div class="nav-item" onclick="nav('incoming',this)"> | |
| <div class="nav-icon">β¬</div><div class="nav-label">Incoming</div> | |
| </div> | |
| </nav> | |
| </div> | |
| <script> | |
| const BACKEND = 'https://supabase-fastapi-backend-production.up.railway.app'; | |
| let TOKEN = null, currentView = 'overview'; | |
| let cache = {}; | |
| // ββ Auth ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function doLogin() { | |
| const email = document.getElementById('inp-email').value.trim().toLowerCase(); | |
| const pass = document.getElementById('inp-pass').value; | |
| const err = document.getElementById('login-err'); | |
| const btn = document.querySelector('.login-btn'); | |
| if (!email || !pass) { err.textContent = 'Please enter email and password.'; return; } | |
| btn.textContent = 'Signing inβ¦'; btn.disabled = true; | |
| err.textContent = ''; | |
| try { | |
| const r = await fetch(BACKEND + '/auth/login', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ email, password: pass }) | |
| }); | |
| if (!r.ok) { | |
| const d = await r.json().catch(() => ({})); | |
| err.textContent = d.detail || 'Invalid email or password.'; | |
| btn.textContent = 'Sign In'; btn.disabled = false; | |
| return; | |
| } | |
| const data = await r.json(); | |
| TOKEN = data.token; | |
| sessionStorage.setItem('ama_tok', TOKEN); | |
| sessionStorage.setItem('ama_usr', email); | |
| sessionStorage.setItem('ama_exp', data.expires_at); | |
| btn.textContent = 'Sign In'; btn.disabled = false; | |
| launch(); | |
| } catch(e) { | |
| err.textContent = 'Could not reach server. Check connection.'; | |
| btn.textContent = 'Sign In'; btn.disabled = false; | |
| } | |
| } | |
| function launch() { | |
| document.getElementById('login-screen').classList.add('hidden'); | |
| document.getElementById('app').classList.add('visible'); | |
| loadAll(); | |
| } | |
| window.addEventListener('DOMContentLoaded', () => { | |
| const tok = sessionStorage.getItem('ama_tok'); | |
| const exp = sessionStorage.getItem('ama_exp'); | |
| if (tok && exp && new Date(exp) > new Date()) { | |
| TOKEN = tok; launch(); | |
| } | |
| document.getElementById('inp-pass').addEventListener('keydown', e => { if (e.key==='Enter') doLogin(); }); | |
| }); | |
| // ββ API βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function api(path) { | |
| const r = await fetch(BACKEND + path, { headers: { 'x-api-token': TOKEN } }); | |
| if (!r.ok) throw new Error('HTTP ' + r.status); | |
| return r.json(); | |
| } | |
| // ββ Nav βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function nav(name, el) { | |
| document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); | |
| document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); | |
| document.getElementById('view-' + name).classList.add('active'); | |
| el.classList.add('active'); | |
| currentView = name; | |
| document.getElementById('scroll-area').scrollTop = 0; | |
| } | |
| function refreshView() { | |
| const btn = document.getElementById('refresh-btn'); | |
| btn.classList.add('spinning'); | |
| cache = {}; | |
| load(currentView).finally(() => setTimeout(() => btn.classList.remove('spinning'), 500)); | |
| } | |
| function loadAll() { | |
| ['overview','inventory','commitments','consignment','incoming'].forEach(v => load(v)); | |
| } | |
| function load(v) { | |
| switch(v) { | |
| case 'overview': return loadOverview(); | |
| case 'inventory': return loadInventory(); | |
| case 'commitments': return loadCommitments(); | |
| case 'consignment': return loadConsignment(); | |
| case 'incoming': return loadIncoming(); | |
| } | |
| return Promise.resolve(); | |
| } | |
| // ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const f4 = n => (isNaN(+n) ? 0 : +n).toFixed(4); | |
| const f2 = n => (isNaN(+n) ? 0 : +n).toFixed(2); | |
| const sk = (n=3) => Array(n).fill('<div class="skeleton"></div>').join(''); | |
| const set = (id, html) => document.getElementById(id).innerHTML = html; | |
| const empty = (icon, msg) => `<div class="empty-state"><div class="empty-icon">${icon}</div>${msg}</div>`; | |
| const errCard = msg => `<div class="error-card">β ${msg}</div>`; | |
| function totalsBar(chips) { | |
| return '<div class="totals-bar">' + | |
| chips.map(([val, lbl, color]) => | |
| `<div class="total-chip"><div class="total-chip-val"${color?` style="color:${color}"`:''}>${val}</div><div class="total-chip-lbl">${lbl}</div></div>` | |
| ).join('') + '</div>'; | |
| } | |
| function secSep(label) { | |
| return `<div class="section-sep"><div class="section-sep-label">${label}</div><div class="section-sep-line"></div></div>`; | |
| } | |
| // ββ Overview ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadOverview() { | |
| set('c-overview', sk(2)); | |
| try { | |
| const d = await api('/master-overview'); | |
| const ru = d.ruthenium||{}, ir = d.iridium||{}; | |
| const block = (cls, name, badge, stats, rows, consign) => ` | |
| <div class="metal-block ${cls}"> | |
| <div class="metal-header"> | |
| <div class="metal-name">${name}</div> | |
| <div class="metal-badge">${badge}</div> | |
| </div> | |
| <div class="stat-grid"> | |
| <div><div class="stat-val">${f4(stats.total)}</div><div class="stat-lbl">Total Physical kg</div></div> | |
| <div><div class="stat-val">${f4(stats.committed)}</div><div class="stat-lbl">Committed kg</div></div> | |
| <div><div class="stat-val" style="color:${+stats.avail>=0?'var(--green)':'var(--danger)'}">${f4(stats.avail)}</div><div class="stat-lbl">Available kg</div></div> | |
| <div><div class="stat-val">${f4(stats.forecast)}</div><div class="stat-lbl">Forecast Avail. kg</div></div> | |
| </div> | |
| <div class="divider"></div> | |
| ${rows.map(([k,v]) => `<div class="breakdown-row"><span class="breakdown-key">${k}</span><span class="breakdown-val">${v}</span></div>`).join('')} | |
| ${consign ? `<div class="breakdown-row"><span class="breakdown-key">At Customer (Consign.)</span><span class="breakdown-val">${f4(consign)} kg</span></div>` : ''} | |
| <div class="divider"></div> | |
| <div class="breakdown-row"><span class="breakdown-key">Avg Cost</span><span class="breakdown-val" style="color:var(--warn)">$${(+stats.avgCost||0).toFixed(2)}/toz</span></div> | |
| </div>`; | |
| const ruHtml = block('ru','RUTHENIUM','Ru', | |
| {avail: ru.available, total: ru.total, committed: ru.committed, forecast: ru.forecast_available, avgCost: ru.avg_cost_per_toz}, | |
| [['Primary Inventory', f4(ru.physical)+' kg'],['325 Mesh', f4(ru.mesh)+' kg'],['Work in Progress (W Orders)', f4(ru.wip)+' kg'],['Incoming', f4(ru.incoming)+' kg']], | |
| ru.consignment | |
| ); | |
| const irHtml = block('ir','IRIDIUM','Ir', | |
| {avail: ir.available, total: ir.total, committed: ir.committed, forecast: ir.forecast_available, avgCost: ir.avg_cost_per_toz}, | |
| [['Primary Inventory', f4(ir.physical)+' kg'],['Targets', f4(ir.targets)+' kg'],['Work in Progress', f4(ir.wip)+' kg'],['Incoming', f4(ir.incoming)+' kg']], | |
| null | |
| ); | |
| set('c-overview', ruHtml + irHtml); | |
| } catch(e) { | |
| set('c-overview', errCard('Could not load overview β ' + e.message)); | |
| } | |
| } | |
| // ββ Inventory βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let invData = {}; | |
| let invFilter = 'ru'; | |
| let invPackFilter = 'unopened'; // 'unopened' | 'opened' | 'all' | |
| let invBrandFilter = 'all'; | |
| function setFilter(f) { | |
| invFilter = f; | |
| invPackFilter = 'unopened'; // reset to unopened when switching metal | |
| invBrandFilter = 'all'; | |
| document.getElementById('tab-ru').classList.toggle('active', f==='ru'); | |
| document.getElementById('tab-ir').classList.toggle('active', f==='ir'); | |
| renderInventory(); | |
| } | |
| function setPackFilter(f) { | |
| invPackFilter = f; | |
| invBrandFilter = 'all'; | |
| document.querySelectorAll('.pack-tab').forEach(t => t.classList.remove('active')); | |
| document.getElementById('pack-tab-' + f).classList.add('active'); | |
| renderInventory(); | |
| } | |
| function setBrandFilter(brand) { | |
| invBrandFilter = brand; | |
| document.querySelectorAll('.brand-btn').forEach(b => { | |
| b.classList.toggle('active', b.dataset.brand === brand); | |
| }); | |
| renderInventory(); | |
| } | |
| async function loadInventory() { | |
| set('c-inventory', sk(4)); | |
| try { | |
| const [ruPrim, irPrim, ruProd] = await Promise.all([ | |
| api('/inventory/ruthenium').catch(() => []), | |
| api('/inventory/iridium').catch(() => []), | |
| api('/production/ru-325').catch(() => ({})) | |
| ]); | |
| invData = { | |
| ruPrimary: ruPrim||[], | |
| ruMesh: (ruProd.mesh_inventory||{}).items||[], | |
| ruWip: (ruProd.wip||{}).items||[], | |
| irPrimary: irPrim||[] | |
| }; | |
| renderInventory(); | |
| } catch(e) { | |
| set('c-inventory', errCard('Could not load inventory β ' + e.message)); | |
| } | |
| } | |
| function renderPrimaryCards(items, cls) { | |
| // Sort: brand β quantity desc β purity | |
| const sorted = [...items].sort((a,b) => { | |
| const bCmp = (a.brand||'').localeCompare(b.brand||''); | |
| if (bCmp !== 0) return bCmp; | |
| const qCmp = (+b.quantity_kg||0) - (+a.quantity_kg||0); | |
| if (qCmp !== 0) return qCmp; | |
| return (a.purity||'').localeCompare(b.purity||''); | |
| }); | |
| // Group by brand for display | |
| const brands = {}; | |
| sorted.forEach(i => { const b = i.brand||'Unknown'; (brands[b]=brands[b]||[]).push(i); }); | |
| let html = ''; | |
| for (const [brand, lots] of Object.entries(brands)) { | |
| const brandTotal = lots.reduce((s,i)=>s+(+i.quantity_kg||0),0); | |
| html += `<div class="brand-section"> | |
| <div class="brand-header"> | |
| <span class="brand-name">${brand}</span> | |
| <span class="brand-total">${brandTotal.toFixed(4)} kg</span> | |
| </div>`; | |
| lots.forEach(i => { | |
| const unopened = i.original_packing === true || i.original_packing === 'Yes'; | |
| html += `<div class="lot-card ${cls}" style="margin-left:8px"> | |
| <div class="lot-top"> | |
| <div class="lot-ama">Lot ${i.ama_lot||'β'} <span style="font-family:'DM Mono',monospace;font-size:11px;color:var(--muted)">${i.producer_lot||''}</span></div> | |
| <div class="lot-qty">${f4(i.quantity_kg)} kg</div> | |
| </div> | |
| <div class="lot-tags"> | |
| ${i.purity ? `<span class="tag hi">${i.purity}</span>` : ''} | |
| <span class="tag ${unopened?'tag-unopened':'tag-opened'}">${unopened?'Unopened':'Opened'}</span> | |
| </div> | |
| </div>`; | |
| }); | |
| html += '</div>'; | |
| } | |
| return html || empty('β«','No lots match'); | |
| } | |
| function renderInventory() { | |
| const metal = invFilter; | |
| const prim = metal === 'ru' ? (invData.ruPrimary||[]) : (invData.irPrimary||[]); | |
| const mesh = invData.ruMesh||[]; | |
| const wip = invData.ruWip||[]; | |
| const cls = metal === 'ru' ? 'ru' : 'ir'; | |
| const color = metal === 'ru' ? 'var(--ru)' : 'var(--ir)'; | |
| // Totals | |
| const tPrim = prim.reduce((s,i)=>s+(+i.quantity_kg||0),0); | |
| const tUnop = prim.filter(i=>i.original_packing===true||i.original_packing==='Yes').reduce((s,i)=>s+(+i.quantity_kg||0),0); | |
| const tOpen = prim.filter(i=>i.original_packing!==true&&i.original_packing!=='Yes').reduce((s,i)=>s+(+i.quantity_kg||0),0); | |
| // Filter by packing status | |
| let filtered = prim; | |
| if (invPackFilter === 'unopened') filtered = prim.filter(i=>i.original_packing===true||i.original_packing==='Yes'); | |
| else if (invPackFilter === 'opened') filtered = prim.filter(i=>i.original_packing!==true&&i.original_packing!=='Yes'); | |
| // Filter by brand if set | |
| if (invBrandFilter !== 'all') filtered = filtered.filter(i=>(i.brand||'')=== invBrandFilter); | |
| // Get all unique brands for the filtered packing status (before brand filter) | |
| let baseFiltered = prim; | |
| if (invPackFilter === 'unopened') baseFiltered = prim.filter(i=>i.original_packing===true||i.original_packing==='Yes'); | |
| else if (invPackFilter === 'opened') baseFiltered = prim.filter(i=>i.original_packing!==true&&i.original_packing!=='Yes'); | |
| const brands = [...new Set(baseFiltered.map(i=>i.brand||'Unknown'))].sort(); | |
| // Build HTML | |
| let html = ''; | |
| // Totals bar | |
| html += totalsBar([ | |
| [tPrim.toFixed(2), 'Total Primary kg', color], | |
| [tUnop.toFixed(2), 'Unopened kg', null], | |
| [tOpen.toFixed(2), 'Opened kg', null] | |
| ]); | |
| // Pack status filter tabs | |
| html += `<div class="pack-tabs"> | |
| <div class="pack-tab ${invPackFilter==='unopened'?'active':''}" id="pack-tab-unopened" onclick="setPackFilter('unopened')">Unopened</div> | |
| <div class="pack-tab ${invPackFilter==='opened'?'active':''}" id="pack-tab-opened" onclick="setPackFilter('opened')">Opened</div> | |
| <div class="pack-tab ${invPackFilter==='all'?'active':''}" id="pack-tab-all" onclick="setPackFilter('all')">All</div> | |
| </div>`; | |
| // Brand filter buttons (only if multiple brands) | |
| if (brands.length > 1) { | |
| html += `<div class="brand-filters"> | |
| <div class="brand-btn ${invBrandFilter==='all'?'active':''}" data-brand="all" onclick="setBrandFilter('all')">All brands</div> | |
| ${brands.map(b=>`<div class="brand-btn ${invBrandFilter===b?'active':''}" data-brand="${b}" onclick="setBrandFilter('${b}')">${b}</div>`).join('')} | |
| </div>`; | |
| } | |
| // Primary inventory cards | |
| if (filtered.length) { | |
| html += renderPrimaryCards(filtered, cls); | |
| } else { | |
| html += empty('β«', invPackFilter === 'unopened' ? 'No unopened stock' : 'No opened stock'); | |
| } | |
| // 325 Mesh section (Ruthenium only) | |
| if (metal === 'ru' && mesh.length) { | |
| html += secSep('325 Mesh (Finished)'); | |
| mesh.forEach(i => { | |
| html += `<div class="lot-card ru"> | |
| <div class="lot-top"><div class="lot-ama">Lot ${i.ama_lot||'β'}</div><div class="lot-qty">${f4(i.quantity_kg)} kg</div></div> | |
| <div class="lot-tags"> | |
| <span class="tag hi">${i.brand||''}</span> | |
| ${i.purity?`<span class="tag">${i.purity}</span>`:''} | |
| <span class="tag">${i.date_produced||''}</span> | |
| </div></div>`; | |
| }); | |
| } | |
| // WIP section (Ruthenium only) | |
| if (metal === 'ru' && wip.length) { | |
| html += secSep('Work in Progress (Open W Orders)'); | |
| wip.forEach(wo => { | |
| const inputs = wo.inputs||[]; | |
| const isOSF = !!wo.parent_work_order_ref; | |
| const inputSummary = inputs.map(i => | |
| `<div class="consign-lot-row"> | |
| <span class="consign-lot-ama">Lot ${i.ama_lot||'β'} Β· ${i.brand||''}</span> | |
| <span class="consign-lot-val">${f4(i.quantity_used_kg)} kg</span> | |
| </div>` | |
| ).join(''); | |
| html += `<div class="lot-card ru dim"> | |
| <div class="lot-top"> | |
| <div class="lot-ama">${wo.reference||'β'}${isOSF?' <span style="font-size:11px;color:var(--muted)">OSF</span>':''}</div> | |
| <div class="lot-qty">${f4(wo.total_input_kg)} kg</div> | |
| </div> | |
| <div class="lot-tags" style="margin-bottom:${inputs.length?'10px':'0'}"> | |
| <span class="tag hi">Open</span> | |
| <span class="tag">${wo.date_opened||''}</span> | |
| ${isOSF?`<span class="tag">from ${wo.parent_work_order_ref}</span>`:''} | |
| </div> | |
| ${inputSummary} | |
| </div>`; | |
| }); | |
| } | |
| set('c-inventory', html); | |
| } | |
| // ββ Commitments βββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadCommitments() { | |
| set('c-commitments', sk(4)); | |
| try { | |
| const data = await api('/commitments'); | |
| const open = data.filter(c => c.status !== 'Fulfilled') | |
| .sort((a,b) => (a.delivery_date||'9999').localeCompare(b.delivery_date||'9999')); | |
| if (!open.length) { set('c-commitments', empty('β·','No open commitments')); return; } | |
| const ruT = open.filter(c=>c.metal==='Ruthenium').reduce((s,c)=>s+(+c.quantity_kg||0),0); | |
| const irT = open.filter(c=>c.metal==='Iridium').reduce((s,c)=>s+(+c.quantity_kg||0),0); | |
| let html = totalsBar([[ruT.toFixed(2),'Ru kg','var(--ru)'],[irT.toFixed(2),'Ir kg','var(--ir)'],[open.length,'Open',null]]); | |
| open.forEach(c => { | |
| const sc = c.status==='Pending' ? 'pending' : c.status==='Partially Fulfilled' ? 'partial' : 'fulfilled'; | |
| const mc = c.metal==='Ruthenium' ? 'var(--ru)' : 'var(--ir)'; | |
| html += `<div class="commit-card" style="border-left:3px solid ${mc}"> | |
| <div class="commit-top"> | |
| <div class="commit-customer">${c.customer||'β'}</div> | |
| <div class="commit-qty" style="color:${mc}">${f4(c.quantity_kg)} kg</div> | |
| </div> | |
| <div class="lot-tags" style="margin-bottom:${c.notes?'8px':'0'}"> | |
| <span class="pill ${sc}">${c.status||''}</span> | |
| <span class="tag" style="color:${mc}">${c.metal||''}</span> | |
| ${c.product_type ? `<span class="tag">${c.product_type}</span>` : ''} | |
| ${c.delivery_date ? `<span class="tag">Due ${c.delivery_date}</span>` : ''} | |
| </div> | |
| ${c.notes ? `<div style="font-size:12px;color:var(--muted);margin-top:2px">${c.notes}</div>` : ''} | |
| </div>`; | |
| }); | |
| set('c-commitments', html); | |
| } catch(e) { | |
| set('c-commitments', errCard('Could not load commitments β ' + e.message)); | |
| } | |
| } | |
| // ββ Consignment βββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadConsignment() { | |
| set('c-consignment', sk(2)); | |
| try { | |
| const data = await api('/consignment/shipments'); | |
| if (!data.length) { set('c-consignment', empty('β','No active consignment shipments')); return; } | |
| const totalRem = data.reduce((s,d)=>s+(+d.total_remaining||0),0); | |
| let html = totalsBar([[totalRem.toFixed(2),'Ru kg at customers','var(--ru)'],[data.length,'Shipments',null]]); | |
| data.forEach(s => { | |
| const alert = s.alert||'ok'; | |
| const bdCls = alert==='expired' ? 'danger' : alert==='warning' ? 'warn' : ''; | |
| const badgeTx = alert==='expired' | |
| ? `π΄ Expired ${Math.abs(s.days_left||0)}d ago` | |
| : alert==='warning' | |
| ? `β ${s.days_left}d left` | |
| : `β ${s.days_left}d left`; | |
| const shipped = +s.total_kg||0; | |
| const rem = +s.total_remaining||0; | |
| const pct = shipped>0 ? Math.min(100,Math.round((rem/shipped)*100)) : 0; | |
| const lots = s.lots||[]; | |
| html += `<div class="consign-card ${bdCls}"> | |
| <div class="consign-top"> | |
| <div class="consign-name">${s.customer||'β'} <span style="font-family:'DM Mono',monospace;font-size:11px;color:var(--muted)">#${s.id}</span></div> | |
| <span class="expiry-pill ${alert}">${badgeTx}</span> | |
| </div> | |
| <div class="progress-bar"><div class="progress-fill" style="width:${pct}%"></div></div> | |
| <div class="progress-labels"><span>${rem.toFixed(4)} kg remaining</span><span>${(shipped-rem).toFixed(4)} kg consumed</span></div> | |
| <div style="font-size:12px;color:var(--muted);margin-top:8px"> | |
| Shipped ${s.ship_date||''} Β· Expires ${s.expiry_date||''} Β· ${s.status||''} | |
| </div> | |
| ${lots.length ? '<div style="margin-top:6px">' + lots.map(l => { | |
| const r = +l.remaining_kg||0, c2 = (+l.quantity_kg||0)-r; | |
| return `<div class="consign-lot-row"> | |
| <span class="consign-lot-ama">Lot ${l.ama_lot||'β'}</span> | |
| <span class="consign-lot-val">${r.toFixed(4)} kg rem Β· ${c2.toFixed(4)} kg used</span> | |
| </div>`; | |
| }).join('') + '</div>' : ''} | |
| </div>`; | |
| }); | |
| set('c-consignment', html); | |
| } catch(e) { | |
| set('c-consignment', errCard('Could not load consignment β ' + e.message)); | |
| } | |
| } | |
| // ββ Incoming ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadIncoming() { | |
| set('c-incoming', sk(3)); | |
| try { | |
| const data = await api('/incoming'); | |
| const open = data.filter(d => d.status !== 'Received') | |
| .sort((a,b) => (a.expected_date||'9999').localeCompare(b.expected_date||'9999')); | |
| if (!open.length) { set('c-incoming', empty('β¬','No pending deliveries')); return; } | |
| const ruT = open.filter(d=>d.metal==='Ruthenium').reduce((s,d)=>s+(+d.quantity_kg||0),0); | |
| const irT = open.filter(d=>d.metal==='Iridium').reduce((s,d)=>s+(+d.quantity_kg||0),0); | |
| let html = totalsBar([[ruT.toFixed(2),'Ru kg','var(--ru)'],[irT.toFixed(2),'Ir kg','var(--ir)'],[open.length,'Pending',null]]); | |
| open.forEach(d => { | |
| const cls = d.metal==='Ruthenium' ? 'ru' : 'ir'; | |
| const mc = d.metal==='Ruthenium' ? 'var(--ru)' : 'var(--ir)'; | |
| const rec = +d.received_quantity_kg||0; | |
| const exp = +d.quantity_kg||0; | |
| const variance = rec - exp; | |
| html += `<div class="incoming-card ${cls}"> | |
| <div class="commit-top"> | |
| <div class="commit-customer">${d.supplier||'β'}</div> | |
| <div class="commit-qty" style="color:${mc}">${f4(d.quantity_kg)} kg</div> | |
| </div> | |
| <div class="lot-tags" style="margin-top:2px"> | |
| <span class="tag hi">${d.metal||''}</span> | |
| ${d.ama_po ? `<span class="tag">${d.ama_po}</span>` : ''} | |
| ${d.expected_date ? `<span class="tag">Due ${d.expected_date}</span>` : ''} | |
| <span class="tag">$${f2(d.price_per_toz)}/toz</span> | |
| <span class="pill ${d.status==='Expected'?'pending':'partial'}">${d.status||''}</span> | |
| </div> | |
| ${rec>0 ? `<div style="margin-top:8px;font-size:12px;color:var(--muted)"> | |
| Received: ${rec.toFixed(4)} kg | |
| ${Math.abs(variance)>0.01 ? `<span style="color:${variance>0?'var(--green)':'var(--danger)'}"> (${variance>0?'+':''}${variance.toFixed(4)} kg)</span>` : ''} | |
| </div>` : ''} | |
| ${d.material_type==='Production Scrap Return' ? `<div style="margin-top:5px;font-size:11px;color:var(--muted)">${d.material_type}${d.secondary_type?' Β· '+d.secondary_type:''}</div>` : ''} | |
| </div>`; | |
| }); | |
| set('c-incoming', html); | |
| } catch(e) { | |
| set('c-incoming', errCard('Could not load incoming β ' + e.message)); | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |