/* @jsx React.createElement */ const { useState, useEffect, useRef, useMemo } = React; /* ============================================================ Yappari — MACHINE CONTROL (remote, dangerous) ============================================================ */ function ControlView({ selectedMachine, machines }) { const Y = window.YAPPARI; const mid = selectedMachine || machines[0].id; const mObj = machines.find(m => m.id === mid); const slots = Y.SLOTS[mid]; const prodMap = Object.fromEntries(Y.PRODUCTS.map(p => [p.id, p])); const [hw, setHw] = useState({ led: true, buzzer: false, glassHeater: false, temp: 6, tempMode: 'dingin' }); const [dispenseSlot, setDispenseSlot] = useState(slots[0].label); const [pending, setPending] = useState(null); const [log, setLog] = useState([ { id: 1, time: '−2 mnt', cmd: 'Sinkronisasi inventaris', status: 'ok', detail: 'ACK · 60 slot tersinkron' }, { id: 2, time: '−14 mnt', cmd: 'Heartbeat ping', status: 'ok', detail: 'Latensi 142ms' }, ]); const logId = useRef(100); useEffect(() => { setDispenseSlot(slots[0].label); }, [mid]); const now = () => new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const sendCommand = (cmd, willFail, apiCmd, apiParams) => { const id = ++logId.current; setLog(prev => [{ id, time: 'baru saja', cmd, machine: mObj.name, status: 'pending', detail: 'Mengirim perintah…' }, ...prev]); // kirim ke backend (tabel vm_commands) bila tersedia if (Y.sendCommand && apiCmd) { Y.sendCommand(mid, apiCmd, apiParams || {}) .then(() => setLog(prev => prev.map(l => l.id === id ? { ...l, time: now(), status: willFail ? 'fail' : 'ok', detail: willFail ? 'Mesin offline · menunggu koneksi' : 'Terkirim · menunggu ACK mesin' } : l))) .catch((e) => setLog(prev => prev.map(l => l.id === id ? { ...l, time: now(), status: 'fail', detail: 'Gagal kirim: ' + (e.message || 'error') } : l))); } else { setTimeout(() => { setLog(prev => prev.map(l => l.id === id ? { ...l, time: now(), status: willFail ? 'fail' : 'ok', detail: willFail ? 'Timeout · mesin tidak merespons' : 'ACK · perintah dieksekusi' } : l)); }, 1400); } }; const confirm = (cfg) => setPending(cfg); const runPending = () => { if (pending) { pending.run(); setPending(null); } }; const offline = mObj.status === 'offline'; // ---- section helpers ---- const sectionCard = (accent, icon, title, sub, children, danger) => React.createElement('div', { className: 'card', style: { overflow: 'hidden', borderTop: '3px solid ' + accent } }, React.createElement('div', { className: 'card-head' }, React.createElement('div', { className: 'row gap-3' }, React.createElement('div', { style: { width: 38, height: 38, borderRadius: 9, background: accent, color: '#fff', display: 'grid', placeItems: 'center', flexShrink: 0 } }, React.createElement(Icon, { name: icon, size: 20 })), React.createElement('div', null, React.createElement('h3', null, title), React.createElement('div', { className: 'ch-sub' }, sub))), danger && React.createElement('span', { className: 'badge badge-error' }, React.createElement(Icon, { name: 'alert', size: 12 }), 'Hati-hati')), React.createElement('div', { className: 'card-pad' }, children), ); const tempColor = hw.tempMode === 'panas' ? 'var(--accent)' : hw.tempMode === 'off' ? 'var(--fg-3)' : 'var(--st-maint)'; return React.createElement('div', { className: 'view' }, // danger banner React.createElement('div', { style: { borderRadius: 'var(--r-3)', padding: '14px 20px', marginBottom: 'var(--sp-5)', background: 'var(--st-error-bg)', border: '1px solid var(--st-error)', display: 'flex', gap: 14, alignItems: 'center' } }, React.createElement('div', { className: 'attn-pulse', style: { width: 40, height: 40, borderRadius: 10, background: 'var(--st-error)', color: '#fff', display: 'grid', placeItems: 'center', flexShrink: 0 } }, React.createElement(Icon, { name: 'zap', size: 22 })), React.createElement('div', { style: { flex: 1 } }, React.createElement('div', { style: { fontWeight: 800, color: 'var(--st-error)', fontSize: 15 } }, 'Kontrol jarak jauh langsung — ' + mObj.name), React.createElement('div', { style: { fontSize: 13, color: 'var(--fg-2)' } }, 'Perintah dikirim langsung ke perangkat fisik. Pastikan tidak ada pelanggan di depan mesin sebelum mengeksekusi.')), offline && React.createElement('span', { className: 'badge badge-offline' }, React.createElement(Icon, { name: 'wifiOff', size: 12 }), 'Offline — perintah akan gagal'), ), React.createElement('div', { className: 'l-split l-half' }, React.createElement('div', { className: 'col gap-5' }, // (a) Dispense sectionCard('var(--accent)', 'package', 'Dispense', 'Keluarkan produk untuk pengujian (gratis)', React.createElement('div', { className: 'col gap-4' }, React.createElement('div', null, React.createElement('label', { className: 'field-label' }, 'Pilih slot'), React.createElement('select', { className: 'select-native', value: dispenseSlot, onChange: e => setDispenseSlot(e.target.value) }, slots.map(s => React.createElement('option', { key: s.label, value: s.label }, s.label + ' — ' + prodMap[s.productId].name + (s.cur === 0 ? ' (habis)' : ' · stok ' + s.cur))))), React.createElement('button', { className: 'btn btn-accent btn-stamp', style: { justifyContent: 'center' }, onClick: () => confirm({ danger: true, eyebrow: 'Konfirmasi dispense', title: 'Keluarkan produk dari slot ' + dispenseSlot + '?', body: 'Mesin akan memutar coil slot ' + dispenseSlot + ' dan menjatuhkan satu unit ' + prodMap[slots.find(s => s.label === dispenseSlot).productId].name + ' tanpa pembayaran. Gunakan hanya untuk pengujian.', confirmLabel: 'Ya, keluarkan', run: () => sendCommand('Dispense slot ' + dispenseSlot + ' (test/gratis)', offline, 'dispense', { slot_label: dispenseSlot, slot_no: (slots.find(function(x){return x.label === dispenseSlot;}) || {}).no }) }) }, React.createElement(Icon, { name: 'play', size: 16 }), 'Keluarkan produk (gratis / test)'), )), // (b) Maintenance sectionCard('var(--st-warn)', 'cpu', 'Maintenance', 'Operasi tingkat sistem', React.createElement('div', { className: 'col gap-3' }, React.createElement('div', { className: 'row gap-3', style: { flexWrap: 'wrap' } }, React.createElement('button', { className: 'btn btn-ghost', style: { flex: 1, minWidth: 130, justifyContent: 'center' }, onClick: () => confirm({ danger: true, eyebrow: 'Konfirmasi reset', title: 'Reset mesin ' + mObj.name + '?', body: 'Mesin akan reboot dan tidak dapat melayani transaksi selama ±90 detik. Semua sesi berjalan akan dibatalkan.', confirmLabel: 'Reset sekarang', run: () => sendCommand('Reset / reboot mesin', offline, 'reset') }) }, React.createElement(Icon, { name: 'reset', size: 16 }), 'Reset mesin'), React.createElement('button', { className: 'btn btn-ghost', style: { flex: 1, minWidth: 130, justifyContent: 'center' }, onClick: () => sendCommand('Self-check diagnostik', offline, 'self_check') }, React.createElement(Icon, { name: 'check2', size: 16 }), 'Self-check')), React.createElement('button', { className: 'btn btn-danger', style: { justifyContent: 'center' }, onClick: () => confirm({ danger: true, eyebrow: 'Operasi berisiko tinggi', title: 'Jalankan Test Mode (semua slot keluar 1)?', body: 'PERINGATAN: mesin akan mengeluarkan SATU unit dari SETIAP slot terisi (hingga 60 produk) secara berurutan. Produk akan jatuh ke tray. Operasi tidak dapat dibatalkan.', confirmLabel: 'Saya paham, jalankan', run: () => sendCommand('Test mode - dispense semua slot x1', offline, 'test_mode') }) }, React.createElement(Icon, { name: 'alert', size: 16 }), 'Test mode — semua slot keluar 1'), ), true), ), React.createElement('div', { className: 'col gap-5' }, // (c) Hardware sectionCard('var(--st-maint)', 'control', 'Hardware', 'Kontrol periferal mesin', React.createElement('div', { className: 'col gap-4' }, [['led', 'bulb', 'Lampu LED', 'Penerangan kabin display'], ['buzzer', 'volume', 'Buzzer', 'Suara konfirmasi & alarm'], ['glassHeater', 'flame', 'Pemanas kaca', 'Anti-embun pada pintu kaca']].map(([key, icon, label, sub]) => React.createElement('div', { key, className: 'row', style: { justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid var(--border-subtle)' } }, React.createElement('div', { className: 'row gap-3' }, React.createElement('div', { style: { width: 34, height: 34, borderRadius: 8, background: 'var(--bg-sunken)', color: hw[key] ? (key === 'glassHeater' ? 'var(--accent)' : 'var(--st-online)') : 'var(--fg-3)', display: 'grid', placeItems: 'center' } }, React.createElement(Icon, { name: icon, size: 18 })), React.createElement('div', null, React.createElement('div', { style: { fontWeight: 700, fontSize: 14 } }, label), React.createElement('div', { style: { fontSize: 12, color: 'var(--fg-3)' } }, sub))), React.createElement('button', { className: 'toggle ' + (hw[key] ? 'on ' : '') + (key === 'glassHeater' ? 'warn' : ''), onClick: () => { setHw(h => ({ ...h, [key]: !h[key] })); sendCommand((hw[key] ? 'Matikan ' : 'Nyalakan ') + label, offline, key === 'glassHeater' ? 'glass_heat' : key, { enable: !hw[key] }); } })), ), // temperature React.createElement('div', { style: { paddingTop: 6 } }, React.createElement('div', { className: 'row', style: { justifyContent: 'space-between', marginBottom: 10 } }, React.createElement('label', { className: 'field-label', style: { margin: 0 } }, 'Set suhu kabin'), React.createElement('span', { style: { fontFamily: 'var(--font-display)', fontSize: 26, color: tempColor, lineHeight: 1 } }, hw.tempMode === 'off' ? 'OFF' : hw.temp + '°C')), React.createElement('input', { type: 'range', min: 2, max: 25, value: hw.temp, disabled: hw.tempMode === 'off', onChange: e => setHw(h => ({ ...h, temp: +e.target.value })), onMouseUp: () => sendCommand('Set suhu ' + hw.temp + 'C', offline, 'set_temp', { temp: hw.temp, mode: hw.tempMode }), style: { width: '100%', accentColor: tempColor } }), React.createElement('div', { className: 'row gap-2', style: { marginTop: 12 } }, [['dingin', 'snow', 'Dingin'], ['panas', 'flame', 'Panas'], ['off', 'power', 'Off']].map(([m, ic, lab]) => React.createElement('button', { key: m, className: 'chip ' + (hw.tempMode === m ? 'active' : ''), style: { flex: 1, justifyContent: 'center' }, onClick: () => { setHw(h => ({ ...h, tempMode: m })); sendCommand('Mode suhu: ' + lab, offline, 'set_temp', { temp: hw.temp, mode: m }); } }, React.createElement(Icon, { name: ic, size: 14 }), lab))), ), )), // (d) Status / log React.createElement(SectionCard, { title: 'Log perintah', sub: 'Riwayat command terakhir', motif: true, bodyStyle: { maxHeight: 320, overflowY: 'auto' } }, log.map(l => React.createElement('div', { key: l.id, className: 'row gap-3', style: { padding: '11px 20px', borderBottom: '1px solid var(--border-subtle)', alignItems: 'flex-start' } }, React.createElement('div', { style: { width: 28, height: 28, borderRadius: 7, flexShrink: 0, display: 'grid', placeItems: 'center', background: l.status === 'ok' ? 'var(--st-online-bg)' : l.status === 'fail' ? 'var(--st-error-bg)' : 'var(--st-warn-bg)', color: l.status === 'ok' ? 'var(--st-online)' : l.status === 'fail' ? 'var(--st-error)' : 'var(--st-warn)' } }, React.createElement(Icon, { name: l.status === 'ok' ? 'check' : l.status === 'fail' ? 'x' : 'refresh', size: 15, className: l.status === 'pending' ? 'spinning' : '' })), React.createElement('div', { style: { flex: 1, minWidth: 0 } }, React.createElement('div', { style: { fontWeight: 700, fontSize: 13.5, color: 'var(--fg-1)' } }, l.cmd), React.createElement('div', { style: { fontSize: 12, color: 'var(--fg-3)' } }, l.detail)), React.createElement('span', { style: { fontSize: 11, color: 'var(--fg-4)', whiteSpace: 'nowrap', fontFamily: 'var(--font-mono)' } }, l.time))), ), ), ), pending && React.createElement(Modal, { danger: pending.danger, eyebrow: pending.eyebrow, title: pending.title, onClose: () => setPending(null), footer: React.createElement('div', { className: 'row gap-3' }, React.createElement('button', { className: 'btn btn-ghost', onClick: () => setPending(null) }, 'Batal'), React.createElement('button', { className: 'btn btn-danger', onClick: runPending }, React.createElement(Icon, { name: 'zap', size: 15 }), pending.confirmLabel)), }, React.createElement('div', { className: 'row gap-3', style: { alignItems: 'flex-start' } }, React.createElement('div', { style: { width: 40, height: 40, borderRadius: 10, background: 'var(--st-error-bg)', color: 'var(--st-error)', display: 'grid', placeItems: 'center', flexShrink: 0 } }, React.createElement(Icon, { name: 'alert', size: 22 })), React.createElement('p', { style: { margin: 0, color: 'var(--fg-2)', fontSize: 14.5, lineHeight: 1.55 } }, pending.body)), ), ); } Object.assign(window, { ControlView });