/* ============================================================
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)+': '+money(b.deposit_paid)+''+
''+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)+'
'+
''+
''+
(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)+'
'+
'';
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";
cell.innerHTML = ''+ddd+''+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();