<!DOCTYPE html> <html lang="id"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" /> <title>GUDANG — Stok Barang</title> <link rel="preconnect" href="https://fonts.googleapis.com"> <link href="https://fonts.googleapis.com/css2?family=Oswald:wght@400;500;600;700&family=Work+Sans:wght@400;500;600&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet"> <style> :root{ --paper:#EDE4D3; --paper-dark:#E0D3B6; --card:#F5EFE1; --ink:#2A2521; --ink-soft:#6B6154; --stamp-red:#B8412E; --stamp-green:#3F5D3A; --brass:#9C7C43; --line:#CBBB98; --shadow: rgba(42,37,33,0.18); } *{box-sizing:border-box;} html,body{margin:0;padding:0;} body{ background: radial-gradient(ellipse at top left, rgba(156,124,67,0.08), transparent 60%), var(--paper); background-attachment:fixed; color:var(--ink); font-family:'Work Sans', sans-serif; min-height:100vh; padding-bottom:40px; } .wrap{max-width:640px;margin:0 auto;padding:18px 16px 0;} /* ---- Header stamp ---- */ header{ position:relative; padding:22px 20px 20px; margin-bottom:18px; background:var(--card); border:2px solid var(--ink); border-radius:2px; box-shadow: 4px 4px 0 var(--brass); } header::before{ content:"MANIFEST GUDANG"; position:absolute; top:-11px; left:16px; background:var(--paper); padding:0 8px; font-family:'JetBrains Mono', monospace; font-size:10px; letter-spacing:2px; color:var(--ink-soft); } h1{ font-family:'Oswald', sans-serif; font-weight:700; text-transform:uppercase; letter-spacing:1px; font-size:30px; margin:6px 0 2px; } .subtitle{ font-size:13px; color:var(--ink-soft); } .sync-row{ display:flex; align-items:center; gap:8px; margin-top:14px; font-family:'JetBrains Mono', monospace; font-size:11px; color:var(--ink-soft); } .dot{width:8px;height:8px;border-radius:50%;background:var(--stamp-green);flex-shrink:0;} .dot.syncing{background:var(--brass); animation:pulse 1s infinite;} @keyframes pulse{0%,100%{opacity:1;}50%{opacity:.35;}} .stats{ display:flex; gap:10px; margin-top:14px; } .stat{ flex:1; border:1px solid var(--line); border-radius:2px; padding:8px 10px; background:var(--paper); } .stat .num{font-family:'JetBrains Mono', monospace; font-weight:700; font-size:20px;} .stat .lbl{font-size:10px; text-transform:uppercase; letter-spacing:1px; color:var(--ink-soft);} /* ---- Add form ---- */ .add-card{ background:var(--card); border:2px dashed var(--brass); border-radius:2px; padding:16px; margin-bottom:18px; } .add-card h2{ font-family:'Oswald', sans-serif; font-size:14px; text-transform:uppercase; letter-spacing:1.5px; margin:0 0 12px; color:var(--ink-soft); } .form-grid{ display:grid; grid-template-columns: 2fr 1fr 1fr; gap:8px; margin-bottom:10px; } input, select{ font-family:'Work Sans', sans-serif; font-size:14px; padding:10px 10px; border:1.5px solid var(--ink); border-radius:2px; background:#fff; color:var(--ink); width:100%; } input:focus, select:focus{outline:2px solid var(--brass); outline-offset:1px;} input::placeholder{color:#a99e8c;} .btn{ font-family:'Oswald', sans-serif; font-weight:600; text-transform:uppercase; letter-spacing:1px; font-size:13px; border:2px solid var(--ink); border-radius:2px; background:var(--ink); color:var(--paper); padding:10px 16px; cursor:pointer; width:100%; transition: transform .05s ease; } .btn:active{transform:scale(0.97);} .btn.ghost{background:transparent; color:var(--ink);} .btn:disabled{opacity:.5; cursor:not-allowed;} /* ---- Search ---- */ .search-row{margin-bottom:14px;} .search-row input{font-family:'JetBrains Mono', monospace; font-size:13px;} /* ---- Item list ---- */ .empty{ text-align:center; padding:40px 20px; color:var(--ink-soft); font-size:14px; border:1.5px dashed var(--line); border-radius:2px; } .item{ display:flex; align-items:center; gap:12px; background:var(--card); border:1.5px solid var(--ink); border-radius:2px; padding:12px 12px; margin-bottom:10px; position:relative; overflow:hidden; } .item.low::before{ content:"STOK MENIPIS"; position:absolute; top:8px; right:-30px; background:var(--stamp-red); color:#fff; font-family:'JetBrains Mono', monospace; font-size:9px; letter-spacing:1px; padding:2px 34px; transform:rotate(35deg); } .badge-initial{ width:38px;height:38px; border:2px solid var(--brass); border-radius:50%; display:flex;align-items:center;justify-content:center; font-family:'Oswald', sans-serif; font-weight:700; font-size:16px; color:var(--brass); flex-shrink:0; } .item-info{flex:1; min-width:0;} .item-name{ font-family:'Oswald', sans-serif; font-weight:600; font-size:16px; text-transform:uppercase; letter-spacing:0.3px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .item-meta{ font-family:'JetBrains Mono', monospace; font-size:11px; color:var(--ink-soft); margin-top:2px; } .stepper{ display:flex; align-items:center; gap:6px; flex-shrink:0; } .step-btn{ width:32px;height:32px; border:2px solid var(--ink); border-radius:50%; background:var(--paper); font-size:18px; font-weight:700; color:var(--ink); display:flex;align-items:center;justify-content:center; cursor:pointer; line-height:0; padding:0; } .step-btn:active{transform:scale(0.9);} .step-btn.minus{color:var(--stamp-red); border-color:var(--stamp-red);} .step-btn.plus{color:var(--stamp-green); border-color:var(--stamp-green);} .qty-box{ min-width:52px; text-align:center; font-family:'JetBrains Mono', monospace; font-weight:700; font-size:18px; border:1.5px solid var(--ink); border-radius:2px; padding:5px 4px; background:#fff; } .qty-box.flash-add{animation:flashGreen .5s ease;} .qty-box.flash-sub{animation:flashRed .5s ease;} @keyframes flashGreen{0%{background:var(--stamp-green); color:#fff;}100%{background:#fff; color:var(--ink);}} @keyframes flashRed{0%{background:var(--stamp-red); color:#fff;}100%{background:#fff; color:var(--ink);}} .del-btn{ background:none;border:none; color:var(--ink-soft); font-size:18px; cursor:pointer; padding:4px 2px; flex-shrink:0; } /* ---- Log ---- */ details{ margin-top:20px; border:1.5px solid var(--line); border-radius:2px; background:var(--card); } summary{ font-family:'Oswald', sans-serif; font-size:13px; text-transform:uppercase; letter-spacing:1px; padding:12px 14px; cursor:pointer; color:var(--ink-soft); } .log-list{padding:0 14px 14px;} .log-entry{ font-family:'JetBrains Mono', monospace; font-size:12px; padding:6px 0; border-bottom:1px solid var(--line); display:flex; justify-content:space-between; gap:8px; } .log-entry:last-child{border-bottom:none;} .log-entry .chg-add{color:var(--stamp-green); font-weight:700;} .log-entry .chg-sub{color:var(--stamp-red); font-weight:700;} .log-time{color:var(--ink-soft); flex-shrink:0;} .footer-note{ text-align:center; font-size:11px; color:var(--ink-soft); margin-top:22px; line-height:1.6; } @media (max-width:380px){ h1{font-size:24px;} .form-grid{grid-template-columns:1fr 1fr;} .form-grid input:first-child{grid-column:1/-1;} } </style> </head> <body> <div class="wrap"> <header> <h1>Gudang</h1> <div class="subtitle">Catatan stok barang — tersinkron di semua perangkat</div> <div class="sync-row"> <span class="dot" id="syncDot"></span> <span id="syncText">Memuat data…</span> </div> <div class="stats"> <div class="stat"> <div class="num" id="statTotalItems">0</div> <div class="lbl">Jenis barang</div> </div> <div class="stat"> <div class="num" id="statTotalQty">0</div> <div class="lbl">Total unit</div> </div> <div class="stat"> <div class="num" id="statLow">0</div> <div class="lbl">Stok menipis</div> </div> </div> </header> <div class="add-card"> <h2>+ Tambah barang baru</h2> <div class="form-grid"> <input type="text" id="newName" placeholder="Nama barang" maxlength="60" /> <input type="number" id="newQty" placeholder="Jumlah" min="0" step="1" /> <input type="text" id="newUnit" placeholder="Satuan (pcs)" maxlength="10" /> </div> <div class="form-grid" style="grid-template-columns:1fr;"> <input type="number" id="newMin" placeholder="Batas stok menipis (opsional, mis. 5)" min="0" step="1" /> </div> <button class="btn" id="addBtn">Tambahkan ke gudang</button> </div> <div class="search-row"> <input type="text" id="searchBox" placeholder="Cari barang…" /> </div> <div id="itemList"></div> <details> <summary id="logSummary">Riwayat perubahan terbaru</summary> <div class="log-list" id="logList"></div> </details> <div class="footer-note"> Data ini dibagikan (shared) — siapa pun yang membuka aplikasi ini bisa melihat dan mengubah stok yang sama.<br/> Tersinkron otomatis setiap beberapa detik di semua perangkat. </div> </div> <script> const ITEMS_KEY = ********; const LOG_KEY = 'gudang-stok-log'; let items = []; let log = []; let isSyncing = false; let lastLocalWrite = 0; const $ = (id) => document.getElementById(id); function uid(){ return Date.now().toString(36) + Math.random().toString(36).slice(2,7); } function fmtTime(ts){ const d = new Date(ts); return d.toLocaleTimeString('id-ID', {hour:'2-digit', minute:'2-digit'}) + ' · ' + d.toLocaleDateString('id-ID', {day:'2-digit', month:'short'}); } function setSyncStatus(state){ const dot = $('syncDot'); const text = $('syncText'); if(state === 'syncing'){ dot.className = 'dot syncing'; text.textContent = 'Menyinkronkan…'; } else if(state === 'error'){ dot.className = 'dot'; dot.style.background = 'var(--stamp-red)'; text.textContent = 'Gagal tersambung — akan dicoba lagi'; } else { dot.className = 'dot'; dot.style.background = 'var(--stamp-green)'; text.textContent = 'Tersambung · terakhir sinkron ' + new Date().toLocaleTimeString('id-ID', {hour:'2-digit', minute:'2-digit', second:'2-digit'}); } } async function loadFromStorage(silent){ if(!silent) isSyncing = true; setSyncStatus('syncing'); try{ const res = await window.storage.get(ITEMS_KEY, true); items = res && res.value ? JSON.parse(res.value) : []; }catch(e){ items = items || []; } try{ const res2 = await window.storage.get(LOG_KEY, true); log = res2 && res2.value ? JSON.parse(res2.value) : []; }catch(e){ log = log || []; } setSyncStatus('ok'); render(); } async function saveItems(){ try{ lastLocalWrite = Date.now(); const result = await window.storage.set(ITEMS_KEY, JSON.stringify(items), true); if(!result){ setSyncStatus('error'); } }catch(e){ setSyncStatus('error'); } } async function saveLog(){ try{ await window.storage.set(LOG_KEY, JSON.stringify(log.slice(0,60)), true); }catch(e){ /* non-fatal */ } } function addLogEntry(itemName, change, newQty){ log.unshift({ id: uid(), name: itemName, change, newQty, time: Date.now() }); log = log.slice(0,60); saveLog(); } function render(){ // stats $('statTotalItems').textContent = items.length; $('statTotalQty').textContent = items.reduce((s,i)=>s+Number(i.qty||0),0); $('statLow').textContent = items.filter(i => i.min != null && i.min > 0 && Number(i.qty) <= Number(i.min)).length; // list const query = ($('searchBox').value || '').trim().toLowerCase(); const filtered = items.filter(i => i.name.toLowerCase().includes(query)); const listEl = $('itemList'); if(items.length === 0){ listEl.innerHTML = '<div class="empty">Belum ada barang. Tambahkan barang pertama di atas.</div>'; } else if(filtered.length === 0){ listEl.innerHTML = '<div class="empty">Tidak ada barang yang cocok dengan pencarian.</div>'; } else { listEl.innerHTML = filtered.map(item => { const isLow = item.min != null && item.min > 0 && Number(item.qty) <= Number(item.min); return ` <div class="item ${isLow ? 'low' : ''}" data-id="${item.id}"> <div class="badge-initial">${escapeHtml(item.name.charAt(0).toUpperCase() || '?')}</div> <div class="item-info"> <div class="item-name">${escapeHtml(item.name)}</div> <div class="item-meta">${escapeHtml(item.unit || 'pcs')}${item.min ? ' · batas minimal ' + item.min : ''}</div> </div> <div class="stepper"> <button class="step-btn minus" data-action="dec" data-id="${item.id}">−</button> <div class="qty-box" id="qty-${item.id}">${item.qty}</div> <button class="step-btn plus" data-action="inc" data-id="${item.id}">+</button> </div> <button class="del-btn" data-action="del" data-id="${item.id}" title="Hapus barang">✕</button> </div>`; }).join(''); } // log if(log.length === 0){ $('logList').innerHTML = '<div class="log-entry" style="color:var(--ink-soft);">Belum ada perubahan tercatat.</div>'; } else { $('logList').innerHTML = log.slice(0,20).map(l => ` <div class="log-entry"> <span>${escapeHtml(l.name)} <span class="${l.change >= 0 ? 'chg-add' : 'chg-sub'}">${l.change >= 0 ? '+' : ''}${l.change}</span> → ${l.newQty}</span> <span class="log-time">${fmtTime(l.time)}</span> </div> `).join(''); } } function escapeHtml(str){ const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } async function changeQty(id, delta){ const item = items.find(i => i.id === id); if(!item) return; const newQty = Math.max(0, Number(item.qty) + delta); const actualDelta = newQty - Number(item.qty); item.qty = newQty; const box = $('qty-' + id); if(box){ box.textContent = newQty; box.classList.remove('flash-add','flash-sub'); void box.offsetWidth; box.classList.add(actualDelta >= 0 ? 'flash-add' : 'flash-sub'); } addLogEntry(item.name, actualDelta, newQty); await saveItems(); render(); } async function deleteItem(id){ const item = items.find(i => i.id === id); if(!item) return; if(!confirm(`Hapus "${item.name}" dari gudang?`)) return; items = items.filter(i => i.id !== id); await saveItems(); render(); } async function addNewItem(){ const nameEl = $('newName'); const qtyEl = $('newQty'); const unitEl = $('newUnit'); const minEl = $('newMin'); const name = nameEl.value.trim(); const qty = Number(qtyEl.value || 0); const unit = unitEl.value.trim() || 'pcs'; const min = minEl.value ? Number(minEl.value) : null; if(!name){ nameEl.focus(); nameEl.style.borderColor = 'var(--stamp-red)'; setTimeout(()=>{ nameEl.style.borderColor = 'var(--ink)'; }, 1200); return; } const newItem = { id: uid(), name, qty: Math.max(0,qty), unit, min, updatedAt: Date.now() }; items.push(newItem); addLogEntry(name, qty, qty); await saveItems(); nameEl.value = ''; qtyEl.value=''; unitEl.value=''; minEl.value=''; render(); } // Event delegation for stepper / delete $('itemList').addEventListener('click', (e) => { const btn = e.target.closest('button[data-action]'); if(!btn) return; const id = btn.getAttribute('data-id'); const action = btn.getAttribute('data-action'); if(action === 'inc') changeQty(id, 1); if(action === 'dec') changeQty(id, -1); if(action === 'del') deleteItem(id); }); $('addBtn').addEventListener('click', addNewItem); $('newQty').addEventListener('keydown', (e) => { if(e.key === 'Enter') addNewItem(); }); $('newMin').addEventListener('keydown', (e) => { if(e.key === 'Enter') addNewItem(); }); $('searchBox').addEventListener('input', render); // Initial load + polling for cross-device sync loadFromStorage(false); setInterval(() => { // avoid clobbering right after our own write if(Date.now() - lastLocalWrite > 1500){ loadFromStorage(true); } }, 4000); </script> </body> </html>