From 57edfd794c4ee73b01579a7b9697701f59abae72 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sun, 19 Apr 2026 12:17:53 +0000 Subject: [PATCH] Initial import --- POSFuku/Belanja.html | 502 ++ POSFuku/Code.gs | 2721 +++++++++++ POSFuku/Dapur.html | 486 ++ POSFuku/Index.html | 7198 +++++++++++++++++++++++++++++ POSFuku/Pelanggan.html | 1481 ++++++ POSFuku/Poin.html | 312 ++ POSFuku/Stok & Belanja/Code.gs | 1192 +++++ POSFuku/Stok & Belanja/Index.html | 2113 +++++++++ RekapTransaksi/Code.gs | 634 +++ RekapTransaksi/Index.html | 1148 +++++ 10 files changed, 17787 insertions(+) create mode 100644 POSFuku/Belanja.html create mode 100644 POSFuku/Code.gs create mode 100644 POSFuku/Dapur.html create mode 100644 POSFuku/Index.html create mode 100644 POSFuku/Pelanggan.html create mode 100644 POSFuku/Poin.html create mode 100644 POSFuku/Stok & Belanja/Code.gs create mode 100644 POSFuku/Stok & Belanja/Index.html create mode 100644 RekapTransaksi/Code.gs create mode 100644 RekapTransaksi/Index.html diff --git a/POSFuku/Belanja.html b/POSFuku/Belanja.html new file mode 100644 index 0000000..7506e59 --- /dev/null +++ b/POSFuku/Belanja.html @@ -0,0 +1,502 @@ + + + + + + + + + + POS - Riwayat Belanja + + + +

Memuat Riwayat Belanja...

