mobile_PMI / index.html
AMA-PMI's picture
Upload index.html
4eee1ef verified
Raw
History Blame Contribute Delete
40.9 kB
<!DOCTYPE html>
<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) !important; background: var(--green-dim) !important; border-color: rgba(74,232,155,0.25) !important; }
.tag-opened { color: var(--warn) !important; background: var(--warn-dim) !important; border-color: rgba(232,196,74,0.25) !important; }
/* ── 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)'}">&nbsp;(${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>