diff --git a/.perm_test_apache b/.perm_test_apache new file mode 100644 index 0000000..e69de29 diff --git a/.perm_test_exec b/.perm_test_exec new file mode 100644 index 0000000..e69de29 diff --git a/POSFuku/Belanja.html b/POSFuku/Belanja.html index 7506e59..0ad2fca 100644 --- a/POSFuku/Belanja.html +++ b/POSFuku/Belanja.html @@ -8,6 +8,7 @@ POS - Riwayat Belanja + + + +
+
+
+ +
+ ERP Restoran + POSFuku Workspace +
+
+ +
+ +
+
+
Satu pintu untuk operasional restoran
+

Kelola kasir, dapur, stok, belanja, pelanggan, dan rekap dari satu halaman yang mudah dibuka.

+

Halaman utama ini dirapikan khusus untuk kebutuhan bisnis restoran. Anda bisa langsung masuk ke modul kasir, memantau antrean dapur, meninjau stok dan pembelian, lalu melihat rekap operasional tanpa harus mencari file satu per satu.

+ + + +
+
+ Alur kerja lebih jelas + Kasir, dapur, pelanggan, dan back office terhubung dari satu beranda. +
+
+ Ramah untuk non-teknis + Tiap modul diberi nama, fungsi, dan tombol masuk yang mudah dikenali. +
+
+ Aman untuk demo lokal + Mode preview tetap dapat dijelajahi walau backend Google Apps Script belum aktif. +
+
+ Siap untuk operasional harian + Mulai dari pesanan meja sampai pemantauan belanja dan laporan. +
+
+
+ + +
+ +
+
+
+

Modul operasional

+

Pilih modul sesuai alur kerja restoran Anda. Setiap kartu di bawah ini sudah diarahkan ke halaman yang tepat supaya staf atau pemilik usaha bisa langsung mulai.

+
+
+ +
+
+ Front Office +

POS Kasir

+

Mencatat pesanan meja, checkout, metode pembayaran, pelanggan, riwayat transaksi, hingga operasional kasir dalam satu layar kerja utama.

+
+ Pesanan + Pembayaran + Riwayat +
+
+ Buka POS +
+
+ +
+ Produksi +

Dapur

+

Memantau antrean pesanan aktif, memisahkan item prioritas, dan berkomunikasi dengan kasir saat pesanan siap diantar ke meja.

+
+ Antrean + Status siap + Chat internal +
+ +
+ +
+ Layanan pelanggan +

Pelanggan & Poin

+

Menangani pesanan pelanggan, review, bukti promo, dan pengecekan poin loyalitas dengan alur yang lebih aman di mode preview.

+
+ Customer + Review + Loyalitas +
+ +
+ +
+ Back Office +

Belanja Harian

+

Mencatat pembelian harian, impor data belanja, sinkron satu arah, dan menjaga daftar pengeluaran tetap rapi untuk kebutuhan operasional.

+
+ Belanja + Supplier + Sinkron +
+ +
+ +
+ Inventori +

Stok & Pembelian

+

Mengelola stok bahan, belanja, operasional, pegawai, supplier, dan pengaturan pendukung lain dari satu dashboard inventori.

+
+ Stok + Operasional + Pegawai +
+ +
+ +
+ Analitik +

Rekap Transaksi

+

Melihat rekap harian atau rentang tanggal, grafik, pengeluaran, catatan kalender, serta ringkasan performa penjualan restoran.

+
+ Dashboard + Grafik + Laporan +
+ +
+
+
+ +
+
+
+

Cara mulai untuk pemilik restoran

+

Jika Anda belum punya pengalaman web development, gunakan urutan sederhana ini untuk mulai mencoba aplikasi dengan nyaman.

+
+
+ +
+
+
1
+ Mulai dari kasir +

Buka modul POS Kasir untuk melihat alur pesanan, meja, checkout, dan riwayat. Ini modul inti yang paling mudah dipahami lebih dulu.

+
+
+
2
+ Lanjut ke dapur & stok +

Setelah memahami alur pesanan, buka Dapur dan Stok & Pembelian untuk melihat bagaimana pesanan diteruskan ke produksi dan kebutuhan bahan.

+
+
+
3
+ Tutup dengan rekap +

Gunakan Rekap Transaksi untuk menilai penjualan, belanja, dan catatan operasional agar keputusan bisnis bisa diambil lebih cepat.

+
+
+
+ +
+
+

Yang sudah saya rapikan

+

Beranda ini dibuat lebih jelas untuk penggunaan restoran dan lebih nyaman bagi pengguna non-teknis.

+
    +
  • Navigasi modul disusun ulang supaya fungsi tiap halaman langsung terlihat.
  • +
  • Bahasa pada elemen baru dibuat konsisten ke Bahasa Indonesia.
  • +
  • Mode preview lokal dibuat lebih informatif agar tetap berguna saat backend belum tersambung.
  • +
  • Audit modul awal difokuskan ke POS, Dapur, dan Stok untuk mengurangi titik bingung saat demo.
  • +
+
+ +
+

Saran alur uji coba

+

Untuk demo ke tim restoran, biasanya urutan berikut paling mudah dipahami.

+
    +
  • Buka POS Kasir untuk simulasi input pesanan dan pembayaran.
  • +
  • Buka Dapur untuk melihat antrean dan status siap saji.
  • +
  • Buka Stok & Pembelian untuk stok bahan dan pengeluaran.
  • +
  • Buka Rekap untuk melihat gambaran hasil operasional.
  • +
+
+
+ + +
+ + diff --git a/local-preview-bridge.js b/local-preview-bridge.js new file mode 100644 index 0000000..423dee8 --- /dev/null +++ b/local-preview-bridge.js @@ -0,0 +1,872 @@ +(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(); + } +})();