+ +
+
POS
+
Manajemen Pengeluaran Belanja
+
+ +
+ +
+
+
Input Belanja Baru
+
+ + + +
+
+ +
+ + +
+
+
Qty
+
Harga Satuan
+
+
+ Total (Otomatis) + +
+ + +
+
Input Bulk (Cepat)
+ +
+
+ Kategori Bulk + +
+
+ Tips: tambah "Q" di depan/akhir nama untuk catat QRIS/Transfer. +
+
+ +
+
+ + +
+
Kalkulator Bantu
+ +
= 0
+
+ + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ + + + diff --git a/POSFuku/Code.gs b/POSFuku/Code.gs new file mode 100644 index 0000000..5f7154d --- /dev/null +++ b/POSFuku/Code.gs @@ -0,0 +1,2721 @@ +var ss = SpreadsheetApp.getActiveSpreadsheet(); + +var parseNumberFlexible = function(val) { + if (val === null || val === undefined || val === '') return 0; + if (typeof val === 'number') return val; + var s = String(val).trim(); + s = s.replace(/[^\d.,-]/g, ''); + if (!s) return 0; + var n = parseFloat(s.replace(/,/g, '')); + return isNaN(n) ? 0 : n; +}; + +var normalizeImageUrl = function(url) { + var raw = String(url || '').trim(); + if (!raw) return ''; + var m = raw.match(/\/d\/([^\/\?\s]+)/); + if (m && m[1]) return 'https://drive.google.com/thumbnail?id=' + m[1] + '&sz=w400'; + return raw; +}; + +var parseDate = function(val) { + if (!val) return ''; + if (val instanceof Date) return Utilities.formatDate(val, "GMT+7", "yyyy-MM-dd HH:mm:ss"); + return String(val); +}; + +var getSettingsSheet_ = function() { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + return ss.getSheetByName('Setup') || ss.getSheetByName('Settings') || null; +}; + +var getSettingsMap_ = function() { + var sheet = getSettingsSheet_(); + if (!sheet) return {}; + var data = sheet.getDataRange().getValues(); + var settings = {}; + for (var i = 1; i < data.length; i++) { + if (data[i][0]) settings[String(data[i][0])] = String(data[i][1]); + } + return settings; +}; + +var normalizeHeaderKey_ = function(s) { + return String(s || '').trim().toLowerCase(); +}; + +var getHeaderIndexMap_ = function(sheet) { + if (!sheet) return {}; + var lastCol = sheet.getLastColumn(); + if (lastCol < 1) return {}; + var headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0]; + var map = {}; + for (var i = 0; i < headers.length; i++) { + var k = normalizeHeaderKey_(headers[i]); + if (!k) continue; + if (map[k] == null) map[k] = i; + } + return map; +}; + +var ensureColumnExists_ = function(sheet, headerName) { + if (!sheet) return -1; + var key = normalizeHeaderKey_(headerName); + var map = getHeaderIndexMap_(sheet); + if (map[key] != null) return map[key] + 1; + var lastCol = sheet.getLastColumn(); + sheet.getRange(1, lastCol + 1).setValue(headerName); + sheet.getRange(1, lastCol + 1).setFontWeight('bold').setBackground('#f3f3f3'); + return lastCol + 1; +}; + +var sendTelegram_ = function(text) { + try { + var settings = getSettingsMap_(); + if (String(settings.telegram_enabled || '').toLowerCase() !== 'true') return { skipped: true }; + var token = String(settings.telegram_bot_token || '').trim(); + var chatId = String(settings.telegram_chat_id || '').trim(); + if (!token || !chatId) return { skipped: true }; + + var url = 'https://api.telegram.org/bot' + token + '/sendMessage'; + var payload = { chat_id: chatId, text: String(text || ''), disable_web_page_preview: true }; + var resp = UrlFetchApp.fetch(url, { + method: 'post', + contentType: 'application/json', + payload: JSON.stringify(payload), + muteHttpExceptions: true + }); + return { success: true, status: resp.getResponseCode() }; + } catch (e) { + return { error: String(e && e.message ? e.message : e) }; + } +}; + +function doGet(e) { + if (!e || !e.parameter) { + return HtmlService.createHtmlOutput('

Error: Parameter e tidak ditemukan.

Gunakan URL Web App untuk mengakses aplikasi ini.

'); + } + var page = e.parameter.p || 'index'; // Default ke index (Kasir) + var meja = e.parameter.meja || ''; + + // Fitur PWA: Manifest & Service Worker + if (page === 'manifest') { + var settings = getSettingsMap_(); + var storeName = String(settings.store_name || 'POS'); + var shortName = storeName.split(' ')[0] || storeName; + if (shortName.length > 12) shortName = shortName.slice(0, 12); + var iconUrl = String(settings.qris_image_url || 'https://lh3.googleusercontent.com/d/1hGPL3AlVGRMeZTzpwOHp-l-JzOG5tS09'); + var manifest = { + "name": storeName + " POS", + "short_name": shortName, + "description": "Point of Sale", + "start_url": "./?p=index", + "display": "standalone", + "background_color": "#111111", + "theme_color": "#e11d48", + "icons": [ + { + "src": iconUrl, + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": iconUrl, + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] + }; + return ContentService.createTextOutput(JSON.stringify(manifest)).setMimeType(ContentService.MimeType.JSON); + } + + if (page === 'sw') { + var sw = "const CACHE_NAME = 'pos-v2'; self.addEventListener('install', (e) => { self.skipWaiting(); }); self.addEventListener('activate', (e) => { e.waitUntil(clients.claim()); }); self.addEventListener('fetch', (e) => { e.respondWith(fetch(e.request).catch(() => caches.match(e.request))); });"; + return ContentService.createTextOutput(sw).setMimeType(ContentService.MimeType.JAVASCRIPT); + } + + var fileName = 'Index'; + if (page === 'dapur') fileName = 'Dapur'; + if (page === 'order' || meja) fileName = 'Pelanggan'; + if (page === 'poin') fileName = 'Poin'; + if (page === 'belanja') fileName = 'Belanja'; + + try { + var settings = getSettingsMap_(); + var storeName = String(settings.store_name || 'POS'); + var template = HtmlService.createTemplateFromFile(fileName); + template.meja = meja; + + return template.evaluate() + .setTitle(storeName + (page === 'dapur' ? ' - DAPUR' : (page === 'poin' ? ' - POIN' : (page === 'belanja' ? ' - BELANJA' : (meja ? ' - MEJA ' + meja : ''))))) + .addMetaTag('viewport', 'width=device-width, initial-scale=1') + .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); + } catch (err) { + return HtmlService.createHtmlOutput( + '

Halaman tidak bisa dimuat

' + + '

Halaman: ' + fileName + '

' + + '

Silakan deploy ulang dan pastikan file HTML sudah ada di project Apps Script yang dideploy.

' + + '
' +
+      String(err && err.stack ? err.stack : err) +
+      '
' + ); + } +} + +function getMoreHistory(offset) { + try { + var sheetTrans = ss.getSheetByName('Transaksi'); + var lastRow = sheetTrans.getLastRow(); + var startRow = Math.max(2, lastRow - offset - 49); // Ambil 50 baris berikutnya + var numRows = Math.min(50, lastRow - offset - 1); + + if (numRows <= 0) return []; + + var values = sheetTrans.getRange(startRow, 1, numRows, sheetTrans.getLastColumn()).getValues(); + var result = values.map(function(row) { + return { + id: row[0], + meja: row[1], + status: row[2], + nama: row[3], + wa: row[4], + tgl: parseDate(row[5]), + jam: parseDate(row[6]), + items: JSON.parse(row[7] || '[]'), + subtotal: Number(row[8]), + diskon: Number(row[9]), + poinDipakai: Number(row[10]), + pajakPersen: Number(row[11]), + servicePersen: Number(row[12]), + adminPersen: Number(row[13]), + pajak: Number(row[14]), + service: Number(row[15]), + adminFee: Number(row[16]), + total: Number(row[17]), + dp: Number(row[18]), + metodeDp: String(row[19]), + tglDp: parseDate(row[20]), + metodeBayar: String(row[21]), + bayar: Number(row[22]), + kembali: Number(row[23]), + poinDapat: Number(row[24]), + timestamp: parseDate(row[25]), + poinAwal: Number(row[26] || 0), + catatan: String(row[27] || ''), + buktiBayar: String(row[28] || ''), + buktiReview: String(row[29] || '') + }; + }); + return result; + } catch (e) { + return []; + } +} + +function uploadPaymentProof(payload) { + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var folder; + var settingsSheet = getSettingsSheet_(); + var folderId = ''; + if (settingsSheet) { + var sd = settingsSheet.getDataRange().getValues(); + for (var i = 1; i < sd.length; i++) { + if (String(sd[i][0]) === 'drive_bukti_folder_id') { folderId = String(sd[i][1] || ''); break; } + } + } + if (folderId) { + folder = DriveApp.getFolderById(folderId); + } else { + var folders = DriveApp.getFoldersByName('POSFuku_BuktiBayar'); + if (folders.hasNext()) folder = folders.next(); + else return { error: "Folder Drive untuk bukti bayar belum ada. Jalankan setupDriveFolders() oleh admin." }; + } + + var contentType = payload.mimeType || 'image/jpeg'; + var bytes = Utilities.base64Decode(payload.base64); + var blob = Utilities.newBlob(bytes, contentType, 'Bukti_' + payload.id + '.jpg'); + var file = folder.createFile(blob); + file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW); + + return { success: true, url: file.getUrl(), fileId: file.getId() }; + } catch (e) { + return { error: e.message }; + } +} + +function uploadReviewProof(payload) { + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var folder; + var settingsSheet = getSettingsSheet_(); + var folderId = ''; + if (settingsSheet) { + var sd = settingsSheet.getDataRange().getValues(); + for (var i = 1; i < sd.length; i++) { + if (String(sd[i][0]) === 'drive_review_folder_id') { folderId = String(sd[i][1] || ''); break; } + } + } + if (folderId) { + folder = DriveApp.getFolderById(folderId); + } else { + var folders = DriveApp.getFoldersByName('POSFuku_ReviewScreenshots'); + if (folders.hasNext()) folder = folders.next(); + else return { error: "Folder Drive untuk screenshot review belum ada. Jalankan setupDriveFolders() oleh admin." }; + } + + var contentType = payload.mimeType || 'image/jpeg'; + var data = String(payload.base64 || ''); + var commaIndex = data.indexOf(','); + if (commaIndex > -1) { + data = data.substr(commaIndex + 1); + } + var bytes = Utilities.base64Decode(data); + var blob = Utilities.newBlob(bytes, contentType, 'Review_' + payload.id + '.jpg'); + var file = folder.createFile(blob); + file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW); + + return { success: true, url: file.getUrl(), fileId: file.getId() }; + } catch (e) { + return { error: e.message }; + } +} + +function setupDriveFolders() { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var settings = getSettingsSheet_(); + if (!settings) { setupSheets(); settings = getSettingsSheet_(); } + if (!settings) throw new Error('Sheet Setup/Settings belum siap.'); + + var setRow = function(key, value, desc) { + var data = settings.getDataRange().getValues(); + for (var i = 1; i < data.length; i++) { + if (String(data[i][0]) === key) { + settings.getRange(i + 1, 2).setValue(String(value)); + if (desc !== undefined) settings.getRange(i + 1, 3).setValue(String(desc)); + return; + } + } + settings.appendRow([key, String(value), String(desc || '')]); + }; + + var findOrCreate = function(name) { + var it = DriveApp.getFoldersByName(name); + if (it.hasNext()) return it.next(); + return DriveApp.createFolder(name); + }; + + var bukti = findOrCreate('POSFuku_BuktiBayar'); + var review = findOrCreate('POSFuku_ReviewScreenshots'); + + setRow('drive_bukti_folder_id', bukti.getId(), 'Folder ID Drive untuk upload bukti bayar'); + setRow('drive_review_folder_id', review.getId(), 'Folder ID Drive untuk upload screenshot review'); + + return { success: true, buktiFolderId: bukti.getId(), reviewFolderId: review.getId() }; +} + +function updateTransactionReviewPhoto(id, photoUrl) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Transaksi'); + var lastRow = sheet.getLastRow(); + if (lastRow < 2) throw new Error('Tidak ada data.'); + + var ids = sheet.getRange(1, 1, lastRow, 1).getValues(); + var rowIdx = -1; + for (var i = 1; i < ids.length; i++) { + if (String(ids[i][0]) === String(id)) { rowIdx = i + 1; break; } + } + + if (rowIdx > -1) { + sheet.getRange(rowIdx, 30).setValue(String(photoUrl)); // Kolom AD adalah 'Bukti Review' + return getInitialData(); + } else { + throw new Error('Pesanan tidak ditemukan.'); + } + } finally { lock.releaseLock(); } +} + +function setupSheets() { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + if (!ss) throw new Error('Spreadsheet tidak aktif'); + + var ensureHeaderRow = function(sheet, headers) { + if (sheet.getLastRow() === 0) { + sheet.getRange(1, 1, 1, headers.length).setValues([headers]).setFontWeight('bold').setBackground('#f3f3f3'); + return; + } + // Jika baris 1 kosong, isi. Jika tidak, jangan ganggu data. + var row1 = sheet.getRange(1, 1, 1, headers.length).getValues()[0]; + var empty = row1.every(function(v) { return !String(v).trim(); }); + if (empty) { + sheet.getRange(1, 1, 1, headers.length).setValues([headers]).setFontWeight('bold').setBackground('#f3f3f3'); + } + }; + + var menu = ss.getSheetByName('Menu') || ss.insertSheet('Menu'); + ensureHeaderRow(menu, ['Nama', 'Harga', 'Kategori', 'Status', 'Gambar URL', 'Stok']); + menu.getRange('B:B').setNumberFormat('#,##0'); + menu.getRange('F:F').setNumberFormat('0'); + + var trans = ss.getSheetByName('Transaksi') || ss.insertSheet('Transaksi'); + ensureHeaderRow(trans, [ + 'ID', 'Meja', 'Status', 'Nama', 'WA', 'Tanggal', 'Jam Booking', 'Items (JSON)', + 'Subtotal', 'Diskon', 'Poin Dipakai (Rp)', 'Pajak %', 'Service %', 'Admin %', + 'Pajak (Rp)', 'Service (Rp)', 'Admin (Rp)', 'Total Akhir', + 'DP', 'Metode DP', 'Tgl DP', 'Metode Bayar', 'Jumlah Bayar', 'Kembali', + 'Poin Didapat', 'Timestamp', 'Poin Awal', 'Catatan', 'Bukti Bayar', 'Bukti Review' + ]); + + var numCols = ['I', 'J', 'K', 'O', 'P', 'Q', 'R', 'S', 'T', 'W', 'X', 'Y', 'AA']; + numCols.forEach(function(c) { trans.getRange(c + ':' + c).setNumberFormat('#,##0'); }); + + var pctCols = ['L', 'M', 'N']; + pctCols.forEach(function(c) { trans.getRange(c + ':' + c).setNumberFormat('0'); }); + + trans.getRange('F:F').setNumberFormat('yyyy-mm-dd'); + trans.getRange('G:G').setNumberFormat('hh:mm'); + trans.getRange('U:U').setNumberFormat('yyyy-mm-dd'); + trans.getRange('Z:Z').setNumberFormat('yyyy-mm-dd hh:mm:ss'); + trans.getRange('E:E').setNumberFormat('@'); + + var cust = ss.getSheetByName('Pelanggan') || ss.insertSheet('Pelanggan'); + ensureHeaderRow(cust, ['Nama', 'WhatsApp', 'Poin', 'Updated At']); + cust.getRange('B:B').setNumberFormat('@'); + cust.getRange('C:C').setNumberFormat('#,##0'); + cust.getRange('D:D').setNumberFormat('yyyy-mm-dd hh:mm:ss'); + + var belanja = ss.getSheetByName('Belanja') || ss.insertSheet('Belanja'); + ensureHeaderRow(belanja, ['ID', 'Nama', 'Harga', 'Qty', 'Total', 'Tanggal', 'Kategori', 'Catatan', 'Timestamp']); + belanja.getRange('C:C').setNumberFormat('#,##0'); + belanja.getRange('E:E').setNumberFormat('#,##0'); + belanja.getRange('F:F').setNumberFormat('yyyy-mm-dd'); + belanja.getRange('I:I').setNumberFormat('yyyy-mm-dd hh:mm:ss'); + + var rekap = ss.getSheetByName('Rekap') || ss.insertSheet('Rekap'); + ensureHeaderRow(rekap, ['ID', 'Tanggal', 'Saldo Awal', 'Tunai', 'QRIS', 'Debit', 'Credit', 'Transfer', 'Catatan', 'Timestamp']); + rekap.getRange('C:H').setNumberFormat('#,##0'); + rekap.getRange('B:B').setNumberFormat('yyyy-mm-dd'); + rekap.getRange('J:J').setNumberFormat('yyyy-mm-dd hh:mm:ss'); + + var stokHarian = ss.getSheetByName('StokHarian') || ss.insertSheet('StokHarian'); + ensureHeaderRow(stokHarian, ['Tanggal', 'Menu', 'Stok Awal', 'Terpakai', 'Sisa', 'Timestamp']); + stokHarian.getRange('A:A').setNumberFormat('yyyy-mm-dd'); + stokHarian.getRange('C:E').setNumberFormat('#,##0'); + stokHarian.getRange('F:F').setNumberFormat('yyyy-mm-dd hh:mm:ss'); + + var supHistory = ss.getSheetByName('SupplierHistory') || ss.insertSheet('SupplierHistory'); + ensureHeaderRow(supHistory, ['ID', 'Nama Supplier', 'Kategori', 'WhatsApp', 'Pesan', 'Timestamp']); + supHistory.getRange('D:D').setNumberFormat('@'); + supHistory.getRange('F:F').setNumberFormat('yyyy-mm-dd hh:mm:ss'); + + var suppliers = ss.getSheetByName('Suppliers') || ss.insertSheet('Suppliers'); + ensureHeaderRow(suppliers, ['ID', 'Nama Supplier', 'WhatsApp', 'Rekening', 'Timestamp']); + suppliers.getRange('C:C').setNumberFormat('@'); + suppliers.getRange('D:D').setNumberFormat('@'); + suppliers.getRange('E:E').setNumberFormat('yyyy-mm-dd hh:mm:ss'); + + var feedback = ss.getSheetByName('Feedback') || ss.insertSheet('Feedback'); + ensureHeaderRow(feedback, ['Timestamp', 'Nama Pelanggan', 'WhatsApp', 'Rating', 'Komentar']); + feedback.getRange('C:C').setNumberFormat('@'); + feedback.getRange('A:A').setNumberFormat('yyyy-mm-dd hh:mm:ss'); + + var waiters = ss.getSheetByName('Waiters') || ss.insertSheet('Waiters'); + ensureHeaderRow(waiters, ['Nama', 'WhatsApp', 'Status', 'Timestamp']); + waiters.getRange('B:B').setNumberFormat('@'); + waiters.getRange('D:D').setNumberFormat('yyyy-mm-dd hh:mm:ss'); + + var paketKustom = ss.getSheetByName('PaketKustom') || ss.insertSheet('PaketKustom'); + ensureHeaderRow(paketKustom, ['ID', 'Nama Paket', 'Items (JSON)', 'Total Harga', 'Timestamp']); + paketKustom.getRange('D:D').setNumberFormat('#,##0'); + paketKustom.getRange('E:E').setNumberFormat('yyyy-mm-dd hh:mm:ss'); + + var setup = ss.getSheetByName('Setup') || ss.insertSheet('Setup'); + ensureHeaderRow(setup, ['Key', 'Value', 'Description']); + setup.getRange('A:A').setNumberFormat('@'); + setup.getRange('B:B').setNumberFormat('@'); + + var legacy = ss.getSheetByName('Settings'); + if (legacy && setup.getLastRow() <= 1) { + var legacyData = legacy.getDataRange().getValues(); + for (var l = 1; l < legacyData.length; l++) { + if (legacyData[l][0]) setup.appendRow([legacyData[l][0], legacyData[l][1], legacyData[l][2] || '']); + } + } + + var setupData = setup.getDataRange().getValues(); + var keys = setupData.map(function(r) { return r[0]; }); + var ensureKey = function(key, value, desc) { + if (keys.indexOf(key) === -1) { + setup.appendRow([key, String(value), String(desc || '')]); + keys.push(key); + } + }; + + ensureKey('store_name', 'Fuku Shabu & Grill', 'Nama toko'); + ensureKey('store_address', 'Ruko Puridelta Tigaraksa No. 17, Kab. Tangerang', 'Alamat toko'); + ensureKey('store_whatsapp', '6285770558047', 'WhatsApp toko (format 62...)'); + ensureKey('social_instagram_url', 'https://www.instagram.com/fukushabu.grill', 'URL Instagram'); + ensureKey('social_tiktok_url', 'https://www.tiktok.com/@fukushabu.grill', 'URL TikTok'); + ensureKey('social_gmaps_url', 'https://s.id/GMapsFusagi', 'URL Google Maps'); + ensureKey('social_linktree_url', 'https://linktr.ee/fusagifukushabugrill', 'URL Linktree/Website'); + ensureKey('report_wa_numbers', '6285770558047', 'Daftar WA penerima laporan (pisah koma, format 62...)'); + + ensureKey('pajak_persen', '0', 'Nilai pajak (%)'); + ensureKey('service_persen', '0', 'Nilai service (%)'); + ensureKey('admin_persen', '0', 'Nilai admin (%)'); + + ensureKey('kasir_table_count', '10', 'Jumlah meja kasir (Meja-1..N)'); + ensureKey('kasir_table_combos', '1&5,2&6,3&7,8&9', 'Meja gabungan (pisahkan dengan koma)'); + ensureKey('kasir_include_dump', 'true', 'Tampilkan Meja-dump (true/false)'); + ensureKey('qr_table_count', '10', 'Jumlah meja QR pelanggan (1..N)'); + + ensureKey('poin_earn_rupiah', '10000', 'Perolehan: 1 poin per berapa rupiah'); + ensureKey('poin_redeem_rupiah', '100', 'Redeem: 1 poin = berapa rupiah'); + + ensureKey('wifi_lock', 'true', 'Kunci informasi WiFi di Pelanggan.html sebelum isi Nama & WA'); + ensureKey('wifi_ssid', 'FukuShabuGrill', 'Nama WiFi'); + ensureKey('wifi_password', 'Fusagi17', 'Password WiFi'); + ensureKey('qris_image_url', 'https://lh3.googleusercontent.com/d/1hGPL3AlVGRMeZTzpwOHp-l-JzOG5tS09', 'URL gambar QRIS'); + + ensureKey('drive_bukti_folder_id', '', 'Folder ID Drive untuk upload bukti bayar'); + ensureKey('drive_review_folder_id', '', 'Folder ID Drive untuk upload screenshot review'); + ensureKey('rekap_archive_spreadsheet_id', '', 'Spreadsheet ID tujuan arsip transaksi (RekapTransaksi). Jika kosong, arsip otomatis nonaktif.'); + + ensureKey('telegram_enabled', 'false', 'Aktifkan notifikasi Telegram (true/false)'); + ensureKey('telegram_bot_token', '', 'Telegram Bot Token (jangan dibagikan)'); + ensureKey('telegram_chat_id', '', 'Telegram Chat ID (user/group)'); + ensureKey('telegram_order_enabled', 'false', 'Notifikasi Telegram untuk pesanan masuk'); + ensureKey('telegram_bell_enabled', 'false', 'Notifikasi Telegram untuk bel/panggilan'); + ensureKey('telegram_waiter_enabled', 'false', 'Notifikasi Telegram untuk kasir->pelayan'); + + if (legacy && legacy.getLastRow() > 0) { + try { legacy.hideSheet(); } catch(e) {} + } + + return 'Berhasil: Sheet Menu, Transaksi, Pelanggan, Belanja, Rekap, StokHarian, SupplierHistory, Suppliers, Waiters, Feedback, PaketKustom, Setup siap.'; +} + +function setRekapArchiveSpreadsheetId(spreadsheetId) { + var raw = String(spreadsheetId || '').trim(); + if (!raw) { + try { + var settings = getSettingsMap_(); + raw = String(settings.rekap_archive_spreadsheet_id || '').trim(); + } catch (e) {} + } + var id = normalizeSpreadsheetId_(raw); + if (!id) throw new Error('Spreadsheet ID kosong.'); + PropertiesService.getScriptProperties().setProperty('rekap_archive_spreadsheet_id', id); + return { ok: true, spreadsheetId: id }; +} + +function getRekapArchiveSpreadsheetId_() { + var props = PropertiesService.getScriptProperties(); + var id = normalizeSpreadsheetId_(String(props.getProperty('rekap_archive_spreadsheet_id') || '').trim()); + if (id) return id; + try { + var settings = getSettingsMap_(); + id = normalizeSpreadsheetId_(String(settings.rekap_archive_spreadsheet_id || '').trim()); + } catch (e) {} + return id; +} + +function normalizeSpreadsheetId_(raw) { + var s = String(raw || '').trim(); + if (!s) return ''; + var m = s.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/); + if (m && m[1]) return m[1]; + m = s.match(/[-\w]{25,}/); + if (m && m[0]) return m[0]; + return ''; +} + +function exportTransaksiRowToRekapArchive_(row, settings) { + try { + var id = normalizeSpreadsheetId_(String((settings && settings.rekap_archive_spreadsheet_id) || getRekapArchiveSpreadsheetId_() || '').trim()); + if (!id) return; + var ss = SpreadsheetApp.openById(id); + var sh = ss.getSheetByName('Transaksi') || ss.insertSheet('Transaksi'); + if (sh.getLastRow() < 1) sh.appendRow([]); + var headers = ['ID','Meja','Status','Nama','WA','Tgl','Jam','Items (JSON)','Subtotal','Diskon','PoinDipakai','PajakPersen','ServicePersen','AdminPersen','Pajak','Service','AdminFee','Total','DP','MetodeDP','TglDP','MetodeBayar','Bayar','Kembali','PoinDapat','Timestamp','PoinAwal','Catatan','BuktiBayar','BuktiReview']; + if (sh.getLastRow() < 1 || String(sh.getRange(1, 1).getValue() || '') !== 'ID') { + sh.clearContents(); + sh.getRange(1, 1, 1, headers.length).setValues([headers]); + } else { + var curHeaders = sh.getRange(1, 1, 1, headers.length).getValues()[0]; + var okHeader = true; + for (var i = 0; i < headers.length; i++) { + if (String(curHeaders[i] || '') !== headers[i]) { okHeader = false; break; } + } + if (!okHeader) sh.getRange(1, 1, 1, headers.length).setValues([headers]); + } + + var targetId = String(row && row[0] ? row[0] : ''); + if (!targetId) return; + var lastRow = sh.getLastRow(); + var rowIdx = -1; + if (lastRow > 1) { + var finder = sh.getRange(2, 1, lastRow - 1, 1).createTextFinder(targetId).matchEntireCell(true); + var cell = finder.findNext(); + if (cell) rowIdx = cell.getRow(); + } + if (rowIdx > -1) sh.getRange(rowIdx, 1, 1, row.length).setValues([row]); + else sh.appendRow(row); + } catch (e) {} +} + +function testRekapArchiveConnection() { + try { + var id = getRekapArchiveSpreadsheetId_(); + if (!id) return { ok: false, error: 'rekap_archive_spreadsheet_id belum diisi.' }; + var ss = SpreadsheetApp.openById(id); + return { ok: true, spreadsheetId: id, name: ss.getName(), url: ss.getUrl() }; + } catch (e) { + return { ok: false, error: e.message }; + } +} + +function updateSetting(key, value) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Setup') || ss.getSheetByName('Settings'); + if (!sheet) { setupSheets(); sheet = ss.getSheetByName('Setup') || ss.getSheetByName('Settings'); } + var data = sheet.getDataRange().getValues(); + var rowIdx = -1; + for (var i = 1; i < data.length; i++) { + if (data[i][0] === key) { rowIdx = i + 1; break; } + } + if (rowIdx > -1) { + sheet.getRange(rowIdx, 2).setValue(String(value)); + } else { + sheet.appendRow([key, String(value), '']); + } + return getInitialData(); + } finally { lock.releaseLock(); } +} + +function authDigestBase64_(s) { + var bytes = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, String(s || ''), Utilities.Charset.UTF_8); + return Utilities.base64Encode(bytes); +} + +function authRandomSalt_() { + var raw = Utilities.getUuid() + ':' + new Date().getTime() + ':' + Math.random(); + return authDigestBase64_(raw).slice(0, 16); +} + +function authEnsureDefault_() { + var props = PropertiesService.getScriptProperties(); + var dbRaw = props.getProperty('auth_users_json'); + var db = null; + try { db = dbRaw ? JSON.parse(dbRaw) : null; } catch (e) { db = null; } + + if (!db || !db.users) { + db = { users: {}, recovery: {} }; + + var legacyUser = props.getProperty('auth_admin_user'); + var legacySalt = props.getProperty('auth_admin_salt'); + var legacyHash = props.getProperty('auth_admin_hash'); + if (legacyUser && legacySalt && legacyHash) { + db.users[String(legacyUser)] = { role: 'admin', salt: String(legacySalt), hashes: [String(legacyHash)] }; + } + } + + var upsertUser = function(username, passwords, role) { + var u = String(username || '').trim(); + if (!u) return; + if (!db.users[u]) { + db.users[u] = { role: String(role || 'admin'), salt: authRandomSalt_(), hashes: [] }; + } + if (!db.users[u].salt) db.users[u].salt = authRandomSalt_(); + if (!Array.isArray(db.users[u].hashes)) db.users[u].hashes = []; + if (role) db.users[u].role = String(role); + (passwords || []).forEach(function(pw) { + var h = authDigestBase64_(db.users[u].salt + ':' + String(pw || '')); + if (db.users[u].hashes.indexOf(h) === -1) db.users[u].hashes.push(h); + }); + }; + + upsertUser('admin', ['11022016', 'admin123'], 'superadmin'); + upsertUser('ridho', ['ridho123'], 'kasir'); + upsertUser('mamah', ['mamah123'], 'kasir'); + Object.keys(db.users || {}).forEach(function(k) { + var u = db.users[k]; + if (!u) return; + if (String(k) === 'admin') u.role = 'superadmin'; + else if (!u.role) u.role = 'kasir'; + }); + + if (!db.recovery || !db.recovery.salt || !db.recovery.hash) { + db.recovery = { salt: authRandomSalt_(), hash: authDigestBase64_(authRandomSalt_() + ':' + '11022016') }; + db.recovery.hash = authDigestBase64_(db.recovery.salt + ':' + '11022016'); + } + + props.setProperty('auth_users_json', JSON.stringify(db)); +} + +function loginUser(username, password) { + authEnsureDefault_(); + var u = String(username || '').trim(); + var p = String(password || ''); + if (!u || !p) return { ok: false }; + + var props = PropertiesService.getScriptProperties(); + var dbRaw = props.getProperty('auth_users_json'); + var db = null; + try { db = dbRaw ? JSON.parse(dbRaw) : null; } catch (e) { db = null; } + if (!db || !db.users) return { ok: false }; + var user = db.users[u]; + if (!user || !user.salt || !Array.isArray(user.hashes)) return { ok: false }; + var h = authDigestBase64_(String(user.salt) + ':' + p); + if (user.hashes.indexOf(h) === -1) return { ok: false }; + return { ok: true, role: String(user.role || ''), username: u }; +} + +function loginAdmin(username, password) { + authEnsureDefault_(); + var u = String(username || '').trim(); + var p = String(password || ''); + if (!u || !p) return { ok: false }; + + var props = PropertiesService.getScriptProperties(); + var dbRaw = props.getProperty('auth_users_json'); + var db = null; + try { db = dbRaw ? JSON.parse(dbRaw) : null; } catch (e) { db = null; } + if (!db || !db.users) return { ok: false }; + var user = db.users[u]; + if (!user || !user.salt || !Array.isArray(user.hashes)) return { ok: false }; + var role = String(user.role || ''); + if (role !== 'admin' && role !== 'superadmin') return { ok: false }; + var h = authDigestBase64_(String(user.salt) + ':' + p); + if (user.hashes.indexOf(h) === -1) return { ok: false }; + return { ok: true, role: role, username: u }; +} + +function resetAdminWithRecovery(recoveryCode, newUsername, newPassword) { + authEnsureDefault_(); + var code = String(recoveryCode || '').trim(); + var u = String(newUsername || '').trim(); + var p = String(newPassword || ''); + if (!code || !u || !p) return { ok: false, error: 'Input tidak lengkap.' }; + + var props = PropertiesService.getScriptProperties(); + var dbRaw = props.getProperty('auth_users_json'); + var db = null; + try { db = dbRaw ? JSON.parse(dbRaw) : null; } catch (e) { db = null; } + if (!db || !db.users) return { ok: false, error: 'Auth belum siap.' }; + + if (!db.recovery || !db.recovery.salt || !db.recovery.hash) return { ok: false, error: 'Recovery belum diset.' }; + var h = authDigestBase64_(String(db.recovery.salt) + ':' + code); + if (h !== String(db.recovery.hash)) return { ok: false, error: 'Recovery code salah.' }; + + db.users[u] = { role: 'admin', salt: authRandomSalt_(), hashes: [authDigestBase64_(authRandomSalt_() + ':' + p)] }; + db.users[u].hashes = [authDigestBase64_(db.users[u].salt + ':' + p)]; + props.setProperty('auth_users_json', JSON.stringify(db)); + return { ok: true, role: 'admin', username: u }; +} + +function changeAdminPassword(username, oldPassword, newPassword) { + authEnsureDefault_(); + var u = String(username || '').trim(); + var oldP = String(oldPassword || ''); + var newP = String(newPassword || ''); + if (!u || !oldP || !newP) return { ok: false, error: 'Input tidak lengkap.' }; + + var ok = loginAdmin(u, oldP); + if (!ok || !ok.ok) return { ok: false, error: 'Username/password lama salah.' }; + + var props = PropertiesService.getScriptProperties(); + var dbRaw = props.getProperty('auth_users_json'); + var db = null; + try { db = dbRaw ? JSON.parse(dbRaw) : null; } catch (e) { db = null; } + if (!db || !db.users || !db.users[u]) return { ok: false, error: 'User tidak ditemukan.' }; + db.users[u].salt = authRandomSalt_(); + db.users[u].hashes = [authDigestBase64_(db.users[u].salt + ':' + newP)]; + if (!db.users[u].role) db.users[u].role = 'admin'; + props.setProperty('auth_users_json', JSON.stringify(db)); + return { ok: true, role: String(db.users[u].role || 'admin'), username: u }; +} + +function changeUserPassword(username, oldPassword, newPassword) { + authEnsureDefault_(); + var u = String(username || '').trim(); + var oldP = String(oldPassword || ''); + var newP = String(newPassword || ''); + if (!u || !oldP || !newP) return { ok: false, error: 'Input tidak lengkap.' }; + + var ok = loginUser(u, oldP); + if (!ok || !ok.ok) return { ok: false, error: 'Username/password lama salah.' }; + + var props = PropertiesService.getScriptProperties(); + var dbRaw = props.getProperty('auth_users_json'); + var db = null; + try { db = dbRaw ? JSON.parse(dbRaw) : null; } catch (e) { db = null; } + if (!db || !db.users || !db.users[u]) return { ok: false, error: 'User tidak ditemukan.' }; + db.users[u].salt = authRandomSalt_(); + db.users[u].hashes = [authDigestBase64_(db.users[u].salt + ':' + newP)]; + if (!db.users[u].role) db.users[u].role = ok.role || ''; + props.setProperty('auth_users_json', JSON.stringify(db)); + return { ok: true, role: String(db.users[u].role || ''), username: u }; +} + +function resetUserPasswordBySuperAdmin(superUsername, superPassword, targetUsername, newPassword) { + authEnsureDefault_(); + var su = String(superUsername || '').trim(); + var sp = String(superPassword || ''); + var tu = String(targetUsername || '').trim(); + var np = String(newPassword || ''); + if (!su || !sp || !tu || !np) return { ok: false, error: 'Input tidak lengkap.' }; + + var ok = loginAdmin(su, sp); + if (!ok || !ok.ok) return { ok: false, error: 'Akses ditolak.' }; + var roleOk = String(ok.role || ''); + if (roleOk !== 'superadmin' && roleOk !== 'admin') return { ok: false, error: 'Akses ditolak.' }; + + var props = PropertiesService.getScriptProperties(); + var dbRaw = props.getProperty('auth_users_json'); + var db = null; + try { db = dbRaw ? JSON.parse(dbRaw) : null; } catch (e) { db = null; } + if (!db || !db.users || !db.users[tu]) return { ok: false, error: 'User target tidak ditemukan.' }; + var role = String(db.users[tu].role || ''); + db.users[tu].salt = authRandomSalt_(); + db.users[tu].hashes = [authDigestBase64_(db.users[tu].salt + ':' + np)]; + db.users[tu].role = role; + props.setProperty('auth_users_json', JSON.stringify(db)); + return { ok: true, username: tu, role: role }; +} + +function listAuthUsers(superUsername, superPassword) { + authEnsureDefault_(); + var su = String(superUsername || '').trim(); + var sp = String(superPassword || ''); + if (!su || !sp) return { ok: false, error: 'Input tidak lengkap.' }; + var ok = loginAdmin(su, sp); + if (!ok || !ok.ok) return { ok: false, error: 'Akses ditolak.' }; + var roleOk = String(ok.role || ''); + if (roleOk !== 'superadmin' && roleOk !== 'admin') return { ok: false, error: 'Akses ditolak.' }; + var props = PropertiesService.getScriptProperties(); + var dbRaw = props.getProperty('auth_users_json'); + var db = null; + try { db = dbRaw ? JSON.parse(dbRaw) : null; } catch (e) { db = null; } + if (!db || !db.users) return { ok: false, error: 'Auth belum siap.' }; + var users = Object.keys(db.users).sort().map(function(u) { + return { username: u, role: String((db.users[u] && db.users[u].role) || '') }; + }); + return { ok: true, users: users }; +} + +function addAuthUser(superUsername, superPassword, username, password, role) { + authEnsureDefault_(); + var su = String(superUsername || '').trim(); + var sp = String(superPassword || ''); + var u = String(username || '').trim(); + var p = String(password || ''); + var r = String(role || 'kasir').trim(); + if (!su || !sp || !u || !p) return { ok: false, error: 'Input tidak lengkap.' }; + var ok = loginAdmin(su, sp); + if (!ok || !ok.ok) return { ok: false, error: 'Akses ditolak.' }; + var roleOk = String(ok.role || ''); + if (roleOk !== 'superadmin' && roleOk !== 'admin') return { ok: false, error: 'Akses ditolak.' }; + if (!/^[a-zA-Z0-9_.-]{2,20}$/.test(u)) return { ok: false, error: 'Username tidak valid.' }; + if (r !== 'kasir' && r !== 'admin' && r !== 'superadmin') r = 'kasir'; + if (r === 'superadmin') r = 'admin'; + if (u === 'admin') r = 'superadmin'; + + var props = PropertiesService.getScriptProperties(); + var dbRaw = props.getProperty('auth_users_json'); + var db = null; + try { db = dbRaw ? JSON.parse(dbRaw) : null; } catch (e) { db = null; } + if (!db || !db.users) return { ok: false, error: 'Auth belum siap.' }; + if (!db.users[u]) db.users[u] = { role: r, salt: authRandomSalt_(), hashes: [] }; + db.users[u].role = r; + db.users[u].salt = authRandomSalt_(); + db.users[u].hashes = [authDigestBase64_(db.users[u].salt + ':' + p)]; + props.setProperty('auth_users_json', JSON.stringify(db)); + return { ok: true, username: u, role: r }; +} + +function resetAndSetup() { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + if (!ss) throw new Error('Spreadsheet tidak aktif'); + ['Menu', 'Transaksi', 'Pelanggan', 'Belanja', 'Rekap', 'StokHarian', 'SupplierHistory', 'Suppliers', 'Waiters', 'Feedback', 'PaketKustom', 'Settings', 'Setup'].forEach(function(n) { + var sh = ss.getSheetByName(n); + if (sh) ss.deleteSheet(sh); + }); + return setupSheets(); +} + +function setupAman() { + return setupSheets(); +} + +function resetTotal() { + return resetAndSetup(); +} + +function getInitialData() { + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + if (!ss) throw new Error('Spreadsheet tidak aktif'); + + var lastReset = PropertiesService.getScriptProperties().getProperty('last_stock_reset') || ''; + var sheets = ss.getSheets(); + var sheetData = { lastReset: lastReset }; + + // Ambil semua data sheet sekaligus untuk mengurangi network call + var targetSheets = ['Menu', 'Transaksi', 'Pelanggan', 'Belanja', 'Rekap', 'SupplierHistory', 'Suppliers', 'Waiters', 'PaketKustom', 'Setup', 'Settings']; + sheets.forEach(function(s) { + var name = s.getName(); + if (targetSheets.indexOf(name) > -1) { + try { + var lastRow = s.getLastRow(); + var values = []; + if (lastRow > 1) { + if (name === 'Transaksi') { + // Untuk Transaksi, ambil 30 baris terakhir (cukup untuk aktif + history awal) + var startRow = Math.max(2, lastRow - 29); + var numRows = lastRow - startRow + 1; + values = s.getRange(startRow, 1, numRows, s.getLastColumn()).getValues(); + } else if (name === 'Belanja' || name === 'SupplierHistory') { + // Untuk Belanja dan SupplierHistory, ambil maksimal 100 baris terakhir + var startRow = Math.max(2, lastRow - 99); + var numRows = lastRow - startRow + 1; + values = s.getRange(startRow, 1, numRows, s.getLastColumn()).getValues(); + } else { + values = s.getRange(2, 1, lastRow - 1, s.getLastColumn()).getValues(); + } + } + sheetData[name] = values; + } catch (e) { + sheetData[name] = []; + } + } + }); + + if (!sheetData['Menu']) { + return { error: "Sheet Menu tidak ditemukan. Klik SETUP AMAN." }; + } + + var settings = {}; + (sheetData['Settings'] || []).forEach(function(r) { + if (r[0]) settings[String(r[0])] = String(r[1]); + }); + (sheetData['Setup'] || []).forEach(function(r) { + if (r[0]) settings[String(r[0])] = String(r[1]); + }); + + var sheetMenuForHeader = ss.getSheetByName('Menu'); + var menuHeaderMap = getHeaderIndexMap_(sheetMenuForHeader); + var sheetPaketForHeader = ss.getSheetByName('PaketKustom'); + var paketHeaderMap = getHeaderIndexMap_(sheetPaketForHeader); + var sheetTransForHeader = ss.getSheetByName('Transaksi'); + var transHeaderMap = getHeaderIndexMap_(sheetTransForHeader); + + var menu = (sheetData['Menu'] || []).map(function(r) { + var idxNama = (menuHeaderMap['nama'] != null) ? menuHeaderMap['nama'] : 0; + var idxHarga = (menuHeaderMap['harga'] != null) ? menuHeaderMap['harga'] : 1; + var idxKategori = (menuHeaderMap['kategori'] != null) ? menuHeaderMap['kategori'] : 2; + var idxStatus = (menuHeaderMap['status'] != null) ? menuHeaderMap['status'] : 3; + var idxGambar = (menuHeaderMap['gambar url'] != null) ? menuHeaderMap['gambar url'] : (menuHeaderMap['gambar'] != null ? menuHeaderMap['gambar'] : 4); + var idxStok = (menuHeaderMap['stok'] != null) ? menuHeaderMap['stok'] : 5; + var idxTampil = (menuHeaderMap['tampildapur'] != null) ? menuHeaderMap['tampildapur'] : (menuHeaderMap['tampil dapur'] != null ? menuHeaderMap['tampil dapur'] : 6); + var idxModal = (menuHeaderMap['modal'] != null) ? menuHeaderMap['modal'] : -1; + + var status = String((idxStatus != null ? r[idxStatus] : r[3]) || '').toLowerCase().trim(); + var tampilDapur = String((idxTampil != null ? r[idxTampil] : r[6]) || '').toLowerCase().trim(); + if (status !== 'aktif') return null; + return { + nama: String(r[idxNama] || ''), + harga: parseNumberFlexible(r[idxHarga]), + kategori: String(r[idxKategori] || ''), + gambar: normalizeImageUrl(r[idxGambar]), + stok: Number(r[idxStok]) || 0, + modal: idxModal > -1 ? parseNumberFlexible(r[idxModal]) : 0, + tampilDapur: tampilDapur // Tambahkan info ini agar frontend bisa filter + }; + }).filter(function(m) { return m !== null; }); + + var transaksi = (sheetData['Transaksi'] || []).map(function(r) { + var items = []; try { items = JSON.parse(r[7] || '[]'); } catch(e) {} + var ppIdx = transHeaderMap['potensiprofit']; + return { + id: String(r[0]), meja: String(r[1]), status: String(r[2]), nama: String(r[3]), wa: String(r[4]), + tgl: parseDate(r[5]), jam: parseDate(r[6]), items: items, subtotal: Number(r[8]), diskon: Number(r[9]), + poinDipakai: Number(r[10]), pajakPersen: Number(r[11]), servicePersen: Number(r[12]), adminPersen: Number(r[13]), + pajak: Number(r[14]), service: Number(r[15]), adminFee: Number(r[16]), total: Number(r[17]), + dp: Number(r[18]), metodeDp: String(r[19]), tglDp: parseDate(r[20]), metodeBayar: String(r[21]), + bayar: Number(r[22]), kembali: Number(r[23]), poinDapat: Number(r[24]), timestamp: parseDate(r[25]), + poinAwal: Number(r[26] || 0), catatan: String(r[27] || ''), + buktiBayar: String(r[28] || ''), buktiReview: String(r[29] || ''), + potensiProfit: (ppIdx != null ? (Number(r[ppIdx]) || 0) : (Number(r[30]) || 0)) + }; + }); + + var pelanggan = (sheetData['Pelanggan'] || []).map(function(r) { + return { nama: String(r[0]), wa: String(r[1]), poin: Number(r[2]), updatedAt: parseDate(r[3]) }; + }); + + var belanja = (sheetData['Belanja'] || []).map(function(r) { + return { id: String(r[0]), nama: String(r[1]), harga: Number(r[2]), qty: Number(r[3]), total: Number(r[4]), tgl: parseDate(r[5]), kategori: String(r[6]), catatan: String(r[7]), timestamp: parseDate(r[8]) }; + }); + + var rekap = (sheetData['Rekap'] || []).map(function(r) { + return { id: String(r[0]), tgl: parseDate(r[1]), saldoAwal: Number(r[2]), tunai: Number(r[3]), qris: Number(r[4]), debit: Number(r[5]), credit: Number(r[6]), transfer: Number(r[7]), catatan: String(r[8]), timestamp: parseDate(r[9]) }; + }); + + var supplierHistory = (sheetData['SupplierHistory'] || []).map(function(r) { + return { id: String(r[0]), nama: String(r[1]), kategori: String(r[2]), wa: String(r[3]), pesan: String(r[4]), timestamp: parseDate(r[5]) }; + }); + + var suppliersMaster = (sheetData['Suppliers'] || []).map(function(r) { + var rekening = ''; + var ts = null; + if (r.length >= 5) { + rekening = String(r[3] || ''); + ts = parseDate(r[4]); + } else { + rekening = ''; + ts = parseDate(r[3]); + } + return { id: String(r[0]), nama: String(r[1]), wa: String(r[2]), rekening: rekening, timestamp: ts }; + }); + + var waiters = (sheetData['Waiters'] || []).map(function(r) { + return { nama: String(r[0]), wa: String(r[1]), status: String(r[2]), timestamp: parseDate(r[3]) }; + }); + + var paketKustom = (sheetData['PaketKustom'] || []).map(function(r) { + var idxId = (paketHeaderMap['id'] != null) ? paketHeaderMap['id'] : 0; + var idxNama = (paketHeaderMap['nama paket'] != null) ? paketHeaderMap['nama paket'] : (paketHeaderMap['nama'] != null ? paketHeaderMap['nama'] : 1); + var idxItems = (paketHeaderMap['items (json)'] != null) ? paketHeaderMap['items (json)'] : 2; + var idxTotal = (paketHeaderMap['total harga'] != null) ? paketHeaderMap['total harga'] : (paketHeaderMap['total'] != null ? paketHeaderMap['total'] : 3); + var idxTimestamp = (paketHeaderMap['timestamp'] != null) ? paketHeaderMap['timestamp'] : 4; + var idxGambar = (paketHeaderMap['gambar url'] != null) ? paketHeaderMap['gambar url'] : (paketHeaderMap['gambar'] != null ? paketHeaderMap['gambar'] : 5); + var idxAktif = (paketHeaderMap['aktif'] != null) ? paketHeaderMap['aktif'] : 6; + var idxModal = paketHeaderMap['modal']; + + var items = []; try { items = JSON.parse(r[idxItems] || '[]'); } catch(e) {} + var isAktif = (r[idxAktif] === true || String(r[idxAktif]).toLowerCase() === 'aktif' || String(r[idxAktif]).toLowerCase() === 'true'); + return { id: String(r[idxId]), nama: String(r[idxNama]), items: items, total: Number(r[idxTotal]), modal: (idxModal != null ? parseNumberFlexible(r[idxModal]) : 0), timestamp: parseDate(r[idxTimestamp]), gambar: normalizeImageUrl(r[idxGambar]), aktif: isAktif }; + }); + + var scriptUrl = ''; + try { scriptUrl = ScriptApp.getService().getUrl(); } catch(e) {} + + return { menu: menu, transaksi: transaksi, pelanggan: pelanggan, belanja: belanja, rekap: rekap, supplierHistory: supplierHistory, suppliersMaster: suppliersMaster, waiters: waiters, paketKustom: paketKustom, settings: settings, lastReset: sheetData.lastReset, scriptUrl: scriptUrl }; + } catch (e) { + return { error: e.message }; + } +} + +function getActiveTransaksiLite() { + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheetTrans = ss.getSheetByName('Transaksi'); + if (!sheetTrans) return { transaksi: [] }; + var lastRow = sheetTrans.getLastRow(); + if (lastRow <= 1) return { transaksi: [] }; + var startRow = Math.max(2, lastRow - 199); + var numRows = lastRow - startRow + 1; + var values = sheetTrans.getRange(startRow, 1, numRows, sheetTrans.getLastColumn()).getValues(); + var list = values.map(function(r) { + var items = []; try { items = JSON.parse(r[7] || '[]'); } catch(e) {} + return { + id: String(r[0]), meja: String(r[1]), status: String(r[2]), nama: String(r[3]), wa: String(r[4]), + tgl: parseDate(r[5]), jam: parseDate(r[6]), items: items, subtotal: Number(r[8]), diskon: Number(r[9]), + poinDipakai: Number(r[10]), pajakPersen: Number(r[11]), servicePersen: Number(r[12]), adminPersen: Number(r[13]), + pajak: Number(r[14]), service: Number(r[15]), adminFee: Number(r[16]), total: Number(r[17]), + dp: Number(r[18]), metodeDp: String(r[19]), tglDp: parseDate(r[20]), metodeBayar: String(r[21]), + bayar: Number(r[22]), kembali: Number(r[23]), poinDapat: Number(r[24]), timestamp: parseDate(r[25]), + poinAwal: Number(r[26] || 0), catatan: String(r[27] || ''), + buktiBayar: String(r[28] || ''), buktiReview: String(r[29] || '') + }; + }).filter(function(t) { + return t.status === 'Pending' || t.status === 'Ready' || t.status === 'Booking' || t.status === 'Selesai' || t.status === 'Void'; + }); + return { transaksi: list }; + } catch (e) { + return { error: e.message, transaksi: [] }; + } +} + +function saveCustomPaket(payload) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('PaketKustom'); + if (!sheet) { setupSheets(); sheet = ss.getSheetByName('PaketKustom'); } + var now = new Date(); + var id = 'PK-' + Date.now(); + + var row = [ + id, + payload.nama || 'Paket Kustom', + JSON.stringify(payload.items || []), + Number(payload.total) || 0, + now, + '', // gambar + false // aktif + ]; + + sheet.appendRow(row); + return getInitialData(); + } finally { lock.releaseLock(); } +} + +function deleteCustomPaket(id) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('PaketKustom'); + if (!sheet) return getInitialData(); // Jika sheet tidak ada, anggap data kosong + var data = sheet.getDataRange().getValues(); + var rowIdx = -1; + for (var i = 1; i < data.length; i++) { + if (String(data[i][0]) === String(id)) { rowIdx = i + 1; break; } + } + if (rowIdx > -1) { + sheet.deleteRow(rowIdx); + } + return getInitialData(); + } finally { lock.releaseLock(); } +} + +function toggleCustomPaketStatus(id, isActive) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('PaketKustom'); + if (!sheet) return getInitialData(); + var data = sheet.getDataRange().getValues(); + var rowIdx = -1; + for (var i = 1; i < data.length; i++) { + if (String(data[i][0]) === String(id)) { rowIdx = i + 1; break; } + } + if (rowIdx > -1) { + sheet.getRange(rowIdx, 7).setValue(isActive); // Kolom G adalah 'Aktif' + } + return getInitialData(); + } finally { lock.releaseLock(); } +} + +function updateCustomPaketName(id, newName) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('PaketKustom'); + if (!sheet) return getInitialData(); + var data = sheet.getDataRange().getValues(); + var rowIdx = -1; + for (var i = 1; i < data.length; i++) { + if (String(data[i][0]) === String(id)) { rowIdx = i + 1; break; } + } + if (rowIdx > -1) { + sheet.getRange(rowIdx, 2).setValue(newName); // Kolom B adalah 'Nama Paket' + } + return getInitialData(); + } finally { lock.releaseLock(); } +} + +function uploadPaketLogo(id, base64Data, mimeType, fileName) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('PaketKustom'); + if (!sheet) throw new Error('Sheet PaketKustom tidak ditemukan.'); + + var data = sheet.getDataRange().getValues(); + var rowIdx = -1; + for (var i = 1; i < data.length; i++) { + if (String(data[i][0]) === String(id)) { rowIdx = i + 1; break; } + } + if (rowIdx === -1) throw new Error('Paket tidak ditemukan.'); + + var folderId = PropertiesService.getScriptProperties().getProperty('folder_paket_logo'); + var folder; + if (folderId) { + try { + folder = DriveApp.getFolderById(folderId); + } catch(e) {} + } + if (!folder) { + var folders = DriveApp.getFoldersByName('POSFuku_PaketLogo'); + if (folders.hasNext()) { + folder = folders.next(); + } else { + folder = DriveApp.createFolder('POSFuku_PaketLogo'); + folder.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW); + } + PropertiesService.getScriptProperties().setProperty('folder_paket_logo', folder.getId()); + } + + var blob = Utilities.newBlob(Utilities.base64Decode(base64Data), mimeType, fileName); + var file = folder.createFile(blob); + var url = file.getUrl(); + + sheet.getRange(rowIdx, 6).setValue(url); // Kolom F adalah 'Gambar' + + return getInitialData(); + } catch (e) { + return { error: e.message }; + } finally { + lock.releaseLock(); + } +} + +function generateReceiptPdfFromHtml(html, fileName) { + try { + var safeName = String(fileName || 'Struk.pdf').replace(/[\\\/\:\*\?\"\<\>\|]/g, '_'); + if (!safeName.toLowerCase().endsWith('.pdf')) safeName += '.pdf'; + var blob = HtmlService.createHtmlOutput(String(html || '')).getAs(MimeType.PDF).setName(safeName); + var b64 = Utilities.base64Encode(blob.getBytes()); + return { fileName: safeName, base64: b64 }; + } catch (e) { + return { error: e.message }; + } +} + +function saveTransaction(payload) { + var lock = LockService.getScriptLock(); + try { + lock.waitLock(30000); + } catch (e) { + throw new Error('Server sibuk. Coba lagi.'); + } + + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheetTrans = ss.getSheetByName('Transaksi'); + var sheetMenu = ss.getSheetByName('Menu'); + var sheetCust = ss.getSheetByName('Pelanggan'); + if (!sheetTrans || !sheetMenu) throw new Error('Sheet belum siap. Klik SETUP DATABASE.'); + + var now = new Date(); + + var normalizeWA = function(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; + }; + payload.wa = normalizeWA(payload.wa); + var settings = getSettingsMap_(); + + var toDateObj = function(val) { + if (!val) return ''; + if (val instanceof Date) return val; + var s = String(val); + if (s.includes('T') || s.includes('-')) { + var d = new Date(s); + return isNaN(d) ? '' : d; + } + return ''; + }; + + // Optimasi: Hanya ambil data yang diperlukan (300 baris terakhir untuk pencarian Pending) + var totalRows = sheetTrans.getLastRow(); + var rowsToRead = Math.min(totalRows, 500); // Batasi pencarian baris ke 500 terakhir + var startRow = Math.max(1, totalRows - rowsToRead + 1); + var rows = sheetTrans.getRange(startRow, 1, rowsToRead, sheetTrans.getLastColumn()).getValues(); + + var findRowById = function(id) { + for (var i = 0; i < rows.length; i++) { + if (String(rows[i][0]) === String(id)) return startRow + i; + } + return -1; + }; + + var rowToUpdate = findRowById(payload.id); + var old = null; + if (rowToUpdate > -1) { + // Index di array `rows` adalah rowToUpdate - startRow + old = rows[rowToUpdate - startRow]; + } + + if (payload.status === 'Pending' && rowToUpdate === -1) { + for (var ip = 0; ip < rows.length; ip++) { + if (String(rows[ip][1]) === String(payload.meja) && String(rows[ip][2]) === 'Pending') { + rowToUpdate = startRow + ip; + payload.id = String(rows[ip][0]); + old = rows[ip]; + break; + } + } + } + + // Auto-generate name if empty (preserve old name if updating existing transaksi) + if (!payload.nama || !String(payload.nama).trim()) { + if (old && old[3] && String(old[3]).trim()) { + payload.nama = String(old[3]).trim(); + } else { + var maxFound = 0; + var scanNames = function(values) { + (values || []).forEach(function(r) { + var n = String((Array.isArray(r) ? r[0] : r) || ''); + if (n.indexOf('an-') === 0) { + var num = parseInt(n.replace('an-', ''), 10); + if (!isNaN(num) && num > maxFound) maxFound = num; + } else if (n.indexOf('Pelanggan-') === 0) { + var num2 = parseInt(n.replace('Pelanggan-', ''), 10); + if (!isNaN(num2) && num2 > maxFound) maxFound = num2; + } + }); + }; + + var transLastRow = sheetTrans.getLastRow(); + var batchSize = 5000; + if (transLastRow > 1) { + for (var rStart = 2; rStart <= transLastRow; rStart += batchSize) { + var count = Math.min(batchSize, transLastRow - rStart + 1); + var transNames = sheetTrans.getRange(rStart, 4, count, 1).getValues(); + scanNames(transNames); + } + } + + var nextNum = maxFound + 1; + payload.nama = 'an-' + nextNum; + } + } + + // Kapitalisasi Nama Pelanggan (Title Case) + if (payload.nama && String(payload.nama).indexOf('an-') !== 0) { + payload.nama = String(payload.nama).split(' ').map(function(word) { + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }).join(' '); + } + + var items = Array.isArray(payload.items) ? payload.items : []; + if (payload.status === 'Pending' && old) { + try { + var oldItems = JSON.parse(old[7] || '[]'); + var newCart = items; // Flat list from UI + + var oldMap = {}; + oldItems.forEach(function(it) { + var k = (it.nama || '').trim(); + oldMap[k] = (oldMap[k] || 0) + (Number(it.qty) || 0); + }); + + var finalItems = []; + newCart.forEach(function(it) { + var k = (it.nama || '').trim(); + var oldQty = oldMap[k] || 0; + var newQty = Number(it.qty) || 0; + + if (oldQty > 0) { + var existingQty = Math.min(oldQty, newQty); + finalItems.push({ nama: it.nama, harga: it.harga, qty: existingQty, type: 'Eks' }); + if (newQty > oldQty) { + finalItems.push({ nama: it.nama, harga: it.harga, qty: newQty - oldQty, type: 'Tam' }); + } + } else { + finalItems.push({ nama: it.nama, harga: it.harga, qty: newQty, type: 'Tam' }); + } + }); + items = finalItems; + } catch (e) { + items = payload.items; + } + // Jika payload.dp tidak dikirim (undefined/null), baru ambil dari data lama + if (payload.dp === undefined || payload.dp === null) { + payload.dp = Number(old[18]) || 0; + payload.metodeDp = String(old[19] || ''); + payload.tglDp = old[20] ? old[20] : ''; + } + } + + var subtotal = items.reduce(function(acc, it) { return acc + (Number(it.qty) || 0) * (Number(it.harga) || 0); }, 0); + var diskon = Number(payload.diskon) || 0; + var poinDipakai = Number(payload.poinDipakai) || 0; + var base = Math.max(0, subtotal - diskon - poinDipakai); + var pajakPersen = Number(payload.pajakPersen) || 0; + var servicePersen = Number(payload.servicePersen) || 0; + var adminPersen = Number(payload.adminPersen) || 0; + + // Gunakan nilai dari payload jika tersedia, jika tidak baru kalkulasi ulang + var service = (payload.service !== undefined) ? Number(payload.service) : Math.floor(base * (servicePersen / 100)); + var pajakBase = base + service; + var pajak = (payload.pajak !== undefined) ? Number(payload.pajak) : Math.floor(pajakBase * (pajakPersen / 100)); + var totalSebelumAdmin = base + service + pajak; + var adminFee = (payload.adminFee !== undefined) ? Number(payload.adminFee) : Math.floor(totalSebelumAdmin * (adminPersen / 100)); + var totalAkhir = (payload.total !== undefined) ? Number(payload.total) : (totalSebelumAdmin + adminFee); + + var dp = Number(payload.dp) || 0; + var bayar = Number(payload.bayar) || 0; + var kembali = (dp + bayar) - totalAkhir; + if (kembali < 0) kembali = 0; + + // Hitung Poin (Divisor: 10.000, Redeem: 1.000) + var poinDapat = 0; + var curPoin = 0; + + var menuDelta = []; + if (payload.status === 'Selesai') { + var oldStatus = old ? String(old[2] || '') : ''; + if (oldStatus !== 'Selesai') { + var menuRange = sheetMenu.getRange(2, 1, sheetMenu.getLastRow() - 1, 6); + var menuData = menuRange.getValues(); + var menuChanged = false; + + items.forEach(function(it) { + for (var m = 0; m < menuData.length; m++) { + if (String(menuData[m][0]) === String(it.nama)) { + var currentStok = Number(menuData[m][5]) || 0; + var buyQty = Number(it.qty) || 0; + menuData[m][5] = Math.max(0, currentStok - buyQty); + menuDelta.push({ nama: String(it.nama), stok: Number(menuData[m][5]) || 0 }); + menuChanged = true; + break; + } + } + }); + + // Simpan SEMUA stok menu sekaligus (Bukan satu per satu) + if (menuChanged) { + menuRange.setValues(menuData); + } + } + } + + var pelangganDelta = null; + if (sheetCust && payload.wa && payload.status === 'Selesai') { + var earnDiv = parseNumberFlexible(settings.poin_earn_rupiah); + if (!earnDiv || earnDiv < 1) earnDiv = 10000; + var redeemRp = parseNumberFlexible(settings.poin_redeem_rupiah); + if (!redeemRp || redeemRp < 1) redeemRp = 100; + poinDapat = Math.floor(Math.max(0, base) / earnDiv); + var wa = String(payload.wa); + var custData = sheetCust.getDataRange().getValues(); + var foundIdx = -1; + for (var c = 1; c < custData.length; c++) { + if (String(custData[c][1]) === wa) { foundIdx = c + 1; break; } + } + + var poinUsed = Math.floor(poinDipakai / redeemRp); + if (foundIdx > -1) { + curPoin = Number(custData[foundIdx - 1][2]) || 0; + var newP = Math.max(0, curPoin - poinUsed + poinDapat); + sheetCust.getRange(foundIdx, 1, 1, 4).setValues([[payload.nama || custData[foundIdx - 1][0], wa, newP, now]]); + pelangganDelta = { nama: String(payload.nama || custData[foundIdx - 1][0] || ''), wa: String(wa), poin: Number(newP) || 0, updatedAt: now }; + } else { + curPoin = 0; + sheetCust.appendRow([payload.nama || '', wa, Math.max(0, poinDapat - poinUsed), now]); + pelangganDelta = { nama: String(payload.nama || ''), wa: String(wa), poin: Number(Math.max(0, poinDapat - poinUsed)) || 0, updatedAt: now }; + } + } + + var ppCol = ensureColumnExists_(sheetTrans, 'PotensiProfit'); + var potensiProfit = 0; + try { + potensiProfit = calcPotensiProfitFromItems_(ss, items); + } catch (ePp) { + potensiProfit = 0; + } + + var row = [ + payload.id, + payload.meja, + payload.status, + payload.nama || '', + payload.wa || '', + toDateObj(payload.tgl) || now, + payload.jam ? payload.jam : '', + JSON.stringify(items), + subtotal, + diskon, + poinDipakai, + pajakPersen, + servicePersen, + adminPersen, + pajak, + service, + adminFee, + totalAkhir, + dp, + payload.metodeDp || '', + toDateObj(payload.tglDp) || '', + payload.metodeBayar || '', + bayar, + kembali, + poinDapat, + now, + curPoin, + payload.catatan || '', + payload.buktiBayar || (old ? (old[28] || '') : ''), + payload.buktiReview || (old ? (old[29] || '') : '') + ]; + + if (rowToUpdate > -1) { + sheetTrans.getRange(rowToUpdate, 1, 1, row.length).setValues([row]); + if (ppCol > 0) sheetTrans.getRange(rowToUpdate, ppCol).setValue(potensiProfit); + } else { + sheetTrans.appendRow(row); + if (ppCol > 0) sheetTrans.getRange(sheetTrans.getLastRow(), ppCol).setValue(potensiProfit); + } + + try { + var st = String(payload.status || ''); + if (st === 'Selesai' || st === 'Void') exportTransaksiRowToRekapArchive_(row, settings); + } catch (eArch) {} + + try { + var muted = /x123/i.test(String(payload.nama || '')); + if (!muted && String(payload.status || '') === 'Pending' && String(settings.telegram_order_enabled || '').toLowerCase() === 'true') { + var oldStatus = old ? String(old[2] || '') : ''; + var isNewPending = !old || oldStatus !== 'Pending'; + var hasTam = false; + try { + hasTam = items.some(function(it) { return String(it.type || '') === 'Tam' && (Number(it.qty) || 0) > 0; }); + } catch (eTam) {} + if (isNewPending || hasTam) { + var meja = String(payload.meja || ''); + var nama = String(payload.nama || ''); + var lines = items.map(function(it) { return 'x' + (Number(it.qty) || 0) + ' ' + String(it.nama || ''); }).join('\n'); + var text = 'PESANAN MASUK\nMeja: ' + meja + '\nNama: ' + nama + (lines ? '\n\n' + lines : ''); + sendTelegram_(text); + } + } + } catch (eNotify) {} + + var isLite = !!(payload && (payload.__lite === true || payload.lite === true)); + if (isLite) { + var trx = { + id: String(payload.id), + meja: String(payload.meja), + status: String(payload.status), + nama: String(payload.nama || ''), + wa: String(payload.wa || ''), + tgl: toDateObj(payload.tgl) || now, + jam: payload.jam ? payload.jam : '', + items: items, + subtotal: Number(subtotal) || 0, + diskon: Number(diskon) || 0, + poinDipakai: Number(poinDipakai) || 0, + pajakPersen: Number(pajakPersen) || 0, + servicePersen: Number(servicePersen) || 0, + adminPersen: Number(adminPersen) || 0, + pajak: Number(pajak) || 0, + service: Number(service) || 0, + adminFee: Number(adminFee) || 0, + total: Number(totalAkhir) || 0, + dp: Number(dp) || 0, + metodeDp: String(payload.metodeDp || ''), + tglDp: toDateObj(payload.tglDp) || '', + metodeBayar: String(payload.metodeBayar || ''), + bayar: Number(bayar) || 0, + kembali: Number(kembali) || 0, + poinDapat: Number(poinDapat) || 0, + timestamp: now, + poinAwal: Number(curPoin) || 0, + catatan: String(payload.catatan || ''), + buktiBayar: String(payload.buktiBayar || (old ? (old[28] || '') : '')), + buktiReview: String(payload.buktiReview || (old ? (old[29] || '') : '')) + }; + var scriptUrl = ''; + try { scriptUrl = ScriptApp.getService().getUrl(); } catch(e) {} + var lastResetLite = PropertiesService.getScriptProperties().getProperty('last_stock_reset') || ''; + return { ok: true, lite: true, transaksiDelta: [trx], menuDelta: menuDelta, pelangganDelta: pelangganDelta, lastReset: lastResetLite, scriptUrl: scriptUrl }; + } + + return getInitialData(); + } finally { + lock.releaseLock(); + } +} + +function saveCustomerInfo(payload) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheetCust = ss.getSheetByName('Pelanggan'); + if (!sheetCust) { setupSheets(); sheetCust = ss.getSheetByName('Pelanggan'); } + if (!sheetCust) throw new Error('Sheet Pelanggan belum siap.'); + + var now = new Date(); + var rawName = payload && payload.nama ? String(payload.nama).trim() : ''; + var rawWa = payload && payload.wa ? String(payload.wa).trim() : ''; + if (!rawWa) throw new Error('Nomor WhatsApp kosong.'); + + var normalizeWA = function(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; + }; + + var wa = normalizeWA(rawWa); + if (!wa || wa.length < 10) throw new Error('Nomor WhatsApp tidak valid.'); + + var nama = rawName; + if (nama) { + nama = nama.split(' ').filter(function(w) { return w; }).map(function(word) { + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }).join(' '); + } + + var lastRow = sheetCust.getLastRow(); + var data = lastRow > 1 ? sheetCust.getRange(2, 1, lastRow - 1, 4).getValues() : []; + var foundRow = -1; + var foundPoin = 0; + for (var i = 0; i < data.length; i++) { + var rowWa = normalizeWA(data[i][1]); + if (rowWa && rowWa === wa) { + foundRow = i + 2; + foundPoin = Number(data[i][2]) || 0; + break; + } + } + + if (foundRow > -1) { + var existingNama = String(data[foundRow - 2][0] || '').trim(); + var finalNama = nama || existingNama; + sheetCust.getRange(foundRow, 1, 1, 4).setValues([[finalNama, wa, foundPoin, now]]); + } else { + sheetCust.appendRow([nama || '', wa, 0, now]); + } + + return { success: true }; + } finally { lock.releaseLock(); } +} + +function updatePaymentMethod(id, newMethod) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Transaksi'); + var lastRow = sheet.getLastRow(); + if (lastRow < 2) throw new Error('Tidak ada data.'); + + var ids = sheet.getRange(1, 1, lastRow, 1).getValues(); + var rowIdx = -1; + for (var i = 1; i < ids.length; i++) { + if (String(ids[i][0]) === String(id)) { rowIdx = i + 1; break; } + } + + if (rowIdx > -1) { + sheet.getRange(rowIdx, 22).setValue(String(newMethod)); // Kolom V adalah 'Metode Bayar' + return getInitialData(); + } else { + throw new Error('Pesanan tidak ditemukan.'); + } + } finally { lock.releaseLock(); } +} + +function updateTransactionPhoto(id, photoUrl) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Transaksi'); + var lastRow = sheet.getLastRow(); + if (lastRow < 2) throw new Error('Tidak ada data.'); + + var ids = sheet.getRange(1, 1, lastRow, 1).getValues(); + var rowIdx = -1; + for (var i = 1; i < ids.length; i++) { + if (String(ids[i][0]) === String(id)) { rowIdx = i + 1; break; } + } + + if (rowIdx > -1) { + sheet.getRange(rowIdx, 29).setValue(String(photoUrl)); // Kolom AC adalah 'Bukti Bayar' + return getInitialData(); + } else { + throw new Error('Pesanan tidak ditemukan.'); + } + } finally { lock.releaseLock(); } +} + +function updateKitchenStatus(id, newStatus) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Transaksi'); + var lastRow = sheet.getLastRow(); + if (lastRow < 2) throw new Error('Tidak ada data.'); + + var ids = sheet.getRange(1, 1, lastRow, 1).getValues(); + var rowIdx = -1; + for (var i = 1; i < ids.length; i++) { + if (String(ids[i][0]) === String(id)) { rowIdx = i + 1; break; } + } + + if (rowIdx > -1) { + sheet.getRange(rowIdx, 3).setValue(newStatus); // Update status di kolom C + return getInitialData(); + } else { + throw new Error('Pesanan tidak ditemukan.'); + } + } finally { lock.releaseLock(); } +} + +function importTransactions(data) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Transaksi'); + if (!sheet) throw new Error('Sheet Transaksi tidak ditemukan.'); + + var existingIds = []; + if (sheet.getLastRow() > 1) { + existingIds = sheet.getRange(2, 1, sheet.getLastRow() - 1, 1).getValues().map(function(r) { return String(r[0]); }); + } + + var rowsToAdd = []; + data.forEach(function(t) { + if (existingIds.indexOf(String(t[0])) > -1) return; // Index 0 adalah ID + + // Pastikan format tanggal benar untuk kolom Tanggal (Index 5), Tgl DP (Index 20), dan Timestamp (Index 25) + var row = t.map(function(val, idx) { + if ((idx === 5 || idx === 20 || idx === 25) && val) { + try { + var d = new Date(val); + if (!isNaN(d.getTime())) return d; + } catch(e) {} + } + return val; + }); + + // Pastikan panjang baris sesuai (28 kolom) + while (row.length < 28) row.push(''); + rowsToAdd.push(row); + }); + + if (rowsToAdd.length > 0) { + sheet.getRange(sheet.getLastRow() + 1, 1, rowsToAdd.length, 28).setValues(rowsToAdd); + } + return getInitialData(); + } finally { + lock.releaseLock(); + } +} + +function importBelanjaBulk(data) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Belanja'); + if (!sheet) throw new Error('Sheet Belanja tidak ditemukan.'); + + data.forEach(function(b) { + var id = 'B-' + Date.now() + Math.floor(Math.random() * 1000); + var row = [ + id, + b.nama || '', + Number(b.harga) || 0, + Number(b.qty) || 0, + Number(b.total) || 0, + b.tgl || Utilities.formatDate(new Date(), "GMT+7", "yyyy-MM-dd"), + b.kategori || b.kat || 'Dapur', + b.catatan || '', + new Date() + ]; + sheet.appendRow(row); + }); + return getInitialData(); + } finally { + lock.releaseLock(); + } +} + +function saveBelanjaBulk(payloads) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Belanja'); + if (!sheet) throw new Error('Sheet Belanja tidak ditemukan.'); + var now = new Date(); + + payloads.forEach(function(p) { + if (p.id) { + var data = sheet.getDataRange().getValues(); + var rowIdx = -1; + for (var i = 1; i < data.length; i++) { + if (String(data[i][0]) === String(p.id)) { rowIdx = i + 1; break; } + } + if (rowIdx > -1) { + sheet.getRange(rowIdx, 2, 1, 7).setValues([[ + p.nama || '', Number(p.harga) || 0, Number(p.qty) || 0, + Number(p.total) || 0, p.tgl || Utilities.formatDate(now, "GMT+7", "yyyy-MM-dd"), + p.kategori || '', p.catatan || '' + ]]); + } + } else { + var id = 'B-' + Date.now() + Math.floor(Math.random() * 1000); + var row = [ + id, p.nama || '', Number(p.harga) || 0, Number(p.qty) || 0, + Number(p.total) || 0, p.tgl || Utilities.formatDate(now, "GMT+7", "yyyy-MM-dd"), + p.kategori || '', p.catatan || '', now + ]; + sheet.appendRow(row); + } + }); + return getInitialData(); + } finally { lock.releaseLock(); } +} + +function syncBelanjaOut(dateStr) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var now = new Date(); + var date = (function() { + var s = String(dateStr || '').trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; + return Utilities.formatDate(now, 'GMT+7', 'yyyy-MM-dd'); + })(); + + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Belanja'); + if (!sheet) throw new Error('Sheet Belanja tidak ditemukan.'); + if (sheet.getLastRow() <= 1) return { date: date, localCount: 0, rekap: { ok: true, inserted: 0, updated: 0, skipped: 0 }, stokBelanja: { ok: true, belanja: 0, operasional: 0, pegawai: 0, skipped: 0 } }; + + var data = sheet.getDataRange().getValues(); + var items = []; + for (var i = 1; i < data.length; i++) { + var r = data[i]; + var id = String(r[0] || '').trim(); + var tgl = r[5] instanceof Date ? Utilities.formatDate(r[5], 'GMT+7', 'yyyy-MM-dd') : String(r[5] || '').trim(); + if (tgl !== date) continue; + if (!id) continue; + items.push({ + id: id, + nama: String(r[1] || ''), + harga: Number(r[2]) || 0, + qty: Number(r[3]) || 0, + total: Number(r[4]) || 0, + tgl: tgl, + kategori: String(r[6] || ''), + catatan: String(r[7] || '') + }); + } + + var result = { + date: date, + localCount: items.length, + rekap: { ok: false, inserted: 0, updated: 0, skipped: 0, error: '' }, + stokBelanja: { ok: false, belanja: 0, operasional: 0, pegawai: 0, skipped: 0, error: '' } + }; + + var props = PropertiesService.getScriptProperties(); + var rekapId = String(props.getProperty('rekap_transaksi_spreadsheet_id') || '').trim(); + var sbId = String(props.getProperty('stok_belanja_spreadsheet_id') || '').trim(); + + if (rekapId) { + try { + result.rekap = syncToRekapTransaksi_(rekapId, items, now); + } catch (e1) { + result.rekap = { ok: false, inserted: 0, updated: 0, skipped: 0, error: (e1 && e1.message) ? e1.message : String(e1) }; + } + } else { + result.rekap = { ok: false, inserted: 0, updated: 0, skipped: 0, error: 'Script Properties rekap_transaksi_spreadsheet_id belum diisi.' }; + } + + if (sbId) { + try { + result.stokBelanja = syncToStokBelanja_(sbId, items, now); + } catch (e2) { + result.stokBelanja = { ok: false, belanja: 0, operasional: 0, pegawai: 0, skipped: 0, error: (e2 && e2.message) ? e2.message : String(e2) }; + } + } else { + result.stokBelanja = { ok: false, belanja: 0, operasional: 0, pegawai: 0, skipped: 0, error: 'Script Properties stok_belanja_spreadsheet_id belum diisi.' }; + } + + return result; + } finally { lock.releaseLock(); } +} + +function ensureSheetWithHeader_(ss, name, headers) { + var sheet = ss.getSheetByName(name); + if (!sheet) sheet = ss.insertSheet(name); + if (sheet.getLastRow() < 1) sheet.appendRow(headers); + return sheet; +} + +function buildIdRowMap_(sheet) { + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return {}; + var ids = sheet.getRange(2, 1, lastRow - 1, 1).getValues(); + var map = {}; + for (var i = 0; i < ids.length; i++) { + var id = String(ids[i][0] || '').trim(); + if (id) map[id] = i + 2; + } + return map; +} + +function syncToRekapTransaksi_(spreadsheetId, items, now) { + var ss = SpreadsheetApp.openById(spreadsheetId); + var sheet = ensureSheetWithHeader_(ss, 'Belanja', ['ID', 'Nama', 'Harga', 'Qty', 'Total', 'Tanggal', 'Kategori', 'Catatan', 'Timestamp']); + var map = buildIdRowMap_(sheet); + var inserted = 0; + var updated = 0; + var skipped = 0; + for (var i = 0; i < items.length; i++) { + var it = items[i]; + if (!it || !it.id) { skipped++; continue; } + var row = [ + it.id, + it.nama || '', + Number(it.harga) || 0, + Number(it.qty) || 0, + Number(it.total) || 0, + it.tgl, + it.kategori || '', + it.catatan || '', + now + ]; + var existingRow = map[it.id]; + if (existingRow) { + sheet.getRange(existingRow, 1, 1, row.length).setValues([row]); + updated++; + } else { + sheet.appendRow(row); + inserted++; + } + } + return { ok: true, inserted: inserted, updated: updated, skipped: skipped }; +} + +function mapKategoriToSB_(kat) { + var s = String(kat || '').trim().toLowerCase(); + if (s === 'modal') return 'MODAL'; + if (s === 'bawah') return 'BAWAH'; + if (s === 'dapur') return 'DAPUR'; + if (s === 'makan') return 'MAKAN'; + return ''; +} + +function syncToStokBelanja_(spreadsheetId, items, now) { + var ss = SpreadsheetApp.openById(spreadsheetId); + var sheetBelanja = ensureSheetWithHeader_(ss, 'Belanja', ['ID', 'Nama', 'Harga', 'Qty', 'Total', 'Tanggal', 'Kategori', 'Catatan', 'User', 'Timestamp']); + var sheetOp = ensureSheetWithHeader_(ss, 'Operasional', ['ID', 'Tanggal', 'Nama', 'Harga', 'Qty', 'Total', 'Catatan', 'User', 'Timestamp']); + var sheetPg = ensureSheetWithHeader_(ss, 'Pegawai', ['ID', 'Tanggal', 'Nama', 'Nilai', 'Kategori', 'Catatan', 'User', 'Timestamp']); + + var mapBel = buildIdRowMap_(sheetBelanja); + var mapOp = buildIdRowMap_(sheetOp); + var mapPg = buildIdRowMap_(sheetPg); + + var belanjaCount = 0; + var operasionalCount = 0; + var pegawaiCount = 0; + var skipped = 0; + + for (var i = 0; i < items.length; i++) { + var it = items[i]; + if (!it || !it.id) { skipped++; continue; } + var kat = String(it.kategori || '').trim(); + var low = kat.toLowerCase(); + + if (low === 'operasional') { + var idOp = 'POS-' + it.id; + var qtyOp = Number(it.qty) || 0; + if (qtyOp <= 0) qtyOp = 1; + var hargaOp = Number(it.harga) || 0; + var totalOp = Number(it.total) || 0; + if (totalOp <= 0 && hargaOp > 0) totalOp = hargaOp * qtyOp; + if (hargaOp <= 0 && totalOp > 0) hargaOp = Math.round(totalOp / qtyOp); + if (totalOp <= 0) { skipped++; continue; } + var rowOp = [idOp, it.tgl, it.nama || '', hargaOp, qtyOp, totalOp, it.catatan || '', 'POSFuku', now]; + var rOp = mapOp[idOp]; + if (rOp) sheetOp.getRange(rOp, 1, 1, rowOp.length).setValues([rowOp]); else sheetOp.appendRow(rowOp); + operasionalCount++; + continue; + } + + if (low === 'pegawai') { + var idPg = 'POS-' + it.id; + var nilai = Number(it.total) || 0; + if (nilai <= 0) { skipped++; continue; } + var kategoriPg = /kasbon/i.test(it.nama || '') || /kasbon/i.test(it.catatan || '') ? 'Kasbon' : 'Gaji'; + var rowPg = [idPg, it.tgl, it.nama || '', nilai, kategoriPg, it.catatan || '', 'POSFuku', now]; + var rPg = mapPg[idPg]; + if (rPg) sheetPg.getRange(rPg, 1, 1, rowPg.length).setValues([rowPg]); else sheetPg.appendRow(rowPg); + pegawaiCount++; + continue; + } + + var sbKat = mapKategoriToSB_(kat); + if (!sbKat) { skipped++; continue; } + var rowBel = [it.id, it.nama || '', Number(it.harga) || 0, Number(it.qty) || 0, Number(it.total) || 0, it.tgl, sbKat, it.catatan || '', 'POSFuku', now]; + var rBel = mapBel[it.id]; + if (rBel) sheetBelanja.getRange(rBel, 1, 1, rowBel.length).setValues([rowBel]); else sheetBelanja.appendRow(rowBel); + belanjaCount++; + } + + return { ok: true, belanja: belanjaCount, operasional: operasionalCount, pegawai: pegawaiCount, skipped: skipped }; +} + +function searchTransactionsGlobal(query, limit) { + var q = String(query || '').trim().toLowerCase(); + var lim = Number(limit) || 50; + if (lim < 1) lim = 50; + if (lim > 200) lim = 200; + if (!q) return { items: [] }; + + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Transaksi'); + if (!sheet) throw new Error('Sheet Transaksi tidak ditemukan.'); + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return { items: [] }; + var lastCol = sheet.getLastColumn(); + var rowCount = lastRow - 1; + var maxScan = 5000; + var startRow = Math.max(2, lastRow - maxScan + 1); + var scanCount = lastRow - startRow + 1; + var data = sheet.getRange(startRow, 1, scanCount, lastCol).getValues(); + + function fmtDate_(v) { + if (!v) return ''; + if (v instanceof Date) return Utilities.formatDate(v, 'GMT+7', 'yyyy-MM-dd'); + var s = String(v).trim(); + if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10); + try { + var d = new Date(s); + if (!isNaN(d.getTime())) return Utilities.formatDate(d, 'GMT+7', 'yyyy-MM-dd'); + } catch (e) {} + return s.slice(0, 10); + } + + function parseItems_(jsonStr) { + if (!jsonStr) return []; + try { + var arr = JSON.parse(String(jsonStr)); + if (!Array.isArray(arr)) return []; + return arr.map(function(it) { + return { nama: String(it.nama || ''), qty: Number(it.qty) || 0 }; + }).filter(function(it) { return it.nama; }); + } catch (e) { + return []; + } + } + + var out = []; + for (var i = data.length - 1; i >= 0; i--) { + var r = data[i]; + var id = String(r[0] || '').trim(); + if (!id) continue; + var meja = String(r[1] || '').trim(); + var status = String(r[2] || '').trim(); + var nama = String(r[3] || '').trim(); + var wa = String(r[4] || '').trim(); + var tanggal = fmtDate_(r[5]); + var itemsJson = r[7]; + var items = parseItems_(itemsJson); + var itemsText = items.slice(0, 4).map(function(it) { return it.nama + (it.qty ? (' x' + it.qty) : ''); }).join(', '); + var total = Number(r[17]) || 0; + var metodeBayar = String(r[21] || '').trim(); + var catatan = String(r[27] || '').trim(); + var hay = (id + ' ' + meja + ' ' + status + ' ' + nama + ' ' + wa + ' ' + tanggal + ' ' + metodeBayar + ' ' + catatan + ' ' + items.map(function(it){ return it.nama; }).join(' ')).toLowerCase(); + if (hay.indexOf(q) === -1) continue; + out.push({ id: id, tanggal: tanggal, meja: meja, nama: nama, status: status, total: total, metodeBayar: metodeBayar, itemsText: itemsText }); + if (out.length >= lim) break; + } + return { items: out }; +} + +function calcPotensiProfitFromItems_(ss, items) { + var list = Array.isArray(items) ? items : []; + if (!list.length) return 0; + var menuMap = {}; + var paketMap = {}; + + var sheetMenu = ss.getSheetByName('Menu'); + if (sheetMenu && sheetMenu.getLastRow() > 1) { + var hMenu = getHeaderIndexMap_(sheetMenu); + var lcMenu = sheetMenu.getLastColumn(); + var idxNamaM = (hMenu['nama'] != null) ? hMenu['nama'] : 0; + var idxHargaM = (hMenu['harga'] != null) ? hMenu['harga'] : 1; + var idxModalM = (hMenu['modal'] != null) ? hMenu['modal'] : -1; + var idxStatusM = (hMenu['status'] != null) ? hMenu['status'] : 3; + var dataMenu = sheetMenu.getRange(2, 1, sheetMenu.getLastRow() - 1, lcMenu).getValues(); + for (var i = 0; i < dataMenu.length; i++) { + var r = dataMenu[i]; + var nm = String(r[idxNamaM] || '').trim(); + if (!nm) continue; + var status = String(r[idxStatusM] || '').toLowerCase().trim(); + if (status && status !== 'aktif') continue; + menuMap[nm.toLowerCase()] = { harga: parseNumberFlexible(r[idxHargaM]), modal: idxModalM > -1 ? parseNumberFlexible(r[idxModalM]) : 0 }; + } + } + + var sheetPaket = ss.getSheetByName('PaketKustom'); + if (sheetPaket && sheetPaket.getLastRow() > 1) { + var hP = getHeaderIndexMap_(sheetPaket); + var lcP = sheetPaket.getLastColumn(); + var idxNamaP = (hP['nama paket'] != null) ? hP['nama paket'] : (hP['nama'] != null ? hP['nama'] : 1); + var idxTotalP = (hP['total harga'] != null) ? hP['total harga'] : (hP['total'] != null ? hP['total'] : 3); + var idxModalP = (hP['modal'] != null) ? hP['modal'] : -1; + var idxAktifP = (hP['aktif'] != null) ? hP['aktif'] : 6; + var dataP = sheetPaket.getRange(2, 1, sheetPaket.getLastRow() - 1, lcP).getValues(); + for (var j = 0; j < dataP.length; j++) { + var rp = dataP[j]; + var np = String(rp[idxNamaP] || '').trim(); + if (!np) continue; + var aktif = rp[idxAktifP]; + var isAktif = (aktif === true || String(aktif).toLowerCase() === 'aktif' || String(aktif).toLowerCase() === 'true' || aktif === 1); + if (!isAktif) continue; + paketMap[np.toLowerCase()] = { harga: parseNumberFlexible(rp[idxTotalP]), modal: idxModalP > -1 ? parseNumberFlexible(rp[idxModalP]) : 0 }; + } + } + + var totalProfit = 0; + for (var k = 0; k < list.length; k++) { + var it = list[k] || {}; + var nama = String(it.nama || '').trim(); + var qty = Number(it.qty) || 0; + if (!nama || !qty) continue; + var key = nama.toLowerCase(); + var ref = menuMap[key] || paketMap[key]; + if (!ref) continue; + var margin = (Number(ref.harga) || 0) - (Number(ref.modal) || 0); + totalProfit += margin * qty; + } + return Math.round(totalProfit); +} + +function deleteBelanja(id) { + if (!id) throw new Error('ID belanja tidak valid.'); + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Belanja'); + var data = sheet.getDataRange().getValues(); + var rowIdx = -1; + for (var i = 1; i < data.length; i++) { + if (String(data[i][0]) === String(id)) { rowIdx = i + 1; break; } + } + if (rowIdx > -1) { + sheet.deleteRow(rowIdx); + } + return getInitialData(); + } finally { lock.releaseLock(); } +} + +function saveRekap(payload) { + if (!payload) throw new Error('Data rekap tidak valid (payload kosong).'); + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Rekap'); + if (!sheet) { setupSheets(); sheet = ss.getSheetByName('Rekap'); } + var now = new Date(); + var id = payload.id || ('R-' + Date.now()); + + // ID, Tanggal, Saldo Awal, Tunai, QRIS, Debit, Credit, Transfer, Catatan, Timestamp + var row = [ + id, + payload.tgl || Utilities.formatDate(now, "GMT+7", "yyyy-MM-dd"), + Number(payload.saldoAwal) || 0, + Number(payload.tunai) || 0, + Number(payload.qris) || 0, + Number(payload.debit) || 0, + Number(payload.credit) || 0, + Number(payload.transfer) || 0, + payload.catatan || '', + now + ]; + + var data = sheet.getDataRange().getValues(); + var rowIdx = -1; + for (var i = 1; i < data.length; i++) { + if (String(data[i][0]) === String(id)) { rowIdx = i + 1; break; } + } + + if (rowIdx > -1) sheet.getRange(rowIdx, 1, 1, row.length).setValues([row]); + else sheet.appendRow(row); + + return getInitialData(); + } finally { lock.releaseLock(); } +} + +function updateMenuStock(nama, newStok, terpakai) { + return saveStokBulk([{ nama: nama, sisa: newStok, terpakai: terpakai }]); +} + +function saveStokBulk(payloads) { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Menu'); + var sheetH = ss.getSheetByName('StokHarian') || ss.insertSheet('StokHarian'); + + var data = sheet.getDataRange().getValues(); + var now = new Date(); + var todayStr = Utilities.formatDate(now, "GMT+7", "yyyy-MM-dd"); + + payloads.forEach(function(p) { + var nama = p.nama; + var newSisa = Number(p.sisa) || 0; + var terpakai = Number(p.terpakai) || 0; + + // Update di sheet Menu + for (var i = 1; i < data.length; i++) { + if (String(data[i][0]) === nama) { + sheet.getRange(i + 1, 6).setValue(newSisa); + break; + } + } + + // Simpan ke StokHarian + sheetH.appendRow([todayStr, nama, terpakai + newSisa, terpakai, newSisa, now]); + }); + + return getInitialData(); +} +function voidTransaction(id, reason, fotoUrl) { + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheetT = ss.getSheetByName('Transaksi'); + var sheetV = ss.getSheetByName('Void') || ss.insertSheet('Void'); + + if (sheetV.getLastRow() === 0) { + sheetV.appendRow(['Timestamp', 'ID Transaksi', 'Meja', 'Total', 'Alasan', 'Foto', 'Data Lengkap']); + } + + // Optimasi: Hanya ambil kolom ID (Kolom A) untuk mencari baris + var lastRow = sheetT.getLastRow(); + if (lastRow < 2) return { error: "Tidak ada transaksi" }; + + var ids = sheetT.getRange(1, 1, lastRow, 1).getValues(); + var foundIdx = -1; + for (var i = 1; i < ids.length; i++) { + if (String(ids[i][0]) === String(id)) { + foundIdx = i + 1; + break; + } + } + + if (foundIdx > -1) { + // Ambil data lengkap baris tersebut untuk dipindah ke Void + var rowData = sheetT.getRange(foundIdx, 1, 1, sheetT.getLastColumn()).getValues()[0]; + sheetV.appendRow([new Date(), id, rowData[1], rowData[17], reason, fotoUrl || '', JSON.stringify(rowData)]); + + // Update status di Transaksi menjadi "Void" (bukan hapus baris) + sheetT.getRange(foundIdx, 3).setValue('Void'); + + return getInitialData(); + } else { + return { error: "Transaksi tidak ditemukan" }; + } + } catch (err) { + console.error('Error voidTransaction:', err); + return { error: err.message }; + } +} + +function getReport(dari, sampai) { + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Transaksi'); + var sheetB = ss.getSheetByName('Belanja'); + if (!sheet) return { omzet: 0, items: [], methods: {}, belanja: 0 }; + + var lastRow = sheet.getLastRow(); + var data = lastRow > 1 ? sheet.getRange(2, 1, lastRow - 1, 28).getValues() : []; + var tz = 'GMT+7'; + var today = Utilities.formatDate(new Date(), tz, 'yyyy-MM-dd'); + var from = dari || today; + var to = sampai || today; + + var omzet = 0; + var counter = {}; + var methods = { 'Tunai': 0, 'QRIS': 0, 'Debit': 0, 'Credit': 0, 'Transfer': 0 }; + + data.forEach(function(r, idx) { + if (r.length < 5) return; // Baris terlalu pendek + var status = String(r[2] || ''); + if (status !== 'Selesai') return; + + // Deteksi tanggal dari berbagai kolom yang mungkin + var dt = null; + if (r[25] instanceof Date) dt = r[25]; + else if (r[5] instanceof Date) dt = r[5]; + else if (r[0] instanceof Date) dt = r[0]; + + // Jika bukan Date, coba parsing string + if (!dt) { + var dateStr = String(r[25] || r[5] || r[0] || ''); + if (dateStr) { + var parsed = new Date(dateStr); + if (!isNaN(parsed.getTime())) dt = parsed; + } + } + + if (!dt) { + console.warn('Baris ' + (idx+2) + ': Gagal mendeteksi tanggal'); + return; + } + + var d = Utilities.formatDate(dt, tz, 'yyyy-MM-dd'); + if (d < from || d > to) return; + + var total = Number(r[17]) || 0; + omzet += total; + + var m = String(r[21] || 'Tunai').trim() || 'Tunai'; + if (m === 'Multi') { + try { + var details = JSON.parse(r[27] || '{}'); // r[27] adalah kolom Catatan + for (var key in details) { + var val = Number(details[key]) || 0; + if (val > 0) { + if (methods.hasOwnProperty(key)) methods[key] += val; + else methods[key] = (methods[key] || 0) + val; + } + } + } catch (e) { + console.error('Gagal parsing JSON multi-payment baris ' + (idx+2) + ':', e); + // Fallback jika gagal parse, masukkan ke Tunai atau Multi + methods['Tunai'] = (methods['Tunai'] || 0) + total; + } + } else { + if (methods.hasOwnProperty(m)) methods[m] += total; + else methods[m] = (methods[m] || 0) + total; + } + + try { + var itemsJson = String(r[7] || '[]'); + if (itemsJson.indexOf('[') === 0) { + var items = JSON.parse(itemsJson); + if (Array.isArray(items)) { + items.forEach(function(it) { + var k = String(it.nama || ''); + if (!k) return; + counter[k] = (counter[k] || 0) + (Number(it.qty) || 0); + }); + } + } + } catch (e) { + console.error('Gagal parsing JSON item baris ' + (idx+2) + ':', e); + } + }); + + console.log('Report generated: omzet=' + omzet + ', methods=' + JSON.stringify(methods)); + + var belanjaTotal = 0; + var belanjaItems = []; + if (sheetB) { + var lastRowB = sheetB.getLastRow(); + var bData = lastRowB > 1 ? sheetB.getRange(2, 1, lastRowB - 1, 9).getValues() : []; + bData.forEach(function(r) { + if (r.length < 9) return; + var dt = r[8] instanceof Date ? r[8] : (r[5] instanceof Date ? r[5] : null); + if (!dt && r[8]) dt = new Date(r[8]); + if (!dt && r[5]) dt = new Date(r[5]); + + if (!dt || isNaN(dt.getTime())) return; + var d = Utilities.formatDate(dt, tz, 'yyyy-MM-dd'); + if (d < from || d > to) return; + var t = Number(r[4]) || 0; + belanjaTotal += t; + belanjaItems.push({ nama: r[1], total: t, tgl: r[5], id: r[0] }); + }); + } + + var itemsSorted = Object.keys(counter).map(function(k) { return [k, counter[k]]; }).sort(function(a, b) { return b[1] - a[1]; }); + return { omzet: omzet, items: itemsSorted, methods: methods, belanja: belanjaTotal, belanjaItems: belanjaItems }; + } catch (err) { + console.error('Error di getReport:', err); + return { error: err.message, omzet: 0, items: [], methods: {}, belanja: 0 }; + } +} + +function saveSupplierWA(payload) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('SupplierHistory'); + if (!sheet) { setupSheets(); sheet = ss.getSheetByName('SupplierHistory'); } + var now = new Date(); + var id = 'S-' + Date.now(); + + // ID, Nama Supplier, Kategori, WhatsApp, Pesan, Timestamp + var row = [ + id, + payload.nama || '', + payload.kategori || '', + payload.wa || '', + payload.pesan || '', + now + ]; + + sheet.appendRow(row); + return getInitialData(); + } finally { lock.releaseLock(); } +} + +// --- Fungsi Chat & Bel --- +function sendChatMessage(sender, message) { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Chat') || ss.insertSheet('Chat'); + if (sheet.getLastRow() === 0) { + sheet.appendRow(['Timestamp', 'Pengirim', 'Pesan', 'Status']); + } + sheet.appendRow([new Date(), sender, message, 'Belum Dibaca']); + return { success: true }; +} + +function getChatMessages() { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Chat'); + if (!sheet) return []; + var data = sheet.getDataRange().getValues(); + data.shift(); // Hapus header + return data.map(function(row) { + return { timestamp: parseDate(row[0]), sender: row[1], message: row[2], status: row[3] }; + }); +} + +function markChatAsRead(receiver) { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Chat'); + if (!sheet) return { success: false }; + var data = sheet.getDataRange().getValues(); + var changed = false; + for (var i = 1; i < data.length; i++) { + // Jika pengirim bukan kita (receiver) dan statusnya Belum Dibaca + if (data[i][1] !== receiver && data[i][3] === 'Belum Dibaca') { + sheet.getRange(i + 1, 4).setValue('Sudah Dibaca'); + changed = true; + } + } + return { success: true, changed: changed }; +} + +function triggerBell(bellId) { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('BellStatus') || ss.insertSheet('BellStatus'); + if (sheet.getLastRow() === 0) { + sheet.appendRow(['BellID', 'Status', 'Timestamp']); + } + var data = sheet.getDataRange().getValues(); + var found = false; + for (var i = 1; i < data.length; i++) { + if (data[i][0] === bellId) { + sheet.getRange(i + 1, 2, 1, 2).setValues([['Active', new Date()]]); + found = true; + break; + } + } + if (!found) { + sheet.appendRow([bellId, 'Active', new Date()]); + } + try { + var settings = getSettingsMap_(); + if (String(settings.telegram_bell_enabled || '').toLowerCase() === 'true') { + sendTelegram_('BEL: ' + String(bellId || '')); + } + } catch (eNotify) {} + return { success: true }; +} + +function resetBell(bellId) { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('BellStatus'); + if (!sheet) return { success: false, error: 'Sheet BellStatus tidak ditemukan.' }; + var data = sheet.getDataRange().getValues(); + for (var i = 1; i < data.length; i++) { + if (data[i][0] === bellId) { + sheet.getRange(i + 1, 2).setValue('Inactive'); + break; + } + } + return { success: true }; +} + +function notifyWaiterTelegram(payload) { + try { + var settings = getSettingsMap_(); + if (String(settings.telegram_waiter_enabled || '').toLowerCase() !== 'true') return { success: true, sent: false, skipped: true }; + var meja = payload && payload.meja ? String(payload.meja) : ''; + var waiter = payload && payload.waiter ? String(payload.waiter) : ''; + var message = payload && payload.message ? String(payload.message) : ''; + var muted = /x123/i.test(String(payload && payload.nama ? payload.nama : '')); + if (muted) return { success: true, sent: false, skipped: true }; + var text = 'PANGGIL PELAYAN\nMeja: ' + meja + (waiter ? '\nPelayan: ' + waiter : '') + (message ? '\n\n' + message : ''); + var res = sendTelegram_(text); + return { success: true, sent: !!res.success, result: res }; + } catch (e) { + return { success: false, sent: false, error: String(e && e.message ? e.message : e) }; + } +} + +function getBellStatus(bellId) { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('BellStatus'); + if (!sheet) return { status: 'Inactive' }; + var data = sheet.getDataRange().getValues(); + for (var i = 1; i < data.length; i++) { + if (data[i][0] === bellId) { + return { status: data[i][1] }; + } + } + return { status: 'Inactive' }; +} + + +function saveMasterSupplier(payload) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Suppliers'); + if (!sheet) { setupSheets(); sheet = ss.getSheetByName('Suppliers'); } + var lastCol = sheet.getLastColumn(); + var now = new Date(); + + var data = sheet.getDataRange().getValues(); + var foundIdx = -1; + for (var i = 1; i < data.length; i++) { + if (payload && payload.id && String(data[i][0]) === String(payload.id)) { + foundIdx = i + 1; + break; + } + if (String(data[i][1]).toLowerCase() === String(payload.nama).toLowerCase()) { + foundIdx = i + 1; + break; + } + } + + var id = (payload && payload.id) ? String(payload.id) : ((foundIdx > -1 && data[foundIdx - 1] && data[foundIdx - 1][0]) ? String(data[foundIdx - 1][0]) : ('SUP-' + Date.now())); + var row = [id, payload.nama || '', payload.wa || '']; + if (lastCol >= 5) { + row.push(payload.rekening || ''); + row.push(now); + } else { + row.push(now); + } + + if (foundIdx > -1) { + sheet.getRange(foundIdx, 1, 1, row.length).setValues([row]); + } else { + sheet.appendRow(row); + } + + return getInitialData(); + } finally { lock.releaseLock(); } +} + +function deleteMasterSupplier(idOrName) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Suppliers'); + if (!sheet) return getInitialData(); + var data = sheet.getDataRange().getValues(); + var key = String(idOrName || '').trim(); + if (!key) return getInitialData(); + var keyLower = key.toLowerCase(); + for (var i = 1; i < data.length; i++) { + if (String(data[i][0]) === key) { + sheet.deleteRow(i + 1); + break; + } + } + if (sheet.getLastRow() > 1) { + data = sheet.getDataRange().getValues(); + for (var j = 1; j < data.length; j++) { + if (String(data[j][1] || '').trim().toLowerCase() === keyLower) { + sheet.deleteRow(j + 1); + break; + } + } + } + return getInitialData(); + } finally { lock.releaseLock(); } +} + +function saveWaiter(payload) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Waiters'); + if (!sheet) { setupSheets(); sheet = ss.getSheetByName('Waiters'); } + var now = new Date(); + + var data = sheet.getDataRange().getValues(); + var foundIdx = -1; + for (var i = 1; i < data.length; i++) { + if (String(data[i][0]).toLowerCase() === String(payload.nama).toLowerCase()) { + foundIdx = i + 1; + break; + } + } + + var row = [ + payload.nama || '', + payload.wa || '', + payload.status || 'Aktif', + now + ]; + + if (foundIdx > -1) { + sheet.getRange(foundIdx, 1, 1, row.length).setValues([row]); + } else { + sheet.appendRow(row); + } + + return getInitialData(); + } finally { lock.releaseLock(); } +} + +function deleteWaiter(nama) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Waiters'); + if (!sheet) return getInitialData(); + + var data = sheet.getDataRange().getValues(); + for (var i = 1; i < data.length; i++) { + if (String(data[i][0]).toLowerCase() === String(nama).toLowerCase()) { + sheet.deleteRow(i + 1); + break; + } + } + return getInitialData(); + } finally { lock.releaseLock(); } +} + +function deleteTransaction(id) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Transaksi'); + if (!sheet) return getInitialData(); + + var data = sheet.getDataRange().getValues(); + var foundIdx = -1; + for (var i = 1; i < data.length; i++) { + if (String(data[i][0]) === String(id)) { + foundIdx = i + 1; + var t = data[i]; + var wa = String(t[4] || ''); + var poinDapat = Number(t[24]) || 0; + var poinDipakaiRp = Number(t[10]) || 0; + var settings = getSettingsMap_(); + var redeemRp = parseNumberFlexible(settings.poin_redeem_rupiah); + if (!redeemRp || redeemRp < 1) redeemRp = 100; + var poinUsed = Math.floor(poinDipakaiRp / redeemRp); + + if (wa) { + var sheetCust = ss.getSheetByName('Pelanggan'); + if (sheetCust) { + var custRows = sheetCust.getDataRange().getValues(); + var cIdx = -1; + for (var j = 1; j < custRows.length; j++) { + if (String(custRows[j][1]) === wa) { cIdx = j + 1; break; } + } + if (cIdx > -1) { + var curP = Number(custRows[cIdx - 1][2]) || 0; + var newP = Math.max(0, curP - poinDapat + poinUsed); // Batal dapat, kembalikan yang dipakai + sheetCust.getRange(cIdx, 3).setValue(newP); + sheetCust.getRange(cIdx, 4).setValue(new Date()); + } + } + } + + sheet.deleteRow(foundIdx); + break; + } + } + return getInitialData(); + } finally { lock.releaseLock(); } +} + +function clearTransactionHistoryKeepLast() { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Transaksi'); + if (!sheet) return getInitialData(); + var lastRow = sheet.getLastRow(); + if (lastRow <= 2) return getInitialData(); // header + 1 row (atau kosong) + sheet.deleteRows(2, lastRow - 2); // sisakan header dan 1 transaksi terakhir + return getInitialData(); + } finally { lock.releaseLock(); } +} + +function resetAllStock() { + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheetM = ss.getSheetByName('Menu'); + var dataM = sheetM.getDataRange().getValues(); + var now = new Date(); + var nowStr = Utilities.formatDate(now, "GMT+7", "yyyy-MM-dd HH:mm:ss"); + + // Simpan timestamp reset ke properties + PropertiesService.getScriptProperties().setProperty('last_stock_reset', nowStr); + + // Log ke StokHarian sebagai pembukaan toko + var sheetH = ss.getSheetByName('StokHarian') || ss.insertSheet('StokHarian'); + var todayStr = Utilities.formatDate(now, "GMT+7", "yyyy-MM-dd"); + var logs = []; + for (var i = 1; i < dataM.length; i++) { + var nama = String(dataM[i][0]); + var sisa = Number(dataM[i][5]) || 0; + logs.push([todayStr, nama, sisa, 0, sisa, now]); + } + if (logs.length > 0) { + sheetH.getRange(sheetH.getLastRow() + 1, 1, logs.length, 6).setValues(logs); + } + + return getInitialData(); + } catch (e) { + return { error: e.message }; + } +} + +function saveFeedback(payload) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Feedback'); + if (!sheet) { setupSheets(); sheet = ss.getSheetByName('Feedback'); } + var now = new Date(); + + // Timestamp, Nama Pelanggan, WhatsApp, Rating, Komentar + var row = [ + now, + payload.nama || '', + payload.wa || '', + payload.rating || 0, + payload.komentar || '' + ]; + + sheet.appendRow(row); + return { success: true }; + } finally { lock.releaseLock(); } +} +function paksaIzinDrive() { + // Baris ini akan memaksa Google mendeteksi kebutuhan akses Drive + var folder = DriveApp.getRootFolder(); + console.log("Akses Drive Berhasil: " + folder.getName()); +} diff --git a/POSFuku/Dapur.html b/POSFuku/Dapur.html new file mode 100644 index 0000000..e473255 --- /dev/null +++ b/POSFuku/Dapur.html @@ -0,0 +1,486 @@ + + + + + + + + + + POS - DAPUR + + + + + +
+
🔔 AKTIFKAN SUARA NOTIFIKASI
+

Klik tombol di bawah agar HP bisa mengeluarkan bunyi pip saat ada pesanan masuk.

+ +
+ +
⌛ Syncing...
+ +
+
⚡ PRIORITAS: DRINKS, NASI & TOMYUM
+
+ +
🥘 MENU UTAMA / GRILL
+
+
+ + + + + + diff --git a/POSFuku/Index.html b/POSFuku/Index.html new file mode 100644 index 0000000..3f9c0e8 --- /dev/null +++ b/POSFuku/Index.html @@ -0,0 +1,7198 @@ + + + + + + + + + + + + Fuku POS + + + + + + + + + + + + + + +
+
+
Memuat Data Fuku...
+
+ + +
+
+ + +
+
🔔 AKTIFKAN SUARA NOTIFIKASI
+

Klik tombol di bawah agar sistem kasir bisa berbunyi saat dipanggil dari Dapur.

+ +
+ + + +
+
+
+
Pilih Nomor Meja
+
+
Meja QR (Pelanggan)
+
Meja 1–10 khusus pesanan dari scan QR pelanggan.
+
+
+
🔗 URL AKSES CEPAT
+
Memuat URL...
+
+
+
+
+
KUNCI INFO WIFI 🔒
+
Pelanggan wajib isi Nama & WA untuk melihat password
+
+
+ AKTIF + +
+
+
+
+
+
Daftar Pesanan Aktif
+
+
+
+ + +
+ +
+
+
Daftar Booking
+
+
+
+ +
+
+
Grafik Penjualan (7 Hari Terakhir)
+
+ +
+
+ +
+
Laporan Filter
+
+
Dari
+
Sampai
+
+
+ + + +
+
+ + + +
+
+
+
+
Omzet
+
Rp 0
+
+
+
Belanja
+
Rp 0
+
+
+
+
Metode Pembayaran
+
+
+
+
Produk Terlaris
+
+
+
+ +
+
+
+
+
+
Set Stok Awal
+ +
+
+ + + + + + + + + + +
MenuStok Awal
+
+
+ +
+
+
Daftar Stok Saat Ini
+
+ + +
+
+
+
+
+ +
+
+
Ringkasan Stok
+
+
+
+
+
+ +
+
+
+
+
+
Input Belanja Satuan
+
+ + + +
+
+ +
+ + +
+
+
Qty
+
Harga Satuan
+
+
+ Total (Otomatis) + +
+
+ +
+
+ +
+
Input Belanja (Bulk)
+ +
+
+ Kategori Bulk + +
+
+
+ Tips: tambah "Q" di depan/akhir nama untuk catat QRIS/Transfer. +
+
+
+
+ +
+
+ +
+
Riwayat Belanja Hari Ini
+
+
+
+ +
+
+
Kalkulator
+ +
= 0
+
+ + + + + +
+
+ +
+
Pencarian Riwayat (Global)
+ + + +
+
+
+
+ +
+
+
+
Kitchen Display System (Dapur)
+ +
+
+
+
+ +
+
+
Rekap Kasir Hari Ini
+
+
+ Saldo Awal (Kasir Buka) + +
+
+ +
+
+
+ +
+
+
Penjualan per Metode
+
+
+
+
Porsi Terjual
+
Potensi Profit (Estimasi): Rp 0
+
+
+
+ +
+
Catatan Rekap
+ +
+
+ + +
+
+
+
+ +
+
+
Data Pelanggan
+ +
+
+
+ +
+
+
+
Riwayat
+
+ + + +
+
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+
+
+
+
+
Database Master Supplier
+ +
+ + + + +
+
+ + +
+ +
+
+
Riwayat Pengiriman WA
+ +
+
+
+
+
+ +
+
+
Manajemen Database Pelayan
+
+ + +
+
+ +
+
+
+
Daftar Pelayan Aktif
+
+
+
+ +
+
+
+
Manajemen User Kasir
+
+ + +
+
+
+
+
+ +
+
+
+
+
Simulasi Paket Kustom
+
+ Nama Paket Simulation + +
+ +
+ Cari Menu + +
+ +
+ + +
+
+ +
+
+
Item Terpilih
+
+
Belum ada menu yang dipilih.
+
+
+
Total Harga AsliRp 0
+
+
+ + +
Kosongkan jika harga paket sama dengan total harga asli.
+
+ + +
+ +
+
Daftar Simulasi Tersimpan
+
+ +
+
+
+
+
+ + + +
+ + + + + + + + + + + + + + +
+ + + + + + diff --git a/POSFuku/Pelanggan.html b/POSFuku/Pelanggan.html new file mode 100644 index 0000000..c818b6f --- /dev/null +++ b/POSFuku/Pelanggan.html @@ -0,0 +1,1481 @@ + + + + + + + Menu Pelanggan - POS + + + + + +

Memuat Menu...

+ + +
+
+ 0 +
+
PESANAN
+
Rp 0
+
+
+ +
+ + + + + + + + + + + + +
+
SYNC
+
POS
+
+
MEJA
+
Follow & Review us on:
+
+ + + + +
+ + + - + +
+
+ +
+
+ +
+ + + + +
+ + + + + +
+
+ + + + diff --git a/POSFuku/Poin.html b/POSFuku/Poin.html new file mode 100644 index 0000000..7301763 --- /dev/null +++ b/POSFuku/Poin.html @@ -0,0 +1,312 @@ + + + + + + + + + + POS - Cek Poin & Riwayat + + + +

Mencari Data...

+ +
+
POS
+
Cek Poin & Riwayat Transaksi
+
+ +
+ +
+
Masuk ke Akun Anda
+
Nama atau nomor WA tidak ditemukan.
+ + +
Bisa input dengan 08... atau 62...
+
+ + + +
+ + + + diff --git a/POSFuku/Stok & Belanja/Code.gs b/POSFuku/Stok & Belanja/Code.gs new file mode 100644 index 0000000..a6aac09 --- /dev/null +++ b/POSFuku/Stok & Belanja/Code.gs @@ -0,0 +1,1192 @@ +function getSS_() { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + if (ss) return ss; + var id = PropertiesService.getScriptProperties().getProperty('spreadsheet_id'); + if (id) return SpreadsheetApp.openById(id); + throw new Error('Spreadsheet aktif tidak ditemukan.'); +} + +function getDebugInfo() { + try { + var props = PropertiesService.getScriptProperties(); + var id = props.getProperty('spreadsheet_id') || ''; + var active = SpreadsheetApp.getActiveSpreadsheet(); + var activeId = active ? active.getId() : ''; + var openOk = false; + var ss = null; + var title = ''; + var url = ''; + var sheets = []; + try { + ss = getSS_(); + openOk = true; + title = ss.getName(); + url = ss.getUrl(); + sheets = ss.getSheets().map(function(s) { return s.getName(); }); + } catch (e) { + openOk = false; + } + return JSON.stringify({ + spreadsheet_id: id, + activeSpreadsheetId: activeId, + openOk: openOk, + spreadsheetTitle: title, + spreadsheetUrl: url, + sheets: sheets + }, null, 2); + } catch (e2) { + return JSON.stringify({ ok: false, error: (e2 && e2.message) ? e2.message : String(e2) }); + } +} + +function normalizeDateStr_(dateStr) { + var today = Utilities.formatDate(new Date(), 'GMT+7', 'yyyy-MM-dd'); + if (!dateStr) return today; + var s = String(dateStr).trim(); + return /^\d{4}-\d{2}-\d{2}$/.test(s) ? s : today; +} + +function findRowBlocksByDate_(sheet, colIndex, targetDateStr, maxScanRows) { + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return []; + var limit = Number(maxScanRows) || 10000; + if (limit < 1) limit = 10000; + var startRow = Math.max(2, lastRow - limit + 1); + var range = sheet.getRange(startRow, colIndex, lastRow - startRow + 1, 1); + var finder = range.createTextFinder(String(targetDateStr)).matchEntireCell(true); + var cells = finder.findAll(); + if (!cells || !cells.length) return []; + var rows = cells.map(function(c) { return c.getRow(); }).sort(function(a, b) { return a - b; }); + var blocks = []; + var start = rows[0]; + var prev = rows[0]; + for (var i = 1; i < rows.length; i++) { + var r = rows[i]; + if (r === prev + 1) { + prev = r; + continue; + } + blocks.push({ start: start, len: prev - start + 1 }); + start = r; + prev = r; + } + blocks.push({ start: start, len: prev - start + 1 }); + return blocks; +} + +function readRowBlocks_(sheet, blocks, colCount) { + var out = []; + if (!blocks || !blocks.length) return out; + for (var i = 0; i < blocks.length; i++) { + var b = blocks[i]; + var vals = sheet.getRange(b.start, 1, b.len, colCount).getValues(); + for (var j = 0; j < vals.length; j++) out.push(vals[j]); + } + return out; +} + +function doGet() { + setupSheets(); + return HtmlService.createTemplateFromFile('Index') + .evaluate() + .setTitle('Fuku - Stok & Belanja') + .addMetaTag('viewport', 'width=device-width, initial-scale=1') + .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); +} + +function setupSheets() { + var ss = getSS_(); + var sheets = { + 'StokHarian': ['Tanggal', 'Menu', 'Stok Awal', 'Re-stock', 'Terpakai', 'Sisa', 'Lokasi', 'User', 'Foto', 'Timestamp'], + 'Belanja': ['ID', 'Nama', 'Harga', 'Qty', 'Total', 'Tanggal', 'Kategori', 'Catatan', 'User', 'Timestamp'], + 'Menu': ['Nama', 'Harga', 'Kategori', 'Lokasi', 'Min Stok', 'Status'], + 'PegawaiList': ['ID', 'Nama', 'Status', 'Catatan', 'User', 'Timestamp'], + 'Pegawai': ['ID', 'Tanggal', 'Nama', 'Nilai', 'Kategori', 'Catatan', 'User', 'Timestamp'], + 'Operasional': ['ID', 'Tanggal', 'Nama', 'Harga', 'Qty', 'Total', 'Catatan', 'User', 'Timestamp'] + }; + + for (var name in sheets) { + if (!ss.getSheetByName(name)) { + var sheet = ss.insertSheet(name); + sheet.appendRow(sheets[name]); + sheet.getRange(1, 1, 1, sheets[name].length).setFontWeight('bold').setBackground('#f3f3f3'); + + if (name === 'Menu') { + var defaultItems = [ + // Lantai Atas + ['Beef', 0, 'Stok', 'Lantai Atas', 5, 'Tersedia'], + ['Saikoro', 0, 'Stok', 'Lantai Atas', 5, 'Tersedia'], + ['Chikuwa', 0, 'Stok', 'Lantai Atas', 10, 'Tersedia'], + // ... (item lainnya disingkat untuk efisiensi, Anda bisa mengisinya manual di sheet) + ]; + sheet.getRange(2, 1, defaultItems.length, 6).setValues(defaultItems); + } + } else { + // Update headers if needed (for existing sheets) + var sheet = ss.getSheetByName(name); + var currentHeaders = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]; + if (currentHeaders.length < sheets[name].length) { + sheet.getRange(1, 1, 1, sheets[name].length).setValues([sheets[name]]).setFontWeight('bold').setBackground('#f3f3f3'); + } + } + } +} + +function getInitialData(dateStr) { + var d = normalizeDateStr_(dateStr); + try { + return { + tanggal: d, + stok: getStokData(d, false), + belanja: getBelanjaData(d), + menu: getMenuListExtended(), + pegawaiList: getPegawaiList(), + pegawai: getPegawaiData(d), + operasional: getOperasionalData(d), + ok: true + }; + } catch (e) { + return { + tanggal: d, + stok: [], + belanja: [], + menu: [], + pegawaiList: [], + pegawai: [], + operasional: [], + ok: false, + error: e && e.message ? e.message : String(e) + }; + } +} + +function getPegawaiList() { + var ss = getSS_(); + var sheet = ss.getSheetByName('PegawaiList'); + if (!sheet) return []; + var data = sheet.getDataRange().getValues(); + return data.slice(1).filter(function(r) { return r && r[1]; }).map(function(r) { + return { + id: String(r[0] || ''), + nama: String(r[1] || ''), + status: String(r[2] || 'Aktif'), + catatan: String(r[3] || '') + }; + }); +} + +function savePegawaiList(rows, userName) { + var ss = getSS_(); + var sheet = ss.getSheetByName('PegawaiList'); + if (!sheet) throw new Error('Sheet PegawaiList tidak ditemukan.'); + var now = new Date(); + var lastRow = sheet.getLastRow(); + if (lastRow > 1) sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn()).clearContent(); + var out = []; + var seen = {}; + (rows || []).forEach(function(r) { + var nama = String((r && r.nama) || '').trim(); + if (!nama) return; + var key = nama.toLowerCase(); + if (seen[key]) return; + seen[key] = true; + var id = (r && r.id) ? String(r.id) : ('E-' + Date.now() + '-' + Math.floor(Math.random() * 1000)); + var status = String((r && r.status) || 'Aktif'); + var catatan = String((r && r.catatan) || ''); + out.push([id, nama, status, catatan, userName || 'Unknown', now]); + }); + if (out.length) sheet.getRange(2, 1, out.length, 6).setValues(out); + return getPegawaiList(); +} + +function getPegawaiData(dateStr) { + var ss = getSS_(); + var sheet = ss.getSheetByName('Pegawai'); + if (!sheet) return []; + var targetDate = normalizeDateStr_(dateStr); + var rows = getRowsByDateExact_(sheet, 2, 8, targetDate, 30000); + if (!rows.length) return []; + return rows.map(function(r) { + return { id: r[0], tanggal: r[1], nama: r[2], nilai: r[3], kategori: r[4], catatan: r[5] }; + }); +} + +function normalizeSheetDateValue_(v) { + if (!v) return ''; + if (v instanceof Date) return Utilities.formatDate(v, 'GMT+7', 'yyyy-MM-dd'); + var s = String(v).trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; + var m = s.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/); + if (m) { + var d = Number(m[1]) || 0; + var mo = Number(m[2]) || 0; + var y = Number(m[3]) || 0; + if (d >= 1 && d <= 31 && mo >= 1 && mo <= 12 && y >= 1900) { + return y + '-' + String(mo).padStart(2, '0') + '-' + String(d).padStart(2, '0'); + } + } + var parsed = new Date(s); + if (!isNaN(parsed.getTime())) return Utilities.formatDate(parsed, 'GMT+7', 'yyyy-MM-dd'); + return ''; +} + +function getRowsByDateExact_(sheet, dateColIndex, colCount, targetDate, maxScanRows) { + if (!sheet) return []; + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return []; + var limit = Number(maxScanRows) || 30000; + if (limit < 1) limit = 30000; + var startRow = Math.max(2, lastRow - limit + 1); + var rowCount = lastRow - startRow + 1; + if (rowCount <= 0) return []; + var values = sheet.getRange(startRow, 1, rowCount, colCount).getValues(); + var out = []; + for (var i = 0; i < values.length; i++) { + var row = values[i]; + var tgl = normalizeSheetDateValue_(row[dateColIndex - 1]); + if (tgl === targetDate) out.push(row); + } + return out; +} + +function normalizeDateRange_(fromStr, toStr) { + var from = normalizeDateStr_(fromStr); + var to = normalizeDateStr_(toStr); + if (from > to) { + var tmp = from; + from = to; + to = tmp; + } + return { from: from, to: to }; +} + +function getPegawaiRekapPeriode(fromStr, toStr) { + var r = normalizeDateRange_(fromStr, toStr); + var ss = getSS_(); + var sheet = ss.getSheetByName('Pegawai'); + if (!sheet) return { from: r.from, to: r.to, totals: { gaji: 0, kasbon: 0, total: 0, count: 0 }, perPegawai: [] }; + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return { from: r.from, to: r.to, totals: { gaji: 0, kasbon: 0, total: 0, count: 0 }, perPegawai: [] }; + var data = sheet.getRange(2, 1, lastRow - 1, 8).getValues(); + var map = {}; + var totals = { gaji: 0, kasbon: 0, total: 0, count: 0 }; + for (var i = 0; i < data.length; i++) { + var row = data[i]; + var tgl = normalizeSheetDateValue_(row[1]); + if (!tgl || tgl < r.from || tgl > r.to) continue; + var nama = String(row[2] || '').trim(); + if (!nama) continue; + var nilai = Number(row[3]) || 0; + var kategori = String(row[4] || '').trim(); + if (!map[nama]) map[nama] = { nama: nama, gaji: 0, kasbon: 0, total: 0, count: 0 }; + if (kategori === 'Kasbon') { + map[nama].kasbon += nilai; + totals.kasbon += nilai; + } else { + map[nama].gaji += nilai; + totals.gaji += nilai; + } + map[nama].total += nilai; + map[nama].count += 1; + totals.total += nilai; + totals.count += 1; + } + var perPegawai = Object.keys(map).map(function(k) { return map[k]; }) + .sort(function(a, b) { return (b.total || 0) - (a.total || 0); }); + return { from: r.from, to: r.to, totals: totals, perPegawai: perPegawai }; +} + +function getOperasionalData(dateStr) { + var ss = getSS_(); + var sheet = ss.getSheetByName('Operasional'); + if (!sheet) return []; + var targetDate = normalizeDateStr_(dateStr); + var colCount = Math.max(7, sheet.getLastColumn()); + var rows = getRowsByDateExact_(sheet, 2, colCount, targetDate, 30000); + if (!rows.length) return []; + return rows.map(function(r) { + var harga = Number(r[3]) || 0; + var qty = Number(r[4]) || 0; + var total = Number(r[5]) || 0; + var catatan = String(r[6] || ''); + if (!total && (Number(r[3]) || 0) > 0 && (typeof r[4] === 'string' || r[4] == null)) { + total = Number(r[3]) || 0; + catatan = String(r[4] || ''); + harga = 0; + qty = 0; + } + return { id: r[0], tanggal: r[1], nama: r[2], harga: harga, qty: qty, total: total, catatan: catatan }; + }); +} + +function getOperasionalRekapPeriode(fromStr, toStr) { + var r = normalizeDateRange_(fromStr, toStr); + var ss = getSS_(); + var sheet = ss.getSheetByName('Operasional'); + if (!sheet) return { from: r.from, to: r.to, total: 0, count: 0, perItem: [] }; + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return { from: r.from, to: r.to, total: 0, count: 0, perItem: [] }; + var colCount = Math.max(7, sheet.getLastColumn()); + var data = sheet.getRange(2, 1, lastRow - 1, colCount).getValues(); + var map = {}; + var total = 0; + var count = 0; + for (var i = 0; i < data.length; i++) { + var row = data[i]; + var tgl = normalizeSheetDateValue_(row[1]); + if (!tgl || tgl < r.from || tgl > r.to) continue; + var nama = String(row[2] || '').trim(); + if (!nama) continue; + var harga = Number(row[3]) || 0; + var qty = Number(row[4]) || 0; + var nilai = Number(row[5]) || 0; + if (!nilai && (Number(row[3]) || 0) > 0 && (typeof row[4] === 'string' || row[4] == null)) { + nilai = Number(row[3]) || 0; + harga = 0; + qty = 0; + } + if (!map[nama]) map[nama] = { nama: nama, harga: 0, qty: 0, total: 0, count: 0 }; + map[nama].qty += qty; + map[nama].total += nilai; + map[nama].count += 1; + total += nilai; + count += 1; + } + Object.keys(map).forEach(function(k) { + var it = map[k]; + var q = Number(it.qty) || 0; + it.harga = q > 0 ? Math.round((Number(it.total) || 0) / q) : 0; + }); + var perItem = Object.keys(map).map(function(k) { return map[k]; }) + .sort(function(a, b) { return (b.total || 0) - (a.total || 0); }); + return { from: r.from, to: r.to, total: total, count: count, perItem: perItem }; +} + +function getMenuListExtended() { + var ss = getSS_(); + var sheet = ss.getSheetByName('Menu'); + if (!sheet) return []; + var data = sheet.getDataRange().getValues(); + return data.slice(1).map(function(r) { + return { + nama: String(r[0]), + lokasi: String(r[3] || 'Lantai Atas'), + minStok: Number(r[4]) || 0 + }; + }); +} + +function getStokData(dateStr, includeFoto) { + var ss = getSS_(); + var sheet = ss.getSheetByName('StokHarian'); + if (!sheet) return []; + var targetDate = normalizeDateStr_(dateStr); + var rows = getRowsByDateExact_(sheet, 1, 10, targetDate, 40000); + if (!rows.length) return []; + var inc = !!includeFoto; + return rows.map(function(r) { + return { + tanggal: r[0] instanceof Date ? Utilities.formatDate(r[0], "GMT+7", "yyyy-MM-dd") : String(r[0]), + menu: String(r[1]), + stokAwal: Number(r[2]) || 0, + restock: Number(r[3]) || 0, + terpakai: Number(r[4]) || 0, + sisa: Number(r[5]) || 0, + lokasi: String(r[6] || ''), + foto: inc ? String(r[8] || '') : '' + }; + }); +} + +function getStokFotoMap(dateStr, lokasi) { + var ss = getSS_(); + var sheet = ss.getSheetByName('StokHarian'); + if (!sheet) return {}; + var targetDate = normalizeDateStr_(dateStr); + var loc = String(lokasi || '').trim(); + if (!loc) return {}; + var blocks = findRowBlocksByDate_(sheet, 1, targetDate, 30000); + if (!blocks.length) return {}; + var rows = readRowBlocks_(sheet, blocks, 10); + var out = {}; + for (var i = 0; i < rows.length; i++) { + var r = rows[i]; + var rowLoc = String(r[6] || '').trim(); + if (rowLoc !== loc) continue; + var menu = String(r[1] || '').trim(); + if (!menu) continue; + out[menu] = String(r[8] || ''); + } + return out; +} + +function getBelanjaData(dateStr) { + var ss = getSS_(); + var sheet = ss.getSheetByName('Belanja'); + if (!sheet) return []; + var targetDate = normalizeDateStr_(dateStr); + var rows = getRowsByDateExact_(sheet, 6, 10, targetDate, 40000); + if (!rows.length) return []; + return rows.map(function(r) { + return { + id: String(r[0]), + nama: String(r[1]), + harga: Number(r[2]) || 0, + qty: Number(r[3]) || 0, + total: Number(r[4]) || 0, + tanggal: r[5] instanceof Date ? Utilities.formatDate(r[5], "GMT+7", "yyyy-MM-dd") : String(r[5]), + kategori: String(r[6]), + catatan: String(r[7]) + }; + }); +} + +function getLastBelanjaHargaMap(kategori, itemNames) { + var ss = getSS_(); + var sheet = ss.getSheetByName('Belanja'); + if (!sheet) return {}; + var kat = String(kategori || '').trim().toUpperCase(); + var names = (itemNames || []).map(function(x) { return String(x || '').trim(); }).filter(function(x) { return x; }); + var wanted = {}; + names.forEach(function(n) { wanted[n.toLowerCase()] = n; }); + var needCount = Object.keys(wanted).length; + if (!needCount) return {}; + + var out = {}; + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return out; + var data = sheet.getRange(2, 1, lastRow - 1, 10).getValues(); + for (var i = data.length - 1; i >= 0; i--) { + var r = data[i]; + var name = String(r[1] || '').trim(); + if (!name) continue; + var key = name.toLowerCase(); + if (!wanted[key]) continue; + var rowKat = String(r[6] || '').trim().toUpperCase(); + if (kat && rowKat !== kat) continue; + if (out[wanted[key]] != null) continue; + out[wanted[key]] = Number(r[2]) || 0; + needCount--; + if (needCount <= 0) break; + } + return out; +} + +function saveBulkStok(rows, userName) { + var ss = getSS_(); + var sheet = ss.getSheetByName('StokHarian'); + var now = new Date(); + var today = Utilities.formatDate(now, "GMT+7", "yyyy-MM-dd"); + var targetDate = today; + if (arguments && arguments.length >= 3 && arguments[2]) targetDate = String(arguments[2]); + if (!rows || !rows.length) return getStokData(targetDate, false); + + var data = sheet.getDataRange().getValues(); + for (var i = data.length - 1; i >= 1; i--) { + var tgl = data[i][0] instanceof Date ? Utilities.formatDate(data[i][0], "GMT+7", "yyyy-MM-dd") : String(data[i][0]); + var loc = String(data[i][6] || ''); + // Hapus data lama untuk lokasi yang sama di hari yang sama agar tidak duplikat saat simpan ulang per tab + if (tgl === targetDate && loc === rows[0].lokasi) { + sheet.deleteRow(i + 1); + } + } + + rows.forEach(function(r) { + if (Number(r.stokAwal) > 0 || Number(r.restock) > 0 || Number(r.terpakai) > 0 || r.foto) { + sheet.appendRow([ + targetDate, + r.menu, + Number(r.stokAwal) || 0, + Number(r.restock) || 0, + Number(r.terpakai) || 0, + Number(r.sisa) || 0, + r.lokasi || '', + userName || 'Unknown', + r.foto || '', + now + ]); + } + }); + return getStokData(targetDate, false); +} + +function saveBulkBelanja(rows, userName) { + var ss = getSS_(); + var sheet = ss.getSheetByName('Belanja'); + var now = new Date(); + var today = Utilities.formatDate(now, "GMT+7", "yyyy-MM-dd"); + var targetDate = today; + if (arguments && arguments.length >= 3 && arguments[2]) targetDate = String(arguments[2]); + if (!rows || !rows.length) return getBelanjaData(targetDate); + + var kategori = String((rows[0] && rows[0].kategori) || '').trim(); + if (!kategori) kategori = 'Bahan Baku'; + + var byNamaKey = {}; + (rows || []).forEach(function(r) { + var nama = String((r && r.nama) || '').trim(); + if (!nama) return; + var total = Number((r && r.total)) || 0; + if (total <= 0) return; + var key = nama.toLowerCase(); + byNamaKey[key] = { + id: String((r && r.id) || ''), + nama: nama, + harga: Number((r && r.harga)) || 0, + qty: Number((r && r.qty)) || 0, + total: total, + kategori: String((r && r.kategori) || kategori), + catatan: String((r && r.catatan) || '') + }; + }); + + var clean = Object.keys(byNamaKey).map(function(k) { return byNamaKey[k]; }); + if (!clean.length) return getBelanjaData(targetDate); + + var nameSet = {}; + clean.forEach(function(r) { nameSet[String(r.nama).toLowerCase()] = true; }); + + var data = sheet.getDataRange().getValues(); + for (var i = data.length - 1; i >= 1; i--) { + var tgl = data[i][5] instanceof Date ? Utilities.formatDate(data[i][5], "GMT+7", "yyyy-MM-dd") : String(data[i][5]); + var cat = String(data[i][6] || ''); + if (tgl !== targetDate || cat !== kategori) continue; + var nm = String(data[i][1] || '').trim(); + if (nm && nameSet[nm.toLowerCase()]) sheet.deleteRow(i + 1); + } + + clean.forEach(function(r) { + var id = r.id || 'B-' + Date.now() + Math.floor(Math.random() * 1000); + sheet.appendRow([ + id, + r.nama, + Number(r.harga) || 0, + Number(r.qty) || 0, + Number(r.total) || 0, + targetDate, + r.kategori || kategori, + r.catatan || '', + userName || 'Unknown', + now + ]); + }); + return getBelanjaData(targetDate); +} + +function deleteBelanjaItem(dateStr, kategori, nama) { + var ss = getSS_(); + var sheet = ss.getSheetByName('Belanja'); + if (!sheet) throw new Error('Sheet Belanja tidak ditemukan.'); + var targetDate = normalizeDateStr_(dateStr); + var cat = String(kategori || '').trim(); + var nm = String(nama || '').trim(); + if (!cat || !nm) return true; + var data = sheet.getDataRange().getValues(); + for (var i = data.length - 1; i >= 1; i--) { + var tgl = data[i][5] instanceof Date ? Utilities.formatDate(data[i][5], "GMT+7", "yyyy-MM-dd") : String(data[i][5]); + var c = String(data[i][6] || '').trim(); + var n = String(data[i][1] || '').trim(); + if (tgl === targetDate && c === cat && n === nm) sheet.deleteRow(i + 1); + } + return true; +} + +function deleteBelanjaById(id) { + var ss = getSS_(); + var sheet = ss.getSheetByName('Belanja'); + if (!sheet) throw new Error('Sheet Belanja tidak ditemukan.'); + var targetId = String(id || '').trim(); + if (!targetId) return true; + var data = sheet.getDataRange().getValues(); + for (var i = data.length - 1; i >= 1; i--) { + var rid = String(data[i][0] || '').trim(); + if (rid === targetId) sheet.deleteRow(i + 1); + } + return true; +} + +function updateBelanjaItem(dateStr, kategori, nama, harga, qty, total, catatan, userName) { + var ss = getSS_(); + var sheet = ss.getSheetByName('Belanja'); + if (!sheet) throw new Error('Sheet Belanja tidak ditemukan.'); + var targetDate = normalizeDateStr_(dateStr); + var cat = String(kategori || '').trim(); + var nm = String(nama || '').trim(); + if (!cat || !nm) return true; + var now = new Date(); + var data = sheet.getDataRange().getValues(); + for (var i = data.length - 1; i >= 1; i--) { + var tgl = data[i][5] instanceof Date ? Utilities.formatDate(data[i][5], "GMT+7", "yyyy-MM-dd") : String(data[i][5]); + var c = String(data[i][6] || '').trim(); + var n = String(data[i][1] || '').trim(); + if (tgl !== targetDate || c !== cat || n !== nm) continue; + sheet.getRange(i + 1, 3).setValue(Number(harga) || 0); + sheet.getRange(i + 1, 4).setValue(Number(qty) || 0); + sheet.getRange(i + 1, 5).setValue(Number(total) || 0); + sheet.getRange(i + 1, 8).setValue(String(catatan || '')); + sheet.getRange(i + 1, 9).setValue(userName || 'Unknown'); + sheet.getRange(i + 1, 10).setValue(now); + } + return true; +} + +function updateBelanjaById(id, fields, userName) { + var ss = getSS_(); + var sheet = ss.getSheetByName('Belanja'); + if (!sheet) throw new Error('Sheet Belanja tidak ditemukan.'); + var targetId = String(id || '').trim(); + if (!targetId) return false; + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return false; + var finderRange = sheet.getRange(2, 1, lastRow - 1, 1); + var cell = finderRange.createTextFinder(targetId).matchEntireCell(true).findNext(); + if (!cell) return false; + var row = cell.getRow(); + var current = sheet.getRange(row, 1, 1, 10).getValues()[0]; + var now = new Date(); + var f = fields || {}; + var newHarga = (f.harga != null) ? (Number(f.harga) || 0) : (Number(current[2]) || 0); + var newQty = (f.qty != null) ? (Number(f.qty) || 0) : (Number(current[3]) || 0); + var newTotal = (f.total != null) ? (Number(f.total) || 0) : (Number(current[4]) || 0); + var newCatatan = (f.catatan != null) ? String(f.catatan || '') : String(current[7] || ''); + sheet.getRange(row, 3).setValue(newHarga); + sheet.getRange(row, 4).setValue(newQty); + sheet.getRange(row, 5).setValue(newTotal); + sheet.getRange(row, 8).setValue(newCatatan); + sheet.getRange(row, 9).setValue(userName || 'Unknown'); + sheet.getRange(row, 10).setValue(now); + return true; +} + +function bulkUpdateBelanjaById(updates, userName) { + if (!updates || !updates.length) return { ok: true, updated: 0 }; + var ss = getSS_(); + var sheet = ss.getSheetByName('Belanja'); + if (!sheet) throw new Error('Sheet Belanja tidak ditemukan.'); + var map = {}; + for (var i = 0; i < updates.length; i++) { + var u = updates[i] || {}; + var id = String(u.id || '').trim(); + if (!id) continue; + map[id] = { + harga: (u.harga != null) ? (Number(u.harga) || 0) : null, + qty: (u.qty != null) ? (Number(u.qty) || 0) : null, + total: (u.total != null) ? (Number(u.total) || 0) : null, + catatan: (u.catatan != null) ? String(u.catatan || '') : null + }; + } + var ids = Object.keys(map); + if (!ids.length) return { ok: true, updated: 0 }; + var now = new Date(); + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return { ok: true, updated: 0 }; + var data = sheet.getRange(2, 1, lastRow - 1, 10).getValues(); + var updated = 0; + for (var r = 0; r < data.length; r++) { + var rowId = String(data[r][0] || '').trim(); + if (!rowId || !map[rowId]) continue; + var f = map[rowId]; + var rowNum = r + 2; + if (f.harga != null) sheet.getRange(rowNum, 3).setValue(f.harga); + if (f.qty != null) sheet.getRange(rowNum, 4).setValue(f.qty); + if (f.total != null) sheet.getRange(rowNum, 5).setValue(f.total); + if (f.catatan != null) sheet.getRange(rowNum, 8).setValue(f.catatan); + sheet.getRange(rowNum, 9).setValue(userName || 'Unknown'); + sheet.getRange(rowNum, 10).setValue(now); + updated += 1; + } + return { ok: true, updated: updated }; +} + +function deleteStokItem(dateStr, lokasi, menu) { + var ss = getSS_(); + var sheet = ss.getSheetByName('StokHarian'); + if (!sheet) throw new Error('Sheet StokHarian tidak ditemukan.'); + var targetDate = normalizeDateStr_(dateStr); + var loc = String(lokasi || '').trim(); + var mn = String(menu || '').trim(); + if (!loc || !mn) return true; + var data = sheet.getDataRange().getValues(); + for (var i = data.length - 1; i >= 1; i--) { + var tgl = data[i][0] instanceof Date ? Utilities.formatDate(data[i][0], "GMT+7", "yyyy-MM-dd") : String(data[i][0]); + var m = String(data[i][1] || '').trim(); + var l = String(data[i][6] || '').trim(); + if (tgl === targetDate && l === loc && m === mn) sheet.deleteRow(i + 1); + } + return true; +} + +function updateStokItem(dateStr, lokasi, menu, stokAwal, restock, terpakai, userName) { + var ss = getSS_(); + var sheet = ss.getSheetByName('StokHarian'); + if (!sheet) throw new Error('Sheet StokHarian tidak ditemukan.'); + var targetDate = normalizeDateStr_(dateStr); + var loc = String(lokasi || '').trim(); + var mn = String(menu || '').trim(); + if (!loc || !mn) return true; + var now = new Date(); + var a = Number(stokAwal) || 0; + var r = Number(restock) || 0; + var p = Number(terpakai) || 0; + var sisa = Math.max(0, (a + r) - p); + var data = sheet.getDataRange().getValues(); + for (var i = data.length - 1; i >= 1; i--) { + var tgl = data[i][0] instanceof Date ? Utilities.formatDate(data[i][0], "GMT+7", "yyyy-MM-dd") : String(data[i][0]); + var m = String(data[i][1] || '').trim(); + var l = String(data[i][6] || '').trim(); + if (tgl !== targetDate || l !== loc || m !== mn) continue; + sheet.getRange(i + 1, 3).setValue(a); + sheet.getRange(i + 1, 4).setValue(r); + sheet.getRange(i + 1, 5).setValue(p); + sheet.getRange(i + 1, 6).setValue(sisa); + sheet.getRange(i + 1, 8).setValue(userName || 'Unknown'); + sheet.getRange(i + 1, 10).setValue(now); + } + return true; +} + +function bulkUpdateStokItems(updates, userName) { + if (!updates || !updates.length) return { ok: true, updated: 0 }; + var ss = getSS_(); + var sheet = ss.getSheetByName('StokHarian'); + if (!sheet) throw new Error('Sheet StokHarian tidak ditemukan.'); + var map = {}; + for (var i = 0; i < updates.length; i++) { + var u = updates[i] || {}; + var t = normalizeDateStr_(u.tanggal); + var loc = String(u.lokasi || '').trim(); + var menu = String(u.menu || '').trim(); + if (!t || !loc || !menu) continue; + var a = Number(u.stokAwal) || 0; + var r = Number(u.restock) || 0; + var p = Number(u.terpakai) || 0; + map[t + '|' + loc + '|' + menu] = { tanggal: t, lokasi: loc, menu: menu, stokAwal: a, restock: r, terpakai: p }; + } + var keys = Object.keys(map); + if (!keys.length) return { ok: true, updated: 0 }; + var now = new Date(); + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return { ok: true, updated: 0 }; + var data = sheet.getRange(2, 1, lastRow - 1, 10).getValues(); + var updated = 0; + for (var r0 = 0; r0 < data.length; r0++) { + var row = data[r0]; + var tgl = normalizeSheetDateValue_(row[0]); + var m = String(row[1] || '').trim(); + var l = String(row[6] || '').trim(); + if (!tgl || !m || !l) continue; + var key = tgl + '|' + l + '|' + m; + var u2 = map[key]; + if (!u2) continue; + var rowNum = r0 + 2; + var sisa = Math.max(0, (u2.stokAwal + u2.restock) - u2.terpakai); + sheet.getRange(rowNum, 3).setValue(u2.stokAwal); + sheet.getRange(rowNum, 4).setValue(u2.restock); + sheet.getRange(rowNum, 5).setValue(u2.terpakai); + sheet.getRange(rowNum, 6).setValue(sisa); + sheet.getRange(rowNum, 8).setValue(userName || 'Unknown'); + sheet.getRange(rowNum, 10).setValue(now); + updated += 1; + } + return { ok: true, updated: updated }; +} + +function getBelanjaDataRange(fromStr, toStr) { + var r = normalizeDateRange_(fromStr, toStr); + var ss = getSS_(); + var sheet = ss.getSheetByName('Belanja'); + if (!sheet) return []; + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return []; + var data = sheet.getRange(2, 1, lastRow - 1, 10).getValues(); + var out = []; + for (var i = 0; i < data.length; i++) { + var row = data[i]; + var tgl = normalizeSheetDateValue_(row[5]); + if (!tgl || tgl < r.from || tgl > r.to) continue; + out.push({ + id: String(row[0] || ''), + nama: String(row[1] || ''), + harga: Number(row[2]) || 0, + qty: Number(row[3]) || 0, + total: Number(row[4]) || 0, + tanggal: tgl, + kategori: String(row[6] || ''), + catatan: String(row[7] || '') + }); + } + return out; +} + +function getStokDataRange(fromStr, toStr) { + var r = normalizeDateRange_(fromStr, toStr); + var ss = getSS_(); + var sheet = ss.getSheetByName('StokHarian'); + if (!sheet) return []; + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return []; + var data = sheet.getRange(2, 1, lastRow - 1, 10).getValues(); + var out = []; + for (var i = 0; i < data.length; i++) { + var row = data[i]; + var tgl = normalizeSheetDateValue_(row[0]); + if (!tgl || tgl < r.from || tgl > r.to) continue; + out.push({ + tanggal: tgl, + menu: String(row[1] || ''), + stokAwal: Number(row[2]) || 0, + restock: Number(row[3]) || 0, + terpakai: Number(row[4]) || 0, + sisa: Number(row[5]) || 0, + lokasi: String(row[6] || '') + }); + } + return out; +} + +function getOperasionalDataRange(fromStr, toStr) { + var r = normalizeDateRange_(fromStr, toStr); + var ss = getSS_(); + var sheet = ss.getSheetByName('Operasional'); + if (!sheet) return []; + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return []; + var colCount = Math.max(7, sheet.getLastColumn()); + var data = sheet.getRange(2, 1, lastRow - 1, colCount).getValues(); + var out = []; + for (var i = 0; i < data.length; i++) { + var row = data[i]; + var tgl = normalizeSheetDateValue_(row[1]); + if (!tgl || tgl < r.from || tgl > r.to) continue; + var harga = Number(row[3]) || 0; + var qty = Number(row[4]) || 0; + var total = Number(row[5]) || 0; + var catatan = String(row[6] || ''); + if (!total && (Number(row[3]) || 0) > 0 && (typeof row[4] === 'string' || row[4] == null)) { + total = Number(row[3]) || 0; + catatan = String(row[4] || ''); + harga = 0; + qty = 0; + } + out.push({ id: String(row[0] || ''), tanggal: tgl, nama: String(row[2] || ''), harga: harga, qty: qty, total: total, catatan: catatan }); + } + return out; +} + +function getPegawaiDataRange(fromStr, toStr) { + var r = normalizeDateRange_(fromStr, toStr); + var ss = getSS_(); + var sheet = ss.getSheetByName('Pegawai'); + if (!sheet) return []; + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return []; + var data = sheet.getRange(2, 1, lastRow - 1, 8).getValues(); + var out = []; + for (var i = 0; i < data.length; i++) { + var row = data[i]; + var tgl = normalizeSheetDateValue_(row[1]); + if (!tgl || tgl < r.from || tgl > r.to) continue; + out.push({ id: String(row[0] || ''), tanggal: tgl, nama: String(row[2] || ''), nilai: Number(row[3]) || 0, kategori: String(row[4] || ''), catatan: String(row[5] || '') }); + } + return out; +} + +function getRingkasanPeriode(fromStr, toStr) { + var r = normalizeDateRange_(fromStr, toStr); + return { + from: r.from, + to: r.to, + menu: getMenuListExtended(), + pegawaiList: getPegawaiList(), + stok: getStokDataRange(r.from, r.to), + belanja: getBelanjaDataRange(r.from, r.to), + operasional: getOperasionalDataRange(r.from, r.to), + pegawai: getPegawaiDataRange(r.from, r.to), + ok: true + }; +} + +function saveBulkPegawai(rows, userName) { + var ss = getSS_(); + var sheet = ss.getSheetByName('Pegawai'); + var now = new Date(); + var today = Utilities.formatDate(now, "GMT+7", "yyyy-MM-dd"); + var targetDate = today; + if (arguments && arguments.length >= 3 && arguments[2]) targetDate = String(arguments[2]); + if (!rows || !rows.length) return getPegawaiData(targetDate); + + var lastRow = sheet.getLastRow(); + var idMap = {}; + if (lastRow > 1) { + var ids = sheet.getRange(2, 1, lastRow - 1, 1).getValues(); + for (var i = 0; i < ids.length; i++) { + var id = String(ids[i][0] || '').trim(); + if (id) idMap[id] = i + 2; + } + } + + rows.forEach(function(r) { + var nama = String((r && r.nama) || '').trim(); + var nilai = Number((r && r.nilai)) || 0; + if (!nama || nilai <= 0) return; + var kategori = String((r && r.kategori) || 'Gaji'); + var catatan = String((r && r.catatan) || ''); + var id = String((r && r.id) || '').trim(); + if (!id) id = 'P-' + Date.now() + '-' + Math.floor(Math.random() * 1000); + var row = [id, targetDate, nama, nilai, kategori, catatan, userName || 'Unknown', now]; + var existingRow = idMap[id]; + if (existingRow) sheet.getRange(existingRow, 1, 1, row.length).setValues([row]); + else sheet.appendRow(row); + }); + return getPegawaiData(targetDate); +} + +function deletePegawaiById(id, dateStr) { + var ss = getSS_(); + var sheet = ss.getSheetByName('Pegawai'); + if (!sheet) return getPegawaiData(dateStr); + var targetId = String(id || '').trim(); + var targetDate = normalizeDateStr_(dateStr); + if (!targetId) return getPegawaiData(targetDate); + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return getPegawaiData(targetDate); + var ids = sheet.getRange(2, 1, lastRow - 1, 1).getValues(); + for (var i = ids.length - 1; i >= 0; i--) { + if (String(ids[i][0] || '').trim() === targetId) sheet.deleteRow(i + 2); + } + return getPegawaiData(targetDate); +} + +function saveBulkOperasional(rows, userName) { + var ss = getSS_(); + var sheet = ss.getSheetByName('Operasional'); + var now = new Date(); + var today = Utilities.formatDate(now, "GMT+7", "yyyy-MM-dd"); + var targetDate = today; + if (arguments && arguments.length >= 3 && arguments[2]) targetDate = String(arguments[2]); + if (!rows || !rows.length) return getOperasionalData(targetDate); + var data = sheet.getDataRange().getValues(); + for (var i = data.length - 1; i >= 1; i--) { + var tgl = data[i][1] instanceof Date ? Utilities.formatDate(data[i][1], "GMT+7", "yyyy-MM-dd") : String(data[i][1]); + if (tgl === targetDate) sheet.deleteRow(i + 1); + } + rows.forEach(function(r) { + if (!r || !r.nama) return; + var qty = Number(r.qty) || 0; + if (qty <= 0) qty = 1; + var harga = Number(r.harga) || 0; + var total = Number(r.total) || 0; + if (total <= 0 && harga > 0) total = harga * qty; + if (harga <= 0 && total > 0) harga = Math.round(total / qty); + if (total <= 0) return; + sheet.appendRow([ + 'O-' + Date.now() + Math.random(), + targetDate, + r.nama, + harga, + qty, + total, + r.catatan || '', + userName || 'Unknown', + now + ]); + }); + return getOperasionalData(targetDate); +} + +function getMenuList() { + var ss = getSS_(); + var sheet = ss.getSheetByName('Menu'); + if (!sheet) return []; + var data = sheet.getDataRange().getValues(); + return data.slice(1).map(function(r) { return String(r[0]); }); +} + +function ensureSheetWithHeader_(ss, name, headers) { + var sheet = ss.getSheetByName(name); + if (!sheet) sheet = ss.insertSheet(name); + if (sheet.getLastRow() < 1) sheet.appendRow(headers); + return sheet; +} + +function buildIdRowMap_(sheet) { + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return {}; + var ids = sheet.getRange(2, 1, lastRow - 1, 1).getValues(); + var map = {}; + for (var i = 0; i < ids.length; i++) { + var id = String(ids[i][0] || '').trim(); + if (id) map[id] = i + 2; + } + return map; +} + +function applyRowUpdatesBatched_(sheet, updates) { + if (!sheet || !updates || !updates.length) return; + updates.sort(function(a, b) { return a.row - b.row; }); + var start = updates[0].row; + var prev = updates[0].row; + var buf = [updates[0].values]; + for (var i = 1; i < updates.length; i++) { + var u = updates[i]; + if (u.row === prev + 1) { + buf.push(u.values); + prev = u.row; + continue; + } + sheet.getRange(start, 1, buf.length, buf[0].length).setValues(buf); + start = u.row; + prev = u.row; + buf = [u.values]; + } + sheet.getRange(start, 1, buf.length, buf[0].length).setValues(buf); +} + +function syncBelanjaToRekap(dateStr, kategori, userName) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var now = new Date(); + var date = normalizeDateStr_(dateStr); + var cat = String(kategori || '').trim().toUpperCase(); + if (cat !== 'MODAL' && cat !== 'BAWAH') throw new Error('Kategori sync hanya MODAL/BAWAH.'); + var props = PropertiesService.getScriptProperties(); + var rekapId = String(props.getProperty('rekap_transaksi_spreadsheet_id') || '').trim(); + if (!rekapId) return { ok: false, inserted: 0, updated: 0, skipped: 0, error: 'Script Properties rekap_transaksi_spreadsheet_id belum diisi.' }; + + var ss = getSS_(); + var sheet = ss.getSheetByName('Belanja'); + if (!sheet || sheet.getLastRow() <= 1) return { ok: true, inserted: 0, updated: 0, skipped: 0 }; + + var lastRow = sheet.getLastRow(); + var colCount = Math.max(10, sheet.getLastColumn()); + var scanLimit = 20000; + var startRow = Math.max(2, lastRow - scanLimit + 1); + var data = sheet.getRange(startRow, 1, lastRow - startRow + 1, colCount).getValues(); + + var items = []; + for (var i = 0; i < data.length; i++) { + var r = data[i]; + var id = String(r[0] || '').trim(); + if (!id) continue; + var tgl = r[5] instanceof Date ? Utilities.formatDate(r[5], 'GMT+7', 'yyyy-MM-dd') : String(r[5] || '').trim(); + if (tgl !== date) continue; + var k = String(r[6] || '').trim().toUpperCase(); + if (k !== cat) continue; + items.push({ + id: 'SB-' + id, + nama: String(r[1] || ''), + harga: Number(r[2]) || 0, + qty: Number(r[3]) || 0, + total: Number(r[4]) || 0, + tgl: tgl, + kategori: k, + catatan: String(r[7] || '') + }); + } + + var rekap = SpreadsheetApp.openById(rekapId); + var sheetRekap = ensureSheetWithHeader_(rekap, 'Belanja', ['ID', 'Nama', 'Harga', 'Qty', 'Total', 'Tanggal', 'Kategori', 'Catatan', 'Timestamp']); + var map = buildIdRowMap_(sheetRekap); + + var inserted = 0; + var updated = 0; + var skipped = 0; + for (var j = 0; j < items.length; j++) { + var it = items[j]; + if (!it || !it.id) { skipped++; continue; } + var row = [it.id, it.nama || '', Number(it.harga) || 0, Number(it.qty) || 0, Number(it.total) || 0, it.tgl, it.kategori || '', it.catatan || '', now]; + var existing = map[it.id]; + if (existing) { + sheetRekap.getRange(existing, 1, 1, row.length).setValues([row]); + updated++; + } else { + sheetRekap.appendRow(row); + inserted++; + } + } + + return { ok: true, inserted: inserted, updated: updated, skipped: skipped }; + } finally { lock.releaseLock(); } +} + +function syncBelanjaToRekapAll(kategori, userName) { + var lock = LockService.getScriptLock(); + try { lock.waitLock(30000); } catch (e) { throw new Error('Server sibuk.'); } + try { + var now = new Date(); + var cat = String(kategori || '').trim().toUpperCase(); + if (cat !== 'MODAL' && cat !== 'BAWAH') throw new Error('Kategori sync hanya MODAL/BAWAH.'); + var props = PropertiesService.getScriptProperties(); + var rekapId = String(props.getProperty('rekap_transaksi_spreadsheet_id') || '').trim(); + if (!rekapId) return { ok: false, inserted: 0, updated: 0, skipped: 0, total: 0, dateCount: 0, error: 'Script Properties rekap_transaksi_spreadsheet_id belum diisi.' }; + + var ss = getSS_(); + var sheet = ss.getSheetByName('Belanja'); + if (!sheet || sheet.getLastRow() <= 1) return { ok: true, inserted: 0, updated: 0, skipped: 0, total: 0, dateCount: 0 }; + + var lastRow = sheet.getLastRow(); + var scanCols = Math.max(8, Math.min(10, sheet.getLastColumn())); + var data = sheet.getRange(2, 1, lastRow - 1, scanCols).getValues(); + + var items = []; + var dateSet = {}; + for (var i = 0; i < data.length; i++) { + var r = data[i]; + var id = String(r[0] || '').trim(); + if (!id) continue; + var k = String(r[6] || '').trim().toUpperCase(); + if (k !== cat) continue; + var total = Number(r[4]) || 0; + if (total <= 0) continue; + var tgl = r[5] instanceof Date ? Utilities.formatDate(r[5], 'GMT+7', 'yyyy-MM-dd') : String(r[5] || '').trim(); + if (!tgl) continue; + dateSet[tgl] = true; + items.push({ + id: 'SB-' + id, + nama: String(r[1] || ''), + harga: Number(r[2]) || 0, + qty: Number(r[3]) || 0, + total: total, + tgl: tgl, + kategori: k, + catatan: String(r[7] || '') + }); + } + + if (!items.length) return { ok: true, inserted: 0, updated: 0, skipped: 0, total: 0, dateCount: Object.keys(dateSet).length }; + + var rekap = SpreadsheetApp.openById(rekapId); + var sheetRekap = ensureSheetWithHeader_(rekap, 'Belanja', ['ID', 'Nama', 'Harga', 'Qty', 'Total', 'Tanggal', 'Kategori', 'Catatan', 'Timestamp']); + var map = buildIdRowMap_(sheetRekap); + + var updates = []; + var appends = []; + var inserted = 0; + var updated = 0; + var skipped = 0; + + for (var j = 0; j < items.length; j++) { + var it = items[j]; + if (!it || !it.id) { skipped++; continue; } + var row = [it.id, it.nama || '', Number(it.harga) || 0, Number(it.qty) || 0, Number(it.total) || 0, it.tgl, it.kategori || '', it.catatan || '', now]; + var existing = map[it.id]; + if (existing) { + updates.push({ row: existing, values: row }); + updated++; + } else { + appends.push(row); + inserted++; + } + } + + applyRowUpdatesBatched_(sheetRekap, updates); + if (appends.length) { + var startRow = sheetRekap.getLastRow() + 1; + sheetRekap.getRange(startRow, 1, appends.length, appends[0].length).setValues(appends); + } + + return { ok: true, inserted: inserted, updated: updated, skipped: skipped, total: items.length, dateCount: Object.keys(dateSet).length }; + } finally { lock.releaseLock(); } +} + diff --git a/POSFuku/Stok & Belanja/Index.html b/POSFuku/Stok & Belanja/Index.html new file mode 100644 index 0000000..c2ad9f8 --- /dev/null +++ b/POSFuku/Stok & Belanja/Index.html @@ -0,0 +1,2113 @@ + + + + + + + Fuku - Stok & Belanja + + + +

Memproses...

+ + +
+ +
+ + +
+
+
FUKU STOK
+
+ +
User: -
+ +
+
+ +
+
+
STOK ATAS
+
STOK BAWAH
+
SHOWCASE
+
BELANJA MODAL
+
BELANJA BAWAH
+
BELANJA DAPUR
+
BELANJA MAKAN
+
DRAFT PESANAN
+ + +
RINGKASAN
+
+ + +
+
+
+
📦 Tabel Stok
+
+ + +
+
+
+ + + + + + + + + + + + + + +
#ItemAwalRe-StokPakaiSisa
+
+ +
+
+ + + + + + + + + + + + + + + +
+ + + + + + + diff --git a/RekapTransaksi/Code.gs b/RekapTransaksi/Code.gs new file mode 100644 index 0000000..1b9941c --- /dev/null +++ b/RekapTransaksi/Code.gs @@ -0,0 +1,634 @@ +function doGet() { + return HtmlService.createTemplateFromFile('Index') + .evaluate() + .setTitle('Dashboard Laporan Fuku Shabu & Grill') + .addMetaTag('viewport', 'width=device-width, initial-scale=1') + .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); +} + +function authDigestBase64_(s) { + var bytes = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, String(s || ''), Utilities.Charset.UTF_8); + return Utilities.base64Encode(bytes); +} + +function rekapLogin(action, username, password) { + var a = String(action || '').trim().toLowerCase(); + var u = String(username || '').trim().toLowerCase(); + var p = String(password || ''); + if (!a || !u || !p) return { ok: false }; + + var db = { + admin: { + salt: 'rKkQ4v3Y8nP1zT6a', + hashes: [ + 'W54PYq8f2cj1Q/wOsZWc6gV7Aj47ZnpT/dxXmzL7CV8=', + 'zrBzOp9x7UZlrE78NqoRFlichiZQrBDvY3HBVf1y1zY=' + ] + }, + ridho: { + salt: 'H2m9Qp7sV4c1X0bN', + hashes: ['UNnM/O3rcanZCQEjL/tfZldwUBuSPRK4X11funwwcKw='] + }, + mamah: { + salt: 'fZ8tK3pL0n2Qm7vR', + hashes: ['OcjVm5Bhz19P9kSqsUu+lUWl7uEu9DXtPLt2fnwdxyk='] + } + }; + + var allow = { + dashboard: { admin: ['W54PYq8f2cj1Q/wOsZWc6gV7Aj47ZnpT/dxXmzL7CV8='] }, + kasir: { admin: ['zrBzOp9x7UZlrE78NqoRFlichiZQrBDvY3HBVf1y1zY='], ridho: ['UNnM/O3rcanZCQEjL/tfZldwUBuSPRK4X11funwwcKw='] }, + stok: { admin: ['zrBzOp9x7UZlrE78NqoRFlichiZQrBDvY3HBVf1y1zY='], ridho: ['UNnM/O3rcanZCQEjL/tfZldwUBuSPRK4X11funwwcKw='], mamah: ['OcjVm5Bhz19P9kSqsUu+lUWl7uEu9DXtPLt2fnwdxyk='] } + }; + + if (!allow[a] || !allow[a][u]) return { ok: false }; + if (!db[u] || !db[u].salt) return { ok: false }; + + var expectedHashes = allow[a][u]; + + var actualHash = authDigestBase64_(db[u].salt + ':' + p); + if (expectedHashes.indexOf(actualHash) === -1) return { ok: false }; + + return { ok: true, action: a, username: u }; +} + +function getDashboardData() { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheetTrans = ss.getSheetByName('Transaksi'); + var sheetBelanja = ss.getSheetByName('Belanja'); + + var transData = sheetTrans ? sheetTrans.getDataRange().getValues() : []; + var belanjaData = sheetBelanja ? sheetBelanja.getDataRange().getValues() : []; + + var totalSales = 0; + var totalTransactions = 0; + var methods = {}; + var dailyTrans = {}; + var itemCounts = {}; + + // Proses Transaksi + if (transData.length > 1) { + transData.slice(1).forEach(function(row) { + var status = String(row[2] || ''); + if (status !== 'Selesai') return; + + var total = Number(row[17] || 0); + var method = String(row[21] || 'Tunai'); + var dateObj = row[25] || row[5]; + var dateStr = dateObj instanceof Date ? Utilities.formatDate(dateObj, "GMT+7", "yyyy-MM-dd") : String(dateObj).split(' ')[0]; + + totalSales += total; + totalTransactions++; + methods[method] = (methods[method] || 0) + total; + dailyTrans[dateStr] = (dailyTrans[dateStr] || 0) + total; + + try { + var items = JSON.parse(row[7] || '[]'); + items.forEach(function(it) { + itemCounts[it.nama] = (itemCounts[it.nama] || 0) + (Number(it.qty) || 0); + }); + } catch (e) {} + }); + } + + // Proses Belanja + var totalBelanja = 0; + var dailyBelanja = {}; + var kategoriBelanja = {}; + var belanjaList = []; + + if (belanjaData.length > 1) { + belanjaData.slice(1).forEach(function(row) { + var nama = String(row[1] || ''); + var harga = Number(row[2] || 0); + var qty = Number(row[3] || 0); + var total = Number(row[4] || 0); + var dateObj = row[5]; + var kat = String(row[6] || 'Lainnya'); + + var dateStr = dateObj instanceof Date ? Utilities.formatDate(dateObj, "GMT+7", "yyyy-MM-dd") : String(dateObj).split(' ')[0]; + + totalBelanja += total; + dailyBelanja[dateStr] = (dailyBelanja[dateStr] || 0) + total; + kategoriBelanja[kat] = (kategoriBelanja[kat] || 0) + total; + + belanjaList.push({ nama: nama, total: total, kat: kat, tgl: dateStr }); + }); + } + + // Sort data + var sortedItems = Object.keys(itemCounts).map(function(k) { return [k, itemCounts[k]]; }).sort(function(a, b) { return b[1] - a[1]; }).slice(0, 10); + var sortedDates = Array.from(new Set(Object.keys(dailyTrans).concat(Object.keys(dailyBelanja)))).sort(); + + var dailyStats = sortedDates.map(function(d) { + return { date: d, sales: dailyTrans[d] || 0, expense: dailyBelanja[d] || 0 }; + }); + + return { + summary: { + totalSales: totalSales, + totalBelanja: totalBelanja, + netProfit: totalSales - totalBelanja, + totalTransactions: totalTransactions, + avgTransaction: totalTransactions > 0 ? (totalSales / totalTransactions) : 0 + }, + methods: methods, + daily: dailyStats, + topItems: sortedItems, + kategoriBelanja: kategoriBelanja, + topBelanja: belanjaList.sort(function(a,b){ return b.total - a.total; }).slice(0, 10) + }; +} + +function ensureHeaderRow_(sheet, headers) { + if (!sheet) return; + var h = headers || []; + if (!h.length) return; + if (sheet.getLastRow() < 1) sheet.appendRow(h); + var first = sheet.getRange(1, 1, 1, h.length).getValues()[0]; + var ok = true; + for (var i = 0; i < h.length; i++) { + if (String(first[i] || '').trim() !== String(h[i] || '').trim()) { ok = false; break; } + } + if (!ok) { + sheet.getRange(1, 1, 1, h.length).setValues([h]); + } + sheet.getRange(1, 1, 1, h.length).setFontWeight('bold').setBackground('#f3f3f3'); +} + +function setupCalendarNotesSheet_() { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('KalenderNotes'); + if (!sheet) sheet = ss.insertSheet('KalenderNotes'); + ensureHeaderRow_(sheet, ['ID', 'Tanggal', 'Judul', 'Catatan', 'User', 'Timestamp']); + sheet.getRange('B:B').setNumberFormat('@'); + sheet.getRange('F:F').setNumberFormat('yyyy-mm-dd hh:mm:ss'); + return sheet; +} + +function getCalendarNotesByDate(dateStr) { + var d = normalizeDateStr_(dateStr); + var sheet = setupCalendarNotesSheet_(); + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return []; + var data = sheet.getRange(2, 1, lastRow - 1, 6).getValues(); + var out = []; + for (var i = 0; i < data.length; i++) { + var r = data[i]; + var tgl = toDateStr_(r[1]); + if (tgl !== d) continue; + out.push({ + id: String(r[0] || ''), + tanggal: tgl, + judul: String(r[2] || ''), + catatan: String(r[3] || ''), + user: String(r[4] || ''), + timestamp: r[5] instanceof Date ? Utilities.formatDate(r[5], 'GMT+7', 'yyyy-MM-dd HH:mm:ss') : String(r[5] || '') + }); + } + out.sort(function(a, b) { return String(b.timestamp || '').localeCompare(String(a.timestamp || '')); }); + return out; +} + +function saveCalendarNote(note, userName) { + var sheet = setupCalendarNotesSheet_(); + var now = new Date(); + var payload = note || {}; + var id = String(payload.id || '').trim(); + var tgl = normalizeDateStr_(payload.tanggal); + var judul = String(payload.judul || '').trim(); + var catatan = String(payload.catatan || '').trim(); + if (!judul && !catatan) throw new Error('Judul atau catatan harus diisi.'); + var user = String(userName || payload.user || ''); + var lastRow = sheet.getLastRow(); + var foundRow = -1; + if (id && lastRow > 1) { + var data = sheet.getRange(2, 1, lastRow - 1, 1).getValues(); + for (var i = 0; i < data.length; i++) { + if (String(data[i][0] || '') === id) { foundRow = i + 2; break; } + } + } + if (!id) id = 'CAL-' + Date.now() + '-' + Math.floor(Math.random() * 1000); + var row = [id, tgl, judul, catatan, user || 'Unknown', now]; + if (foundRow > -1) { + sheet.getRange(foundRow, 1, 1, row.length).setValues([row]); + } else { + sheet.appendRow(row); + } + return getCalendarNotesByDate(tgl); +} + +function deleteCalendarNote(id) { + var sheet = setupCalendarNotesSheet_(); + var target = String(id || '').trim(); + if (!target) return true; + var lastRow = sheet.getLastRow(); + if (lastRow <= 1) return true; + var data = sheet.getRange(2, 1, lastRow - 1, 1).getValues(); + for (var i = data.length - 1; i >= 0; i--) { + if (String(data[i][0] || '') === target) sheet.deleteRow(i + 2); + } + return true; +} + +function include(filename) { + return HtmlService.createHtmlOutputFromFile(filename).getContent(); +} + +function normalizeDateStr_(s) { + var str = String(s || '').trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str; + return Utilities.formatDate(new Date(), 'GMT+7', 'yyyy-MM-dd'); +} + +function normalizeRange_(startDate, endDate) { + var s = normalizeDateStr_(startDate); + var e = normalizeDateStr_(endDate); + if (s > e) { var t = s; s = e; e = t; } + return { start: s, end: e }; +} + +function toDateStr_(val) { + if (!val) return ''; + if (val instanceof Date) return Utilities.formatDate(val, 'GMT+7', 'yyyy-MM-dd'); + var s = String(val); + if (!s) return ''; + return s.split(' ')[0].split('T')[0]; +} + +function toHour_(val) { + if (!val) return -1; + if (val instanceof Date) return val.getHours(); + var s = String(val); + if (!s) return -1; + var m = s.match(/(\d{1,2}):(\d{2})/); + if (m && m[1]) return Math.min(23, Math.max(0, parseInt(m[1], 10))); + return -1; +} + +function daysBetweenInclusive_(startDate, endDate) { + var s = new Date(startDate + 'T00:00:00'); + var e = new Date(endDate + 'T00:00:00'); + var ms = e.getTime() - s.getTime(); + if (isNaN(ms)) return 1; + return Math.max(1, Math.floor(ms / 86400000) + 1); +} + +function normalizePorsiBaseName_(name) { + var s = String(name || '').trim(); + if (!s) return ''; + return s.replace(/^->\s*/, '').trim(); +} + +function getRekapByDate(dateStr) { + var d = String(dateStr || '').trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) { + d = Utilities.formatDate(new Date(), "GMT+7", "yyyy-MM-dd"); + } + return buildRekap_(d, d); +} + +function getRekapByRange(startDate, endDate) { + var s = String(startDate || '').trim(); + var e = String(endDate || '').trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(s) || !/^\d{4}-\d{2}-\d{2}$/.test(e)) { + throw new Error('Format tanggal tidak valid. Gunakan YYYY-MM-DD.'); + } + if (s > e) { + var tmp = s; s = e; e = tmp; + } + return buildRekap_(s, e); +} + +function getSalesAnalyticsByRange(startDate, endDate) { + var r = normalizeRange_(startDate, endDate); + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheetTrans = ss.getSheetByName('Transaksi'); + if (!sheetTrans || sheetTrans.getLastRow() <= 1) { + return { + range: { start: r.start, end: r.end }, + summary: { totalSales: 0, totalTransactions: 0, avgTransaction: 0 }, + methods: {}, + dailySales: [], + topItems: [], + hourlyCounts: new Array(24).fill(0), + buckets: { lt100: 0, gte100: 0, gte200: 0 }, + avgMenuPerDay: [] + }; + } + + var lastRow = sheetTrans.getLastRow(); + var data = sheetTrans.getRange(2, 1, lastRow - 1, Math.max(30, sheetTrans.getLastColumn())).getValues(); + + var totalSales = 0; + var totalTransactions = 0; + var methods = {}; + var dailyTrans = {}; + var itemCounts = {}; + var hourlyCounts = new Array(24).fill(0); + var buckets = { lt100: 0, gte100: 0, gte200: 0 }; + + data.forEach(function(row) { + var status = String(row[2] || ''); + if (status !== 'Selesai') return; + var dateObj = row[25] || row[5]; + var dateStr = toDateStr_(dateObj); + if (!dateStr || dateStr < r.start || dateStr > r.end) return; + + var total = Number(row[17] || 0); + totalSales += total; + totalTransactions++; + + if (total < 100000) buckets.lt100++; + if (total >= 100000) buckets.gte100++; + if (total >= 200000) buckets.gte200++; + + var method = String(row[21] || 'Tunai'); + if (method === 'Multi') { + var catatan = String(row[27] || ''); + var details = {}; + try { details = JSON.parse(catatan || '{}'); } catch (e) { details = {}; } + Object.keys(details || {}).forEach(function(k) { + methods[k] = (methods[k] || 0) + (Number(details[k]) || 0); + }); + } else { + methods[method] = (methods[method] || 0) + total; + } + dailyTrans[dateStr] = (dailyTrans[dateStr] || 0) + total; + + var hour = toHour_(row[25] || row[6] || row[5]); + if (hour >= 0) hourlyCounts[hour] = (hourlyCounts[hour] || 0) + 1; + + try { + var items = JSON.parse(row[7] || '[]'); + items.forEach(function(it) { + var nmRaw = String(it.nama || '').trim(); + if (!nmRaw) return; + var nm = normalizePorsiBaseName_(nmRaw) || nmRaw; + itemCounts[nm] = (itemCounts[nm] || 0) + (Number(it.qty) || 0); + }); + } catch (e) {} + }); + + var dailySales = Object.keys(dailyTrans).sort().map(function(d) { return { date: d, sales: dailyTrans[d] || 0 }; }); + var topItems = Object.keys(itemCounts).map(function(k) { return [k, itemCounts[k]]; }).sort(function(a, b) { return b[1] - a[1]; }).slice(0, 10); + var dayCount = daysBetweenInclusive_(r.start, r.end); + var avgMenuPerDay = Object.keys(itemCounts).map(function(k) { + return { nama: k, avg: (itemCounts[k] || 0) / dayCount, total: itemCounts[k] || 0 }; + }).sort(function(a, b) { return b.avg - a.avg; }).slice(0, 10); + + return { + range: { start: r.start, end: r.end }, + summary: { totalSales: totalSales, totalTransactions: totalTransactions, avgTransaction: totalTransactions > 0 ? (totalSales / totalTransactions) : 0 }, + methods: methods, + dailySales: dailySales, + topItems: topItems, + hourlyCounts: hourlyCounts, + buckets: buckets, + avgMenuPerDay: avgMenuPerDay + }; +} + +function getExpenseAnalyticsByRange(startDate, endDate) { + var r = normalizeRange_(startDate, endDate); + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheetBelanja = ss.getSheetByName('Belanja'); + if (!sheetBelanja || sheetBelanja.getLastRow() <= 1) { + return { range: { start: r.start, end: r.end }, summary: { totalBelanja: 0, maxBelanja: 0 }, dailyBelanja: [], kategoriBelanja: {}, topBelanja: [] }; + } + var lastRow = sheetBelanja.getLastRow(); + var data = sheetBelanja.getRange(2, 1, lastRow - 1, Math.max(9, sheetBelanja.getLastColumn())).getValues(); + var totalBelanja = 0; + var dailyBelanja = {}; + var kategoriBelanja = {}; + var belanjaList = []; + data.forEach(function(row) { + var dateStr = toDateStr_(row[5]); + if (!dateStr || dateStr < r.start || dateStr > r.end) return; + var nama = String(row[1] || ''); + var total = Number(row[4] || 0); + var kat = String(row[6] || 'Lainnya'); + totalBelanja += total; + dailyBelanja[dateStr] = (dailyBelanja[dateStr] || 0) + total; + kategoriBelanja[kat] = (kategoriBelanja[kat] || 0) + total; + belanjaList.push({ nama: nama, total: total, kat: kat, tgl: dateStr }); + }); + var daily = Object.keys(dailyBelanja).sort().map(function(d) { return { date: d, total: dailyBelanja[d] || 0 }; }); + var topBelanja = belanjaList.sort(function(a, b) { return b.total - a.total; }).slice(0, 10); + var maxBelanja = topBelanja.length ? topBelanja[0].total : 0; + return { range: { start: r.start, end: r.end }, summary: { totalBelanja: totalBelanja, maxBelanja: maxBelanja }, dailyBelanja: daily, kategoriBelanja: kategoriBelanja, topBelanja: topBelanja }; +} + +function getDashboardBundleByRange(startDate, endDate) { + var sales = getSalesAnalyticsByRange(startDate, endDate); + var exp = getExpenseAnalyticsByRange(startDate, endDate); + var profit = Number((sales && sales.summary && sales.summary.totalSales) || 0) - Number((exp && exp.summary && exp.summary.totalBelanja) || 0); + return { range: sales.range, sales: sales, expense: exp, profit: profit }; +} + +function getTransaksiHistoryPage(startDate, endDate, query, offset, limit) { + var r = normalizeRange_(startDate, endDate); + var q = String(query || '').trim().toLowerCase(); + var off = Math.max(0, Number(offset) || 0); + var lim = Math.min(300, Math.max(20, Number(limit) || 50)); + + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheetTrans = ss.getSheetByName('Transaksi'); + if (!sheetTrans || sheetTrans.getLastRow() <= 1) return { range: { start: r.start, end: r.end }, items: [], offset: off, nextOffset: off, hasMore: false }; + + var lastRow = sheetTrans.getLastRow(); + var data = sheetTrans.getRange(2, 1, lastRow - 1, Math.max(30, sheetTrans.getLastColumn())).getValues(); + var out = []; + var skipped = 0; + var stoppedAt = -1; + for (var i = data.length - 1; i >= 0; i--) { + var row = data[i]; + var status = String(row[2] || ''); + var id = String(row[0] || ''); + if (!id) continue; + if (!status) continue; + var dateStr = toDateStr_(row[25] || row[5]); + if (!dateStr || dateStr < r.start || dateStr > r.end) continue; + + var meja = String(row[1] || ''); + var nama = String(row[3] || ''); + var wa = String(row[4] || ''); + var metode = String(row[21] || ''); + var total = Number(row[17] || 0); + var ts = row[25] || row[5]; + var tsStr = (ts instanceof Date) ? Utilities.formatDate(ts, 'GMT+7', 'yyyy-MM-dd HH:mm:ss') : String(ts || ''); + + if (q) { + var hay = (id + ' ' + meja + ' ' + nama + ' ' + wa + ' ' + metode + ' ' + status).toLowerCase(); + if (hay.indexOf(q) === -1) continue; + } + + if (skipped < off) { skipped++; continue; } + + var items = []; + try { items = JSON.parse(row[7] || '[]'); } catch (e) { items = []; } + out.push({ id: id, meja: meja, status: status, nama: nama, wa: wa, metodeBayar: metode, total: total, timestamp: tsStr, tgl: dateStr, items: items, catatan: String(row[27] || '') }); + if (out.length >= lim) { stoppedAt = i - 1; break; } + } + var nextOffset = off + out.length; + var hasMore = false; + if (stoppedAt >= 0) { + for (var j = stoppedAt; j >= 0; j--) { + var row2 = data[j]; + var status2 = String(row2[2] || ''); + var id2 = String(row2[0] || ''); + if (!id2) continue; + if (!status2) continue; + var dateStr2 = toDateStr_(row2[25] || row2[5]); + if (!dateStr2 || dateStr2 < r.start || dateStr2 > r.end) continue; + var meja2 = String(row2[1] || ''); + var nama2 = String(row2[3] || ''); + var wa2 = String(row2[4] || ''); + var metode2 = String(row2[21] || ''); + if (q) { + var hay2 = (id2 + ' ' + meja2 + ' ' + nama2 + ' ' + wa2 + ' ' + metode2 + ' ' + status2).toLowerCase(); + if (hay2.indexOf(q) === -1) continue; + } + hasMore = true; + break; + } + } + return { range: { start: r.start, end: r.end }, items: out, offset: off, nextOffset: nextOffset, hasMore: hasMore }; +} + +function buildRekap_(startDate, endDate) { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheetTrans = ss.getSheetByName('Transaksi'); + var sheetBelanja = ss.getSheetByName('Belanja'); + + var transData = sheetTrans ? sheetTrans.getDataRange().getValues() : []; + var belanjaData = sheetBelanja ? sheetBelanja.getDataRange().getValues() : []; + + var methods = { 'Tunai': 0, 'QRIS': 0, 'Debit': 0, 'Credit': 0, 'Transfer': 0 }; + var portions = {}; + var totalNota = 0; + var totalSales = 0; + var totalBelanja = 0; + + if (transData.length > 1) { + transData.slice(1).forEach(function(row) { + var status = String(row[2] || ''); + if (status !== 'Selesai') return; + + var dateObj = row[25] || row[5]; + var dateStr = dateObj instanceof Date ? Utilities.formatDate(dateObj, "GMT+7", "yyyy-MM-dd") : String(dateObj).split(' ')[0]; + if (dateStr < startDate || dateStr > endDate) return; + + totalNota++; + var total = Number(row[17] || 0); + totalSales += total; + + var method = String(row[21] || 'Tunai'); + if (method === 'Multi') { + var catatan = String(row[27] || ''); + var details = {}; + try { details = JSON.parse(catatan || '{}'); } catch (e) { details = {}; } + Object.keys(details || {}).forEach(function(k) { + if (methods.hasOwnProperty(k)) methods[k] += (Number(details[k]) || 0); + }); + } else { + if (methods.hasOwnProperty(method)) methods[method] += total; + else methods[method] = (methods[method] || 0) + total; + } + + try { + var items = JSON.parse(row[7] || '[]'); + items.forEach(function(it) { + var nm = String(it.nama || '').trim(); + if (!nm) return; + portions[nm] = (portions[nm] || 0) + (Number(it.qty) || 0); + }); + } catch (e) {} + }); + } + + if (belanjaData.length > 1) { + belanjaData.slice(1).forEach(function(row) { + var dateObj = row[5]; + var dateStr = dateObj instanceof Date ? Utilities.formatDate(dateObj, "GMT+7", "yyyy-MM-dd") : String(dateObj).split(' ')[0]; + if (dateStr < startDate || dateStr > endDate) return; + totalBelanja += (Number(row[4] || 0)); + }); + } + + var sortedPortions = Object.keys(portions).map(function(k) { return { nama: k, qty: portions[k] }; }) + .sort(function(a, b) { return b.qty - a.qty; }); + + return { + startDate: startDate, + endDate: endDate, + isRange: startDate !== endDate, + summary: { + totalNota: totalNota, + totalSales: totalSales, + totalBelanja: totalBelanja, + netProfit: totalSales - totalBelanja + }, + methods: methods, + portions: sortedPortions + }; +} + +function importTransactions(data) { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Transaksi'); + if (!sheet) throw new Error('Sheet Transaksi tidak ditemukan.'); + + var existingIds = []; + if (sheet.getLastRow() > 1) { + existingIds = sheet.getRange(2, 1, sheet.getLastRow() - 1, 1).getValues().map(function(r) { return String(r[0]); }); + } + + var rowsToAdd = []; + data.forEach(function(t) { + if (existingIds.indexOf(String(t[0])) > -1) return; // Index 0 adalah ID + + // Pastikan format tanggal benar + var row = t.map(function(val, idx) { + if ((idx === 5 || idx === 20 || idx === 25) && val) { // Kolom Tanggal, Tgl DP, Timestamp + return new Date(val); + } + return val; + }); + + // Pastikan panjang baris sesuai (28 kolom) + while (row.length < 28) row.push(''); + rowsToAdd.push(row); + }); + + if (rowsToAdd.length > 0) { + sheet.getRange(sheet.getLastRow() + 1, 1, rowsToAdd.length, 28).setValues(rowsToAdd); + } + return { success: true, count: rowsToAdd.length }; +} + +function importBelanja(data) { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName('Belanja'); + if (!sheet) throw new Error('Sheet Belanja tidak ditemukan.'); + + var rowsToAdd = []; + data.forEach(function(b) { + // Pastikan format tanggal/timestamp benar + var row = b.map(function(val, idx) { + if ((idx === 5 || idx === 8) && val) { // Kolom Tanggal, Timestamp + return new Date(val); + } + return val; + }); + + // Pastikan panjang baris sesuai (9 kolom) + while (row.length < 9) row.push(''); + rowsToAdd.push(row); + }); + + if (rowsToAdd.length > 0) { + sheet.getRange(sheet.getLastRow() + 1, 1, rowsToAdd.length, 9).setValues(rowsToAdd); + } + return { success: true, count: rowsToAdd.length }; +} diff --git a/RekapTransaksi/Index.html b/RekapTransaksi/Index.html new file mode 100644 index 0000000..84b3cd3 --- /dev/null +++ b/RekapTransaksi/Index.html @@ -0,0 +1,1148 @@ + + + + + + + Dashboard Laporan Fuku + + + + +
+
+
RekapTransaksi
+
Pilih halaman yang ingin dibuka
+
+ + + +
+
+
+ + + + + + + + + +