(function () { var host = window.location.hostname || ''; var isGAS = /script\.google\.|googleusercontent\.com/.test(host); if (isGAS) return; var STORAGE_KEY = '__posfuku_local_preview_state_v3__'; var today = formatYmd(new Date()); var yesterday = shiftDate(today, -1); var twoDaysAgo = shiftDate(today, -2); function pad(n) { return String(n).padStart(2, '0'); } function formatYmd(date) { return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()); } function shiftDate(ymd, delta) { var d = ymd ? new Date(ymd + 'T00:00:00') : new Date(); d.setDate(d.getDate() + Number(delta || 0)); return formatYmd(d); } function makeTs(ymd, hm) { return String(ymd) + ' ' + String(hm || '09:00') + ':00'; } function clone(obj) { return JSON.parse(JSON.stringify(obj)); } function toRp(n) { return Number(n || 0); } function escHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function svgDataUri(label, bg, fg) { var svg = '' + '' + '' + '' + '' + escHtml(label || 'POSFuku') + '' + 'Aset pratinjau lokal' + ''; return 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg); } function sum(list, key) { return (list || []).reduce(function (acc, item) { return acc + Number(item && item[key] || 0); }, 0); } function normalizeWa(wa) { var clean = String(wa || '').replace(/\D/g, ''); if (!clean) return ''; if (clean.indexOf('0') === 0) clean = '62' + clean.slice(1); else if (clean.indexOf('8') === 0) clean = '62' + clean; return clean; } function isCountedTransaction(t) { var status = String((t && t.status) || '').toLowerCase(); return status === 'selesai' || status === 'review' || status === 'ready' || status === 'paid'; } function inRange(dateStr, from, to) { if (!dateStr) return false; if (from && dateStr < from) return false; if (to && dateStr > to) return false; return true; } function nextId(prefix, list) { var max = 0; (list || []).forEach(function (item) { var match = String((item && item.id) || '').match(/(\d+)$/); if (match) max = Math.max(max, Number(match[1] || 0)); }); return prefix + '-' + String(max + 1).padStart(3, '0'); } function baseState() { var qris = svgDataUri('POSFuku QRIS', '#0f172a', '#ffffff'); return { settings: { store_name: 'POSFuku Demo Restoran', store_address: 'Jl. Preview Lokal No. 12, Jakarta', store_whatsapp: '6281234567890', social_instagram_url: '#', social_tiktok_url: '#', social_gmaps_url: '#', social_linktree_url: '#', wifi_lock: 'false', qris_image_url: qris, wifi_ssid: 'POSFuku-Guest', wifi_password: 'preview123' }, menu: [ { nama: 'Tom Yum Seafood', kategori: 'Ala Carte', harga: 38000, minStok: 6, gambar: svgDataUri('Tom Yum Seafood', '#b91c1c', '#fff') }, { nama: 'Nasi Putih', kategori: 'Ala Carte', harga: 6000, minStok: 20, gambar: svgDataUri('Nasi Putih', '#7c2d12', '#fff') }, { nama: 'Thai Tea', kategori: 'Drinks', harga: 18000, minStok: 8, gambar: svgDataUri('Thai Tea', '#ea580c', '#fff') }, { nama: 'Es Jeruk', kategori: 'Drinks', harga: 14000, minStok: 8, gambar: svgDataUri('Es Jeruk', '#d97706', '#fff') }, { nama: 'Paket Hemat 2 Pax', kategori: 'Paket', harga: 69000, minStok: 3, gambar: svgDataUri('Paket Hemat', '#be123c', '#fff') }, { nama: 'Ayam Sambal Matah', kategori: 'Ala Carte', harga: 32000, minStok: 5, gambar: svgDataUri('Ayam Sambal Matah', '#7f1d1d', '#fff') } ], pelanggan: [ { nama: 'Alya', wa: '6281234567890', poin: 28 }, { nama: 'Bima', wa: '6289876543210', poin: 12 }, { nama: 'Citra', wa: '628111223344', poin: 7 } ], transaksi: [ { id: 'TRX-101', nama: 'Alya', wa: '6281234567890', meja: 'A3', status: 'Pending', tgl: today, timestamp: makeTs(today, '10:15'), metodeBayar: 'QRIS', items: [ { nama: 'Thai Tea', qty: 2, harga: 18000, kat: 'Drinks' }, { nama: 'Tom Yum Seafood', qty: 1, harga: 38000, kat: 'Ala Carte' } ], catatan: 'Tom yum pedas sedang', total: 74000, poinDipakai: 0, poinDapat: 7 }, { id: 'TRX-102', nama: 'Bima', wa: '6289876543210', meja: 'B1', status: 'Ready', tgl: today, timestamp: makeTs(today, '11:05'), metodeBayar: 'Tunai', items: [ { nama: 'Ayam Sambal Matah', qty: 1, harga: 32000, kat: 'Ala Carte' }, { nama: 'Nasi Putih', qty: 2, harga: 6000, kat: 'Ala Carte' }, { nama: 'Es Jeruk', qty: 2, harga: 14000, kat: 'Drinks' } ], catatan: '', total: 72000, poinDipakai: 0, poinDapat: 7 }, { id: 'TRX-103', nama: 'Alya', wa: '6281234567890', meja: 'A3', status: 'Selesai', tgl: yesterday, timestamp: makeTs(yesterday, '19:20'), metodeBayar: 'Debit', items: [ { nama: 'Tom Yum Seafood', qty: 1, harga: 38000, kat: 'Ala Carte' }, { nama: 'Nasi Putih', qty: 2, harga: 6000, kat: 'Ala Carte' }, { nama: 'Thai Tea', qty: 1, harga: 18000, kat: 'Drinks' } ], catatan: 'Ulang tahun kecil-kecilan', total: 68000, poinDipakai: 0, poinDapat: 7 }, { id: 'TRX-104', nama: 'Alya', wa: '6281234567890', meja: 'A3', status: 'Review', tgl: today, timestamp: makeTs(today, '09:10'), metodeBayar: '-', items: [], catatan: 'Review Google sudah diupload', total: 0, poinDipakai: 0, poinDapat: 5, buktiReview: svgDataUri('Bukti Review', '#15803d', '#fff') }, { id: 'TRX-105', nama: 'Citra', wa: '628111223344', meja: 'C2', status: 'Selesai', tgl: twoDaysAgo, timestamp: makeTs(twoDaysAgo, '13:40'), metodeBayar: 'QRIS', items: [ { nama: 'Paket Hemat 2 Pax', qty: 1, harga: 69000, kat: 'Paket' }, { nama: 'Es Jeruk', qty: 1, harga: 14000, kat: 'Drinks' } ], catatan: 'Take away', total: 83000, poinDipakai: 0, poinDapat: 8 } ], belanja: [ { id: 'BL-001', nama: 'Udang', harga: 95000, qty: 2, total: 190000, kategori: 'BAHAN BASAH', catatan: 'Pagi', tgl: today, tanggal: today, timestamp: makeTs(today, '07:40') }, { id: 'BL-002', nama: 'Teh Thailand', harga: 85000, qty: 1, total: 85000, kategori: 'BAHAN KERING', catatan: 'Supplier utama', tgl: today, tanggal: today, timestamp: makeTs(today, '08:10') }, { id: 'BL-003', nama: 'Cup plastik', harga: 45000, qty: 3, total: 135000, kategori: 'NON MAKANAN', catatan: '', tgl: yesterday, tanggal: yesterday, timestamp: makeTs(yesterday, '09:00') } ], stok: [ { id: 'ST-001', tanggal: today, lokasi: 'Lantai Atas', menu: 'Nasi Putih', stokAwal: 35, restock: 15, terpakai: 18, sisa: 32, foto: '' }, { id: 'ST-002', tanggal: today, lokasi: 'Lantai Atas', menu: 'Thai Tea', stokAwal: 12, restock: 6, terpakai: 7, sisa: 11, foto: '' }, { id: 'ST-003', tanggal: today, lokasi: 'Lantai Bawah', menu: 'Es Jeruk', stokAwal: 10, restock: 4, terpakai: 8, sisa: 6, foto: '' }, { id: 'ST-004', tanggal: today, lokasi: 'Showcase', menu: 'Tom Yum Seafood', stokAwal: 8, restock: 3, terpakai: 5, sisa: 6, foto: '' } ], operasional: [ { id: 'OP-001', nama: 'Gas LPG', harga: 220000, qty: 1, total: 220000, catatan: 'Ganti tabung', tanggal: today, tgl: today }, { id: 'OP-002', nama: 'Kebersihan', harga: 75000, qty: 1, total: 75000, catatan: 'Sabun + pel', tanggal: yesterday, tgl: yesterday } ], pegawaiList: [ { id: 'PL-001', nama: 'Rina', status: 'Aktif', catatan: 'Kasir' }, { id: 'PL-002', nama: 'Dimas', status: 'Aktif', catatan: 'Dapur' }, { id: 'PL-003', nama: 'Sari', status: 'Aktif', catatan: 'Service' } ], pegawai: [ { id: 'PG-001', nama: 'Rina', nilai: 120000, kategori: 'Gaji', catatan: 'Shift pagi', tanggal: today, tgl: today }, { id: 'PG-002', nama: 'Dimas', nilai: 140000, kategori: 'Gaji', catatan: 'Dapur', tanggal: today, tgl: today }, { id: 'PG-003', nama: 'Rina', nilai: 30000, kategori: 'Kasbon', catatan: 'Transport', tanggal: yesterday, tgl: yesterday } ], paketKustom: [ { id: 'PK-001', nama: 'Paket Lunch 2 Pax', aktif: true, harga: 79000, items: [ { nama: 'Tom Yum Seafood', qty: 1, harga: 38000 }, { nama: 'Nasi Putih', qty: 2, harga: 6000 }, { nama: 'Thai Tea', qty: 2, harga: 18000 } ], logo_url: svgDataUri('Paket Lunch', '#0f766e', '#fff') } ], supplierHistory: [ { nama: 'Supplier Laut Segar', wa: '628222111333', item: 'Udang' }, { nama: 'Toko Kemasan Maju', wa: '628555444222', item: 'Cup plastik' } ], calendarNotes: [ { id: 'CAL-001', tanggal: today, judul: 'Promo lunch', catatan: 'Push menu lunch set ke meja kantor sekitar jam 11.30.', user: 'Owner', timestamp: makeTs(today, '08:00') }, { id: 'CAL-002', tanggal: yesterday, judul: 'Tindak lanjut supplier', catatan: 'Konfirmasi harga seafood untuk minggu depan.', user: 'Pembelian', timestamp: makeTs(yesterday, '16:20') } ], chatMessages: [ { id: 'CH-001', from: 'Kasir', to: 'Dapur', sender: 'Kasir', message: '2 Thai Tea untuk meja A3, tolong diprioritaskan.', text: '2 Thai Tea untuk meja A3, tolong diprioritaskan.', timestamp: makeTs(today, '10:18'), read: false }, { id: 'CH-002', from: 'Dapur', to: 'Kasir', sender: 'Dapur', message: 'Pesanan meja B1 siap diambil.', text: 'Pesanan meja B1 siap diambil.', timestamp: makeTs(today, '11:10'), read: false } ], bellStatus: { status: 'Inactive' } }; } function loadState() { try { var raw = localStorage.getItem(STORAGE_KEY); if (raw) return JSON.parse(raw); } catch (e) {} return baseState(); } function saveState() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {} } function resetState() { state = baseState(); saveState(); } var state = loadState(); function getInitialDataPayload() { return { menu: clone(state.menu), transaksi: clone(state.transaksi), pelanggan: clone(state.pelanggan), rekap: clone(state.transaksi), belanja: clone(state.belanja), paketKustom: clone(state.paketKustom), supplierHistory: clone(state.supplierHistory), settings: clone(state.settings) }; } function getTransactionsInRange(from, to) { return clone((state.transaksi || []).filter(function (t) { return isCountedTransaction(t) && inRange(String(t.tgl || '').slice(0, 10), from, to); })); } function getBelanjaInRange(from, to) { return clone((state.belanja || []).filter(function (b) { var dt = String(b.tanggal || b.tgl || '').slice(0, 10); return inRange(dt, from, to); })); } function buildSalesBundle(from, to) { var tx = getTransactionsInRange(from, to); var expRows = getBelanjaInRange(from, to); var totalSales = sum(tx, 'total'); var totalTransactions = tx.length; var avgTransaction = totalTransactions ? Math.round(totalSales / totalTransactions) : 0; var topMap = {}; var avgMap = {}; var methods = {}; var hourlyCounts = []; var dailyMap = {}; for (var i = 0; i < 24; i++) hourlyCounts.push(0); tx.forEach(function (t) { var hour = Number(String(t.timestamp || '').slice(11, 13)) || 0; hourlyCounts[hour] = (hourlyCounts[hour] || 0) + 1; var day = String(t.tgl || '').slice(0, 10); if (!dailyMap[day]) dailyMap[day] = { date: day, total: 0 }; dailyMap[day].total += Number(t.total || 0); var method = String(t.metodeBayar || 'Lainnya'); methods[method] = (methods[method] || 0) + Number(t.total || 0); (t.items || []).forEach(function (it) { var key = String(it.nama || 'Item'); var qty = Number(it.qty || 0); topMap[key] = (topMap[key] || 0) + qty; if (!avgMap[key]) avgMap[key] = { total: 0, days: {} }; avgMap[key].total += qty; avgMap[key].days[day] = true; }); }); var topItems = Object.keys(topMap) .map(function (k) { return [k, topMap[k]]; }) .sort(function (a, b) { return b[1] - a[1]; }) .slice(0, 8); var avgMenuPerDay = Object.keys(avgMap) .map(function (k) { var activeDays = Object.keys(avgMap[k].days).length || 1; return { nama: k, avg: Number((avgMap[k].total / activeDays).toFixed(1)) }; }) .sort(function (a, b) { return b.avg - a.avg; }) .slice(0, 8); var dailySales = Object.keys(dailyMap).sort().map(function (k) { return dailyMap[k]; }); var expenseSummary = buildExpenseAnalytics(from, to); return { sales: { summary: { totalSales: totalSales, totalTransactions: totalTransactions, avgTransaction: avgTransaction }, buckets: { lt100: tx.filter(function (t) { return Number(t.total || 0) < 100000; }).length, gte100: tx.filter(function (t) { return Number(t.total || 0) >= 100000; }).length, gte200: tx.filter(function (t) { return Number(t.total || 0) >= 200000; }).length }, topItems: topItems, avgMenuPerDay: avgMenuPerDay, hourlyCounts: hourlyCounts, dailySales: dailySales, methods: methods }, expense: expenseSummary, profit: totalSales - Number((expenseSummary.summary && expenseSummary.summary.totalBelanja) || 0) }; } function buildExpenseAnalytics(from, to) { var rows = getBelanjaInRange(from, to); var kategoriBelanja = {}; var dailyMap = {}; rows.forEach(function (b) { var total = Number(b.total || 0); var day = String(b.tanggal || b.tgl || '').slice(0, 10); var cat = String(b.kategori || 'Lainnya'); if (!dailyMap[day]) dailyMap[day] = { date: day, total: 0 }; dailyMap[day].total += total; kategoriBelanja[cat] = (kategoriBelanja[cat] || 0) + total; }); var topBelanja = rows.slice().sort(function (a, b) { return Number(b.total || 0) - Number(a.total || 0); }).slice(0, 8) .map(function (b) { return { nama: b.nama, total: Number(b.total || 0), tgl: b.tgl || b.tanggal || '', kat: b.kategori || '' }; }); var dailyBelanja = Object.keys(dailyMap).sort().map(function (k) { return dailyMap[k]; }); return { summary: { totalBelanja: sum(rows, 'total'), maxBelanja: topBelanja.length ? Number(topBelanja[0].total || 0) : 0 }, topBelanja: topBelanja, dailyBelanja: dailyBelanja, kategoriBelanja: kategoriBelanja }; } function buildRekap(from, to, isRange) { var tx = getTransactionsInRange(from, to); var exp = getBelanjaInRange(from, to); var methods = {}; var portionMap = {}; tx.forEach(function (t) { var method = String(t.metodeBayar || 'Lainnya'); methods[method] = (methods[method] || 0) + Number(t.total || 0); (t.items || []).forEach(function (it) { var key = String(it.nama || 'Item'); portionMap[key] = (portionMap[key] || 0) + Number(it.qty || 0); }); }); var portions = Object.keys(portionMap).map(function (k) { return { nama: k, qty: portionMap[k] }; }) .sort(function (a, b) { return b.qty - a.qty; }); return { isRange: !!isRange, startDate: from, endDate: to, summary: { totalNota: tx.length, totalSales: sum(tx, 'total'), totalBelanja: sum(exp, 'total'), netProfit: sum(tx, 'total') - sum(exp, 'total') }, methods: methods, portions: portions }; } function getHistoryPage(from, to, q, offset, limit) { var needle = String(q || '').trim().toLowerCase(); var items = clone((state.transaksi || []).filter(function (t) { var tgl = String(t.tgl || '').slice(0, 10); if (!inRange(tgl, from, to)) return false; if (!needle) return true; var hay = [t.id, t.nama, t.meja, t.metodeBayar, t.status, t.wa].join(' ').toLowerCase(); return hay.indexOf(needle) > -1; })).sort(function (a, b) { return String(b.timestamp || b.tgl || '').localeCompare(String(a.timestamp || a.tgl || '')); }); var start = Number(offset || 0); var size = Number(limit || 50); return { items: items.slice(start, start + size), nextOffset: start + size, hasMore: start + size < items.length }; } function getCalendarNotesByDate(dateStr) { return clone((state.calendarNotes || []).filter(function (n) { return String(n.tanggal || '').slice(0, 10) === String(dateStr || '').slice(0, 10); }).sort(function (a, b) { return String(b.timestamp || '').localeCompare(String(a.timestamp || '')); })); } function updateCustomerPoints(tx) { var wa = normalizeWa(tx && tx.wa); var nama = String((tx && tx.nama) || 'Pelanggan'); if (!wa) return; var found = null; (state.pelanggan || []).forEach(function (p) { if (normalizeWa(p.wa) === wa) found = p; }); if (!found) { found = { nama: nama, wa: wa, poin: 0 }; state.pelanggan.push(found); } if (tx.status === 'Selesai' || tx.status === 'Review') { found.poin = Number(found.poin || 0) + Number(tx.poinDapat || 0); } if (nama && found.nama !== nama) found.nama = nama; } function mergeBelanjaRows(rows) { (rows || []).forEach(function (row) { var incoming = clone(row || {}); incoming.id = incoming.id || nextId('BL', state.belanja); incoming.qty = Number(incoming.qty || 0); incoming.harga = Number(incoming.harga || 0); incoming.total = Number(incoming.total || (incoming.harga * incoming.qty) || 0); incoming.tgl = incoming.tgl || incoming.tanggal || today; incoming.tanggal = incoming.tanggal || incoming.tgl; incoming.timestamp = incoming.timestamp || makeTs(incoming.tgl, '09:00'); var idx = state.belanja.findIndex(function (b) { return String(b.id) === String(incoming.id); }); if (idx > -1) state.belanja[idx] = Object.assign({}, state.belanja[idx], incoming); else state.belanja.push(incoming); }); state.belanja.sort(function (a, b) { return String(b.timestamp || '').localeCompare(String(a.timestamp || '')); }); saveState(); return { success: true, belanja: clone(state.belanja), mode: 'local-preview', note: 'Disimpan di browser preview ini.' }; } function saveStokRows(rows, dateStr) { var useDate = String(dateStr || today); state.stok = (state.stok || []).filter(function (s) { return String(s.tanggal || '') !== useDate; }); (rows || []).forEach(function (row) { var r = clone(row || {}); r.id = r.id || nextId('ST', state.stok); r.tanggal = useDate; r.stokAwal = Number(r.stokAwal || 0); r.restock = Number(r.restock || 0); r.terpakai = Number(r.terpakai || 0); r.sisa = Math.max(0, r.stokAwal + r.restock - r.terpakai); state.stok.push(r); }); saveState(); return clone(state.stok.filter(function (s) { return String(s.tanggal || '') === useDate; })); } function saveOperasionalRows(rows, dateStr) { var useDate = String(dateStr || today); state.operasional = (state.operasional || []).filter(function (o) { return String(o.tanggal || '') !== useDate; }); (rows || []).forEach(function (row) { var r = clone(row || {}); r.id = r.id || nextId('OP', state.operasional); r.tanggal = useDate; r.tgl = useDate; r.harga = Number(r.harga || 0); r.qty = Number(r.qty || 0); r.total = Number(r.total || (r.harga * r.qty) || 0); state.operasional.push(r); }); saveState(); return clone(state.operasional.filter(function (o) { return String(o.tanggal || '') === useDate; })); } function savePegawaiRows(rows, dateStr) { var useDate = String(dateStr || today); state.pegawai = (state.pegawai || []).filter(function (p) { return String(p.tanggal || '') !== useDate; }); (rows || []).forEach(function (row) { var r = clone(row || {}); r.id = r.id || nextId('PG', state.pegawai); r.tanggal = useDate; r.tgl = useDate; r.nilai = Number(r.nilai || 0); state.pegawai.push(r); }); saveState(); return clone(state.pegawai.filter(function (p) { return String(p.tanggal || '') === useDate; })); } function perItemOperasional(from, to) { var map = {}; (state.operasional || []).forEach(function (o) { if (!inRange(String(o.tanggal || o.tgl || ''), from, to)) return; var key = String(o.nama || 'Operasional'); if (!map[key]) map[key] = { nama: key, harga: 0, qty: 0, total: 0 }; map[key].qty += Number(o.qty || 0); map[key].total += Number(o.total || 0); }); return Object.keys(map).map(function (k) { var item = map[k]; item.harga = item.qty ? Math.round(item.total / item.qty) : item.total; return item; }).sort(function (a, b) { return b.total - a.total; }); } function perPegawai(from, to) { var map = {}; (state.pegawai || []).forEach(function (p) { if (!inRange(String(p.tanggal || p.tgl || ''), from, to)) return; var key = String(p.nama || 'Pegawai'); if (!map[key]) map[key] = { nama: key, gaji: 0, kasbon: 0, total: 0 }; var nilai = Number(p.nilai || 0); if (String(p.kategori || '').toLowerCase() === 'kasbon') map[key].kasbon += nilai; else map[key].gaji += nilai; map[key].total += nilai; }); return Object.keys(map).map(function (k) { return map[k]; }).sort(function (a, b) { return b.total - a.total; }); } function responseFor(fn, args) { var from = args && args[0] ? String(args[0]) : today; var to = args && args[1] ? String(args[1]) : from; switch (fn) { case 'getInitialData': return getInitialDataPayload(); case 'getChatMessages': return clone(state.chatMessages || []); case 'markChatAsRead': (state.chatMessages || []).forEach(function (m) { if (!args[0] || String(m.to || '').toLowerCase() === String(args[0] || '').toLowerCase()) m.read = true; }); saveState(); return { success: true, mode: 'local-preview' }; case 'sendChatMessage': state.chatMessages.push({ id: nextId('CH', state.chatMessages), from: String(args[0] || 'User'), to: String(args[0] || 'User') === 'Kasir' ? 'Dapur' : 'Kasir', sender: String(args[0] || 'User'), message: String(args[1] || ''), text: String(args[1] || ''), timestamp: makeTs(today, '12:00'), read: false }); saveState(); return { success: true, messages: clone(state.chatMessages) }; case 'triggerBell': state.bellStatus = { status: 'Active' }; saveState(); return { success: true, status: 'Active', mode: 'local-preview' }; case 'getBellStatus': return clone(state.bellStatus || { status: 'Inactive' }); case 'resetBell': state.bellStatus = { status: 'Inactive' }; saveState(); return { success: true, status: 'Inactive' }; case 'rekapLogin': return { ok: true, mode: 'local-preview' }; case 'getDashboardBundleByRange': return buildSalesBundle(from, to); case 'getExpenseAnalyticsByRange': return buildExpenseAnalytics(from, to); case 'getRekapByDate': return buildRekap(from, from, false); case 'getRekapByRange': return buildRekap(from, to, true); case 'getTransaksiHistoryPage': return getHistoryPage(from, to, args[2], args[3], args[4]); case 'loadCalendarNotes': case 'getCalendarNotes': case 'getCalendarNotesByDate': return getCalendarNotesByDate(from); case 'saveCalendarNote': var note = clone(args[0] || {}); note.id = note.id || nextId('CAL', state.calendarNotes); note.tanggal = note.tanggal || today; note.timestamp = makeTs(note.tanggal, '09:30'); var noteIdx = state.calendarNotes.findIndex(function (n) { return String(n.id) === String(note.id); }); if (noteIdx > -1) state.calendarNotes[noteIdx] = Object.assign({}, state.calendarNotes[noteIdx], note); else state.calendarNotes.push(note); saveState(); return getCalendarNotesByDate(note.tanggal); case 'deleteCalendarNote': state.calendarNotes = (state.calendarNotes || []).filter(function (n) { return String(n.id) !== String(args[0]); }); saveState(); return []; case 'getRingkasanPeriode': return { from: from, to: to, summary: { totalQty: sum(getBelanjaInRange(from, to), 'qty'), totalNilai: sum(getBelanjaInRange(from, to), 'total') }, menu: clone(state.menu), stok: clone((state.stok || []).filter(function (s) { return inRange(String(s.tanggal || ''), from, to); })), belanja: clone(getBelanjaInRange(from, to)), operasional: clone((state.operasional || []).filter(function (o) { return inRange(String(o.tanggal || o.tgl || ''), from, to); })), pegawaiList: clone(state.pegawaiList || []), pegawai: clone((state.pegawai || []).filter(function (p) { return inRange(String(p.tanggal || p.tgl || ''), from, to); })) }; case 'getLastBelanjaHargaMap': var map = {}; (state.belanja || []).forEach(function (b) { map[String(b.nama || '')] = Number(b.harga || 0); }); return map; case 'getDebugInfo': return { mode: 'local-preview', source: 'bridge-pratinjau-lokal', stored: true }; case 'getMenuListExtended': return clone(state.menu || []); case 'getBelanjaData': return clone((state.belanja || []).filter(function (b) { return String(b.tanggal || b.tgl || '') === from; })); case 'getStokData': return clone((state.stok || []).filter(function (s) { return String(s.tanggal || '') === from; })); case 'getOperasionalData': return clone((state.operasional || []).filter(function (o) { return String(o.tanggal || o.tgl || '') === from; })); case 'getPegawaiData': return clone((state.pegawai || []).filter(function (p) { return String(p.tanggal || p.tgl || '') === from; })); case 'getPegawaiList': return clone(state.pegawaiList || []); case 'getOperasionalRekapPeriode': return { total: sum(perItemOperasional(from, to), 'total'), perItem: perItemOperasional(from, to) }; case 'getPegawaiRekapPeriode': var perPg = perPegawai(from, to); return { totals: { gaji: perPg.reduce(function (a, b) { return a + Number(b.gaji || 0); }, 0), kasbon: perPg.reduce(function (a, b) { return a + Number(b.kasbon || 0); }, 0), total: perPg.reduce(function (a, b) { return a + Number(b.total || 0); }, 0) }, perPegawai: perPg }; case 'getStokFotoMap': var fotoMap = {}; (state.menu || []).forEach(function (m) { fotoMap[String(m.nama || '')] = m.gambar || ''; }); return fotoMap; case 'saveCustomerInfo': var customer = clone(args[0] || {}); customer.wa = normalizeWa(customer.wa); if (customer.wa) { var existing = state.pelanggan.find(function (p) { return normalizeWa(p.wa) === customer.wa; }); if (existing) existing.nama = customer.nama || existing.nama; else state.pelanggan.push({ nama: customer.nama || 'Pelanggan', wa: customer.wa, poin: 0 }); saveState(); } return { success: true, pelanggan: clone(state.pelanggan) }; case 'saveTransaction': var tx = clone(args[0] || {}); tx.id = tx.id || nextId('TRX', state.transaksi); tx.tgl = tx.tgl || today; tx.timestamp = tx.timestamp || makeTs(tx.tgl, '12:30'); tx.status = tx.status || 'Pending'; tx.items = Array.isArray(tx.items) ? tx.items : []; if (!tx.total) { tx.total = tx.items.reduce(function (a, it) { return a + (Number(it.qty || 0) * Number(it.harga || 0)); }, 0); } if (!tx.poinDapat && tx.total > 0) tx.poinDapat = Math.max(1, Math.floor(Number(tx.total || 0) / 10000)); var txIdx = state.transaksi.findIndex(function (t) { return String(t.id) === String(tx.id); }); if (txIdx > -1) state.transaksi[txIdx] = Object.assign({}, state.transaksi[txIdx], tx); else state.transaksi.push(tx); updateCustomerPoints(tx); saveState(); return { ok: true, transaksi: clone(state.transaksi), pelanggan: clone(state.pelanggan), mode: 'local-preview' }; case 'uploadReviewProof': case 'uploadPaymentProof': case 'uploadPaketLogo': return { success: true, url: svgDataUri('Unggah Preview', '#2563eb', '#fff'), mode: 'local-preview' }; case 'updateTransactionReviewPhoto': case 'updateTransactionPhoto': var id = args[0], url = args[1]; (state.transaksi || []).forEach(function (t) { if (String(t.id) === String(id)) t.buktiReview = url || svgDataUri('Review', '#15803d', '#fff'); }); saveState(); return { success: true, transaksi: clone(state.transaksi) }; case 'saveFeedback': return { success: true, mode: 'local-preview' }; case 'saveBelanjaBulk': case 'saveBulkBelanja': return mergeBelanjaRows(args[0] || []); case 'syncBelanjaOut': case 'syncBelanjaToRekap': case 'syncBelanjaToRekapAll': return { success: true, belanja: clone(state.belanja), mode: 'local-preview' }; case 'bulkUpdateBelanjaById': (args[0] || []).forEach(function (u) { var item = state.belanja.find(function (b) { return String(b.id) === String(u.id); }); if (item) Object.assign(item, { harga: Number(u.harga || 0), qty: Number(u.qty || 0), total: Number(u.total || 0), catatan: u.catatan || item.catatan || '' }); }); saveState(); return { success: true, belanja: clone(state.belanja) }; case 'updateBelanjaById': var itemById = state.belanja.find(function (b) { return String(b.id) === String(args[0]); }); if (itemById) Object.assign(itemById, args[1] || {}); saveState(); return { success: true, belanja: clone(state.belanja) }; case 'updateBelanjaItem': var bl = state.belanja.find(function (b) { return String(b.tgl || b.tanggal || '') === String(args[0]) && String(b.kategori || '') === String(args[1]) && String(b.nama || '') === String(args[2]); }); if (bl) { bl.harga = Number(args[3] || 0); bl.qty = Number(args[4] || 0); bl.total = Number(args[5] || 0); bl.catatan = args[6] || bl.catatan || ''; } saveState(); return { success: true, belanja: clone(state.belanja) }; case 'deleteBelanjaById': state.belanja = (state.belanja || []).filter(function (b) { return String(b.id) !== String(args[0]); }); saveState(); return { success: true, belanja: clone(state.belanja) }; case 'deleteBelanjaItem': case 'deleteBelanja': state.belanja = (state.belanja || []).filter(function (b) { return !(String(b.tgl || b.tanggal || '') === String(args[0]) && String(b.kategori || '') === String(args[1]) && String(b.nama || '') === String(args[2])); }); saveState(); return { success: true, belanja: clone(state.belanja) }; case 'saveBulkStok': return saveStokRows(args[0] || [], args[2] || today); case 'bulkUpdateStokItems': (args[0] || []).forEach(function (u) { var item = state.stok.find(function (s) { return String(s.tanggal || '') === String(u.tanggal || '') && String(s.lokasi || '') === String(u.lokasi || '') && String(s.menu || '') === String(u.menu || ''); }); if (item) { item.stokAwal = Number(u.stokAwal || 0); item.restock = Number(u.restock || 0); item.terpakai = Number(u.terpakai || 0); item.sisa = Math.max(0, item.stokAwal + item.restock - item.terpakai); } }); saveState(); return { success: true, stok: clone(state.stok) }; case 'updateStokItem': var stokItem = state.stok.find(function (s) { return String(s.tanggal || '') === String(args[0]) && String(s.lokasi || '') === String(args[1]) && String(s.menu || '') === String(args[2]); }); if (stokItem) { stokItem.stokAwal = Number(args[3] || 0); stokItem.restock = Number(args[4] || 0); stokItem.terpakai = Number(args[5] || 0); stokItem.sisa = Math.max(0, stokItem.stokAwal + stokItem.restock - stokItem.terpakai); } saveState(); return { success: true, stok: clone(state.stok) }; case 'deleteStokItem': state.stok = (state.stok || []).filter(function (s) { return !(String(s.tanggal || '') === String(args[0]) && String(s.lokasi || '') === String(args[1]) && String(s.menu || '') === String(args[2])); }); saveState(); return { success: true, stok: clone(state.stok) }; case 'saveBulkOperasional': return saveOperasionalRows(args[0] || [], args[2] || today); case 'saveBulkPegawai': return savePegawaiRows(args[0] || [], args[2] || today); case 'savePegawaiList': state.pegawaiList = (args[0] || []).map(function (r, idx) { return { id: r.id || ('PL-' + String(idx + 1).padStart(3, '0')), nama: r.nama || '', status: r.status || 'Aktif', catatan: r.catatan || '' }; }).filter(function (r) { return String(r.nama || '').trim(); }); saveState(); return clone(state.pegawaiList); case 'deletePegawaiById': state.pegawai = (state.pegawai || []).filter(function (p) { return String(p.id) !== String(args[0]); }); saveState(); return { success: true, pegawai: clone(state.pegawai) }; case 'updateKitchenStatus': var kitchenId = args[0], nextStatus = args[1] || 'Ready'; (state.transaksi || []).forEach(function (t) { if (String(t.id) === String(kitchenId)) t.status = nextStatus; }); saveState(); return getInitialDataPayload(); } if (/Bundle|Analytics/.test(fn)) return buildSalesBundle(from, to); if (/HistoryPage/.test(fn)) return getHistoryPage(from, to, '', 0, 50); if (/Rekap/.test(fn)) return buildRekap(from, to, true); if (/Notes/.test(fn)) return getCalendarNotesByDate(from); if (/save|update|delete|import|reset|sync|trigger|mark/i.test(fn)) return { success: true, mode: 'local-preview', note: 'Ditangani oleh bridge pratinjau lokal.' }; if (/get|load|search|find/i.test(fn)) return {}; return { success: true, mode: 'local-preview' }; } if (!window.google) window.google = {}; if (!window.google.script) window.google.script = {}; if (!window.google.script.host) window.google.script.host = { close: function () {} }; if (window.google.script.run) return; function createRunner(stateRef) { stateRef = stateRef || { success: null, failure: null }; return new Proxy({}, { get: function (_, prop) { if (prop === 'withSuccessHandler') { return function (cb) { stateRef.success = cb; return createRunner(stateRef); }; } if (prop === 'withFailureHandler') { return function (cb) { stateRef.failure = cb; return createRunner(stateRef); }; } return function () { var args = Array.prototype.slice.call(arguments); var result = clone(responseFor(String(prop), args)); window.console && console.info && console.info('[pratinjau-lokal]', String(prop), args, result); setTimeout(function () { try { if (typeof stateRef.success === 'function') stateRef.success(result); } catch (err) { if (typeof stateRef.failure === 'function') stateRef.failure(err); } }, 0); return createRunner({ success: null, failure: null }); }; } }); } window.google.script.run = createRunner(); window.__POSFUKU_LOCAL_PREVIEW__ = true; window.__resetPosfukuLocalPreview = function () { resetState(); window.location.reload(); }; function pageTip() { var path = window.location.pathname || ''; if (/Poin\.html$/i.test(path)) return 'Coba WA demo: 0812-3456-7890.'; if (/Pelanggan\.html$/i.test(path)) return 'Pesanan dan review disimpan lokal di browser preview ini.'; if (/Dapur\.html$/i.test(path)) return 'Contoh antrean kasir dan kitchen chat sudah diisi.'; if (/Belanja\.html$/i.test(path) || /Stok/i.test(path)) return 'Perubahan stok/belanja di preview ini tersimpan lokal di browser.'; if (/RekapTransaksi/i.test(path)) return 'Grafik dan rekap memakai data demo restoran lokal.'; return 'Data demo ini aman untuk eksplorasi browser.'; } function injectBanner() { if (document.getElementById('local-preview-banner')) return; var banner = document.createElement('div'); banner.id = 'local-preview-banner'; banner.innerHTML = 'Mode pratinjau lokal. Google Apps Script backend tidak aktif di VM ini, jadi halaman memakai data demo yang tersimpan lokal di browser. ' + escHtml(pageTip()) + ''; banner.style.cssText = [ 'position:sticky','top:0','z-index:9999','padding:10px 14px','font:700 12px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif', 'background:#fff7ed','color:#9a3412','border-bottom:1px solid #fdba74','text-align:center','box-shadow:0 2px 10px rgba(0,0,0,.06)' ].join(';'); document.body.insertBefore(banner, document.body.firstChild); } function injectQuickNav() { if (document.getElementById('local-preview-nav')) return; var path = window.location.pathname || ''; if (path === '/' || path === '/index.html') return; var nav = document.createElement('div'); nav.id = 'local-preview-nav'; nav.innerHTML = '' + 'Beranda' + 'POS' + 'Dapur' + 'Pelanggan' + 'Poin' + 'Belanja' + 'Stok & Belanja' + 'Rekap & Grafik' + ''; nav.style.cssText = [ 'position:sticky','top:42px','z-index:9998','display:flex','gap:8px','flex-wrap:wrap','align-items:center', 'padding:10px 12px','background:#0f172a','border-bottom:1px solid rgba(255,255,255,.08)','box-shadow:0 6px 18px rgba(0,0,0,.12)' ].join(';'); document.body.insertBefore(nav, document.body.children[1] || document.body.firstChild); Array.prototype.forEach.call(nav.querySelectorAll('a'), function (a) { a.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;min-height:32px;padding:0 10px;border-radius:999px;background:rgba(255,255,255,.08);color:#fff;text-decoration:none;font:800 12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;'; }); var btn = nav.querySelector('#local-preview-reset'); if (btn) { btn.style.cssText = 'margin-left:auto;min-height:32px;padding:0 12px;border:none;border-radius:999px;background:#fb7185;color:#fff;font:800 12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;cursor:pointer;'; btn.onclick = function () { if (window.confirm('Reset semua data demo lokal di browser ini?')) window.__resetPosfukuLocalPreview(); }; } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { injectBanner(); injectQuickNav(); }); } else { injectBanner(); injectQuickNav(); } })();