/* ============================================================ Viton Paella Crew — app logic (Supabase-backed) ============================================================ */ "use strict"; /* ---------------- config & client ---------------- */ const CFG = (window.CREW_CONFIG || {}); const CONFIGURED = !!(CFG.url && CFG.anonKey && !CFG.url.includes("PASTE") && !CFG.anonKey.includes("PASTE")); let sb = null; function supabaseClient(){ if (sb) return sb; if (!CONFIGURED) return null; if (!window.supabase || !window.supabase.createClient) return null; sb = window.supabase.createClient(CFG.url, CFG.anonKey); return sb; } /* ---------------- helpers ---------------- */ const $ = id => document.getElementById(id); const esc = s => String(s == null ? "" : s) .replace(/&/g,"&").replace(//g,">") .replace(/"/g,""").replace(/'/g,"'"); const money = n => "$" + Number(n || 0).toLocaleString("en-US",{minimumFractionDigits:2}); const num = v => { const n = parseFloat(v); return isNaN(n) ? 0 : n; }; const int = v => { const n = parseInt(v,10); return isNaN(n) ? 0 : n; }; function fmtDate(iso){ if (!iso) return "—"; try { return new Date(iso).toLocaleString(lang === "es" ? "es-US" : "en-US", {weekday:"short", month:"short", day:"numeric", hour:"numeric", minute:"2-digit"}); } catch(e){ return iso; } } /* ISO timestamptz -> "YYYY-MM-DDTHH:MM" for datetime-local */ function toLocalInput(iso){ if (!iso) return ""; const d = new Date(iso); const p = n => String(n).padStart(2,"0"); return d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate())+"T"+p(d.getHours())+":"+p(d.getMinutes()); } function showErr(boxId, msg){ const el = $(boxId); el.textContent = msg; el.classList.add("show"); clearTimeout(el._t); el._t = setTimeout(()=>el.classList.remove("show"), 6000); } /* ---------------- i18n ---------------- */ const T = { en:{ loginTitle:"Viton Paella Crew", loginSub:"Workers only. Sign in with your crew account.", emailPh:"Email", passPh:"Password", signIn:"Sign in", loginFine:"Private crew app — each worker signs in with their own account.", setupTitle:"Waiting for setup", setupMsg:"This app isn't connected to its database yet. Your admin will finish setup soon — nothing is broken.", loginError:"Couldn't sign in — check your email and password.", logout:"Log out", crewSub:"Crew app", upcomingBookings:"Upcoming bookings", pastBookings:"Past", back:"Back", inventory:"Inventory", tabNeeds:"Event needs", tabCosts:"Costs", navBookings:"Bookings", navInventory:"Inventory", navCalendar:"Calendar", navFinance:"Finance", today:"Today", noDayBookings:"No bookings this day.", revenue:"Revenue", totalCosts:"Total costs", profit:"Profit", expenses:"Expenses", eventCosts:"Event costs", weekOf:"Week of", thisWeek:"This week", moneyIn:"Money in", moneyOut:"Money out", noBookingsWeek:"No bookings this week yet.", noExpensesWeek:"No expenses this week yet — add your first one below.", addExpense:"Add expense", addExpenseBtn:"Add expense", description:"Description", expLabelPh:"e.g. Rice, staff, fuel", amount:"Amount", date:"Date", category:"Category", confirmDeleteExpense:"Delete this expense?", scanReceipt:"Scan receipt", readingReceipt:"Reading receipt…", scanError:"Couldn't read the receipt — check the photo and try again, or enter it manually.", photoError:"Couldn't process that photo — try another one or enter the expense manually.", noItemsFound:"No line items found — the total is set. Add items below if you want them in the price book.", confirmReceiptTitle:"Confirm receipt", storeName:"Store", storeNamePh:"e.g. Restaurant Depot", receiptItems:"Items", receiptTotal:"Total", addItemRow:"+ Add item", saveToPriceBook:"Save items to price book", receiptDefault:"Receipt", priceBook:"Price book", priceBookSub:"Best prices — drop automatically when a cheaper receipt is scanned", viewReceipt:"View receipt", unitEach:"each", cat_Ingredients:"Ingredients", cat_Labor:"Labor", cat_Fuel:"Fuel", cat_Rent:"Rent", cat_Equipment:"Equipment", cat_Marketing:"Marketing", cat_Other:"Other", labelPh:"Label (e.g. ingredients)", noCostLines:"No cost lines yet — add the first one above.", confirmDeleteCostLine:"Delete this cost line?", guests:"guests", total:"Total", depositPaid:"Deposit paid", balanceDue:"Balance due", customer:"Customer", phone:"Phone", dateTime:"Date & time", venue:"Venue", city:"City", menu:"Menu", notes:"Notes", callCustomer:"Call customer", textCrew:"Text prep sheet to crew", estTotal:"Est. total", per:"per", eventName:"Event name", addBooking:"+ New booking", newBooking:"New booking", editBooking:"Edit booking", save:"Save", cancel:"Cancel", delete:"Delete", edit:"Edit", add:"Add", name:"Name", namePh:"Name", unit:"Unit", unitPh:"Unit (e.g. 25 lb bag)", unitCost:"Unit cost", itemPh:"Item (e.g. Bomba rice)", qtyPh:"Qty", addCostTitle:"Add cost item", noBookings:"No bookings yet — tap “+ New booking” above.", noPast:"", noNeeds:"No items yet — add what this event needs above.", noCosts:"No cost items yet.", confirmDeleteBooking:"Delete this booking and its checklist? This can't be undone.", confirmDeleteNeed:"Remove this item?", confirmDeleteCost:"Delete this cost item?", loading:"Loading…", saveError:"Couldn't save — try again.", loadError:"Couldn't load data — check your connection and try again.", deleteError:"Couldn't delete — try again.", past:"Past", soon:"Soon", booked:"Booked", bring:"Bring:", }, es:{ loginTitle:"Equipo Viton Paella", loginSub:"Solo trabajadores. Ingresa con tu cuenta.", emailPh:"Correo electrónico", passPh:"Contraseña", signIn:"Ingresar", loginFine:"App privada del equipo — cada trabajador ingresa con su propia cuenta.", setupTitle:"Esperando configuración", setupMsg:"Esta app aún no está conectada a su base de datos. Tu administrador terminará la configuración pronto — nada está roto.", loginError:"No se pudo ingresar — revisa tu correo y contraseña.", logout:"Salir", crewSub:"App del equipo", upcomingBookings:"Próximas reservaciones", pastBookings:"Anteriores", back:"Atrás", inventory:"Inventario", tabNeeds:"Necesidades", tabCosts:"Costos", navBookings:"Reservas", navInventory:"Inventario", navCalendar:"Calendario", navFinance:"Finanzas", today:"Hoy", noDayBookings:"Sin reservas este día.", revenue:"Ingresos", totalCosts:"Costos totales", profit:"Ganancia", expenses:"Gastos", eventCosts:"Costos de eventos", weekOf:"Semana del", thisWeek:"Esta semana", moneyIn:"Entradas", moneyOut:"Salidas", noBookingsWeek:"Sin reservas esta semana.", noExpensesWeek:"Sin gastos esta semana — agrega el primero abajo.", addExpense:"Agregar gasto", addExpenseBtn:"Agregar gasto", description:"Descripción", expLabelPh:"ej. Arroz, personal, gasolina", amount:"Monto", date:"Fecha", category:"Categoría", confirmDeleteExpense:"¿Eliminar este gasto?", scanReceipt:"Escanear recibo", readingReceipt:"Leyendo recibo…", scanError:"No se pudo leer el recibo — revisa la foto e intenta de nuevo, o ingrésalo manualmente.", photoError:"No se pudo procesar esa foto — intenta con otra o ingresa el gasto manualmente.", noItemsFound:"No se encontraron artículos — el total está listo. Agrega artículos abajo si quieres guardarlos en la lista de precios.", confirmReceiptTitle:"Confirmar recibo", storeName:"Tienda", storeNamePh:"ej. Restaurant Depot", receiptItems:"Artículos", receiptTotal:"Total", addItemRow:"+ Agregar artículo", saveToPriceBook:"Guardar artículos en lista de precios", receiptDefault:"Recibo", priceBook:"Lista de precios", priceBookSub:"Mejores precios — bajan automáticamente cuando se escanea un recibo más barato", viewReceipt:"Ver recibo", unitEach:"c/u", cat_Ingredients:"Ingredientes", cat_Labor:"Mano de obra", cat_Fuel:"Combustible", cat_Rent:"Renta", cat_Equipment:"Equipo", cat_Marketing:"Marketing", cat_Other:"Otro", labelPh:"Concepto (ej. ingredientes)", noCostLines:"Sin costos aún — agrega el primero arriba.", confirmDeleteCostLine:"¿Eliminar este costo?", guests:"invitados", total:"Total", depositPaid:"Depósito pagado", balanceDue:"Saldo pendiente", customer:"Cliente", phone:"Teléfono", dateTime:"Fecha y hora", venue:"Lugar", city:"Ciudad", menu:"Menú", notes:"Notas", callCustomer:"Llamar al cliente", textCrew:"Enviar lista al equipo", estTotal:"Total est.", per:"por", eventName:"Nombre del evento", addBooking:"+ Nueva reserva", newBooking:"Nueva reserva", editBooking:"Editar reserva", save:"Guardar", cancel:"Cancelar", delete:"Eliminar", edit:"Editar", add:"Agregar", name:"Nombre", namePh:"Nombre", unit:"Unidad", unitPh:"Unidad (ej. bolsa de 25 lb)", unitCost:"Costo unitario", itemPh:"Artículo (ej. arroz bomba)", qtyPh:"Cant.", addCostTitle:"Agregar costo", noBookings:"Aún no hay reservas — toca “+ Nueva reserva” arriba.", noPast:"", noNeeds:"Sin artículos — agrega lo que necesita este evento arriba.", noCosts:"Aún no hay costos.", confirmDeleteBooking:"¿Eliminar esta reserva y su lista? No se puede deshacer.", confirmDeleteNeed:"¿Quitar este artículo?", confirmDeleteCost:"¿Eliminar este costo?", loading:"Cargando…", saveError:"No se pudo guardar — intenta de nuevo.", loadError:"No se pudieron cargar los datos — revisa tu conexión e intenta de nuevo.", deleteError:"No se pudo eliminar — intenta de nuevo.", past:"Pasado", soon:"Pronto", booked:"Confirmado", bring:"Llevar:", }}; let lang = "en"; function applyI18n(){ document.querySelectorAll("[data-i18n]").forEach(el=>{ const k = el.getAttribute("data-i18n"); if (T[lang][k] !== undefined) el.textContent = T[lang][k]; }); document.querySelectorAll("[data-i18n-ph]").forEach(el=>{ const k = el.getAttribute("data-i18n-ph"); if (T[lang][k] !== undefined) el.placeholder = T[lang][k]; }); document.documentElement.lang = lang; $("langToggle").textContent = lang === "en" ? "ES" : "EN"; } function toggleLang(){ lang = lang === "en" ? "es" : "en"; applyI18n(); renderBookings(); buildPicker(); renderNeeds(); renderCosts(); renderCalendar(); buildExpCats(); if ($("scr-finance").classList.contains("active")) renderFinance(); if (currentBooking && $("scr-detail").classList.contains("active")) renderDetail(currentBooking.id); if (editingCostId) renderCostEditor(); if (sheetOpen) renderReceiptSheet(); } /* ---------------- state ---------------- */ let bookings = []; // all bookings, event_date asc let needs = []; // needs for selected booking let costs = []; // master cost list let bookingCosts = []; // cost lines for current booking (v2) let currentBooking = null; let formBookingId = null; // null = add, uuid = edit let formReturn = "scr-bookings"; let editingCostId = null; let selectedBookingId = null; // calendar state (v2) const _today0 = new Date(); let calCursor = new Date(_today0.getFullYear(), _today0.getMonth(), 1); let calSelected = {y:_today0.getFullYear(), m:_today0.getMonth(), d:_today0.getDate()}; // finance state (v2.1) let expenses = []; // all expense rows (filtered to week at render) let financeCosts = []; // all booking_costs rows, loaded with finance screen let finCursor = new Date(); // any date inside the selected week // receipt scan state (v2.2) let sheetOpen = false, sheetMode = null, sheetErrorKey = null; let scanItems = []; // [{desc, price}] let scanStore = "", scanTotal = 0, scanBlob = null, scanThumbUrl = null; let scanSaveToBook = true, tessPromise = null; /* ---------------- navigation ---------------- */ function show(id){ document.querySelectorAll(".screen").forEach(s=>s.classList.remove("active")); $(id).classList.add("active"); $("navBookings").classList.toggle("on", id==="scr-bookings"||id==="scr-detail"||id==="scr-booking-form"); $("navInventory").classList.toggle("on", id==="scr-inventory"); $("navCalendar").classList.toggle("on", id==="scr-calendar"); $("navFinance").classList.toggle("on", id==="scr-finance"); document.querySelector("main").scrollTop = 0; } /* ---------------- auth ---------------- */ function setLoggedIn(on){ $("loginScreen").classList.toggle("hidden", on); } async function initAuth(){ if (!CONFIGURED){ $("setupNotice").hidden = false; $("loginForm").style.display = "none"; return; } const client = supabaseClient(); if (!client){ showErr("bookingsErr", T[lang].loadError); return; } const { data } = await client.auth.getSession(); if (data && data.session){ setLoggedIn(true); await loadAll(); } else setLoggedIn(false); client.auth.onAuthStateChange(async (evt, session)=>{ if (session){ setLoggedIn(true); await loadAll(); } else { setLoggedIn(false); bookings=[]; needs=[]; costs=[]; currentBooking=null; } }); } async function doLogin(){ const email = $("loginEmail").value.trim(); const pass = $("loginPass").value; const errBox = $("loginError"); errBox.classList.remove("show"); const btn = $("loginBtn"); btn.disabled = true; try{ const { error } = await supabaseClient().auth.signInWithPassword({email, password:pass}); if (error) throw error; // onAuthStateChange flips the UI }catch(e){ errBox.textContent = T[lang].loginError; errBox.classList.add("show"); }finally{ btn.disabled = false; } } async function doLogout(){ const client = supabaseClient(); if (client) await client.auth.signOut(); setLoggedIn(false); } /* ---------------- bookings ---------------- */ async function loadAll(){ await Promise.all([loadBookings(), loadCosts()]); buildPicker(); await loadNeeds(); } async function loadBookings(){ const client = supabaseClient(); if (!client) return; const { data, error } = await client.from("bookings") .select("*").order("event_date", {ascending:true}); if (error){ showErr("bookingsErr", T[lang].loadError); return; } bookings = data || []; renderBookings(); renderCalendar(); } function pillFor(b){ const now = Date.now(), t = new Date(b.event_date).getTime(); if (t < now - 4*3600*1000) return ["past", T[lang].past]; if (t < now + 7*24*3600*1000) return ["soon", T[lang].soon]; return ["upcoming", T[lang].booked]; } function bookingCard(b){ const bal = num(b.total) - num(b.deposit_paid); const [cls, label] = pillFor(b); const d = document.createElement("div"); d.className = "card booking-card"; d.innerHTML = '
'+esc(b.event_name)+'
'+ '
'+esc(fmtDate(b.event_date))+' · '+esc([b.venue,b.city].filter(Boolean).join(" — "))+ '
'+ ''+esc(label)+'
'+ '
'+int(b.guests)+' '+esc(T[lang].guests)+''+esc(b.menu||"")+'
'+ '
'+esc(T[lang].depositPaid)+': '+ ''+esc(T[lang].balanceDue)+': '+money(bal)+'
'; d.onclick = ()=>{ currentBooking = b; renderDetail(b.id); }; return d; } function renderBookings(){ const list = $("bookingList"), past = $("pastList"); list.innerHTML = ""; past.innerHTML = ""; const now = Date.now(); const up = bookings.filter(b=>new Date(b.event_date).getTime() >= now - 4*3600*1000); const old = bookings.filter(b=>new Date(b.event_date).getTime() < now - 4*3600*1000).reverse(); if (!up.length) list.innerHTML = '
'+esc(T[lang].noBookings)+'
'; up.forEach(b=>list.appendChild(bookingCard(b))); $("pastTitle").style.display = old.length ? "block" : "none"; old.forEach(b=>past.appendChild(bookingCard(b))); } function kv(k,v,cls){ return '
'+esc(k)+''+v+'
'; } async function renderDetail(id){ const b = bookings.find(x=>x.id===id) || currentBooking; if (!b) return; currentBooking = b; const bal = num(b.total) - num(b.deposit_paid); const tel = String(b.customer_phone||"").replace(/\D/g,""); $("detailBody").innerHTML = '

'+esc(b.event_name)+'

'+esc(fmtDate(b.event_date))+'
'+ '
'+ kv(T[lang].customer, esc(b.customer_name||"—"))+ kv(T[lang].phone, tel ? ''+esc(b.customer_phone)+'' : "—")+ kv(T[lang].dateTime, esc(fmtDate(b.event_date)))+ kv(T[lang].venue, esc(b.venue||"—"))+ kv(T[lang].city, esc(b.city||"—"))+ kv(T[lang].guests, int(b.guests))+ kv(T[lang].menu, esc(b.menu||"—"))+ kv(T[lang].total, money(b.total), "money")+ kv(T[lang].depositPaid, money(b.deposit_paid), "ok")+ kv(T[lang].balanceDue, money(bal), "money")+ (b.notes ? kv(T[lang].notes, esc(b.notes)) : "")+ '
'+ '
'+esc(T[lang].tabCosts)+'
'+ '
'+ '
'+esc(T[lang].loading)+'
'+ '
'+ ''+ ''+ '
'+ '
'+ (tel ? ''+esc(T[lang].callCustomer)+'' : "")+ ''+ '
'+ '
'; $("prepBtn").onclick = ()=>prepSheet(b.id); $("editBookingBtn").onclick = ()=>openBookingForm(b.id); $("delBookingBtn").onclick = ()=>deleteBooking(b.id); $("bcostAdd").onclick = addCostLine; $("bcostLabel").addEventListener("keydown", e=>{ if (e.key==="Enter") addCostLine(); }); $("bcostAmount").addEventListener("keydown", e=>{ if (e.key==="Enter") addCostLine(); }); show("scr-detail"); await refreshBookingCosts(); } /* ---------------- booking costs (v2) ---------------- */ async function loadBookingCosts(bookingId){ const client = supabaseClient(); if (!client) return null; const { data, error } = await client.from("booking_costs") .select("*").eq("booking_id", bookingId).order("created_at",{ascending:true}); if (error) return null; return data || []; } async function refreshBookingCosts(){ const list = $("bcostList"); if (!list || !currentBooking) return; const rows = await loadBookingCosts(currentBooking.id); if (rows === null){ list.innerHTML = '
'+esc(T[lang].loadError)+'
'; $("bcostSummary").innerHTML = ""; return; } bookingCosts = rows; renderCostLines(); } function renderCostLines(){ const list = $("bcostList"); if (!list || !currentBooking) return; list.innerHTML = ""; if (!bookingCosts.length){ list.innerHTML = '
'+esc(T[lang].noCostLines)+'
'; } bookingCosts.forEach(c=>{ const row = document.createElement("div"); row.className = "cost-row"; row.innerHTML = '
'+esc(c.label)+'
'+ '
'+money(c.amount)+'
'+ '
'; row.querySelector("button").onclick = async ()=>{ if (!confirm(T[lang].confirmDeleteCostLine)) return; const { error } = await supabaseClient().from("booking_costs").delete().eq("id", c.id); if (error){ showErr("detailErr", T[lang].deleteError); return; } await refreshBookingCosts(); }; list.appendChild(row); }); const costTotal = bookingCosts.reduce((s,c)=>s+num(c.amount),0); const revenue = num(currentBooking.total); const profit = revenue - costTotal; $("bcostSummary").innerHTML = kv(T[lang].revenue, money(revenue))+ kv(T[lang].totalCosts, money(costTotal), "money")+ kv(T[lang].profit, money(profit), profit >= 0 ? "ok" : "neg"); } async function addCostLine(){ const label = $("bcostLabel").value.trim(); const amount = $("bcostAmount").value === "" ? 0 : num($("bcostAmount").value); if (!label || !currentBooking) return; const { data, error } = await supabaseClient().from("booking_costs") .insert({booking_id:currentBooking.id, label, amount}) .select().single(); if (error){ showErr("detailErr", T[lang].saveError); return; } bookingCosts.push(data); $("bcostLabel").value = ""; $("bcostAmount").value = ""; renderCostLines(); } function openBookingForm(id){ formBookingId = id || null; formReturn = id ? "scr-detail" : "scr-bookings"; const b = id ? bookings.find(x=>x.id===id) : null; $("bookingFormTitle").textContent = b ? T[lang].editBooking : T[lang].newBooking; $("f_event_name").value = b ? b.event_name||"" : ""; $("f_event_date").value = b ? toLocalInput(b.event_date) : ""; $("f_venue").value = b ? b.venue||"" : ""; $("f_city").value = b ? b.city||"" : ""; $("f_guests").value = b && b.guests ? b.guests : ""; $("f_menu").value = b ? b.menu||"" : ""; $("f_customer_name").value = b ? b.customer_name||"" : ""; $("f_customer_phone").value = b ? b.customer_phone||"" : ""; $("f_total").value = b && b.total ? b.total : ""; $("f_deposit_paid").value = b && b.deposit_paid ? b.deposit_paid : ""; $("f_notes").value = b ? b.notes||"" : ""; $("formErr").classList.remove("show"); show("scr-booking-form"); } async function saveBooking(){ const name = $("f_event_name").value.trim(); const dt = $("f_event_date").value; if (!name || !dt){ showErr("formErr", T[lang].saveError); return; } const row = { event_name: name, event_date: new Date(dt).toISOString(), venue: $("f_venue").value.trim() || null, city: $("f_city").value.trim() || null, guests: $("f_guests").value === "" ? null : int($("f_guests").value), menu: $("f_menu").value.trim() || null, customer_name: $("f_customer_name").value.trim() || null, customer_phone: $("f_customer_phone").value.trim() || null, total: $("f_total").value === "" ? 0 : num($("f_total").value), deposit_paid: $("f_deposit_paid").value === "" ? 0 : num($("f_deposit_paid").value), notes: $("f_notes").value.trim() || null, updated_at: new Date().toISOString(), }; const client = supabaseClient(); let error; if (formBookingId){ ({ error } = await client.from("bookings").update(row).eq("id", formBookingId)); } else { ({ error } = await client.from("bookings").insert(row)); } if (error){ showErr("formErr", T[lang].saveError); return; } await loadBookings(); buildPicker(); if (formBookingId){ const b = bookings.find(x=>x.id===formBookingId); if (b){ currentBooking = b; renderDetail(b.id); return; } } show("scr-bookings"); } async function deleteBooking(id){ if (!confirm(T[lang].confirmDeleteBooking)) return; const { error } = await supabaseClient().from("bookings").delete().eq("id", id); if (error){ showErr("bookingsErr", T[lang].deleteError); return; } if (selectedBookingId === id) selectedBookingId = null; currentBooking = null; await loadBookings(); buildPicker(); await loadNeeds(); show("scr-bookings"); } /* ---------------- inventory needs ---------------- */ function buildPicker(){ const p = $("eventPicker"); const cur = selectedBookingId; p.innerHTML = ""; const now = Date.now(); const up = bookings.filter(b=>new Date(b.event_date).getTime() >= now - 4*3600*1000); const list = up.length ? up : bookings; list.forEach(b=>{ const o = document.createElement("option"); o.value = b.id; o.textContent = b.event_name + " — " + fmtDate(b.event_date); p.appendChild(o); }); if (!list.length){ const o = document.createElement("option"); o.textContent = "—"; p.appendChild(o); } if (cur && list.some(b=>b.id===cur)) p.value = cur; selectedBookingId = p.value || null; } async function loadNeeds(){ const client = supabaseClient(); if (!client || !selectedBookingId){ needs=[]; renderNeeds(); return; } const { data, error } = await client.from("inventory_needs") .select("*").eq("booking_id", selectedBookingId) .order("sort_order",{ascending:true}).order("created_at",{ascending:true}); if (error){ showErr("invErr", T[lang].loadError); return; } needs = data || []; renderNeeds(); } function renderNeeds(){ const el = $("needsList"); el.innerHTML = ""; if (!selectedBookingId){ el.innerHTML = '
'+esc(T[lang].noBookings)+'
'; return; } if (!needs.length){ el.innerHTML = '
'+esc(T[lang].noNeeds)+'
'; return; } needs.forEach(n=>{ const row = document.createElement("label"); row.className = "need-row" + (n.checked ? " done" : ""); row.innerHTML = ''+ ''+esc(n.item)+''+ (n.qty ? ''+esc(n.qty)+'' : "")+ ''; row.querySelector("input").onchange = async e=>{ const { error } = await supabaseClient().from("inventory_needs") .update({checked:e.target.checked}).eq("id", n.id); if (error){ showErr("invErr", T[lang].saveError); e.target.checked = !e.target.checked; return; } n.checked = e.target.checked; row.classList.toggle("done", n.checked); }; row.querySelector(".need-del").onclick = async e=>{ e.preventDefault(); if (!confirm(T[lang].confirmDeleteNeed)) return; const { error } = await supabaseClient().from("inventory_needs").delete().eq("id", n.id); if (error){ showErr("invErr", T[lang].deleteError); return; } needs = needs.filter(x=>x.id!==n.id); renderNeeds(); }; el.appendChild(row); }); } async function addNeed(){ const item = $("needItem").value.trim(); const qty = $("needQty").value.trim(); if (!item || !selectedBookingId) return; const maxOrder = needs.reduce((m,n)=>Math.max(m, n.sort_order||0), 0); const { data, error } = await supabaseClient().from("inventory_needs") .insert({booking_id:selectedBookingId, item, qty:qty||null, sort_order:maxOrder+1}) .select().single(); if (error){ showErr("invErr", T[lang].saveError); return; } needs.push(data); $("needItem").value = ""; $("needQty").value = ""; renderNeeds(); } /* ---------------- inventory costs ---------------- */ async function loadCosts(){ const client = supabaseClient(); if (!client) return; const { data, error } = await client.from("inventory_items") .select("*").order("name",{ascending:true}); if (error){ showErr("invErr", T[lang].loadError); return; } costs = data || []; renderCosts(); } function renderCosts(){ const el = $("costsList"); el.innerHTML = ""; if (!costs.length){ el.innerHTML = '
'+esc(T[lang].noCosts)+'
'; } let total = 0; costs.forEach(c=>{ total += num(c.unit_cost); const row = document.createElement("div"); row.className = "cost-row"; if (editingCostId === c.id){ row.innerHTML = '
'+ '
'+ '
'+ '
'+ '
'; el.appendChild(row); $("ce_save").onclick = ()=>saveCostEdit(c.id); $("ce_cancel").onclick = ()=>{ editingCostId=null; renderCosts(); }; } else { row.innerHTML = '
'+esc(c.name)+'
'+esc(T[lang].per+" "+(c.unit||""))+'
'+ '
'+money(c.unit_cost)+'
'+ '
'+ '
'; const [editBtn, delBtn] = row.querySelectorAll(".mini-btn"); editBtn.onclick = ()=>{ editingCostId = c.id; renderCosts(); }; delBtn.onclick = async ()=>{ if (!confirm(T[lang].confirmDeleteCost)) return; const { error } = await supabaseClient().from("inventory_items").delete().eq("id", c.id); if (error){ showErr("invErr", T[lang].deleteError); return; } costs = costs.filter(x=>x.id!==c.id); renderCosts(); }; el.appendChild(row); } }); $("costTotal").textContent = money(total); } function renderCostEditor(){ renderCosts(); } async function saveCostEdit(id){ const row = { name: $("ce_name").value.trim(), unit: $("ce_unit").value.trim() || null, unit_cost: num($("ce_price").value), updated_at: new Date().toISOString(), }; if (!row.name) return; const { error } = await supabaseClient().from("inventory_items").update(row).eq("id", id); if (error){ showErr("invErr", T[lang].saveError); return; } editingCostId = null; await loadCosts(); } async function addCost(){ const name = $("costName").value.trim(); if (!name) return; const row = { name, unit: $("costUnit").value.trim() || null, unit_cost: $("costPrice").value === "" ? 0 : num($("costPrice").value), }; const { data, error } = await supabaseClient().from("inventory_items").insert(row).select().single(); if (error){ showErr("invErr", T[lang].saveError); return; } costs.push(data); costs.sort((a,b)=>String(a.name).localeCompare(String(b.name))); $("costName").value = ""; $("costUnit").value = ""; $("costPrice").value = ""; renderCosts(); } function invTab(which){ $("tabNeeds").classList.toggle("on", which==="needs"); $("tabCosts").classList.toggle("on", which==="costs"); $("needsPane").style.display = which==="needs" ? "block" : "none"; $("costsPane").style.display = which==="costs" ? "block" : "none"; } /* ---------------- calendar (v2) ---------------- */ function calDayKey(y, m, d){ const p = n => String(n).padStart(2,"0"); return y+"-"+p(m+1)+"-"+p(d); } function renderCalendar(){ if (!$("calGrid")) return; const locale = lang === "es" ? "es-US" : "en-US"; const y = calCursor.getFullYear(), m = calCursor.getMonth(); $("calTitle").textContent = new Date(y, m, 1).toLocaleDateString(locale, {month:"long", year:"numeric"}); const wh = $("calWeekHead"); wh.innerHTML = ""; for (let i=0;i<7;i++){ const d = document.createElement("div"); d.textContent = new Date(2024,0,7+i).toLocaleDateString(locale, {weekday:"narrow"}); wh.appendChild(d); } const counts = {}; bookings.forEach(b=>{ const dt = new Date(b.event_date); if (isNaN(dt)) return; const k = calDayKey(dt.getFullYear(), dt.getMonth(), dt.getDate()); counts[k] = (counts[k]||0) + 1; }); const grid = $("calGrid"); grid.innerHTML = ""; const first = new Date(y, m, 1).getDay(); const daysIn = new Date(y, m+1, 0).getDate(); const prevDays = new Date(y, m, 0).getDate(); const t = new Date(); const ty = t.getFullYear(), tm = t.getMonth(), td = t.getDate(); for (let i=0;i<42;i++){ let dy, dm; if (i < first){ dy = prevDays - first + 1 + i; dm = m-1; } else if (i < first + daysIn){ dy = i - first + 1; dm = m; } else { dy = i - first - daysIn + 1; dm = m+1; } const dd = new Date(y, dm, dy); // normalizes month overflow const yy = dd.getFullYear(), mm = dd.getMonth(), ddd = dd.getDate(); const n = counts[calDayKey(yy, mm, ddd)] || 0; const cell = document.createElement("div"); cell.className = "cal-day" + (mm !== m ? " dim" : ""); if (yy===ty && mm===tm && ddd===td) cell.classList.add("today"); if (calSelected && yy===calSelected.y && mm===calSelected.m && ddd===calSelected.d) cell.classList.add("sel"); let dots = ""; for (let j=0;j'+dots+''; cell.onclick = ()=>{ calSelected = {y:yy, m:mm, d:ddd}; renderCalendar(); }; grid.appendChild(cell); } renderCalDay(); } function renderCalDay(){ const el = $("calDayList"); if (!el || !calSelected) return; el.innerHTML = ""; const {y, m, d} = calSelected; const locale = lang === "es" ? "es-US" : "en-US"; const title = document.createElement("div"); title.className = "section-title"; title.textContent = new Date(y, m, d).toLocaleDateString(locale, {weekday:"long", month:"long", day:"numeric"}); el.appendChild(title); const list = bookings.filter(b=>{ const dt = new Date(b.event_date); return !isNaN(dt) && dt.getFullYear()===y && dt.getMonth()===m && dt.getDate()===d; }); if (!list.length){ const e = document.createElement("div"); e.className = "empty"; e.textContent = T[lang].noDayBookings; el.appendChild(e); return; } list.forEach(b=>el.appendChild(bookingCard(b))); } /* ---------------- finance (v2.1) ---------------- */ const EXPENSE_CATS = ["Ingredients","Labor","Fuel","Rent","Equipment","Marketing","Other"]; function catLabel(c){ return T[lang]["cat_"+c] || c; } function buildExpCats(){ const s = $("expCategory"); if (!s) return; const cur = s.value || "Other"; s.innerHTML = ""; EXPENSE_CATS.forEach(c=>{ const o = document.createElement("option"); o.value = c; o.textContent = catLabel(c); s.appendChild(o); }); s.value = EXPENSE_CATS.includes(cur) ? cur : "Other"; } /* Monday 00:00 local of the week containing d */ function weekStartOf(d){ const x = new Date(d); x.setHours(0,0,0,0); x.setDate(x.getDate() - ((x.getDay()+6)%7)); return x; } /* "YYYY-MM-DD" -> local Date at noon (avoids UTC-midnight shifts) */ function localDay(ymd){ const p = String(ymd||"").split("-"); if (p.length < 3) return new Date(NaN); return new Date(+p[0], +p[1]-1, +p[2], 12, 0, 0); } /* Date -> "YYYY-MM-DD" for date inputs */ function toDateInput(d){ const p = n => String(n).padStart(2,"0"); return d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate()); } function fmtExpDate(ymd){ const d = localDay(ymd); if (isNaN(d)) return "—"; return d.toLocaleDateString(lang === "es" ? "es-US" : "en-US", {month:"short", day:"numeric"}); } function finTile(title, val, cls){ return '
'+esc(title)+'
'+ '
'+esc(val)+'
'; } async function loadFinance(){ const client = supabaseClient(); if (!client) return; buildExpCats(); if (!$("expDate").value) $("expDate").value = toDateInput(new Date()); const [cRes, eRes] = await Promise.all([ client.from("booking_costs").select("booking_id,amount"), client.from("expenses").select("*").order("expense_date",{ascending:false}) ]); if (cRes.error || eRes.error){ showErr("financeErr", T[lang].loadError); return; } financeCosts = cRes.data || []; expenses = eRes.data || []; renderFinance(); } function renderFinance(){ if (!$("finWeekLabel")) return; const locale = lang === "es" ? "es-US" : "en-US"; const ws = weekStartOf(finCursor); const we = new Date(ws); we.setDate(we.getDate()+6); we.setHours(23,59,59,999); $("finWeekLabel").textContent = T[lang].weekOf + " " + ws.toLocaleDateString(locale, {weekday:"short", month:"short", day:"numeric"}); const inWeek = iso => { const t = new Date(iso).getTime(); return !isNaN(t) && t >= ws.getTime() && t <= we.getTime(); }; // money in: this week's bookings (auto) const wb = bookings.filter(b=>inWeek(b.event_date)) .sort((a,b)=>new Date(a.event_date)-new Date(b.event_date)); const ids = new Set(wb.map(b=>b.id)); const costBy = {}; (financeCosts||[]).forEach(c=>{ if (ids.has(c.booking_id)) costBy[c.booking_id] = (costBy[c.booking_id]||0) + num(c.amount); }); const revenue = wb.reduce((s,b)=>s+num(b.total), 0); const eventCosts = Object.values(costBy).reduce((s,v)=>s+v, 0); // money out: this week's manual expenses const wExp = (expenses||[]).filter(e=>inWeek(localDay(e.expense_date))); const expTotal = wExp.reduce((s,e)=>s+num(e.amount), 0); const profit = revenue - eventCosts - expTotal; $("financeSummary").innerHTML = finTile(T[lang].revenue, money(revenue), "")+ finTile(T[lang].eventCosts, money(eventCosts), "red")+ finTile(T[lang].expenses, money(expTotal), "red")+ finTile(T[lang].profit, money(profit), profit >= 0 ? "ok" : "neg"); // ledger: money in const inEl = $("financeIn"); inEl.innerHTML = ""; if (!wb.length){ inEl.innerHTML = '
'+esc(T[lang].noBookingsWeek)+'
'; } wb.forEach(b=>{ const d = document.createElement("div"); d.className = "card booking-card"; d.innerHTML = '
'+esc(b.event_name)+'
'+ '
'+esc(fmtDate(b.event_date))+ (b.venue ? ' · '+esc(b.venue) : '')+'
'+ '
'+money(b.total)+'
'; d.onclick = ()=>{ const full = bookings.find(x=>x.id===b.id); currentBooking = full || b; renderDetail(b.id); }; inEl.appendChild(d); }); // ledger: money out const outEl = $("financeOut"); outEl.innerHTML = ""; if (!wExp.length){ outEl.innerHTML = '
'+esc(T[lang].noExpensesWeek)+'
'; } wExp.forEach(e=>{ const row = document.createElement("div"); row.className = "cost-row"; row.innerHTML = '
'+esc(e.label)+'
'+ '
'+ ''+esc(catLabel(e.category))+''+ ''+esc(fmtExpDate(e.expense_date))+'
'+ '
'+ '
'+money(e.amount)+'
'+ (e.receipt_url ? '' : "")+ '
'; const rBtn = row.querySelector('[data-receipt]'); if (rBtn) rBtn.onclick = ev=>viewReceipt(e.receipt_url, ev); row.querySelector('[data-del]').onclick = async ()=>{ if (!confirm(T[lang].confirmDeleteExpense)) return; const { error } = await supabaseClient().from("expenses").delete().eq("id", e.id); if (error){ showErr("financeErr", T[lang].deleteError); return; } expenses = expenses.filter(x=>x.id!==e.id); renderFinance(); }; outEl.appendChild(row); }); } async function addExpense(){ const label = $("expLabel").value.trim(); const amount = $("expAmount").value === "" ? 0 : num($("expAmount").value); const expDate = $("expDate").value || toDateInput(new Date()); const category = $("expCategory").value || "Other"; if (!label) return; const { data, error } = await supabaseClient().from("expenses") .insert({label, amount, expense_date:expDate, category}) .select().single(); if (error){ showErr("financeErr", T[lang].saveError); return; } expenses.push(data); $("expLabel").value = ""; $("expAmount").value = ""; $("expDate").value = toDateInput(new Date()); renderFinance(); } /* ---------------- receipt scan + price book (v2.2) ---------------- */ function loadTesseract(){ if (tessPromise) return tessPromise; tessPromise = new Promise((resolve, reject)=>{ if (window.Tesseract) return resolve(window.Tesseract); const s = document.createElement("script"); s.src = "https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"; s.onload = ()=> window.Tesseract ? resolve(window.Tesseract) : reject(new Error("tesseract missing")); s.onerror = ()=>reject(new Error("tesseract load failed")); document.head.appendChild(s); }); return tessPromise; } /* Photo file -> resized JPEG blob (max 1600px) for OCR + upload */ function fileToJpegBlob(file){ return new Promise((resolve, reject)=>{ const url = URL.createObjectURL(file); const img = new Image(); img.onload = ()=>{ try{ const max = 1600; const sc = Math.min(1, max / Math.max(img.width, img.height)); const c = document.createElement("canvas"); c.width = Math.max(1, Math.round(img.width * sc)); c.height = Math.max(1, Math.round(img.height * sc)); c.getContext("2d").drawImage(img, 0, 0, c.width, c.height); URL.revokeObjectURL(url); c.toBlob( b=> b ? resolve({blob:b, canvas:c}) : reject(new Error("encode failed")), "image/jpeg", 0.85); }catch(e){ URL.revokeObjectURL(url); reject(e); } }; img.onerror = ()=>{ URL.revokeObjectURL(url); reject(new Error("decode failed")); }; img.src = url; }); } /* Photo file -> preprocessed canvas tuned for OCR (NOT for upload): grayscale + contrast stretch + upscale-if-small + mild sharpen. The storage upload keeps using fileToJpegBlob's plain resized JPEG. */ function photoToOcrCanvas(file){ return new Promise((resolve, reject)=>{ const url = URL.createObjectURL(file); const img = new Image(); img.onload = ()=>{ try{ let w = img.width, h = img.height; const s0 = Math.min(1, 2000 / Math.max(w, h)); w = Math.round(w * s0); h = Math.round(h * s0); if (w < 1600){ const s2 = Math.min(2, 1600 / w, Math.sqrt(6200000 / (w * h))); w = Math.max(1, Math.round(w * s2)); h = Math.max(1, Math.round(h * s2)); } const c = document.createElement("canvas"); c.width = Math.max(1, w); c.height = Math.max(1, h); const ctx = c.getContext("2d", {willReadFrequently:true}); ctx.drawImage(img, 0, 0, c.width, c.height); URL.revokeObjectURL(url); // Grayscale + histogram stretch (contrast normalization) const id = ctx.getImageData(0, 0, c.width, c.height); const d = id.data; let mn = 255, mx = 0; const n = c.width * c.height; const gray = new Uint8ClampedArray(n); for (let i = 0, j = 0; i < d.length; i += 4, j++){ const g = 0.299 * d[i] + 0.587 * d[i+1] + 0.114 * d[i+2]; gray[j] = g; if (g < mn) mn = g; if (g > mx) mx = g; } const range = (mx - mn) || 1; for (let j = 0, o = 0; j < n; j++, o += 4){ const v = Math.round((gray[j] - mn) * 255 / range); d[o] = d[o+1] = d[o+2] = v; d[o+3] = 255; } ctx.putImageData(id, 0, 0); // Mild sharpen (3x3), skipped on huge canvases for speed if (n <= 8000000){ const src = ctx.getImageData(0, 0, c.width, c.height); const sd = src.data, W = c.width, H = c.height; const out = ctx.createImageData(W, H); const od = out.data; const k = [0,-0.2,0, -0.2,1.8,-0.2, 0,-0.2,0]; for (let y = 0; y < H; y++){ for (let x = 0; x < W; x++){ let acc = 0; for (let ky = -1; ky <= 1; ky++){ const yy = y + ky < 0 ? 0 : (y + ky >= H ? H - 1 : y + ky); for (let kx = -1; kx <= 1; kx++){ const xx = x + kx < 0 ? 0 : (x + kx >= W ? W - 1 : x + kx); acc += sd[(yy * W + xx) * 4] * k[(ky + 1) * 3 + (kx + 1)]; } } const o = (y * W + x) * 4; od[o] = od[o+1] = od[o+2] = acc; od[o+3] = 255; } } ctx.putImageData(out, 0, 0); } resolve(c); }catch(e){ URL.revokeObjectURL(url); reject(e); } }; img.onerror = ()=>{ URL.revokeObjectURL(url); reject(new Error("decode failed")); }; img.src = url; }); } function parseMoney(s){ return num(String(s == null ? "" : s).replace(/[$,\s]/g, "")); } /* "2 MILK" -> "MILK" (leading quantity); keeps "12PK SODA" and "7UP" intact */ function stripLeadingQty(desc){ let s = String(desc), m; m = s.match(/^\d{5,9}\s+(.+)$/); // Sam's-style leading item numbers if (m && m[1].length >= 2 && /[a-zA-ZÀ-ÿ]/.test(m[1])) return m[1].trim(); m = s.match(/^\d{1,3}\s+(.+)$/); if (m && m[1].length >= 2 && /[a-zA-ZÀ-ÿ]/.test(m[1])) return m[1].trim(); return s.trim(); } function parseReceipt(text){ const raw = String(text || "").split("\n").map(l=>l.trim()).filter(Boolean); const SKIP = /total|sub\s*total|sales|\bamount\b|tax|impuesto|change|cambio|tender|balance|discount|descuento|savings?|you\s+saved|tip|propina|\bcash\b|efectivo|\bcard\b|tarjeta|visa|mastercard|amex|debit|d[eé]bito|credit|cr[eé]dito|thank|gracias|clerk|cashier|cajero|served by|items?\s+sold|art[ií]culos|receipt|recibo|invoice|factura/i; const priceOnly = /^\$?\s*[\d,]+\.\d{2}\s*[A-Za-z]{0,2}\s*$/; const hasTrailingPrice = /\$?\s*[\d,]+\.\d{2}\s*[A-Za-z]{0,2}\s*$/; const upcPriceOnly = /^\d{10,14}\s+\$?\s*[\d,]+\.\d{2}\s*[A-Za-z]{0,2}\s*$/; // Join wrapped lines: "BOMBA RICE" + "12.99" -> "BOMBA RICE 12.99" const lines = []; for (let i = 0; i < raw.length; i++){ const l = raw[i], nx = raw[i+1]; if (!hasTrailingPrice.test(l) && nx && (priceOnly.test(nx) || upcPriceOnly.test(nx)) && !SKIP.test(l) && /[a-zA-ZÀ-ÿ]{2,}/.test(l) && !/^\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4}/.test(l) && !/^\d{1,2}:\d{2}/.test(l)){ lines.push((l + " " + nx).trim()); i++; } else lines.push(l); } const totalRe = /(total|amount due|grand total|balance|amt due)[^\d$]*\$?\s*([\d,]+\.\d{2})/i; let total = 0, lastIdx = -1; lines.forEach((l,i)=>{ if (totalRe.test(l)) lastIdx = i; }); if (lastIdx >= 0){ const m = lines[lastIdx].match(totalRe); if (m) total = parseMoney(m[2]); } if (!(total > 0)){ let max = 0; (String(text || "").match(/\$?\s*[\d,]+\.\d{2}/g) || []).forEach(a=>{ const v = parseMoney(a); if (v > max) max = v; }); total = max; } // Junk lines that carry no item, even if they look price-adjacent const junkLine = l => /[*Xx]{4,}\d*/.test(l) || // ****1234 masked card /https?:|www\.|\.com\b/i.test(l) || // URLs /^\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4}(\s+\d{1,2}:\d{2})?$/.test(l) || // date / date+time /^\d{1,2}:\d{2}(:\d{2})?\s*(AM|PM)?$/i.test(l) || // time only /^[\d\s().\-+]{7,}$/.test(l) || // phone-like /^\d+\s*(items?|art[ií]culos?)$/i.test(l); // "5 ITEMS" const qtyAtRe = /^(.+?)\s+(\d{1,3})\s*[@×xX]\s*\$?([\d,]+\.\d{2})\s+\$?([\d,]+\.\d{2})\s*[A-Za-z]{0,2}\s*$/; const qtyXRe = /^(.+?)\s+(\d{1,3})\s*[×xX]\s*\$?([\d,]+\.\d{2})\s*[A-Za-z]{0,2}\s*$/; const plainRe = /^(.+?)\s+\$?([\d,]+\.\d{2})\s*[A-Za-z]{0,2}\s*$/; const items = []; lines.forEach(l=>{ if (SKIP.test(l) || junkLine(l)) return; let desc = "", price = 0, m; if ((m = l.match(qtyAtRe))){ desc = stripLeadingQty(m[1]); price = parseMoney(m[4]); } else if ((m = l.match(qtyXRe))){ desc = stripLeadingQty(m[1]); price = parseMoney(m[3]); } else if ((m = l.match(plainRe))){ desc = stripLeadingQty(m[1]); price = parseMoney(m[2]); } else return; desc = String(desc).replace(/^[$\s*]+/, "").replace(/\b\d{10,14}\b/g, "").replace(/\s+/g, " ").trim(); if (desc.length < 2 || !/[a-zA-ZÀ-ÿ]/.test(desc)) return; if (/^[\d\s.,$*xX#\-–—\/]+$/.test(desc)) return; if (!(price > 0)) return; items.push({desc, price}); }); let store = ""; for (const l of lines.slice(0, 6)){ if (/\d+\.\d{2}/.test(l)) continue; const clean = l.replace(/[^a-zA-ZÀ-ÿ0-9 &'.,-]/g, "").trim(); if (clean.replace(/[^a-zA-ZÀ-ÿ]/g, "").length >= 3){ store = clean; break; } } return {items, total, store}; } function openSheet(mode){ sheetOpen = true; sheetMode = mode; $("sheetOverlay").hidden = false; renderReceiptSheet(); } function closeSheet(){ sheetOpen = false; sheetMode = null; sheetErrorKey = null; $("sheetOverlay").hidden = true; if (scanThumbUrl){ URL.revokeObjectURL(scanThumbUrl); scanThumbUrl = null; } scanItems = []; scanBlob = null; scanStore = ""; scanTotal = 0; scanSaveToBook = true; } function setScanProgress(p){ const el = $("scanProg"); if (el) el.textContent = T[lang].readingReceipt + (p > 0 ? " " + p + "%" : ""); } function renderScanItems(){ const list = $("scanItemList"); if (!list) return; list.innerHTML = ""; scanItems.forEach((it, i)=>{ const row = document.createElement("div"); row.className = "scan-item"; row.innerHTML = ''+ ''+ ''; const ins = row.querySelectorAll("input"); ins[0].value = it.desc; ins[1].value = it.price ? Number(it.price).toFixed(2) : ""; ins[0].oninput = e=>{ scanItems[i].desc = e.target.value; }; ins[1].oninput = e=>{ scanItems[i].price = num(e.target.value); }; row.querySelector(".sx").onclick = ()=>{ scanItems.splice(i, 1); renderScanItems(); }; list.appendChild(row); }); } function renderReceiptSheet(){ const body = $("sheetBody"); if (!body) return; body.innerHTML = ""; if (sheetMode === "reading"){ body.innerHTML = '
'+esc(T[lang].readingReceipt)+'
'; return; } if (sheetMode === "error"){ body.innerHTML = '
'+esc(sheetErrorKey ? T[lang][sheetErrorKey] : T[lang].scanError)+'
'+ ''; $("sheetClose").onclick = closeSheet; return; } const h = document.createElement("div"); h.innerHTML = '
'+esc(T[lang].confirmReceiptTitle)+'
'+ (scanThumbUrl ? '' : "")+ '
'+ '
'+ '
'+esc(T[lang].receiptItems)+'
'+ '
'+ (scanItems.length ? "" : '
'+esc(T[lang].noItemsFound)+'
')+ ''+ '
'+ '
'+ ''+ '
'+ '
'; body.appendChild(h); $("scanStore").value = scanStore; $("scanTotal").value = scanTotal > 0 ? Number(scanTotal).toFixed(2) : ""; $("scanToBook").checked = scanSaveToBook; $("scanStore").oninput = e=>{ scanStore = e.target.value; }; $("scanTotal").oninput = e=>{ scanTotal = num(e.target.value); }; $("scanToBook").onchange = e=>{ scanSaveToBook = e.target.checked; }; renderScanItems(); $("scanAddRow").onclick = ()=>{ scanItems.push({desc:"", price:0}); renderScanItems(); }; $("scanCancel").onclick = closeSheet; $("scanConfirm").onclick = confirmReceipt; } async function handleReceiptFile(){ const f = $("receiptFile").files && $("receiptFile").files[0]; $("receiptFile").value = ""; if (!f) return; openSheet("reading"); let prep; try{ prep = await fileToJpegBlob(f); } catch(e){ sheetErrorKey = "photoError"; sheetMode = "error"; renderReceiptSheet(); return; } scanBlob = prep.blob; scanThumbUrl = URL.createObjectURL(prep.blob); let ocrCanvas = prep.canvas; try{ ocrCanvas = await photoToOcrCanvas(f); }catch(e){ /* fall back to resized photo */ } try{ const Tz = await loadTesseract(); const worker = await Tz.createWorker("eng", 1, { workerPath: "https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/worker.min.js", langPath: "https://tessdata.projectnaptha.com/4.0.0", corePath: "https://cdn.jsdelivr.net/npm/tesseract.js-core@5/tesseract-core.wasm.js", logger: m=>{ if (m && m.status === "recognizing text" && typeof m.progress === "number") setScanProgress(Math.round(m.progress * 100)); } }, { tessedit_pageseg_mode: "6", preserve_interword_spaces: "1" }); try{ if (typeof worker.setParameters === "function") await worker.setParameters({ tessedit_pageseg_mode: "6", preserve_interword_spaces: "1" }); }catch(_){ /* older build: 4th-arg config already applied */ } const { data } = await worker.recognize(ocrCanvas); await worker.terminate(); const parsed = parseReceipt((data && data.text) || ""); scanItems = parsed.items; scanTotal = parsed.total; scanStore = parsed.store; scanSaveToBook = true; sheetMode = "confirm"; renderReceiptSheet(); }catch(e){ sheetErrorKey = "scanError"; sheetMode = "error"; renderReceiptSheet(); } } /* Price book sync: cheaper price automatically replaces stored cost (never rises) */ async function syncPriceBook(items, expenseId){ const client = supabaseClient(); if (!client) return; const { data: current } = await client.from("inventory_items").select("*"); const byName = {}; (current || []).forEach(c=>{ byName[String(c.name).trim().toLowerCase()] = c; }); const unitEach = T[lang].unitEach; for (const it of items){ const written = String(it.desc || "").trim(); const norm = written.toLowerCase(); const price = num(it.price); if (!norm || !(price > 0)) continue; try{ const ex = byName[norm]; if (!ex){ const { data: ins, error } = await client.from("inventory_items") .insert({name: written, unit: unitEach, unit_cost: price}).select().single(); if (!error && ins){ byName[norm] = ins; await client.from("price_history") .insert({item_name: written, unit: unitEach, amount: price, expense_id: expenseId}); } } else if (price < num(ex.unit_cost)){ const { error } = await client.from("inventory_items") .update({unit_cost: price, updated_at: new Date().toISOString()}).eq("id", ex.id); if (!error){ ex.unit_cost = price; await client.from("price_history") .insert({item_name: ex.name, unit: ex.unit, amount: price, expense_id: expenseId}); } } else { await client.from("price_history") .insert({item_name: ex.name, unit: ex.unit, amount: price, expense_id: expenseId}); } }catch(e){ /* one bad item never blocks the rest */ } } } async function confirmReceipt(){ const client = supabaseClient(); if (!client){ closeSheet(); showErr("financeErr", T[lang].saveError); return; } const btn = $("scanConfirm"); if (btn) btn.disabled = true; const label = (scanStore || "").trim() || T[lang].receiptDefault; const amount = num($("scanTotal").value); const category = $("expCategory") ? ($("expCategory").value || "Other") : "Other"; const { data, error } = await client.from("expenses") .insert({label, amount, expense_date: toDateInput(new Date()), category}) .select().single(); if (error){ closeSheet(); showErr("financeErr", T[lang].saveError); return; } const expenseId = data.id; if (scanBlob){ try{ const fname = expenseId + ".jpg"; const up = await client.storage.from("receipts") .upload(fname, scanBlob, {contentType:"image/jpeg", upsert:true}); if (!up.error){ await client.from("expenses").update({receipt_url: fname}).eq("id", expenseId); data.receipt_url = fname; } }catch(e){ /* photo is optional — the expense is saved regardless */ } } if (scanSaveToBook && scanItems.length){ try{ await syncPriceBook(scanItems, expenseId); }catch(e){} } expenses.push(data); expenses.sort((a,b)=>String(b.expense_date).localeCompare(String(a.expense_date))); closeSheet(); finCursor = new Date(); await loadCosts(); renderFinance(); } async function viewReceipt(path, ev){ if (ev) ev.stopPropagation(); const client = supabaseClient(); if (!client || !path) return; const { data, error } = await client.storage.from("receipts").createSignedUrl(path, 3600); if (error || !data || !data.signedUrl){ showErr("financeErr", T[lang].loadError); return; } window.open(data.signedUrl, "_blank"); } /* ---------------- prep sheet share ---------------- */ async function prepSheet(id){ const b = bookings.find(x=>x.id===id); if (!b) return; const client = supabaseClient(); let items = []; if (client){ const { data } = await client.from("inventory_needs") .select("item,qty").eq("booking_id", id) .order("sort_order",{ascending:true}); items = data || []; } let txt = "VITON PAELLA — " + String(b.event_name||"").toUpperCase() + "\n" + fmtDate(b.event_date) + " · " + [b.venue,b.city].filter(Boolean).join(", ") + "\n" + int(b.guests) + " " + T[lang].guests + " · " + (b.menu||"") + "\n\n" + T[lang].bring + "\n"; items.forEach(n=>{ txt += "• " + n.item + (n.qty ? " — " + n.qty : "") + "\n"; }); txt += "\n" + T[lang].balanceDue + ": " + money(num(b.total)-num(b.deposit_paid)); if (navigator.share){ try{ await navigator.share({title:b.event_name, text:txt}); }catch(e){} } else alert(txt); } /* ---------------- wiring ---------------- */ function wire(){ $("langToggle").onclick = toggleLang; $("logoutBtn").onclick = doLogout; $("loginBtn").onclick = doLogin; $("loginEmail").addEventListener("keydown", e=>{ if (e.key==="Enter") doLogin(); }); $("loginPass").addEventListener("keydown", e=>{ if (e.key==="Enter") doLogin(); }); $("navBookings").onclick = ()=>show("scr-bookings"); $("navInventory").onclick = ()=>show("scr-inventory"); $("navCalendar").onclick = ()=>{ show("scr-calendar"); renderCalendar(); }; $("navFinance").onclick = ()=>{ show("scr-finance"); loadFinance(); }; $("finPrev").onclick = ()=>{ finCursor = new Date(finCursor.getFullYear(), finCursor.getMonth(), finCursor.getDate()-7); loadFinance(); }; $("finNext").onclick = ()=>{ finCursor = new Date(finCursor.getFullYear(), finCursor.getMonth(), finCursor.getDate()+7); loadFinance(); }; $("finThisWeek").onclick = ()=>{ finCursor = new Date(); loadFinance(); }; $("expAdd").onclick = addExpense; $("expLabel").addEventListener("keydown", e=>{ if (e.key==="Enter") addExpense(); }); $("expAmount").addEventListener("keydown", e=>{ if (e.key==="Enter") addExpense(); }); $("scanReceiptBtn").onclick = ()=>$("receiptFile").click(); $("receiptFile").addEventListener("change", handleReceiptFile); $("sheetOverlay").addEventListener("click", e=>{ if (e.target === $("sheetOverlay") && sheetOpen && sheetMode !== "reading") closeSheet(); }); $("calPrev").onclick = ()=>{ calCursor = new Date(calCursor.getFullYear(), calCursor.getMonth()-1, 1); renderCalendar(); }; $("calNext").onclick = ()=>{ calCursor = new Date(calCursor.getFullYear(), calCursor.getMonth()+1, 1); renderCalendar(); }; $("calToday").onclick = ()=>{ const t = new Date(); calCursor = new Date(t.getFullYear(), t.getMonth(), 1); calSelected = {y:t.getFullYear(), m:t.getMonth(), d:t.getDate()}; renderCalendar(); }; $("tabNeeds").onclick = ()=>invTab("needs"); $("tabCosts").onclick = ()=>invTab("costs"); $("addBookingBtn").onclick = ()=>openBookingForm(null); $("detailBack").onclick = ()=>show("scr-bookings"); $("formBack").onclick = ()=>show(formReturn); $("formCancel").onclick = ()=>show(formReturn); $("formSave").onclick = saveBooking; $("eventPicker").onchange = ()=>{ selectedBookingId = $("eventPicker").value || null; loadNeeds(); }; $("needAdd").onclick = addNeed; $("needItem").addEventListener("keydown", e=>{ if (e.key==="Enter") addNeed(); }); $("needQty").addEventListener("keydown", e=>{ if (e.key==="Enter") addNeed(); }); $("costAdd").onclick = addCost; } /* ---------------- boot ---------------- */ applyI18n(); wire(); initAuth();