Vídeo
tutorial
📖 Dossier
completo
o usa tu correo
🏪

Asistente del Campo

IA hostelero · datos en tiempo real

👨‍🍳 Jornada 📦 Venta 💸 Gasto 🏪 Local 💰 Resumen
ray array['gastos','ventas','jornadas','jornaleros','fincas','riegos','productos_finca','campanas','mc_stock'] loop if exists (select 1 from information_schema.tables where table_schema='public' and table_name=t) then execute format('alter table public.%I enable row level security', t); execute format('drop policy if exists "admin_read_all_%I" on public.%I', t, t); execute format('create policy "admin_read_all_%I" on public.%I for select using (public.is_admin())', t, t); execute format('drop policy if exists "admin_write_all_%I" on public.%I', t, t); execute format('create policy "admin_write_all_%I" on public.%I for all using (public.is_admin()) with check (public.is_admin())', t, t); end if; end loop; end $$; -- 3) Función list_users — SIN PARÁMETROS, firma explícita create function public.list_users() returns setof json language plpgsql security definer stable set search_path = public, auth as $$ begin if not public.is_admin() then return; end if; return query select json_build_object( 'id', u.id, 'email', u.email, 'created_at', u.created_at, 'last_sign_in_at', u.last_sign_in_at, 'name', u.raw_user_meta_data->>'name' ) from auth.users u order by u.created_at desc; end; $$; revoke all on function public.list_users() from public; grant execute on function public.list_users() to authenticated; -- 4) Tabla de SESIONES/VISITAS (analítica) create table if not exists public.mc_sessions( id bigserial primary key, user_id uuid references auth.users(id) on delete cascade, email text, user_agent text, ts timestamptz default now() ); -- 4b) Tabla de CAMPAÑAS (si no existía ya) create table if not exists public.campanas( id uuid primary key default gen_random_uuid(), user_id uuid references auth.users(id) on delete cascade, nombre text not null, fecha_inicio date, fecha_fin date, descripcion text, fincas jsonb default '[]'::jsonb, created_at timestamptz default now() ); alter table public.campanas enable row level security; drop policy if exists "users_own_campanas" on public.campanas; create policy "users_own_campanas" on public.campanas for all using (auth.uid() = user_id) with check (auth.uid() = user_id); drop policy if exists "admin_all_campanas" on public.campanas; create policy "admin_all_campanas" on public.campanas for all using (public.is_admin()) with check (public.is_admin()); -- 4d) Tabla de FICHAJES (entradas/salidas vía QR en fincas) create table if not exists public.mc_fichajes( id uuid primary key default gen_random_uuid(), finca_id uuid not null, jornalero_id uuid, jornalero_nombre text, tipo text check(tipo in ('entrada','salida')), ts timestamptz default now(), user_agent text ); create index if not exists mc_fichajes_finca_idx on public.mc_fichajes(finca_id); create index if not exists mc_fichajes_ts_idx on public.mc_fichajes(ts desc); alter table public.mc_fichajes enable row level security; -- Cualquiera (incluso anónimo) puede insertar un fichaje (escaneando el QR del campo) drop policy if exists "anyone_insert_fichaje" on public.mc_fichajes; create policy "anyone_insert_fichaje" on public.mc_fichajes for insert to anon, authenticated with check (true); -- El dueño de la finca lee sus fichajes; admin lee todos drop policy if exists "owner_read_fichajes" on public.mc_fichajes; create policy "owner_read_fichajes" on public.mc_fichajes for select using ( public.is_admin() or exists(select 1 from public.fincas f where f.id=finca_id and f.user_id=auth.uid()) ); drop policy if exists "owner_manage_fichajes" on public.mc_fichajes; create policy "owner_manage_fichajes" on public.mc_fichajes for all using ( public.is_admin() or exists(select 1 from public.fincas f where f.id=finca_id and f.user_id=auth.uid()) ) with check ( public.is_admin() or exists(select 1 from public.fincas f where f.id=finca_id and f.user_id=auth.uid()) ); -- Función pública para leer el nombre de una finca (sin login) — para mostrarlo en la página de fichaje create or replace function public.get_finca_publica(p_finca_id uuid) returns table(nombre text) language sql security definer stable set search_path = public as $$ select nombre from public.fincas where id = p_finca_id; $$; grant execute on function public.get_finca_publica(uuid) to anon, authenticated; -- 4e) MENSAJERÍA GLOBAL — funciones para que todos los usuarios puedan buscarse y escribirse -- Búsqueda de usuarios por email/nombre (cualquier usuario autenticado puede usarla) create or replace function public.search_users(q text) returns table(id uuid, email text, name text) language sql security definer stable set search_path = public, auth as $$ select u.id, u.email::text, coalesce(u.raw_user_meta_data->>'name', split_part(u.email,'@',1))::text as name from auth.users u where auth.uid() is not null -- solo usuarios autenticados and u.id != auth.uid() -- no listar al propio usuario and u.email is not null and ( q is null or q = '' or u.email ilike '%'||q||'%' or coalesce(u.raw_user_meta_data->>'name','') ilike '%'||q||'%' ) order by u.email limit 30; $$; grant execute on function public.search_users(text) to authenticated; -- Información puntual de un usuario por su ID create or replace function public.get_user_info(p_user_id uuid) returns table(id uuid, email text, name text) language sql security definer stable set search_path = public, auth as $$ select u.id, u.email::text, coalesce(u.raw_user_meta_data->>'name', split_part(u.email,'@',1))::text as name from auth.users u where auth.uid() is not null and u.id = p_user_id; $$; grant execute on function public.get_user_info(uuid) to authenticated; -- Información de varios usuarios a la vez (para resolver lista de conversaciones) create or replace function public.get_users_info(p_user_ids uuid[]) returns table(id uuid, email text, name text) language sql security definer stable set search_path = public, auth as $$ select u.id, u.email::text, coalesce(u.raw_user_meta_data->>'name', split_part(u.email,'@',1))::text as name from auth.users u where auth.uid() is not null and u.id = any(p_user_ids); $$; grant execute on function public.get_users_info(uuid[]) to authenticated; -- RLS de la tabla mensajes — cualquier usuario puede enviar/leer SUS mensajes (recibidos o enviados) do $$ begin if exists (select 1 from information_schema.tables where table_schema='public' and table_name='mensajes') then execute 'alter table public.mensajes enable row level security'; execute 'drop policy if exists "send_own_messages" on public.mensajes'; execute 'create policy "send_own_messages" on public.mensajes for insert with check (auth.uid() = de_user)'; execute 'drop policy if exists "read_my_messages" on public.mensajes'; execute 'create policy "read_my_messages" on public.mensajes for select using (auth.uid() = de_user or auth.uid() = para_user or grupo_id is not null)'; execute 'drop policy if exists "delete_own_messages" on public.mensajes'; execute 'create policy "delete_own_messages" on public.mensajes for delete using (auth.uid() = de_user)'; execute 'drop policy if exists "admin_all_messages" on public.mensajes'; execute 'create policy "admin_all_messages" on public.mensajes for all using (public.is_admin()) with check (public.is_admin())'; end if; end $$; create table if not exists public.mc_stock( id uuid primary key default gen_random_uuid(), user_id uuid references auth.users(id) on delete cascade, nombre text not null, categoria text, cantidad numeric not null default 0, unidad text default 'kg', consumo_diario numeric default 0, precio_unitario numeric default 0, proveedor text, finca_nombre text, observaciones text, ultima_actualizacion timestamptz default now(), created_at timestamptz default now() ); alter table public.mc_stock enable row level security; drop policy if exists "users_own_stock" on public.mc_stock; create policy "users_own_stock" on public.mc_stock for all using (auth.uid() = user_id) with check (auth.uid() = user_id); drop policy if exists "admin_all_stock" on public.mc_stock; create policy "admin_all_stock" on public.mc_stock for all using (public.is_admin()) with check (public.is_admin()); create index if not exists mc_sessions_ts_idx on public.mc_sessions(ts desc); create index if not exists mc_sessions_user_idx on public.mc_sessions(user_id); alter table public.mc_sessions enable row level security; drop policy if exists "users_insert_own_session" on public.mc_sessions; create policy "users_insert_own_session" on public.mc_sessions for insert with check (auth.uid() = user_id); drop policy if exists "admin_read_all_sessions" on public.mc_sessions; create policy "admin_read_all_sessions" on public.mc_sessions for select using (public.is_admin()); drop policy if exists "admin_manage_sessions" on public.mc_sessions; create policy "admin_manage_sessions" on public.mc_sessions for all using (public.is_admin()) with check (public.is_admin()); -- 5) IMPORTANTE: refrescar schema cache de PostgREST (varios métodos) notify pgrst, 'reload schema'; notify pgrst; -- 6) Verificación: si esta consulta devuelve filas, todo está OK -- NOTA: en el SQL Editor, is_admin será 'false' porque el editor corre como superusuario, -- no como un usuario logueado. La función funcionará bien cuando se llame desde la app. -- list_users count será '0' aquí por la misma razón (no admin = no devuelve usuarios). select 'is_admin' as f, public.is_admin()::text as resultado union all select 'list_users count', coalesce(json_array_length(json_agg(t)::json), 0)::text from public.list_users() t;` // Guardar el SQL en una variable global para que el botón pueda leerlo sin escapado window._sqlAdminFull=sql h+=`
⚠️ Importante: Pega este SQL en Supabase → SQL Editor y ejecútalo UNA SOLA VEZ. Crea: políticas de admin, función list_users() y tabla mc_sessions para analítica.
${esc(sql)}
Pasos:
1. Pulsa el botón verde para copiar el SQL
2. Ve a app.supabase.com → tu proyecto → SQL Editor
3. Pega el SQL y dale a "Run"
4. Vuelve aquí y pulsa "🔄 Recargar datos"
5. Listo: verás todos los usuarios y todas las visitas
📊 ¿Y para visitas a la web ANTES del registro? Esta analítica solo cuenta usuarios autenticados. Para ver visitas anónimas a tu landing/web pública, añade un script de Plausible (recomendado, sin cookies, ~9€/mes) o Google Analytics 4 (gratis) en el <head> de tu HTML — basta con una línea.
` } h+=`` container.innerHTML=h } window.adminViewMode=adminViewMode function buildSys(){ // Helper para limpiar campos internos de Supabase/memoria const clean=(r,keep)=>{const o={};for(const k of keep)if(r[k]!=null&&r[k]!=='')o[k]=r[k];return o} const snap={ gastos:D.ga.slice(0,15).map(r=>clean(r,['fecha','concepto','categoria','importe','proveedor','finca_nombre','cantidad','unidad'])), ventas:D.ve.slice(0,15).map(r=>clean(r,['fecha','producto','kilos_kg','precio_kg','importe_total','cliente','finca_nombre','numero_albaran'])), jornadas:D.jo.slice(0,15).map(r=>clean(r,['fecha','jornalero','horas','tarea','finca_nombre','incidencia'])), jornaleros:D.pl.map(r=>clean(r,['nombre','tarifa','tarea'])), fincas:D.fi.map(f=>({ nombre:f.nombre, cultivo:f.cultivo||'', hectareas:f.hectareas||0, riegos:D.ri.filter(r=>r.finca_id===f._id).slice(0,5).map(r=>clean(r,['fecha','horas','metodo','observaciones'])), productos:D.rp.filter(r=>r.finca_id===f._id).slice(0,5).map(r=>clean(r,['fecha','nombre','tipo','cantidad','observaciones'])) })), stock:(stockItems||[]).slice(0,30).map(it=>{ const dias=it.consumo_diario>0&&it.cantidad>0?Math.floor(it.cantidad/it.consumo_diario):null return { nombre:it.nombre, categoria:it.categoria, cantidad:parseFloat(it.cantidad||0), unidad:it.unidad, consumo_diario:parseFloat(it.consumo_diario||0), dias_restantes:dias, precio_unitario:parseFloat(it.precio_unitario||0), valor_total_eur:Math.round(parseFloat(it.cantidad||0)*parseFloat(it.precio_unitario||0)), proveedor:it.proveedor, finca:it.finca_nombre } }) } // Añadir dispositivos con sus lecturas actuales (sin credenciales — no aportan y ocupan tokens) if(typeof dispositivos!=='undefined'&&dispositivos.length){ snap.dispositivos=dispositivos.map(d=>{ const fi=D.fi.find(f=>f._id===d.finca_id) const o={nombre:d.nombre,tipo:d.tipo||'ecowitt',finca:fi?fi.nombre:'sin asignar',activo:!!d.activo} const dt=d.datos if(dt&&!dt.error){ const lect={} if(dt.temp!=null)lect.temp_exterior_C=+parseFloat(dt.temp).toFixed(1) if(dt.hum!=null)lect.humedad_exterior_pct=+parseFloat(dt.hum).toFixed(0) if(dt.tempIn!=null)lect.temp_interior_C=+parseFloat(dt.tempIn).toFixed(1) if(dt.humIn!=null)lect.humedad_interior_pct=+parseFloat(dt.humIn).toFixed(0) if(dt.wind!=null)lect.viento_kmh=+parseFloat(dt.wind).toFixed(1) if(dt.gust!=null)lect.rachas_kmh=+parseFloat(dt.gust).toFixed(1) if(dt.windDir!=null){const dirs=['N','NE','E','SE','S','SO','O','NO'];lect.direccion_viento=dirs[Math.round(dt.windDir/45)%8]} if(dt.rainHour!=null)lect.lluvia_hora_mm=+parseFloat(dt.rainHour).toFixed(1) if(dt.rainDay!=null)lect.lluvia_dia_mm=+parseFloat(dt.rainDay).toFixed(1) if(dt.rainWeek!=null&&dt.rainWeek>0)lect.lluvia_semana_mm=+parseFloat(dt.rainWeek).toFixed(1) if(dt.rainMonth!=null&&dt.rainMonth>0)lect.lluvia_mes_mm=+parseFloat(dt.rainMonth).toFixed(1) if(dt.pressure!=null)lect.presion_hPa=+parseFloat(dt.pressure).toFixed(0) if(dt.uvi!=null)lect.indice_uv=+parseFloat(dt.uvi).toFixed(0) if(dt.solar!=null)lect.solar_W_m2=+parseFloat(dt.solar).toFixed(0) if(dt.co2In!=null)lect.co2_ppm=+parseFloat(dt.co2In).toFixed(0) if(dt.lightningCount!=null)lect.rayos_hoy=dt.lightningCount if(dt.lightningDist!=null)lect.km_ultimo_rayo=dt.lightningDist // DPV / VPD calculado — estado fisiológico if(dt.temp!=null&&dt.hum!=null){ const c=calcDPV(dt.temp,dt.hum) if(c){ const cl=clasificarDPV(c.dpv) lect.dpv_kPa=+c.dpv.toFixed(2) lect.dpv_estado=cl?cl.label:'' lect.dpv_recomendacion=cl?cl.accion:'' } } if(dt.soilChannels?.length)lect.suelo=dt.soilChannels.map(s=>({canal:s.ch,humedad_pct:s.moist!=null?Math.round(s.moist):null,temp_C:s.temp!=null?+parseFloat(s.temp).toFixed(1):null})) if(dt.tempHumChannels?.length)lect.temp_hum_extra=dt.tempHumChannels.map(c=>({canal:c.ch,temp_C:c.temp!=null?+parseFloat(c.temp).toFixed(1):null,hum_pct:c.hum!=null?Math.round(c.hum):null})) if(dt.leafChannels?.length)lect.humedad_foliar=dt.leafChannels.map(c=>({canal:c.ch,wet_pct:c.wet})) if(dt.pm25Channels?.length)lect.pm25=dt.pm25Channels.map(c=>({canal:c.ch,ugm3:c.pm25})) if(dt.ts)lect.ultima_lectura=new Date(dt.ts).toLocaleString('es-ES',{dateStyle:'short',timeStyle:'short'}) o.lecturas=lect }else if(dt&&dt.error){ o.estado='desconectado: '+dt.error }else{ o.estado='sin datos todavía' } return o }) } // Si el usuario es admin, añadir estadísticas globales y lista resumida de usuarios let adminContext='' if(isAdmin()){ try{ const stats=adminGlobalStats() const ss=adminSessionStats() const usersForIA=(stats.users_all||stats.users_top||[]).slice(0,12).map(u=>({ email:u.email||((u.id||u.user_id||'').slice(0,8)+'…'), registro:u.created_at?new Date(u.created_at).toISOString().slice(0,10):null, ultimo_login:u.last_sign_in_at?new Date(u.last_sign_in_at).toISOString().slice(0,10):null, fincas:u.fincas||0,ventas:u.ventas||0,gastos:u.gastos||0,jornadas:u.jornadas||0, ingresos_eur:Math.round(u.total_ingresos||0), costes_eur:Math.round(u.total_costes||0), ultima_actividad:u.ultima?new Date(u.ultima).toISOString().slice(0,10):'sin_actividad' })) // Estado real del backend (HONESTIDAD: si RLS no aplicado, decirlo claro) const estadoBackend=stats.rls_aplicado ?'OK — datos completos de todos los usuarios cargados desde Supabase' :'⚠️ INCOMPLETO — el SQL de RLS no está aplicado todavía, así que solo veo los datos del propio admin (1 usuario aparente). Para ver TODOS los usuarios reales, el admin debe ir a la pestaña ⚙️ Admin > Configurar y ejecutar el SQL en Supabase.' adminContext=` ESTÁS HABLANDO CON EL ADMINISTRADOR (${(currentUser?.email||'').toLowerCase()}). ESTADO DEL BACKEND DE ADMIN: ${estadoBackend} ESTADÍSTICAS GLOBALES DE LA PLATAFORMA MI HOSTELERÍA: ${JSON.stringify({ usuarios_registrados_real:stats.n_users_real, usuarios_con_actividad:stats.n_users_con_datos, total_fincas:stats.n_fincas, total_gastos:stats.n_gastos, total_ventas:stats.n_ventas, total_jornadas:stats.n_jornadas, total_jornaleros:stats.n_jornaleros, ingresos_totales_eur:Math.round(stats.ingresos_total), costes_totales_eur:Math.round(stats.costes_total), margen_total_eur:Math.round(stats.margen), })} ANALÍTICA DE VISITAS (sesiones autenticadas en la app): ${ss?JSON.stringify({ visitas_hoy:ss.visitas_hoy, visitas_ultimos_7d:ss.visitas_7d, visitas_ultimos_30d:ss.visitas_30d, usuarios_unicos_hoy:ss.usuarios_hoy, usuarios_unicos_7d:ss.usuarios_7d, total_sesiones_historico:ss.total_sesiones, ultimas_dos_semanas_por_dia:ss.serie_14d }):'(no hay datos de sesiones — la tabla mc_sessions aún no existe o está vacía. Aplicar SQL del panel ⚙️ Configurar)'} USUARIOS (lista resumida): ${JSON.stringify(usersForIA)} REGLAS DE HONESTIDAD CRÍTICAS: - Si "estado_backend" indica INCOMPLETO, DEBES avisar al admin de que los números de usuarios pueden no reflejar la realidad y dirigirlo al panel ⚙️ Configurar. - NUNCA digas "hay 1 usuario en total" cuando el estado sea INCOMPLETO. En su lugar di: "Solo puedo ver tu propia cuenta porque el RLS de admin no está aplicado todavía. Aplica el SQL en Supabase para que aparezcan los demás usuarios." - Para preguntas sobre VISITAS A LA WEB PÚBLICA (gente que entra sin estar registrada), responde que Mi Hostelería tiene Google Analytics 4 activo (propiedad G-QMG1YFJ0CF, dominio micampoconia.com) y Google Tag Manager (GTM-5MRXGJ6B). Las estadísticas completas (países, dispositivos, embudos, conversiones) se ven en analytics.google.com. En la app solo registramos sesiones de usuarios autenticados. - Para preguntas sobre USO DE LA APP (sesiones de usuarios autenticados), usa los números de "analítica de visitas" arriba. - NUNCA inventes números. Si un campo es null o no aparece, di que no lo tienes. Como admin puedes: - Responder estadísticas globales con los números EXACTOS arriba - Identificar usuarios inactivos (los que tienen ultima_actividad="sin_actividad") - Detectar el más activo - Sugerir acciones (animar inactivos, etc.) - Recomendar al admin que use la pestaña Admin > Inspeccionar para ver los datos crudos de un usuario.` }catch(e){console.warn('admin context error:',e)} } return `Eres el asistente de gestión de un hostelero. Eres conciso y cercano. 🌐 IDIOMA: El usuario tiene la app en ${LANG_NAMES[userSettings.lang]||'español'}. RESPONDE SIEMPRE EN ${(LANG_NAMES[userSettings.lang]||'español').toUpperCase()}, salvo si el propio usuario te escribe en otro idioma — en ese caso, responde en el idioma en que te haya escrito. Los datos del usuario están en español (nombres de cultivos, productos, etc.) — déjalos como están y solo traduce TUS textos de respuesta.${adminContext} INFORMACIÓN DEL CREADOR DE LA APP: Mi Hostelería ha sido creada por Ali Aauicha Azghouli (aliaauicha@gmail.com, tel. 678902270). - Especialista en operaciones y analítica. - Trabajó en el campo durante sus años universitarios en la costa almeriense. - Grado en Ciencias Políticas (Universidad de Granada), Máster en Análisis Económico (Universidad de Málaga), Máster en SAP BTP y AI (en curso, EuropeanBTech 2026). - Carrera profesional: Analista de Operaciones en Fluiconnecto (UK), Responsable de Operaciones en Paack (UK), Analista de Cadena de Suministro en Grupo Sesé (Barcelona). - Especializado en optimización de procesos, gestión de stock, análisis de datos, Lean y Six Sigma. - LinkedIn: https://www.linkedin.com/in/ali-aauicha/ Si alguien pregunta sobre el autor, el creador o quién ha hecho la app, responde con esta información. DATOS ACTUALES: ${JSON.stringify(snap)} MÓDULOS: - ga (gasto): alimentos, bebidas, limpieza, menaje, energía, suministros, mantenimiento, otros. Puede incluir finca_nombre (establecimiento). - ve (venta): tickets / cierre de caja — producto (menú del día, carta, desayunos, bebidas, etc.), kilos_kg (=número de tickets o cubiertos), precio_kg (=ticket medio €), importe_total, cliente (sala/barra/terraza/take-away), numero_albaran (número de ticket Z), finca_nombre (establecimiento) - jo (jornada): días trabajados — jornalero (nombre del trabajador), horas, tarea (cocina, sala, barra, limpieza, encargado, desayunos…), fecha, incidencia, finca_nombre (establecimiento) - jornalero_nuevo: AÑADIR un trabajador a la plantilla — nombre, tarifa (€/hora), tarea (puesto: camarero, cocinero, ayudante, encargado, limpieza, recepción). Usar cuando digan "añade a Fernando", "nuevo camarero Fernando 9€/h sala", "apunta a X al equipo". - riego: servicios o aperturas por establecimiento — finca (nombre del establecimiento), horas (de servicio), metodo (mediodía/cena/desayuno/continuo/turno doble), fecha, observaciones - campana: crear temporada — nombre (Verano 2026, Navidad, Semana Santa…), fecha_inicio (YYYY-MM-DD), fecha_fin (YYYY-MM-DD), descripcion, fincas (array de establecimientos) - evento: añadir cita al calendario — titulo, tipo (reserva grupo/evento privado/mantenimiento/inspección/proveedor/reunion/otro), fecha (YYYY-MM-DD), hora (HH:MM), descripcion - producto_finca: productos consumidos en local — finca (establecimiento), nombre (del producto), tipo (limpieza/menaje/desinfección/lavandería/otro), cantidad, fecha, observaciones - stock_nuevo: AÑADIR producto al stock/almacén — nombre, categoria (alimentos/bebidas/limpieza/menaje/energia/mantenimiento/otros), cantidad (número), unidad (kg/L/cajas/botellas/barril/pack/unidades/rollos), consumo_diario (número, 0 si no consume), precio_unitario (€/unidad, número), proveedor (Makro, Mahou, Coosur, Selecta, distribuidor local…), finca_nombre opcional, observaciones. Usar cuando digan "tengo X de producto Y", "he comprado Z cajas de…", "añade al stock", "apunta en almacén", "quedan N botellas de…". SOBRE LOS DISPOSITIVOS/SENSORES: - En "dispositivos" tienes el listado de sensores conectados del hostelero (sondas de temperatura de cámaras frigoríficas, sensores de terraza, estaciones meteo), con el establecimiento donde están instalados y sus lecturas en vivo (temperatura cámara, congelador, ambiente terraza, viento, humedad, etc.). - Si te preguntan por los dispositivos, sensores, estaciones o por la temperatura de una cámara concreta, USA estos datos y responde con las lecturas reales. - Si preguntan "¿qué dispositivos tengo?" o "¿qué sensores hay en el local X?", responde listando los que hay con su establecimiento asignado. - Si preguntan por la temperatura de cámaras o el tiempo en la terraza, busca el sensor asignado a ese local y da las cifras exactas. Avisa si una cámara está fuera de rango (carnes/lácteos: 0-4 °C, congelador: -18 °C o menos, vinos: 10-14 °C). - Si un dispositivo tiene estado "desconectado" o "sin datos", indícalo amablemente y sugiere pulsar 🔄 Actualizar. INSTRUCCIONES: - "Fernando 9€ hora sala" o "añade a Fernando al equipo" → tipo jornalero_nuevo con nombre, tarifa y tarea (puesto). - "Fernando trabajó 8h cocina hoy" → tipo jo (jornada trabajada ese día). - "He comprado 24 cajas de cerveza a 13€" o "tengo 50L de aceite" → tipo stock_nuevo con nombre, categoria, cantidad, unidad, precio_unitario. - Para jornadas múltiples "Antonio 8h sala, Carmen 6h cocina" → una entrada jo por persona. - Para establecimientos: "Bar Centro: 42 menús del día, ticket medio 14,80€" → UNA venta (ve). - Si menciona un establecimiento que no existe, créalo automáticamente. - En consultas de totales → calcula y responde con precisión. - Para consultas sobre sensores/dispositivos/temperaturas/cámaras → usa la sección "dispositivos" y "guardar" queda vacío. Responde con JSON puro: {"respuesta":"texto","guardar":[{"tipo":"gasto|venta|jo|jornalero_nuevo|riego|producto_finca|stock_nuevo|campana|evento","datos":{...}}]} Sin guardar → "guardar":[]` } function addUserMsg(t){ const b=document.getElementById('chat-msgs') const d=document.createElement('div');d.className='msg user' d.innerHTML=`
${esc(t)}
`;b.appendChild(d);b.scrollTop=b.scrollHeight } function addBotMsg(html,pill=''){ const b=document.getElementById('chat-msgs') const d=document.createElement('div');d.className='msg bot' d.innerHTML=`
🏪
${html}${pill?'
'+pill+'':''}
` b.appendChild(d);b.scrollTop=b.scrollHeight } function showTyping(){ const b=document.getElementById('chat-msgs') const d=document.createElement('div');d.id='typ';d.className='msg bot' d.innerHTML='
🏪
' b.appendChild(d);b.scrollTop=b.scrollHeight } function rmTyping(){document.getElementById('typ')?.remove()} async function sendMsg(){ const inp=document.getElementById('chat-input') const txt=inp.value.trim();if(!txt)return inp.value='';inp.style.height='auto' document.getElementById('send-btn').disabled=true addUserMsg(txt);convHistory.push({role:'user',content:txt});showTyping() try{ const res=await fetch('https://5bffv7il4g.execute-api.eu-south-2.amazonaws.com/',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({model:'claude-haiku-4-5-20251001',max_tokens:900,system:buildSys(),messages:convHistory.slice(-10)}) }) if(!res.ok){ const errText=await res.text() console.error('Chat error:',res.status,errText) if(res.status===429){ rmTyping() addBotMsg('⏳ Se ha alcanzado el límite de peticiones por minuto. Espera unos 30-60 segundos y vuelve a preguntar.

Consejo: preguntas más cortas y sin repetir todo el historial consumen menos.') document.getElementById('send-btn').disabled=false return } throw new Error('API error '+res.status) } const raw=await res.json() const t2=(raw.content||[]).map(i=>i.text||'').join('') let p={respuesta:t2,guardar:[]} try{p=JSON.parse(t2.replace(/```json|```/g,'').trim())}catch{} const saved=[] for(const item of(p.guardar||[])){ if(!item.tipo||!item.datos)continue await saveEntry(item.tipo,item.datos) saved.push({gasto:'💸 Gastos',venta:'📦 Ventas',jo:'👨‍🍳 Personal',jornalero_nuevo:'👨‍🍳 Personal',riego:'🏪 Establecimientos',producto_finca:'🏪 Establecimientos'}[item.tipo]||'✓') } rmTyping() const pill=saved.length?'✓ Guardado en: '+[...new Set(saved)].join(', '):'' addBotMsg(esc(p.respuesta),pill) convHistory.push({role:'assistant',content:p.respuesta}) if(convHistory.length>20)convHistory=convHistory.slice(-20) if(saved.length){ const tabMap={'Gastos':'g','Ventas':'v','Personal':'j','Establecimientos':'fc'} const dest=Object.entries(tabMap).find(([k])=>saved[0]?.includes(k)) if(dest&&dest[1]!==curTab)setTimeout(()=>goTab(dest[1],document.querySelector(`[data-tab="${dest[1]}"]`)),600) } }catch(err){ rmTyping() console.error('Chat error:',err) addBotMsg('⚠️ Error: '+err.message+'. Abre la consola del navegador (F12) para ver más detalles.') } document.getElementById('send-btn').disabled=false } // ═══════════════ INIT ════════════════════════ document.getElementById('shell').style.display='none' document.getElementById('bottom-nav').style.display='none' async function initAuth(){ try{ // 0 — Si la URL es de fichaje (#fichar=ID), mostrar la página de fichaje sin pasar por login if(typeof checkFichajeRoute==='function'&&checkFichajeRoute())return; if(typeof checkReservaRoute==='function'&&checkReservaRoute())return; if(typeof checkPedidoRoute==='function'&&checkPedidoRoute())return // 1 — Handle OAuth/email callback codes in URL const url=new URL(window.location.href) const code=url.searchParams.get('code') const accessToken=url.hash.match(/access_token=([^&]+)/)?.[1] const errorDesc=url.searchParams.get('error_description') if(errorDesc){ showAuthScreen() authErr('Error: '+decodeURIComponent(errorDesc)) window.history.replaceState({},document.title,window.location.pathname) return } if(code){ // PKCE flow — exchange code for session try{ const {data,error}=await sb.auth.exchangeCodeForSession(code) window.history.replaceState({},document.title,window.location.pathname) if(data?.session?.user){ await showApp(data.session.user); return } }catch(e){ console.warn('Code exchange failed:',e) } } if(accessToken){ // Implicit flow — set session from hash try{ await sb.auth.setSession({ access_token:accessToken, refresh_token:url.hash.match(/refresh_token=([^&]+)/)?.[1]||'' }) window.history.replaceState({},document.title,window.location.pathname) }catch(e){ console.warn('Set session failed:',e) } } // 2 — Check for existing session const {data:{session}}=await sb.auth.getSession() if(session?.user){ await showApp(session.user); return } // 3 — No session showAuthScreen() // 4 — Listen for future changes sb.auth.onAuthStateChange(async(event,session)=>{ if(event==='SIGNED_IN'&&session?.user){ await showApp(session.user) } else if(event==='SIGNED_OUT'){ showAuthScreen() } }) }catch(e){ console.warn('Auth init error:',e) showAuthScreen() } } initAuth() // ─── Red de seguridad: exponer handlers de onclick en window ─── // Los handlers inline (onclick="foo()") sólo pueden encontrar funciones // que estén en `window`. Esto lo garantiza aunque cambie el ámbito. try{ if(typeof guardarDispositivo==='function')window.guardarDispositivo=guardarDispositivo if(typeof editarDispositivo==='function')window.editarDispositivo=editarDispositivo if(typeof eliminarDispositivo==='function')window.eliminarDispositivo=eliminarDispositivo if(typeof refreshDispositivo==='function')window.refreshDispositivo=refreshDispositivo if(typeof refreshAllDispositivos==='function')window.refreshAllDispositivos=refreshAllDispositivos if(typeof showDispInfoGuide==='function')window.showDispInfoGuide=showDispInfoGuide if(typeof startDispQR==='function')window.startDispQR=startDispQR if(typeof stopDispQR==='function')window.stopDispQR=stopDispQR if(typeof onDispPhotoUpload==='function')window.onDispPhotoUpload=onDispPhotoUpload if(typeof toggleDispVoice==='function')window.toggleDispVoice=toggleDispVoice if(typeof processDispIA==='function')window.processDispIA=processDispIA }catch(e){console.warn('Expose handlers error:',e)} inp.value='';inp.style.height='auto' document.getElementById('send-btn').disabled=true addUserMsg(txt);convHistory.push({role:'user',content:txt});showTyping() try{ const res=await fetch('https://5bffv7il4g.execute-api.eu-south-2.amazonaws.com/',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({model:'claude-haiku-4-5-20251001',max_tokens:900,system:buildSys(),messages:convHistory.slice(-10)}) }) if(!res.ok){ const errText=await res.text() console.error('Chat error:',res.status,errText) if(res.status===429){ rmTyping() addBotMsg('⏳ Se ha alcanzado el límite de peticiones por minuto. Espera unos 30-60 segundos y vuelve a preguntar.

Consejo: preguntas más cortas y sin repetir todo el historial consumen menos.') document.getElementById('send-btn').disabled=false return } throw new Error('API error '+res.status) } const raw=await res.json() const t2=(raw.content||[]).map(i=>i.text||'').join('') let p={respuesta:t2,guardar:[]} try{p=JSON.parse(t2.replace(/```json|```/g,'').trim())}catch{} const saved=[] for(const item of(p.guardar||[])){ if(!item.tipo||!item.datos)continue await saveEntry(item.tipo,item.datos) saved.push({gasto:'💸 Gastos',venta:'📦 Ventas',jo:'👨‍🍳 Personal',jornalero_nuevo:'👨‍🍳 Personal',riego:'🏪 Establecimientos',producto_finca:'🏪 Establecimientos'}[item.tipo]||'✓') } rmTyping() const pill=saved.length?'✓ Guardado en: '+[...new Set(saved)].join(', '):'' addBotMsg(esc(p.respuesta),pill) convHistory.push({role:'assistant',content:p.respuesta}) if(convHistory.length>20)convHistory=convHistory.slice(-20) if(saved.length){ const tabMap={'Gastos':'g','Ventas':'v','Personal':'j','Establecimientos':'fc'} const dest=Object.entries(tabMap).find(([k])=>saved[0]?.includes(k)) if(dest&&dest[1]!==curTab)setTimeout(()=>goTab(dest[1],document.querySelector(`[data-tab="${dest[1]}"]`)),600) } }catch(err){ rmTyping() console.error('Chat error:',err) addBotMsg('⚠️ Error: '+err.message+'. Abre la consola del navegador (F12) para ver más detalles.') } document.getElementById('send-btn').disabled=false } // ═══════════════ INIT ════════════════════════ document.getElementById('shell').style.display='none' document.getElementById('bottom-nav').style.display='none' async function initAuth(){ try{ // 0 — Si la URL es de fichaje (#fichar=ID), mostrar la página de fichaje sin pasar por login if(typeof checkFichajeRoute==='function'&&checkFichajeRoute())return; if(typeof checkReservaRoute==='function'&&checkReservaRoute())return; if(typeof checkPedidoRoute==='function'&&checkPedidoRoute())return // 1 — Handle OAuth/email callback codes in URL const url=new URL(window.location.href) const code=url.searchParams.get('code') const accessToken=url.hash.match(/access_token=([^&]+)/)?.[1] const errorDesc=url.searchParams.get('error_description') if(errorDesc){ showAuthScreen() authErr('Error: '+decodeURIComponent(errorDesc)) window.history.replaceState({},document.title,window.location.pathname) return } if(code){ // PKCE flow — exchange code for session try{ const {data,error}=await sb.auth.exchangeCodeForSession(code) window.history.replaceState({},document.title,window.location.pathname) if(data?.session?.user){ await showApp(data.session.user); return } }catch(e){ console.warn('Code exchange failed:',e) } } if(accessToken){ // Implicit flow — set session from hash try{ await sb.auth.setSession({ access_token:accessToken, refresh_token:url.hash.match(/refresh_token=([^&]+)/)?.[1]||'' }) window.history.replaceState({},document.title,window.location.pathname) }catch(e){ console.warn('Set session failed:',e) } } // 2 — Check for existing session const {data:{session}}=await sb.auth.getSession() if(session?.user){ await showApp(session.user); return } // 3 — No session showAuthScreen() // 4 — Listen for future changes sb.auth.onAuthStateChange(async(event,session)=>{ if(event==='SIGNED_IN'&&session?.user){ await showApp(session.user) } else if(event==='SIGNED_OUT'){ showAuthScreen() } }) }catch(e){ console.warn('Auth init error:',e) showAuthScreen() } } initAuth() // ─── Red de seguridad: exponer handlers de onclick en window ─── // Los handlers inline (onclick="foo()") sólo pueden encontrar funciones // que estén en `window`. Esto lo garantiza aunque cambie el ámbito. try{ if(typeof guardarDispositivo==='function')window.guardarDispositivo=guardarDispositivo if(typeof editarDispositivo==='function')window.editarDispositivo=editarDispositivo if(typeof eliminarDispositivo==='function')window.eliminarDispositivo=eliminarDispositivo if(typeof refreshDispositivo==='function')window.refreshDispositivo=refreshDispositivo if(typeof refreshAllDispositivos==='function')window.refreshAllDispositivos=refreshAllDispositivos if(typeof showDispInfoGuide==='function')window.showDispInfoGuide=showDispInfoGuide if(typeof startDispQR==='function')window.startDispQR=startDispQR if(typeof stopDispQR==='function')window.stopDispQR=stopDispQR if(typeof onDispPhotoUpload==='function')window.onDispPhotoUpload=onDispPhotoUpload if(typeof toggleDispVoice==='function')window.toggleDispVoice=toggleDispVoice if(typeof processDispIA==='function')window.processDispIA=processDispIA }catch(e){console.warn('Expose handlers error:',e)} ray array['gastos','ventas','jornadas','jornaleros','fincas','riegos','productos_finca','campanas','mc_stock'] loop if exists (select 1 from information_schema.tables where table_schema='public' and table_name=t) then execute format('alter table public.%I enable row level security', t); execute format('drop policy if exists "admin_read_all_%I" on public.%I', t, t); execute format('create policy "admin_read_all_%I" on public.%I for select using (public.is_admin())', t, t); execute format('drop policy if exists "admin_write_all_%I" on public.%I', t, t); execute format('create policy "admin_write_all_%I" on public.%I for all using (public.is_admin()) with check (public.is_admin())', t, t); end if; end loop; end $$; -- 3) Función list_users — SIN PARÁMETROS, firma explícita create function public.list_users() returns setof json language plpgsql security definer stable set search_path = public, auth as $$ begin if not public.is_admin() then return; end if; return query select json_build_object( 'id', u.id, 'email', u.email, 'created_at', u.created_at, 'last_sign_in_at', u.last_sign_in_at, 'name', u.raw_user_meta_data->>'name' ) from auth.users u order by u.created_at desc; end; $$; revoke all on function public.list_users() from public; grant execute on function public.list_users() to authenticated; -- 4) Tabla de SESIONES/VISITAS (analítica) create table if not exists public.mc_sessions( id bigserial primary key, user_id uuid references auth.users(id) on delete cascade, email text, user_agent text, ts timestamptz default now() ); -- 4b) Tabla de CAMPAÑAS (si no existía ya) create table if not exists public.campanas( id uuid primary key default gen_random_uuid(), user_id uuid references auth.users(id) on delete cascade, nombre text not null, fecha_inicio date, fecha_fin date, descripcion text, fincas jsonb default '[]'::jsonb, created_at timestamptz default now() ); alter table public.campanas enable row level security; drop policy if exists "users_own_campanas" on public.campanas; create policy "users_own_campanas" on public.campanas for all using (auth.uid() = user_id) with check (auth.uid() = user_id); drop policy if exists "admin_all_campanas" on public.campanas; create policy "admin_all_campanas" on public.campanas for all using (public.is_admin()) with check (public.is_admin()); -- 4d) Tabla de FICHAJES (entradas/salidas vía QR en fincas) create table if not exists public.mc_fichajes( id uuid primary key default gen_random_uuid(), finca_id uuid not null, jornalero_id uuid, jornalero_nombre text, tipo text check(tipo in ('entrada','salida')), ts timestamptz default now(), user_agent text ); create index if not exists mc_fichajes_finca_idx on public.mc_fichajes(finca_id); create index if not exists mc_fichajes_ts_idx on public.mc_fichajes(ts desc); alter table public.mc_fichajes enable row level security; -- Cualquiera (incluso anónimo) puede insertar un fichaje (escaneando el QR del campo) drop policy if exists "anyone_insert_fichaje" on public.mc_fichajes; create policy "anyone_insert_fichaje" on public.mc_fichajes for insert to anon, authenticated with check (true); -- El dueño de la finca lee sus fichajes; admin lee todos drop policy if exists "owner_read_fichajes" on public.mc_fichajes; create policy "owner_read_fichajes" on public.mc_fichajes for select using ( public.is_admin() or exists(select 1 from public.fincas f where f.id=finca_id and f.user_id=auth.uid()) ); drop policy if exists "owner_manage_fichajes" on public.mc_fichajes; create policy "owner_manage_fichajes" on public.mc_fichajes for all using ( public.is_admin() or exists(select 1 from public.fincas f where f.id=finca_id and f.user_id=auth.uid()) ) with check ( public.is_admin() or exists(select 1 from public.fincas f where f.id=finca_id and f.user_id=auth.uid()) ); -- Función pública para leer el nombre de una finca (sin login) — para mostrarlo en la página de fichaje create or replace function public.get_finca_publica(p_finca_id uuid) returns table(nombre text) language sql security definer stable set search_path = public as $$ select nombre from public.fincas where id = p_finca_id; $$; grant execute on function public.get_finca_publica(uuid) to anon, authenticated; -- 4e) MENSAJERÍA GLOBAL — funciones para que todos los usuarios puedan buscarse y escribirse -- Búsqueda de usuarios por email/nombre (cualquier usuario autenticado puede usarla) create or replace function public.search_users(q text) returns table(id uuid, email text, name text) language sql security definer stable set search_path = public, auth as $$ select u.id, u.email::text, coalesce(u.raw_user_meta_data->>'name', split_part(u.email,'@',1))::text as name from auth.users u where auth.uid() is not null -- solo usuarios autenticados and u.id != auth.uid() -- no listar al propio usuario and u.email is not null and ( q is null or q = '' or u.email ilike '%'||q||'%' or coalesce(u.raw_user_meta_data->>'name','') ilike '%'||q||'%' ) order by u.email limit 30; $$; grant execute on function public.search_users(text) to authenticated; -- Información puntual de un usuario por su ID create or replace function public.get_user_info(p_user_id uuid) returns table(id uuid, email text, name text) language sql security definer stable set search_path = public, auth as $$ select u.id, u.email::text, coalesce(u.raw_user_meta_data->>'name', split_part(u.email,'@',1))::text as name from auth.users u where auth.uid() is not null and u.id = p_user_id; $$; grant execute on function public.get_user_info(uuid) to authenticated; -- Información de varios usuarios a la vez (para resolver lista de conversaciones) create or replace function public.get_users_info(p_user_ids uuid[]) returns table(id uuid, email text, name text) language sql security definer stable set search_path = public, auth as $$ select u.id, u.email::text, coalesce(u.raw_user_meta_data->>'name', split_part(u.email,'@',1))::text as name from auth.users u where auth.uid() is not null and u.id = any(p_user_ids); $$; grant execute on function public.get_users_info(uuid[]) to authenticated; -- RLS de la tabla mensajes — cualquier usuario puede enviar/leer SUS mensajes (recibidos o enviados) do $$ begin if exists (select 1 from information_schema.tables where table_schema='public' and table_name='mensajes') then execute 'alter table public.mensajes enable row level security'; execute 'drop policy if exists "send_own_messages" on public.mensajes'; execute 'create policy "send_own_messages" on public.mensajes for insert with check (auth.uid() = de_user)'; execute 'drop policy if exists "read_my_messages" on public.mensajes'; execute 'create policy "read_my_messages" on public.mensajes for select using (auth.uid() = de_user or auth.uid() = para_user or grupo_id is not null)'; execute 'drop policy if exists "delete_own_messages" on public.mensajes'; execute 'create policy "delete_own_messages" on public.mensajes for delete using (auth.uid() = de_user)'; execute 'drop policy if exists "admin_all_messages" on public.mensajes'; execute 'create policy "admin_all_messages" on public.mensajes for all using (public.is_admin()) with check (public.is_admin())'; end if; end $$; create table if not exists public.mc_stock( id uuid primary key default gen_random_uuid(), user_id uuid references auth.users(id) on delete cascade, nombre text not null, categoria text, cantidad numeric not null default 0, unidad text default 'kg', consumo_diario numeric default 0, precio_unitario numeric default 0, proveedor text, finca_nombre text, observaciones text, ultima_actualizacion timestamptz default now(), created_at timestamptz default now() ); alter table public.mc_stock enable row level security; drop policy if exists "users_own_stock" on public.mc_stock; create policy "users_own_stock" on public.mc_stock for all using (auth.uid() = user_id) with check (auth.uid() = user_id); drop policy if exists "admin_all_stock" on public.mc_stock; create policy "admin_all_stock" on public.mc_stock for all using (public.is_admin()) with check (public.is_admin()); create index if not exists mc_sessions_ts_idx on public.mc_sessions(ts desc); create index if not exists mc_sessions_user_idx on public.mc_sessions(user_id); alter table public.mc_sessions enable row level security; drop policy if exists "users_insert_own_session" on public.mc_sessions; create policy "users_insert_own_session" on public.mc_sessions for insert with check (auth.uid() = user_id); drop policy if exists "admin_read_all_sessions" on public.mc_sessions; create policy "admin_read_all_sessions" on public.mc_sessions for select using (public.is_admin()); drop policy if exists "admin_manage_sessions" on public.mc_sessions; create policy "admin_manage_sessions" on public.mc_sessions for all using (public.is_admin()) with check (public.is_admin()); -- 5) IMPORTANTE: refrescar schema cache de PostgREST (varios métodos) notify pgrst, 'reload schema'; notify pgrst; -- 6) Verificación: si esta consulta devuelve filas, todo está OK -- NOTA: en el SQL Editor, is_admin será 'false' porque el editor corre como superusuario, -- no como un usuario logueado. La función funcionará bien cuando se llame desde la app. -- list_users count será '0' aquí por la misma razón (no admin = no devuelve usuarios). select 'is_admin' as f, public.is_admin()::text as resultado union all select 'list_users count', coalesce(json_array_length(json_agg(t)::json), 0)::text from public.list_users() t;` // Guardar el SQL en una variable global para que el botón pueda leerlo sin escapado window._sqlAdminFull=sql h+=`
⚠️ Importante: Pega este SQL en Supabase → SQL Editor y ejecútalo UNA SOLA VEZ. Crea: políticas de admin, función list_users() y tabla mc_sessions para analítica.
${esc(sql)}
Pasos:
1. Pulsa el botón verde para copiar el SQL
2. Ve a app.supabase.com → tu proyecto → SQL Editor
3. Pega el SQL y dale a "Run"
4. Vuelve aquí y pulsa "🔄 Recargar datos"
5. Listo: verás todos los usuarios y todas las visitas
📊 ¿Y para visitas a la web ANTES del registro? Esta analítica solo cuenta usuarios autenticados. Para ver visitas anónimas a tu landing/web pública, añade un script de Plausible (recomendado, sin cookies, ~9€/mes) o Google Analytics 4 (gratis) en el <head> de tu HTML — basta con una línea.
` } h+=`` container.innerHTML=h } window.adminViewMode=adminViewMode function buildSys(){ // Helper para limpiar campos internos de Supabase/memoria const clean=(r,keep)=>{const o={};for(const k of keep)if(r[k]!=null&&r[k]!=='')o[k]=r[k];return o} const snap={ gastos:D.ga.slice(0,15).map(r=>clean(r,['fecha','concepto','categoria','importe','proveedor','finca_nombre','cantidad','unidad'])), ventas:D.ve.slice(0,15).map(r=>clean(r,['fecha','producto','kilos_kg','precio_kg','importe_total','cliente','finca_nombre','numero_albaran'])), jornadas:D.jo.slice(0,15).map(r=>clean(r,['fecha','jornalero','horas','tarea','finca_nombre','incidencia'])), jornaleros:D.pl.map(r=>clean(r,['nombre','tarifa','tarea'])), fincas:D.fi.map(f=>({ nombre:f.nombre, cultivo:f.cultivo||'', hectareas:f.hectareas||0, riegos:D.ri.filter(r=>r.finca_id===f._id).slice(0,5).map(r=>clean(r,['fecha','horas','metodo','observaciones'])), productos:D.rp.filter(r=>r.finca_id===f._id).slice(0,5).map(r=>clean(r,['fecha','nombre','tipo','cantidad','observaciones'])) })), stock:(stockItems||[]).slice(0,30).map(it=>{ const dias=it.consumo_diario>0&&it.cantidad>0?Math.floor(it.cantidad/it.consumo_diario):null return { nombre:it.nombre, categoria:it.categoria, cantidad:parseFloat(it.cantidad||0), unidad:it.unidad, consumo_diario:parseFloat(it.consumo_diario||0), dias_restantes:dias, precio_unitario:parseFloat(it.precio_unitario||0), valor_total_eur:Math.round(parseFloat(it.cantidad||0)*parseFloat(it.precio_unitario||0)), proveedor:it.proveedor, finca:it.finca_nombre } }) } // Añadir dispositivos con sus lecturas actuales (sin credenciales — no aportan y ocupan tokens) if(typeof dispositivos!=='undefined'&&dispositivos.length){ snap.dispositivos=dispositivos.map(d=>{ const fi=D.fi.find(f=>f._id===d.finca_id) const o={nombre:d.nombre,tipo:d.tipo||'ecowitt',finca:fi?fi.nombre:'sin asignar',activo:!!d.activo} const dt=d.datos if(dt&&!dt.error){ const lect={} if(dt.temp!=null)lect.temp_exterior_C=+parseFloat(dt.temp).toFixed(1) if(dt.hum!=null)lect.humedad_exterior_pct=+parseFloat(dt.hum).toFixed(0) if(dt.tempIn!=null)lect.temp_interior_C=+parseFloat(dt.tempIn).toFixed(1) if(dt.humIn!=null)lect.humedad_interior_pct=+parseFloat(dt.humIn).toFixed(0) if(dt.wind!=null)lect.viento_kmh=+parseFloat(dt.wind).toFixed(1) if(dt.gust!=null)lect.rachas_kmh=+parseFloat(dt.gust).toFixed(1) if(dt.windDir!=null){const dirs=['N','NE','E','SE','S','SO','O','NO'];lect.direccion_viento=dirs[Math.round(dt.windDir/45)%8]} if(dt.rainHour!=null)lect.lluvia_hora_mm=+parseFloat(dt.rainHour).toFixed(1) if(dt.rainDay!=null)lect.lluvia_dia_mm=+parseFloat(dt.rainDay).toFixed(1) if(dt.rainWeek!=null&&dt.rainWeek>0)lect.lluvia_semana_mm=+parseFloat(dt.rainWeek).toFixed(1) if(dt.rainMonth!=null&&dt.rainMonth>0)lect.lluvia_mes_mm=+parseFloat(dt.rainMonth).toFixed(1) if(dt.pressure!=null)lect.presion_hPa=+parseFloat(dt.pressure).toFixed(0) if(dt.uvi!=null)lect.indice_uv=+parseFloat(dt.uvi).toFixed(0) if(dt.solar!=null)lect.solar_W_m2=+parseFloat(dt.solar).toFixed(0) if(dt.co2In!=null)lect.co2_ppm=+parseFloat(dt.co2In).toFixed(0) if(dt.lightningCount!=null)lect.rayos_hoy=dt.lightningCount if(dt.lightningDist!=null)lect.km_ultimo_rayo=dt.lightningDist // DPV / VPD calculado — estado fisiológico if(dt.temp!=null&&dt.hum!=null){ const c=calcDPV(dt.temp,dt.hum) if(c){ const cl=clasificarDPV(c.dpv) lect.dpv_kPa=+c.dpv.toFixed(2) lect.dpv_estado=cl?cl.label:'' lect.dpv_recomendacion=cl?cl.accion:'' } } if(dt.soilChannels?.length)lect.suelo=dt.soilChannels.map(s=>({canal:s.ch,humedad_pct:s.moist!=null?Math.round(s.moist):null,temp_C:s.temp!=null?+parseFloat(s.temp).toFixed(1):null})) if(dt.tempHumChannels?.length)lect.temp_hum_extra=dt.tempHumChannels.map(c=>({canal:c.ch,temp_C:c.temp!=null?+parseFloat(c.temp).toFixed(1):null,hum_pct:c.hum!=null?Math.round(c.hum):null})) if(dt.leafChannels?.length)lect.humedad_foliar=dt.leafChannels.map(c=>({canal:c.ch,wet_pct:c.wet})) if(dt.pm25Channels?.length)lect.pm25=dt.pm25Channels.map(c=>({canal:c.ch,ugm3:c.pm25})) if(dt.ts)lect.ultima_lectura=new Date(dt.ts).toLocaleString('es-ES',{dateStyle:'short',timeStyle:'short'}) o.lecturas=lect }else if(dt&&dt.error){ o.estado='desconectado: '+dt.error }else{ o.estado='sin datos todavía' } return o }) } // Si el usuario es admin, añadir estadísticas globales y lista resumida de usuarios let adminContext='' if(isAdmin()){ try{ const stats=adminGlobalStats() const ss=adminSessionStats() const usersForIA=(stats.users_all||stats.users_top||[]).slice(0,12).map(u=>({ email:u.email||((u.id||u.user_id||'').slice(0,8)+'…'), registro:u.created_at?new Date(u.created_at).toISOString().slice(0,10):null, ultimo_login:u.last_sign_in_at?new Date(u.last_sign_in_at).toISOString().slice(0,10):null, fincas:u.fincas||0,ventas:u.ventas||0,gastos:u.gastos||0,jornadas:u.jornadas||0, ingresos_eur:Math.round(u.total_ingresos||0), costes_eur:Math.round(u.total_costes||0), ultima_actividad:u.ultima?new Date(u.ultima).toISOString().slice(0,10):'sin_actividad' })) // Estado real del backend (HONESTIDAD: si RLS no aplicado, decirlo claro) const estadoBackend=stats.rls_aplicado ?'OK — datos completos de todos los usuarios cargados desde Supabase' :'⚠️ INCOMPLETO — el SQL de RLS no está aplicado todavía, así que solo veo los datos del propio admin (1 usuario aparente). Para ver TODOS los usuarios reales, el admin debe ir a la pestaña ⚙️ Admin > Configurar y ejecutar el SQL en Supabase.' adminContext=` ESTÁS HABLANDO CON EL ADMINISTRADOR (${(currentUser?.email||'').toLowerCase()}). ESTADO DEL BACKEND DE ADMIN: ${estadoBackend} ESTADÍSTICAS GLOBALES DE LA PLATAFORMA MI HOSTELERÍA: ${JSON.stringify({ usuarios_registrados_real:stats.n_users_real, usuarios_con_actividad:stats.n_users_con_datos, total_fincas:stats.n_fincas, total_gastos:stats.n_gastos, total_ventas:stats.n_ventas, total_jornadas:stats.n_jornadas, total_jornaleros:stats.n_jornaleros, ingresos_totales_eur:Math.round(stats.ingresos_total), costes_totales_eur:Math.round(stats.costes_total), margen_total_eur:Math.round(stats.margen), })} ANALÍTICA DE VISITAS (sesiones autenticadas en la app): ${ss?JSON.stringify({ visitas_hoy:ss.visitas_hoy, visitas_ultimos_7d:ss.visitas_7d, visitas_ultimos_30d:ss.visitas_30d, usuarios_unicos_hoy:ss.usuarios_hoy, usuarios_unicos_7d:ss.usuarios_7d, total_sesiones_historico:ss.total_sesiones, ultimas_dos_semanas_por_dia:ss.serie_14d }):'(no hay datos de sesiones — la tabla mc_sessions aún no existe o está vacía. Aplicar SQL del panel ⚙️ Configurar)'} USUARIOS (lista resumida): ${JSON.stringify(usersForIA)} REGLAS DE HONESTIDAD CRÍTICAS: - Si "estado_backend" indica INCOMPLETO, DEBES avisar al admin de que los números de usuarios pueden no reflejar la realidad y dirigirlo al panel ⚙️ Configurar. - NUNCA digas "hay 1 usuario en total" cuando el estado sea INCOMPLETO. En su lugar di: "Solo puedo ver tu propia cuenta porque el RLS de admin no está aplicado todavía. Aplica el SQL en Supabase para que aparezcan los demás usuarios." - Para preguntas sobre VISITAS A LA WEB PÚBLICA (gente que entra sin estar registrada), responde que Mi Hostelería tiene Google Analytics 4 activo (propiedad G-QMG1YFJ0CF, dominio micampoconia.com) y Google Tag Manager (GTM-5MRXGJ6B). Las estadísticas completas (países, dispositivos, embudos, conversiones) se ven en analytics.google.com. En la app solo registramos sesiones de usuarios autenticados. - Para preguntas sobre USO DE LA APP (sesiones de usuarios autenticados), usa los números de "analítica de visitas" arriba. - NUNCA inventes números. Si un campo es null o no aparece, di que no lo tienes. Como admin puedes: - Responder estadísticas globales con los números EXACTOS arriba - Identificar usuarios inactivos (los que tienen ultima_actividad="sin_actividad") - Detectar el más activo - Sugerir acciones (animar inactivos, etc.) - Recomendar al admin que use la pestaña Admin > Inspeccionar para ver los datos crudos de un usuario.` }catch(e){console.warn('admin context error:',e)} } return `Eres el asistente de gestión de un hostelero. Eres conciso y cercano. 🌐 IDIOMA: El usuario tiene la app en ${LANG_NAMES[userSettings.lang]||'español'}. RESPONDE SIEMPRE EN ${(LANG_NAMES[userSettings.lang]||'español').toUpperCase()}, salvo si el propio usuario te escribe en otro idioma — en ese caso, responde en el idioma en que te haya escrito. Los datos del usuario están en español (nombres de cultivos, productos, etc.) — déjalos como están y solo traduce TUS textos de respuesta.${adminContext} INFORMACIÓN DEL CREADOR DE LA APP: Mi Hostelería ha sido creada por Ali Aauicha Azghouli (aliaauicha@gmail.com, tel. 678902270). - Especialista en operaciones y analítica. - Trabajó en el campo durante sus años universitarios en la costa almeriense. - Grado en Ciencias Políticas (Universidad de Granada), Máster en Análisis Económico (Universidad de Málaga), Máster en SAP BTP y AI (en curso, EuropeanBTech 2026). - Carrera profesional: Analista de Operaciones en Fluiconnecto (UK), Responsable de Operaciones en Paack (UK), Analista de Cadena de Suministro en Grupo Sesé (Barcelona). - Especializado en optimización de procesos, gestión de stock, análisis de datos, Lean y Six Sigma. - LinkedIn: https://www.linkedin.com/in/ali-aauicha/ Si alguien pregunta sobre el autor, el creador o quién ha hecho la app, responde con esta información. DATOS ACTUALES: ${JSON.stringify(snap)} MÓDULOS: - ga (gasto): alimentos, bebidas, limpieza, menaje, energía, suministros, mantenimiento, otros. Puede incluir finca_nombre (establecimiento). - ve (venta): tickets / cierre de caja — producto (menú del día, carta, desayunos, bebidas, etc.), kilos_kg (=número de tickets o cubiertos), precio_kg (=ticket medio €), importe_total, cliente (sala/barra/terraza/take-away), numero_albaran (número de ticket Z), finca_nombre (establecimiento) - jo (jornada): días trabajados — jornalero (nombre del trabajador), horas, tarea (cocina, sala, barra, limpieza, encargado, desayunos…), fecha, incidencia, finca_nombre (establecimiento) - jornalero_nuevo: AÑADIR un trabajador a la plantilla — nombre, tarifa (€/hora), tarea (puesto: camarero, cocinero, ayudante, encargado, limpieza, recepción). Usar cuando digan "añade a Fernando", "nuevo camarero Fernando 9€/h sala", "apunta a X al equipo". - riego: servicios o aperturas por establecimiento — finca (nombre del establecimiento), horas (de servicio), metodo (mediodía/cena/desayuno/continuo/turno doble), fecha, observaciones - campana: crear temporada — nombre (Verano 2026, Navidad, Semana Santa…), fecha_inicio (YYYY-MM-DD), fecha_fin (YYYY-MM-DD), descripcion, fincas (array de establecimientos) - evento: añadir cita al calendario — titulo, tipo (reserva grupo/evento privado/mantenimiento/inspección/proveedor/reunion/otro), fecha (YYYY-MM-DD), hora (HH:MM), descripcion - producto_finca: productos consumidos en local — finca (establecimiento), nombre (del producto), tipo (limpieza/menaje/desinfección/lavandería/otro), cantidad, fecha, observaciones - stock_nuevo: AÑADIR producto al stock/almacén — nombre, categoria (alimentos/bebidas/limpieza/menaje/energia/mantenimiento/otros), cantidad (número), unidad (kg/L/cajas/botellas/barril/pack/unidades/rollos), consumo_diario (número, 0 si no consume), precio_unitario (€/unidad, número), proveedor (Makro, Mahou, Coosur, Selecta, distribuidor local…), finca_nombre opcional, observaciones. Usar cuando digan "tengo X de producto Y", "he comprado Z cajas de…", "añade al stock", "apunta en almacén", "quedan N botellas de…". SOBRE LOS DISPOSITIVOS/SENSORES: - En "dispositivos" tienes el listado de sensores conectados del hostelero (sondas de temperatura de cámaras frigoríficas, sensores de terraza, estaciones meteo), con el establecimiento donde están instalados y sus lecturas en vivo (temperatura cámara, congelador, ambiente terraza, viento, humedad, etc.). - Si te preguntan por los dispositivos, sensores, estaciones o por la temperatura de una cámara concreta, USA estos datos y responde con las lecturas reales. - Si preguntan "¿qué dispositivos tengo?" o "¿qué sensores hay en el local X?", responde listando los que hay con su establecimiento asignado. - Si preguntan por la temperatura de cámaras o el tiempo en la terraza, busca el sensor asignado a ese local y da las cifras exactas. Avisa si una cámara está fuera de rango (carnes/lácteos: 0-4 °C, congelador: -18 °C o menos, vinos: 10-14 °C). - Si un dispositivo tiene estado "desconectado" o "sin datos", indícalo amablemente y sugiere pulsar 🔄 Actualizar. INSTRUCCIONES: - "Fernando 9€ hora sala" o "añade a Fernando al equipo" → tipo jornalero_nuevo con nombre, tarifa y tarea (puesto). - "Fernando trabajó 8h cocina hoy" → tipo jo (jornada trabajada ese día). - "He comprado 24 cajas de cerveza a 13€" o "tengo 50L de aceite" → tipo stock_nuevo con nombre, categoria, cantidad, unidad, precio_unitario. - Para jornadas múltiples "Antonio 8h sala, Carmen 6h cocina" → una entrada jo por persona. - Para establecimientos: "Bar Centro: 42 menús del día, ticket medio 14,80€" → UNA venta (ve). - Si menciona un establecimiento que no existe, créalo automáticamente. - En consultas de totales → calcula y responde con precisión. - Para consultas sobre sensores/dispositivos/temperaturas/cámaras → usa la sección "dispositivos" y "guardar" queda vacío. Responde con JSON puro: {"respuesta":"texto","guardar":[{"tipo":"gasto|venta|jo|jornalero_nuevo|riego|producto_finca|stock_nuevo|campana|evento","datos":{...}}]} Sin guardar → "guardar":[]` } function addUserMsg(t){ const b=document.getElementById('chat-msgs') const d=document.createElement('div');d.className='msg user' d.innerHTML=`
${esc(t)}
`;b.appendChild(d);b.scrollTop=b.scrollHeight } function addBotMsg(html,pill=''){ const b=document.getElementById('chat-msgs') const d=document.createElement('div');d.className='msg bot' d.innerHTML=`
🏪
${html}${pill?'
'+pill+'':''}
` b.appendChild(d);b.scrollTop=b.scrollHeight } function showTyping(){ const b=document.getElementById('chat-msgs') const d=document.createElement('div');d.id='typ';d.className='msg bot' d.innerHTML='
🏪
' b.appendChild(d);b.scrollTop=b.scrollHeight } function rmTyping(){document.getElementById('typ')?.remove()} async function sendMsg(){ const inp=document.getElementById('chat-input') const txt=inp.value.trim();if(!txt)return inp.value='';inp.style.height='auto' document.getElementById('send-btn').disabled=true addUserMsg(txt);convHistory.push({role:'user',content:txt});showTyping() try{ const res=await fetch('https://5bffv7il4g.execute-api.eu-south-2.amazonaws.com/',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({model:'claude-haiku-4-5-20251001',max_tokens:900,system:buildSys(),messages:convHistory.slice(-10)}) }) if(!res.ok){ const errText=await res.text() console.error('Chat error:',res.status,errText) if(res.status===429){ rmTyping() addBotMsg('⏳ Se ha alcanzado el límite de peticiones por minuto. Espera unos 30-60 segundos y vuelve a preguntar.

Consejo: preguntas más cortas y sin repetir todo el historial consumen menos.') document.getElementById('send-btn').disabled=false return } throw new Error('API error '+res.status) } const raw=await res.json() const t2=(raw.content||[]).map(i=>i.text||'').join('') let p={respuesta:t2,guardar:[]} try{p=JSON.parse(t2.replace(/```json|```/g,'').trim())}catch{} const saved=[] for(const item of(p.guardar||[])){ if(!item.tipo||!item.datos)continue await saveEntry(item.tipo,item.datos) saved.push({gasto:'💸 Gastos',venta:'📦 Ventas',jo:'👨‍🍳 Personal',jornalero_nuevo:'👨‍🍳 Personal',riego:'🏪 Establecimientos',producto_finca:'🏪 Establecimientos'}[item.tipo]||'✓') } rmTyping() const pill=saved.length?'✓ Guardado en: '+[...new Set(saved)].join(', '):'' addBotMsg(esc(p.respuesta),pill) convHistory.push({role:'assistant',content:p.respuesta}) if(convHistory.length>20)convHistory=convHistory.slice(-20) if(saved.length){ const tabMap={'Gastos':'g','Ventas':'v','Personal':'j','Establecimientos':'fc'} const dest=Object.entries(tabMap).find(([k])=>saved[0]?.includes(k)) if(dest&&dest[1]!==curTab)setTimeout(()=>goTab(dest[1],document.querySelector(`[data-tab="${dest[1]}"]`)),600) } }catch(err){ rmTyping() console.error('Chat error:',err) addBotMsg('⚠️ Error: '+err.message+'. Abre la consola del navegador (F12) para ver más detalles.') } document.getElementById('send-btn').disabled=false } // ═══════════════ INIT ════════════════════════ document.getElementById('shell').style.display='none' document.getElementById('bottom-nav').style.display='none' async function initAuth(){ try{ // 0 — Si la URL es de fichaje (#fichar=ID), mostrar la página de fichaje sin pasar por login if(typeof checkFichajeRoute==='function'&&checkFichajeRoute())return; if(typeof checkReservaRoute==='function'&&checkReservaRoute())return; if(typeof checkPedidoRoute==='function'&&checkPedidoRoute())return // 1 — Handle OAuth/email callback codes in URL const url=new URL(window.location.href) const code=url.searchParams.get('code') const accessToken=url.hash.match(/access_token=([^&]+)/)?.[1] const errorDesc=url.searchParams.get('error_description') if(errorDesc){ showAuthScreen() authErr('Error: '+decodeURIComponent(errorDesc)) window.history.replaceState({},document.title,window.location.pathname) return } if(code){ // PKCE flow — exchange code for session try{ const {data,error}=await sb.auth.exchangeCodeForSession(code) window.history.replaceState({},document.title,window.location.pathname) if(data?.session?.user){ await showApp(data.session.user); return } }catch(e){ console.warn('Code exchange failed:',e) } } if(accessToken){ // Implicit flow — set session from hash try{ await sb.auth.setSession({ access_token:accessToken, refresh_token:url.hash.match(/refresh_token=([^&]+)/)?.[1]||'' }) window.history.replaceState({},document.title,window.location.pathname) }catch(e){ console.warn('Set session failed:',e) } } // 2 — Check for existing session const {data:{session}}=await sb.auth.getSession() if(session?.user){ await showApp(session.user); return } // 3 — No session showAuthScreen() // 4 — Listen for future changes sb.auth.onAuthStateChange(async(event,session)=>{ if(event==='SIGNED_IN'&&session?.user){ await showApp(session.user) } else if(event==='SIGNED_OUT'){ showAuthScreen() } }) }catch(e){ console.warn('Auth init error:',e) showAuthScreen() } } initAuth() // ─── Red de seguridad: exponer handlers de onclick en window ─── // Los handlers inline (onclick="foo()") sólo pueden encontrar funciones // que estén en `window`. Esto lo garantiza aunque cambie el ámbito. try{ if(typeof guardarDispositivo==='function')window.guardarDispositivo=guardarDispositivo if(typeof editarDispositivo==='function')window.editarDispositivo=editarDispositivo if(typeof eliminarDispositivo==='function')window.eliminarDispositivo=eliminarDispositivo if(typeof refreshDispositivo==='function')window.refreshDispositivo=refreshDispositivo if(typeof refreshAllDispositivos==='function')window.refreshAllDispositivos=refreshAllDispositivos if(typeof showDispInfoGuide==='function')window.showDispInfoGuide=showDispInfoGuide if(typeof startDispQR==='function')window.startDispQR=startDispQR if(typeof stopDispQR==='function')window.stopDispQR=stopDispQR if(typeof onDispPhotoUpload==='function')window.onDispPhotoUpload=onDispPhotoUpload if(typeof toggleDispVoice==='function')window.toggleDispVoice=toggleDispVoice if(typeof processDispIA==='function')window.processDispIA=processDispIA }catch(e){console.warn('Expose handlers error:',e)} inp.value='';inp.style.height='auto' document.getElementById('send-btn').disabled=true addUserMsg(txt);convHistory.push({role:'user',content:txt});showTyping() try{ const res=await fetch('https://5bffv7il4g.execute-api.eu-south-2.amazonaws.com/',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({model:'claude-haiku-4-5-20251001',max_tokens:900,system:buildSys(),messages:convHistory.slice(-10)}) }) if(!res.ok){ const errText=await res.text() console.error('Chat error:',res.status,errText) if(res.status===429){ rmTyping() addBotMsg('⏳ Se ha alcanzado el límite de peticiones por minuto. Espera unos 30-60 segundos y vuelve a preguntar.

Consejo: preguntas más cortas y sin repetir todo el historial consumen menos.') document.getElementById('send-btn').disabled=false return } throw new Error('API error '+res.status) } const raw=await res.json() const t2=(raw.content||[]).map(i=>i.text||'').join('') let p={respuesta:t2,guardar:[]} try{p=JSON.parse(t2.replace(/```json|```/g,'').trim())}catch{} const saved=[] for(const item of(p.guardar||[])){ if(!item.tipo||!item.datos)continue await saveEntry(item.tipo,item.datos) saved.push({gasto:'💸 Gastos',venta:'📦 Ventas',jo:'👨‍🍳 Personal',jornalero_nuevo:'👨‍🍳 Personal',riego:'🏪 Establecimientos',producto_finca:'🏪 Establecimientos'}[item.tipo]||'✓') } rmTyping() const pill=saved.length?'✓ Guardado en: '+[...new Set(saved)].join(', '):'' addBotMsg(esc(p.respuesta),pill) convHistory.push({role:'assistant',content:p.respuesta}) if(convHistory.length>20)convHistory=convHistory.slice(-20) if(saved.length){ const tabMap={'Gastos':'g','Ventas':'v','Personal':'j','Establecimientos':'fc'} const dest=Object.entries(tabMap).find(([k])=>saved[0]?.includes(k)) if(dest&&dest[1]!==curTab)setTimeout(()=>goTab(dest[1],document.querySelector(`[data-tab="${dest[1]}"]`)),600) } }catch(err){ rmTyping() console.error('Chat error:',err) addBotMsg('⚠️ Error: '+err.message+'. Abre la consola del navegador (F12) para ver más detalles.') } document.getElementById('send-btn').disabled=false } // ═══════════════ INIT ════════════════════════ document.getElementById('shell').style.display='none' document.getElementById('bottom-nav').style.display='none' async function initAuth(){ try{ // 0 — Si la URL es de fichaje (#fichar=ID), mostrar la página de fichaje sin pasar por login if(typeof checkFichajeRoute==='function'&&checkFichajeRoute())return; if(typeof checkReservaRoute==='function'&&checkReservaRoute())return; if(typeof checkPedidoRoute==='function'&&checkPedidoRoute())return // 1 — Handle OAuth/email callback codes in URL const url=new URL(window.location.href) const code=url.searchParams.get('code') const accessToken=url.hash.match(/access_token=([^&]+)/)?.[1] const errorDesc=url.searchParams.get('error_description') if(errorDesc){ showAuthScreen() authErr('Error: '+decodeURIComponent(errorDesc)) window.history.replaceState({},document.title,window.location.pathname) return } if(code){ // PKCE flow — exchange code for session try{ const {data,error}=await sb.auth.exchangeCodeForSession(code) window.history.replaceState({},document.title,window.location.pathname) if(data?.session?.user){ await showApp(data.session.user); return } }catch(e){ console.warn('Code exchange failed:',e) } } if(accessToken){ // Implicit flow — set session from hash try{ await sb.auth.setSession({ access_token:accessToken, refresh_token:url.hash.match(/refresh_token=([^&]+)/)?.[1]||'' }) window.history.replaceState({},document.title,window.location.pathname) }catch(e){ console.warn('Set session failed:',e) } } // 2 — Check for existing session const {data:{session}}=await sb.auth.getSession() if(session?.user){ await showApp(session.user); return } // 3 — No session showAuthScreen() // 4 — Listen for future changes sb.auth.onAuthStateChange(async(event,session)=>{ if(event==='SIGNED_IN'&&session?.user){ await showApp(session.user) } else if(event==='SIGNED_OUT'){ showAuthScreen() } }) }catch(e){ console.warn('Auth init error:',e) showAuthScreen() } } initAuth() // ─── Red de seguridad: exponer handlers de onclick en window ─── // Los handlers inline (onclick="foo()") sólo pueden encontrar funciones // que estén en `window`. Esto lo garantiza aunque cambie el ámbito. try{ if(typeof guardarDispositivo==='function')window.guardarDispositivo=guardarDispositivo if(typeof editarDispositivo==='function')window.editarDispositivo=editarDispositivo if(typeof eliminarDispositivo==='function')window.eliminarDispositivo=eliminarDispositivo if(typeof refreshDispositivo==='function')window.refreshDispositivo=refreshDispositivo if(typeof refreshAllDispositivos==='function')window.refreshAllDispositivos=refreshAllDispositivos if(typeof showDispInfoGuide==='function')window.showDispInfoGuide=showDispInfoGuide if(typeof startDispQR==='function')window.startDispQR=startDispQR if(typeof stopDispQR==='function')window.stopDispQR=stopDispQR if(typeof onDispPhotoUpload==='function')window.onDispPhotoUpload=onDispPhotoUpload if(typeof toggleDispVoice==='function')window.toggleDispVoice=toggleDispVoice if(typeof processDispIA==='function')window.processDispIA=processDispIA }catch(e){console.warn('Expose handlers error:',e)} ray array['gastos','ventas','jornadas','jornaleros','fincas','riegos','productos_finca','campanas','mc_stock'] loop if exists (select 1 from information_schema.tables where table_schema='public' and table_name=t) then execute format('alter table public.%I enable row level security', t); execute format('drop policy if exists "admin_read_all_%I" on public.%I', t, t); execute format('create policy "admin_read_all_%I" on public.%I for select using (public.is_admin())', t, t); execute format('drop policy if exists "admin_write_all_%I" on public.%I', t, t); execute format('create policy "admin_write_all_%I" on public.%I for all using (public.is_admin()) with check (public.is_admin())', t, t); end if; end loop; end $$; -- 3) Función list_users — SIN PARÁMETROS, firma explícita create function public.list_users() returns setof json language plpgsql security definer stable set search_path = public, auth as $$ begin if not public.is_admin() then return; end if; return query select json_build_object( 'id', u.id, 'email', u.email, 'created_at', u.created_at, 'last_sign_in_at', u.last_sign_in_at, 'name', u.raw_user_meta_data->>'name' ) from auth.users u order by u.created_at desc; end; $$; revoke all on function public.list_users() from public; grant execute on function public.list_users() to authenticated; -- 4) Tabla de SESIONES/VISITAS (analítica) create table if not exists public.mc_sessions( id bigserial primary key, user_id uuid references auth.users(id) on delete cascade, email text, user_agent text, ts timestamptz default now() ); -- 4b) Tabla de CAMPAÑAS (si no existía ya) create table if not exists public.campanas( id uuid primary key default gen_random_uuid(), user_id uuid references auth.users(id) on delete cascade, nombre text not null, fecha_inicio date, fecha_fin date, descripcion text, fincas jsonb default '[]'::jsonb, created_at timestamptz default now() ); alter table public.campanas enable row level security; drop policy if exists "users_own_campanas" on public.campanas; create policy "users_own_campanas" on public.campanas for all using (auth.uid() = user_id) with check (auth.uid() = user_id); drop policy if exists "admin_all_campanas" on public.campanas; create policy "admin_all_campanas" on public.campanas for all using (public.is_admin()) with check (public.is_admin()); -- 4d) Tabla de FICHAJES (entradas/salidas vía QR en fincas) create table if not exists public.mc_fichajes( id uuid primary key default gen_random_uuid(), finca_id uuid not null, jornalero_id uuid, jornalero_nombre text, tipo text check(tipo in ('entrada','salida')), ts timestamptz default now(), user_agent text ); create index if not exists mc_fichajes_finca_idx on public.mc_fichajes(finca_id); create index if not exists mc_fichajes_ts_idx on public.mc_fichajes(ts desc); alter table public.mc_fichajes enable row level security; -- Cualquiera (incluso anónimo) puede insertar un fichaje (escaneando el QR del campo) drop policy if exists "anyone_insert_fichaje" on public.mc_fichajes; create policy "anyone_insert_fichaje" on public.mc_fichajes for insert to anon, authenticated with check (true); -- El dueño de la finca lee sus fichajes; admin lee todos drop policy if exists "owner_read_fichajes" on public.mc_fichajes; create policy "owner_read_fichajes" on public.mc_fichajes for select using ( public.is_admin() or exists(select 1 from public.fincas f where f.id=finca_id and f.user_id=auth.uid()) ); drop policy if exists "owner_manage_fichajes" on public.mc_fichajes; create policy "owner_manage_fichajes" on public.mc_fichajes for all using ( public.is_admin() or exists(select 1 from public.fincas f where f.id=finca_id and f.user_id=auth.uid()) ) with check ( public.is_admin() or exists(select 1 from public.fincas f where f.id=finca_id and f.user_id=auth.uid()) ); -- Función pública para leer el nombre de una finca (sin login) — para mostrarlo en la página de fichaje create or replace function public.get_finca_publica(p_finca_id uuid) returns table(nombre text) language sql security definer stable set search_path = public as $$ select nombre from public.fincas where id = p_finca_id; $$; grant execute on function public.get_finca_publica(uuid) to anon, authenticated; -- 4e) MENSAJERÍA GLOBAL — funciones para que todos los usuarios puedan buscarse y escribirse -- Búsqueda de usuarios por email/nombre (cualquier usuario autenticado puede usarla) create or replace function public.search_users(q text) returns table(id uuid, email text, name text) language sql security definer stable set search_path = public, auth as $$ select u.id, u.email::text, coalesce(u.raw_user_meta_data->>'name', split_part(u.email,'@',1))::text as name from auth.users u where auth.uid() is not null -- solo usuarios autenticados and u.id != auth.uid() -- no listar al propio usuario and u.email is not null and ( q is null or q = '' or u.email ilike '%'||q||'%' or coalesce(u.raw_user_meta_data->>'name','') ilike '%'||q||'%' ) order by u.email limit 30; $$; grant execute on function public.search_users(text) to authenticated; -- Información puntual de un usuario por su ID create or replace function public.get_user_info(p_user_id uuid) returns table(id uuid, email text, name text) language sql security definer stable set search_path = public, auth as $$ select u.id, u.email::text, coalesce(u.raw_user_meta_data->>'name', split_part(u.email,'@',1))::text as name from auth.users u where auth.uid() is not null and u.id = p_user_id; $$; grant execute on function public.get_user_info(uuid) to authenticated; -- Información de varios usuarios a la vez (para resolver lista de conversaciones) create or replace function public.get_users_info(p_user_ids uuid[]) returns table(id uuid, email text, name text) language sql security definer stable set search_path = public, auth as $$ select u.id, u.email::text, coalesce(u.raw_user_meta_data->>'name', split_part(u.email,'@',1))::text as name from auth.users u where auth.uid() is not null and u.id = any(p_user_ids); $$; grant execute on function public.get_users_info(uuid[]) to authenticated; -- RLS de la tabla mensajes — cualquier usuario puede enviar/leer SUS mensajes (recibidos o enviados) do $$ begin if exists (select 1 from information_schema.tables where table_schema='public' and table_name='mensajes') then execute 'alter table public.mensajes enable row level security'; execute 'drop policy if exists "send_own_messages" on public.mensajes'; execute 'create policy "send_own_messages" on public.mensajes for insert with check (auth.uid() = de_user)'; execute 'drop policy if exists "read_my_messages" on public.mensajes'; execute 'create policy "read_my_messages" on public.mensajes for select using (auth.uid() = de_user or auth.uid() = para_user or grupo_id is not null)'; execute 'drop policy if exists "delete_own_messages" on public.mensajes'; execute 'create policy "delete_own_messages" on public.mensajes for delete using (auth.uid() = de_user)'; execute 'drop policy if exists "admin_all_messages" on public.mensajes'; execute 'create policy "admin_all_messages" on public.mensajes for all using (public.is_admin()) with check (public.is_admin())'; end if; end $$; create table if not exists public.mc_stock( id uuid primary key default gen_random_uuid(), user_id uuid references auth.users(id) on delete cascade, nombre text not null, categoria text, cantidad numeric not null default 0, unidad text default 'kg', consumo_diario numeric default 0, precio_unitario numeric default 0, proveedor text, finca_nombre text, observaciones text, ultima_actualizacion timestamptz default now(), created_at timestamptz default now() ); alter table public.mc_stock enable row level security; drop policy if exists "users_own_stock" on public.mc_stock; create policy "users_own_stock" on public.mc_stock for all using (auth.uid() = user_id) with check (auth.uid() = user_id); drop policy if exists "admin_all_stock" on public.mc_stock; create policy "admin_all_stock" on public.mc_stock for all using (public.is_admin()) with check (public.is_admin()); create index if not exists mc_sessions_ts_idx on public.mc_sessions(ts desc); create index if not exists mc_sessions_user_idx on public.mc_sessions(user_id); alter table public.mc_sessions enable row level security; drop policy if exists "users_insert_own_session" on public.mc_sessions; create policy "users_insert_own_session" on public.mc_sessions for insert with check (auth.uid() = user_id); drop policy if exists "admin_read_all_sessions" on public.mc_sessions; create policy "admin_read_all_sessions" on public.mc_sessions for select using (public.is_admin()); drop policy if exists "admin_manage_sessions" on public.mc_sessions; create policy "admin_manage_sessions" on public.mc_sessions for all using (public.is_admin()) with check (public.is_admin()); -- 5) IMPORTANTE: refrescar schema cache de PostgREST (varios métodos) notify pgrst, 'reload schema'; notify pgrst; -- 6) Verificación: si esta consulta devuelve filas, todo está OK -- NOTA: en el SQL Editor, is_admin será 'false' porque el editor corre como superusuario, -- no como un usuario logueado. La función funcionará bien cuando se llame desde la app. -- list_users count será '0' aquí por la misma razón (no admin = no devuelve usuarios). select 'is_admin' as f, public.is_admin()::text as resultado union all select 'list_users count', coalesce(json_array_length(json_agg(t)::json), 0)::text from public.list_users() t;` // Guardar el SQL en una variable global para que el botón pueda leerlo sin escapado window._sqlAdminFull=sql h+=`
⚠️ Importante: Pega este SQL en Supabase → SQL Editor y ejecútalo UNA SOLA VEZ. Crea: políticas de admin, función list_users() y tabla mc_sessions para analítica.
${esc(sql)}
Pasos:
1. Pulsa el botón verde para copiar el SQL
2. Ve a app.supabase.com → tu proyecto → SQL Editor
3. Pega el SQL y dale a "Run"
4. Vuelve aquí y pulsa "🔄 Recargar datos"
5. Listo: verás todos los usuarios y todas las visitas
📊 ¿Y para visitas a la web ANTES del registro? Esta analítica solo cuenta usuarios autenticados. Para ver visitas anónimas a tu landing/web pública, añade un script de Plausible (recomendado, sin cookies, ~9€/mes) o Google Analytics 4 (gratis) en el <head> de tu HTML — basta con una línea.
` } h+=`` container.innerHTML=h } window.adminViewMode=adminViewMode function buildSys(){ // Helper para limpiar campos internos de Supabase/memoria const clean=(r,keep)=>{const o={};for(const k of keep)if(r[k]!=null&&r[k]!=='')o[k]=r[k];return o} const snap={ gastos:D.ga.slice(0,15).map(r=>clean(r,['fecha','concepto','categoria','importe','proveedor','finca_nombre','cantidad','unidad'])), ventas:D.ve.slice(0,15).map(r=>clean(r,['fecha','producto','kilos_kg','precio_kg','importe_total','cliente','finca_nombre','numero_albaran'])), jornadas:D.jo.slice(0,15).map(r=>clean(r,['fecha','jornalero','horas','tarea','finca_nombre','incidencia'])), jornaleros:D.pl.map(r=>clean(r,['nombre','tarifa','tarea'])), fincas:D.fi.map(f=>({ nombre:f.nombre, cultivo:f.cultivo||'', hectareas:f.hectareas||0, riegos:D.ri.filter(r=>r.finca_id===f._id).slice(0,5).map(r=>clean(r,['fecha','horas','metodo','observaciones'])), productos:D.rp.filter(r=>r.finca_id===f._id).slice(0,5).map(r=>clean(r,['fecha','nombre','tipo','cantidad','observaciones'])) })), stock:(stockItems||[]).slice(0,30).map(it=>{ const dias=it.consumo_diario>0&&it.cantidad>0?Math.floor(it.cantidad/it.consumo_diario):null return { nombre:it.nombre, categoria:it.categoria, cantidad:parseFloat(it.cantidad||0), unidad:it.unidad, consumo_diario:parseFloat(it.consumo_diario||0), dias_restantes:dias, precio_unitario:parseFloat(it.precio_unitario||0), valor_total_eur:Math.round(parseFloat(it.cantidad||0)*parseFloat(it.precio_unitario||0)), proveedor:it.proveedor, finca:it.finca_nombre } }) } // Añadir dispositivos con sus lecturas actuales (sin credenciales — no aportan y ocupan tokens) if(typeof dispositivos!=='undefined'&&dispositivos.length){ snap.dispositivos=dispositivos.map(d=>{ const fi=D.fi.find(f=>f._id===d.finca_id) const o={nombre:d.nombre,tipo:d.tipo||'ecowitt',finca:fi?fi.nombre:'sin asignar',activo:!!d.activo} const dt=d.datos if(dt&&!dt.error){ const lect={} if(dt.temp!=null)lect.temp_exterior_C=+parseFloat(dt.temp).toFixed(1) if(dt.hum!=null)lect.humedad_exterior_pct=+parseFloat(dt.hum).toFixed(0) if(dt.tempIn!=null)lect.temp_interior_C=+parseFloat(dt.tempIn).toFixed(1) if(dt.humIn!=null)lect.humedad_interior_pct=+parseFloat(dt.humIn).toFixed(0) if(dt.wind!=null)lect.viento_kmh=+parseFloat(dt.wind).toFixed(1) if(dt.gust!=null)lect.rachas_kmh=+parseFloat(dt.gust).toFixed(1) if(dt.windDir!=null){const dirs=['N','NE','E','SE','S','SO','O','NO'];lect.direccion_viento=dirs[Math.round(dt.windDir/45)%8]} if(dt.rainHour!=null)lect.lluvia_hora_mm=+parseFloat(dt.rainHour).toFixed(1) if(dt.rainDay!=null)lect.lluvia_dia_mm=+parseFloat(dt.rainDay).toFixed(1) if(dt.rainWeek!=null&&dt.rainWeek>0)lect.lluvia_semana_mm=+parseFloat(dt.rainWeek).toFixed(1) if(dt.rainMonth!=null&&dt.rainMonth>0)lect.lluvia_mes_mm=+parseFloat(dt.rainMonth).toFixed(1) if(dt.pressure!=null)lect.presion_hPa=+parseFloat(dt.pressure).toFixed(0) if(dt.uvi!=null)lect.indice_uv=+parseFloat(dt.uvi).toFixed(0) if(dt.solar!=null)lect.solar_W_m2=+parseFloat(dt.solar).toFixed(0) if(dt.co2In!=null)lect.co2_ppm=+parseFloat(dt.co2In).toFixed(0) if(dt.lightningCount!=null)lect.rayos_hoy=dt.lightningCount if(dt.lightningDist!=null)lect.km_ultimo_rayo=dt.lightningDist // DPV / VPD calculado — estado fisiológico if(dt.temp!=null&&dt.hum!=null){ const c=calcDPV(dt.temp,dt.hum) if(c){ const cl=clasificarDPV(c.dpv) lect.dpv_kPa=+c.dpv.toFixed(2) lect.dpv_estado=cl?cl.label:'' lect.dpv_recomendacion=cl?cl.accion:'' } } if(dt.soilChannels?.length)lect.suelo=dt.soilChannels.map(s=>({canal:s.ch,humedad_pct:s.moist!=null?Math.round(s.moist):null,temp_C:s.temp!=null?+parseFloat(s.temp).toFixed(1):null})) if(dt.tempHumChannels?.length)lect.temp_hum_extra=dt.tempHumChannels.map(c=>({canal:c.ch,temp_C:c.temp!=null?+parseFloat(c.temp).toFixed(1):null,hum_pct:c.hum!=null?Math.round(c.hum):null})) if(dt.leafChannels?.length)lect.humedad_foliar=dt.leafChannels.map(c=>({canal:c.ch,wet_pct:c.wet})) if(dt.pm25Channels?.length)lect.pm25=dt.pm25Channels.map(c=>({canal:c.ch,ugm3:c.pm25})) if(dt.ts)lect.ultima_lectura=new Date(dt.ts).toLocaleString('es-ES',{dateStyle:'short',timeStyle:'short'}) o.lecturas=lect }else if(dt&&dt.error){ o.estado='desconectado: '+dt.error }else{ o.estado='sin datos todavía' } return o }) } // Si el usuario es admin, añadir estadísticas globales y lista resumida de usuarios let adminContext='' if(isAdmin()){ try{ const stats=adminGlobalStats() const ss=adminSessionStats() const usersForIA=(stats.users_all||stats.users_top||[]).slice(0,12).map(u=>({ email:u.email||((u.id||u.user_id||'').slice(0,8)+'…'), registro:u.created_at?new Date(u.created_at).toISOString().slice(0,10):null, ultimo_login:u.last_sign_in_at?new Date(u.last_sign_in_at).toISOString().slice(0,10):null, fincas:u.fincas||0,ventas:u.ventas||0,gastos:u.gastos||0,jornadas:u.jornadas||0, ingresos_eur:Math.round(u.total_ingresos||0), costes_eur:Math.round(u.total_costes||0), ultima_actividad:u.ultima?new Date(u.ultima).toISOString().slice(0,10):'sin_actividad' })) // Estado real del backend (HONESTIDAD: si RLS no aplicado, decirlo claro) const estadoBackend=stats.rls_aplicado ?'OK — datos completos de todos los usuarios cargados desde Supabase' :'⚠️ INCOMPLETO — el SQL de RLS no está aplicado todavía, así que solo veo los datos del propio admin (1 usuario aparente). Para ver TODOS los usuarios reales, el admin debe ir a la pestaña ⚙️ Admin > Configurar y ejecutar el SQL en Supabase.' adminContext=` ESTÁS HABLANDO CON EL ADMINISTRADOR (${(currentUser?.email||'').toLowerCase()}). ESTADO DEL BACKEND DE ADMIN: ${estadoBackend} ESTADÍSTICAS GLOBALES DE LA PLATAFORMA MI HOSTELERÍA: ${JSON.stringify({ usuarios_registrados_real:stats.n_users_real, usuarios_con_actividad:stats.n_users_con_datos, total_fincas:stats.n_fincas, total_gastos:stats.n_gastos, total_ventas:stats.n_ventas, total_jornadas:stats.n_jornadas, total_jornaleros:stats.n_jornaleros, ingresos_totales_eur:Math.round(stats.ingresos_total), costes_totales_eur:Math.round(stats.costes_total), margen_total_eur:Math.round(stats.margen), })} ANALÍTICA DE VISITAS (sesiones autenticadas en la app): ${ss?JSON.stringify({ visitas_hoy:ss.visitas_hoy, visitas_ultimos_7d:ss.visitas_7d, visitas_ultimos_30d:ss.visitas_30d, usuarios_unicos_hoy:ss.usuarios_hoy, usuarios_unicos_7d:ss.usuarios_7d, total_sesiones_historico:ss.total_sesiones, ultimas_dos_semanas_por_dia:ss.serie_14d }):'(no hay datos de sesiones — la tabla mc_sessions aún no existe o está vacía. Aplicar SQL del panel ⚙️ Configurar)'} USUARIOS (lista resumida): ${JSON.stringify(usersForIA)} REGLAS DE HONESTIDAD CRÍTICAS: - Si "estado_backend" indica INCOMPLETO, DEBES avisar al admin de que los números de usuarios pueden no reflejar la realidad y dirigirlo al panel ⚙️ Configurar. - NUNCA digas "hay 1 usuario en total" cuando el estado sea INCOMPLETO. En su lugar di: "Solo puedo ver tu propia cuenta porque el RLS de admin no está aplicado todavía. Aplica el SQL en Supabase para que aparezcan los demás usuarios." - Para preguntas sobre VISITAS A LA WEB PÚBLICA (gente que entra sin estar registrada), responde que Mi Hostelería tiene Google Analytics 4 activo (propiedad G-QMG1YFJ0CF, dominio micampoconia.com) y Google Tag Manager (GTM-5MRXGJ6B). Las estadísticas completas (países, dispositivos, embudos, conversiones) se ven en analytics.google.com. En la app solo registramos sesiones de usuarios autenticados. - Para preguntas sobre USO DE LA APP (sesiones de usuarios autenticados), usa los números de "analítica de visitas" arriba. - NUNCA inventes números. Si un campo es null o no aparece, di que no lo tienes. Como admin puedes: - Responder estadísticas globales con los números EXACTOS arriba - Identificar usuarios inactivos (los que tienen ultima_actividad="sin_actividad") - Detectar el más activo - Sugerir acciones (animar inactivos, etc.) - Recomendar al admin que use la pestaña Admin > Inspeccionar para ver los datos crudos de un usuario.` }catch(e){console.warn('admin context error:',e)} } return `Eres el asistente de gestión de un hostelero. Eres conciso y cercano. 🌐 IDIOMA: El usuario tiene la app en ${LANG_NAMES[userSettings.lang]||'español'}. RESPONDE SIEMPRE EN ${(LANG_NAMES[userSettings.lang]||'español').toUpperCase()}, salvo si el propio usuario te escribe en otro idioma — en ese caso, responde en el idioma en que te haya escrito. Los datos del usuario están en español (nombres de cultivos, productos, etc.) — déjalos como están y solo traduce TUS textos de respuesta.${adminContext} INFORMACIÓN DEL CREADOR DE LA APP: Mi Hostelería ha sido creada por Ali Aauicha Azghouli (aliaauicha@gmail.com, tel. 678902270). - Especialista en operaciones y analítica. - Trabajó en el campo durante sus años universitarios en la costa almeriense. - Grado en Ciencias Políticas (Universidad de Granada), Máster en Análisis Económico (Universidad de Málaga), Máster en SAP BTP y AI (en curso, EuropeanBTech 2026). - Carrera profesional: Analista de Operaciones en Fluiconnecto (UK), Responsable de Operaciones en Paack (UK), Analista de Cadena de Suministro en Grupo Sesé (Barcelona). - Especializado en optimización de procesos, gestión de stock, análisis de datos, Lean y Six Sigma. - LinkedIn: https://www.linkedin.com/in/ali-aauicha/ Si alguien pregunta sobre el autor, el creador o quién ha hecho la app, responde con esta información. DATOS ACTUALES: ${JSON.stringify(snap)} MÓDULOS: - ga (gasto): alimentos, bebidas, limpieza, menaje, energía, suministros, mantenimiento, otros. Puede incluir finca_nombre (establecimiento). - ve (venta): tickets / cierre de caja — producto (menú del día, carta, desayunos, bebidas, etc.), kilos_kg (=número de tickets o cubiertos), precio_kg (=ticket medio €), importe_total, cliente (sala/barra/terraza/take-away), numero_albaran (número de ticket Z), finca_nombre (establecimiento) - jo (jornada): días trabajados — jornalero (nombre del trabajador), horas, tarea (cocina, sala, barra, limpieza, encargado, desayunos…), fecha, incidencia, finca_nombre (establecimiento) - jornalero_nuevo: AÑADIR un trabajador a la plantilla — nombre, tarifa (€/hora), tarea (puesto: camarero, cocinero, ayudante, encargado, limpieza, recepción). Usar cuando digan "añade a Fernando", "nuevo camarero Fernando 9€/h sala", "apunta a X al equipo". - riego: servicios o aperturas por establecimiento — finca (nombre del establecimiento), horas (de servicio), metodo (mediodía/cena/desayuno/continuo/turno doble), fecha, observaciones - campana: crear temporada — nombre (Verano 2026, Navidad, Semana Santa…), fecha_inicio (YYYY-MM-DD), fecha_fin (YYYY-MM-DD), descripcion, fincas (array de establecimientos) - evento: añadir cita al calendario — titulo, tipo (reserva grupo/evento privado/mantenimiento/inspección/proveedor/reunion/otro), fecha (YYYY-MM-DD), hora (HH:MM), descripcion - producto_finca: productos consumidos en local — finca (establecimiento), nombre (del producto), tipo (limpieza/menaje/desinfección/lavandería/otro), cantidad, fecha, observaciones - stock_nuevo: AÑADIR producto al stock/almacén — nombre, categoria (alimentos/bebidas/limpieza/menaje/energia/mantenimiento/otros), cantidad (número), unidad (kg/L/cajas/botellas/barril/pack/unidades/rollos), consumo_diario (número, 0 si no consume), precio_unitario (€/unidad, número), proveedor (Makro, Mahou, Coosur, Selecta, distribuidor local…), finca_nombre opcional, observaciones. Usar cuando digan "tengo X de producto Y", "he comprado Z cajas de…", "añade al stock", "apunta en almacén", "quedan N botellas de…". SOBRE LOS DISPOSITIVOS/SENSORES: - En "dispositivos" tienes el listado de sensores conectados del hostelero (sondas de temperatura de cámaras frigoríficas, sensores de terraza, estaciones meteo), con el establecimiento donde están instalados y sus lecturas en vivo (temperatura cámara, congelador, ambiente terraza, viento, humedad, etc.). - Si te preguntan por los dispositivos, sensores, estaciones o por la temperatura de una cámara concreta, USA estos datos y responde con las lecturas reales. - Si preguntan "¿qué dispositivos tengo?" o "¿qué sensores hay en el local X?", responde listando los que hay con su establecimiento asignado. - Si preguntan por la temperatura de cámaras o el tiempo en la terraza, busca el sensor asignado a ese local y da las cifras exactas. Avisa si una cámara está fuera de rango (carnes/lácteos: 0-4 °C, congelador: -18 °C o menos, vinos: 10-14 °C). - Si un dispositivo tiene estado "desconectado" o "sin datos", indícalo amablemente y sugiere pulsar 🔄 Actualizar. INSTRUCCIONES: - "Fernando 9€ hora sala" o "añade a Fernando al equipo" → tipo jornalero_nuevo con nombre, tarifa y tarea (puesto). - "Fernando trabajó 8h cocina hoy" → tipo jo (jornada trabajada ese día). - "He comprado 24 cajas de cerveza a 13€" o "tengo 50L de aceite" → tipo stock_nuevo con nombre, categoria, cantidad, unidad, precio_unitario. - Para jornadas múltiples "Antonio 8h sala, Carmen 6h cocina" → una entrada jo por persona. - Para establecimientos: "Bar Centro: 42 menús del día, ticket medio 14,80€" → UNA venta (ve). - Si menciona un establecimiento que no existe, créalo automáticamente. - En consultas de totales → calcula y responde con precisión. - Para consultas sobre sensores/dispositivos/temperaturas/cámaras → usa la sección "dispositivos" y "guardar" queda vacío. Responde con JSON puro: {"respuesta":"texto","guardar":[{"tipo":"gasto|venta|jo|jornalero_nuevo|riego|producto_finca|stock_nuevo|campana|evento","datos":{...}}]} Sin guardar → "guardar":[]` } function addUserMsg(t){ const b=document.getElementById('chat-msgs') const d=document.createElement('div');d.className='msg user' d.innerHTML=`
${esc(t)}
`;b.appendChild(d);b.scrollTop=b.scrollHeight } function addBotMsg(html,pill=''){ const b=document.getElementById('chat-msgs') const d=document.createElement('div');d.className='msg bot' d.innerHTML=`
🏪
${html}${pill?'
'+pill+'':''}
` b.appendChild(d);b.scrollTop=b.scrollHeight } function showTyping(){ const b=document.getElementById('chat-msgs') const d=document.createElement('div');d.id='typ';d.className='msg bot' d.innerHTML='
🏪
' b.appendChild(d);b.scrollTop=b.scrollHeight } function rmTyping(){document.getElementById('typ')?.remove()} async function sendMsg(){ const inp=document.getElementById('chat-input') const txt=inp.value.trim();if(!txt)return inp.value='';inp.style.height='auto' document.getElementById('send-btn').disabled=true addUserMsg(txt);convHistory.push({role:'user',content:txt});showTyping() try{ const res=await fetch('https://5bffv7il4g.execute-api.eu-south-2.amazonaws.com/',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({model:'claude-haiku-4-5-20251001',max_tokens:900,system:buildSys(),messages:convHistory.slice(-10)}) }) if(!res.ok){ const errText=await res.text() console.error('Chat error:',res.status,errText) if(res.status===429){ rmTyping() addBotMsg('⏳ Se ha alcanzado el límite de peticiones por minuto. Espera unos 30-60 segundos y vuelve a preguntar.

Consejo: preguntas más cortas y sin repetir todo el historial consumen menos.') document.getElementById('send-btn').disabled=false return } throw new Error('API error '+res.status) } const raw=await res.json() const t2=(raw.content||[]).map(i=>i.text||'').join('') let p={respuesta:t2,guardar:[]} try{p=JSON.parse(t2.replace(/```json|```/g,'').trim())}catch{} const saved=[] for(const item of(p.guardar||[])){ if(!item.tipo||!item.datos)continue await saveEntry(item.tipo,item.datos) saved.push({gasto:'💸 Gastos',venta:'📦 Ventas',jo:'👨‍🍳 Personal',jornalero_nuevo:'👨‍🍳 Personal',riego:'🏪 Establecimientos',producto_finca:'🏪 Establecimientos'}[item.tipo]||'✓') } rmTyping() const pill=saved.length?'✓ Guardado en: '+[...new Set(saved)].join(', '):'' addBotMsg(esc(p.respuesta),pill) convHistory.push({role:'assistant',content:p.respuesta}) if(convHistory.length>20)convHistory=convHistory.slice(-20) if(saved.length){ const tabMap={'Gastos':'g','Ventas':'v','Personal':'j','Establecimientos':'fc'} const dest=Object.entries(tabMap).find(([k])=>saved[0]?.includes(k)) if(dest&&dest[1]!==curTab)setTimeout(()=>goTab(dest[1],document.querySelector(`[data-tab="${dest[1]}"]`)),600) } }catch(err){ rmTyping() console.error('Chat error:',err) addBotMsg('⚠️ Error: '+err.message+'. Abre la consola del navegador (F12) para ver más detalles.') } document.getElementById('send-btn').disabled=false } // ═══════════════ INIT ════════════════════════ document.getElementById('shell').style.display='none' document.getElementById('bottom-nav').style.display='none' async function initAuth(){ try{ // 0 — Si la URL es de fichaje (#fichar=ID), mostrar la página de fichaje sin pasar por login if(typeof checkFichajeRoute==='function'&&checkFichajeRoute())return; if(typeof checkReservaRoute==='function'&&checkReservaRoute())return; if(typeof checkPedidoRoute==='function'&&checkPedidoRoute())return // 1 — Handle OAuth/email callback codes in URL const url=new URL(window.location.href) const code=url.searchParams.get('code') const accessToken=url.hash.match(/access_token=([^&]+)/)?.[1] const errorDesc=url.searchParams.get('error_description') if(errorDesc){ showAuthScreen() authErr('Error: '+decodeURIComponent(errorDesc)) window.history.replaceState({},document.title,window.location.pathname) return } if(code){ // PKCE flow — exchange code for session try{ const {data,error}=await sb.auth.exchangeCodeForSession(code) window.history.replaceState({},document.title,window.location.pathname) if(data?.session?.user){ await showApp(data.session.user); return } }catch(e){ console.warn('Code exchange failed:',e) } } if(accessToken){ // Implicit flow — set session from hash try{ await sb.auth.setSession({ access_token:accessToken, refresh_token:url.hash.match(/refresh_token=([^&]+)/)?.[1]||'' }) window.history.replaceState({},document.title,window.location.pathname) }catch(e){ console.warn('Set session failed:',e) } } // 2 — Check for existing session const {data:{session}}=await sb.auth.getSession() if(session?.user){ await showApp(session.user); return } // 3 — No session showAuthScreen() // 4 — Listen for future changes sb.auth.onAuthStateChange(async(event,session)=>{ if(event==='SIGNED_IN'&&session?.user){ await showApp(session.user) } else if(event==='SIGNED_OUT'){ showAuthScreen() } }) }catch(e){ console.warn('Auth init error:',e) showAuthScreen() } } initAuth() // ─── Red de seguridad: exponer handlers de onclick en window ─── // Los handlers inline (onclick="foo()") sólo pueden encontrar funciones // que estén en `window`. Esto lo garantiza aunque cambie el ámbito. try{ if(typeof guardarDispositivo==='function')window.guardarDispositivo=guardarDispositivo if(typeof editarDispositivo==='function')window.editarDispositivo=editarDispositivo if(typeof eliminarDispositivo==='function')window.eliminarDispositivo=eliminarDispositivo if(typeof refreshDispositivo==='function')window.refreshDispositivo=refreshDispositivo if(typeof refreshAllDispositivos==='function')window.refreshAllDispositivos=refreshAllDispositivos if(typeof showDispInfoGuide==='function')window.showDispInfoGuide=showDispInfoGuide if(typeof startDispQR==='function')window.startDispQR=startDispQR if(typeof stopDispQR==='function')window.stopDispQR=stopDispQR if(typeof onDispPhotoUpload==='function')window.onDispPhotoUpload=onDispPhotoUpload if(typeof toggleDispVoice==='function')window.toggleDispVoice=toggleDispVoice if(typeof processDispIA==='function')window.processDispIA=processDispIA }catch(e){console.warn('Expose handlers error:',e)} inp.value='';inp.style.height='auto' document.getElementById('send-btn').disabled=true addUserMsg(txt);convHistory.push({role:'user',content:txt});showTyping() try{ const res=await fetch('https://5bffv7il4g.execute-api.eu-south-2.amazonaws.com/',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({model:'claude-haiku-4-5-20251001',max_tokens:900,system:buildSys(),messages:convHistory.slice(-10)}) }) if(!res.ok){ const errText=await res.text() console.error('Chat error:',res.status,errText) if(res.status===429){ rmTyping() addBotMsg('⏳ Se ha alcanzado el límite de peticiones por minuto. Espera unos 30-60 segundos y vuelve a preguntar.

Consejo: preguntas más cortas y sin repetir todo el historial consumen menos.') document.getElementById('send-btn').disabled=false return } throw new Error('API error '+res.status) } const raw=await res.json() const t2=(raw.content||[]).map(i=>i.text||'').join('') let p={respuesta:t2,guardar:[]} try{p=JSON.parse(t2.replace(/```json|```/g,'').trim())}catch{} const saved=[] for(const item of(p.guardar||[])){ if(!item.tipo||!item.datos)continue await saveEntry(item.tipo,item.datos) saved.push({gasto:'💸 Gastos',venta:'📦 Ventas',jo:'👨‍🍳 Personal',jornalero_nuevo:'👨‍🍳 Personal',riego:'🏪 Establecimientos',producto_finca:'🏪 Establecimientos'}[item.tipo]||'✓') } rmTyping() const pill=saved.length?'✓ Guardado en: '+[...new Set(saved)].join(', '):'' addBotMsg(esc(p.respuesta),pill) convHistory.push({role:'assistant',content:p.respuesta}) if(convHistory.length>20)convHistory=convHistory.slice(-20) if(saved.length){ const tabMap={'Gastos':'g','Ventas':'v','Personal':'j','Establecimientos':'fc'} const dest=Object.entries(tabMap).find(([k])=>saved[0]?.includes(k)) if(dest&&dest[1]!==curTab)setTimeout(()=>goTab(dest[1],document.querySelector(`[data-tab="${dest[1]}"]`)),600) } }catch(err){ rmTyping() console.error('Chat error:',err) addBotMsg('⚠️ Error: '+err.message+'. Abre la consola del navegador (F12) para ver más detalles.') } document.getElementById('send-btn').disabled=false } // ═══════════════ INIT ════════════════════════ document.getElementById('shell').style.display='none' document.getElementById('bottom-nav').style.display='none' async function initAuth(){ try{ // 0 — Si la URL es de fichaje (#fichar=ID), mostrar la página de fichaje sin pasar por login if(typeof checkFichajeRoute==='function'&&checkFichajeRoute())return; if(typeof checkReservaRoute==='function'&&checkReservaRoute())return; if(typeof checkPedidoRoute==='function'&&checkPedidoRoute())return // 1 — Handle OAuth/email callback codes in URL const url=new URL(window.location.href) const code=url.searchParams.get('code') const accessToken=url.hash.match(/access_token=([^&]+)/)?.[1] const errorDesc=url.searchParams.get('error_description') if(errorDesc){ showAuthScreen() authErr('Error: '+decodeURIComponent(errorDesc)) window.history.replaceState({},document.title,window.location.pathname) return } if(code){ // PKCE flow — exchange code for session try{ const {data,error}=await sb.auth.exchangeCodeForSession(code) window.history.replaceState({},document.title,window.location.pathname) if(data?.session?.user){ await showApp(data.session.user); return } }catch(e){ console.warn('Code exchange failed:',e) } } if(accessToken){ // Implicit flow — set session from hash try{ await sb.auth.setSession({ access_token:accessToken, refresh_token:url.hash.match(/refresh_token=([^&]+)/)?.[1]||'' }) window.history.replaceState({},document.title,window.location.pathname) }catch(e){ console.warn('Set session failed:',e) } } // 2 — Check for existing session const {data:{session}}=await sb.auth.getSession() if(session?.user){ await showApp(session.user); return } // 3 — No session showAuthScreen() // 4 — Listen for future changes sb.auth.onAuthStateChange(async(event,session)=>{ if(event==='SIGNED_IN'&&session?.user){ await showApp(session.user) } else if(event==='SIGNED_OUT'){ showAuthScreen() } }) }catch(e){ console.warn('Auth init error:',e) showAuthScreen() } } initAuth() // ─── Red de seguridad: exponer handlers de onclick en window ─── // Los handlers inline (onclick="foo()") sólo pueden encontrar funciones // que estén en `window`. Esto lo garantiza aunque cambie el ámbito. try{ if(typeof guardarDispositivo==='function')window.guardarDispositivo=guardarDispositivo if(typeof editarDispositivo==='function')window.editarDispositivo=editarDispositivo if(typeof eliminarDispositivo==='function')window.eliminarDispositivo=eliminarDispositivo if(typeof refreshDispositivo==='function')window.refreshDispositivo=refreshDispositivo if(typeof refreshAllDispositivos==='function')window.refreshAllDispositivos=refreshAllDispositivos if(typeof showDispInfoGuide==='function')window.showDispInfoGuide=showDispInfoGuide if(typeof startDispQR==='function')window.startDispQR=startDispQR if(typeof stopDispQR==='function')window.stopDispQR=stopDispQR if(typeof onDispPhotoUpload==='function')window.onDispPhotoUpload=onDispPhotoUpload if(typeof toggleDispVoice==='function')window.toggleDispVoice=toggleDispVoice if(typeof processDispIA==='function')window.processDispIA=processDispIA }catch(e){console.warn('Expose handlers error:',e)} ray array['gastos','ventas','jornadas','jornaleros','fincas','riegos','productos_finca','campanas','mc_stock'] loop if exists (select 1 from information_schema.tables where table_schema='public' and table_name=t) then execute format('alter table public.%I enable row level security', t); execute format('drop policy if exists "admin_read_all_%I" on public.%I', t, t); execute format('create policy "admin_read_all_%I" on public.%I for select using (public.is_admin())', t, t); execute format('drop policy if exists "admin_write_all_%I" on public.%I', t, t); execute format('create policy "admin_write_all_%I" on public.%I for all using (public.is_admin()) with check (public.is_admin())', t, t); end if; end loop; end $$; -- 3) Función list_users — SIN PARÁMETROS, firma explícita create function public.list_users() returns setof json language plpgsql security definer stable set search_path = public, auth as $$ begin if not public.is_admin() then return; end if; return query select json_build_object( 'id', u.id, 'email', u.email, 'created_at', u.created_at, 'last_sign_in_at', u.last_sign_in_at, 'name', u.raw_user_meta_data->>'name' ) from auth.users u order by u.created_at desc; end; $$; revoke all on function public.list_users() from public; grant execute on function public.list_users() to authenticated; -- 4) Tabla de SESIONES/VISITAS (analítica) create table if not exists public.mc_sessions( id bigserial primary key, user_id uuid references auth.users(id) on delete cascade, email text, user_agent text, ts timestamptz default now() ); -- 4b) Tabla de CAMPAÑAS (si no existía ya) create table if not exists public.campanas( id uuid primary key default gen_random_uuid(), user_id uuid references auth.users(id) on delete cascade, nombre text not null, fecha_inicio date, fecha_fin date, descripcion text, fincas jsonb default '[]'::jsonb, created_at timestamptz default now() ); alter table public.campanas enable row level security; drop policy if exists "users_own_campanas" on public.campanas; create policy "users_own_campanas" on public.campanas for all using (auth.uid() = user_id) with check (auth.uid() = user_id); drop policy if exists "admin_all_campanas" on public.campanas; create policy "admin_all_campanas" on public.campanas for all using (public.is_admin()) with check (public.is_admin()); -- 4d) Tabla de FICHAJES (entradas/salidas vía QR en fincas) create table if not exists public.mc_fichajes( id uuid primary key default gen_random_uuid(), finca_id uuid not null, jornalero_id uuid, jornalero_nombre text, tipo text check(tipo in ('entrada','salida')), ts timestamptz default now(), user_agent text ); create index if not exists mc_fichajes_finca_idx on public.mc_fichajes(finca_id); create index if not exists mc_fichajes_ts_idx on public.mc_fichajes(ts desc); alter table public.mc_fichajes enable row level security; -- Cualquiera (incluso anónimo) puede insertar un fichaje (escaneando el QR del campo) drop policy if exists "anyone_insert_fichaje" on public.mc_fichajes; create policy "anyone_insert_fichaje" on public.mc_fichajes for insert to anon, authenticated with check (true); -- El dueño de la finca lee sus fichajes; admin lee todos drop policy if exists "owner_read_fichajes" on public.mc_fichajes; create policy "owner_read_fichajes" on public.mc_fichajes for select using ( public.is_admin() or exists(select 1 from public.fincas f where f.id=finca_id and f.user_id=auth.uid()) ); drop policy if exists "owner_manage_fichajes" on public.mc_fichajes; create policy "owner_manage_fichajes" on public.mc_fichajes for all using ( public.is_admin() or exists(select 1 from public.fincas f where f.id=finca_id and f.user_id=auth.uid()) ) with check ( public.is_admin() or exists(select 1 from public.fincas f where f.id=finca_id and f.user_id=auth.uid()) ); -- Función pública para leer el nombre de una finca (sin login) — para mostrarlo en la página de fichaje create or replace function public.get_finca_publica(p_finca_id uuid) returns table(nombre text) language sql security definer stable set search_path = public as $$ select nombre from public.fincas where id = p_finca_id; $$; grant execute on function public.get_finca_publica(uuid) to anon, authenticated; -- 4e) MENSAJERÍA GLOBAL — funciones para que todos los usuarios puedan buscarse y escribirse -- Búsqueda de usuarios por email/nombre (cualquier usuario autenticado puede usarla) create or replace function public.search_users(q text) returns table(id uuid, email text, name text) language sql security definer stable set search_path = public, auth as $$ select u.id, u.email::text, coalesce(u.raw_user_meta_data->>'name', split_part(u.email,'@',1))::text as name from auth.users u where auth.uid() is not null -- solo usuarios autenticados and u.id != auth.uid() -- no listar al propio usuario and u.email is not null and ( q is null or q = '' or u.email ilike '%'||q||'%' or coalesce(u.raw_user_meta_data->>'name','') ilike '%'||q||'%' ) order by u.email limit 30; $$; grant execute on function public.search_users(text) to authenticated; -- Información puntual de un usuario por su ID create or replace function public.get_user_info(p_user_id uuid) returns table(id uuid, email text, name text) language sql security definer stable set search_path = public, auth as $$ select u.id, u.email::text, coalesce(u.raw_user_meta_data->>'name', split_part(u.email,'@',1))::text as name from auth.users u where auth.uid() is not null and u.id = p_user_id; $$; grant execute on function public.get_user_info(uuid) to authenticated; -- Información de varios usuarios a la vez (para resolver lista de conversaciones) create or replace function public.get_users_info(p_user_ids uuid[]) returns table(id uuid, email text, name text) language sql security definer stable set search_path = public, auth as $$ select u.id, u.email::text, coalesce(u.raw_user_meta_data->>'name', split_part(u.email,'@',1))::text as name from auth.users u where auth.uid() is not null and u.id = any(p_user_ids); $$; grant execute on function public.get_users_info(uuid[]) to authenticated; -- RLS de la tabla mensajes — cualquier usuario puede enviar/leer SUS mensajes (recibidos o enviados) do $$ begin if exists (select 1 from information_schema.tables where table_schema='public' and table_name='mensajes') then execute 'alter table public.mensajes enable row level security'; execute 'drop policy if exists "send_own_messages" on public.mensajes'; execute 'create policy "send_own_messages" on public.mensajes for insert with check (auth.uid() = de_user)'; execute 'drop policy if exists "read_my_messages" on public.mensajes'; execute 'create policy "read_my_messages" on public.mensajes for select using (auth.uid() = de_user or auth.uid() = para_user or grupo_id is not null)'; execute 'drop policy if exists "delete_own_messages" on public.mensajes'; execute 'create policy "delete_own_messages" on public.mensajes for delete using (auth.uid() = de_user)'; execute 'drop policy if exists "admin_all_messages" on public.mensajes'; execute 'create policy "admin_all_messages" on public.mensajes for all using (public.is_admin()) with check (public.is_admin())'; end if; end $$; create table if not exists public.mc_stock( id uuid primary key default gen_random_uuid(), user_id uuid references auth.users(id) on delete cascade, nombre text not null, categoria text, cantidad numeric not null default 0, unidad text default 'kg', consumo_diario numeric default 0, precio_unitario numeric default 0, proveedor text, finca_nombre text, observaciones text, ultima_actualizacion timestamptz default now(), created_at timestamptz default now() ); alter table public.mc_stock enable row level security; drop policy if exists "users_own_stock" on public.mc_stock; create policy "users_own_stock" on public.mc_stock for all using (auth.uid() = user_id) with check (auth.uid() = user_id); drop policy if exists "admin_all_stock" on public.mc_stock; create policy "admin_all_stock" on public.mc_stock for all using (public.is_admin()) with check (public.is_admin()); create index if not exists mc_sessions_ts_idx on public.mc_sessions(ts desc); create index if not exists mc_sessions_user_idx on public.mc_sessions(user_id); alter table public.mc_sessions enable row level security; drop policy if exists "users_insert_own_session" on public.mc_sessions; create policy "users_insert_own_session" on public.mc_sessions for insert with check (auth.uid() = user_id); drop policy if exists "admin_read_all_sessions" on public.mc_sessions; create policy "admin_read_all_sessions" on public.mc_sessions for select using (public.is_admin()); drop policy if exists "admin_manage_sessions" on public.mc_sessions; create policy "admin_manage_sessions" on public.mc_sessions for all using (public.is_admin()) with check (public.is_admin()); -- 5) IMPORTANTE: refrescar schema cache de PostgREST (varios métodos) notify pgrst, 'reload schema'; notify pgrst; -- 6) Verificación: si esta consulta devuelve filas, todo está OK -- NOTA: en el SQL Editor, is_admin será 'false' porque el editor corre como superusuario, -- no como un usuario logueado. La función funcionará bien cuando se llame desde la app. -- list_users count será '0' aquí por la misma razón (no admin = no devuelve usuarios). select 'is_admin' as f, public.is_admin()::text as resultado union all select 'list_users count', coalesce(json_array_length(json_agg(t)::json), 0)::text from public.list_users() t;` // Guardar el SQL en una variable global para que el botón pueda leerlo sin escapado window._sqlAdminFull=sql h+=`
⚠️ Importante: Pega este SQL en Supabase → SQL Editor y ejecútalo UNA SOLA VEZ. Crea: políticas de admin, función list_users() y tabla mc_sessions para analítica.
${esc(sql)}
Pasos:
1. Pulsa el botón verde para copiar el SQL
2. Ve a app.supabase.com → tu proyecto → SQL Editor
3. Pega el SQL y dale a "Run"
4. Vuelve aquí y pulsa "🔄 Recargar datos"
5. Listo: verás todos los usuarios y todas las visitas
📊 ¿Y para visitas a la web ANTES del registro? Esta analítica solo cuenta usuarios autenticados. Para ver visitas anónimas a tu landing/web pública, añade un script de Plausible (recomendado, sin cookies, ~9€/mes) o Google Analytics 4 (gratis) en el <head> de tu HTML — basta con una línea.
` } h+=`` container.innerHTML=h } window.adminViewMode=adminViewMode function buildSys(){ // Helper para limpiar campos internos de Supabase/memoria const clean=(r,keep)=>{const o={};for(const k of keep)if(r[k]!=null&&r[k]!=='')o[k]=r[k];return o} const snap={ gastos:D.ga.slice(0,15).map(r=>clean(r,['fecha','concepto','categoria','importe','proveedor','finca_nombre','cantidad','unidad'])), ventas:D.ve.slice(0,15).map(r=>clean(r,['fecha','producto','kilos_kg','precio_kg','importe_total','cliente','finca_nombre','numero_albaran'])), jornadas:D.jo.slice(0,15).map(r=>clean(r,['fecha','jornalero','horas','tarea','finca_nombre','incidencia'])), jornaleros:D.pl.map(r=>clean(r,['nombre','tarifa','tarea'])), fincas:D.fi.map(f=>({ nombre:f.nombre, cultivo:f.cultivo||'', hectareas:f.hectareas||0, riegos:D.ri.filter(r=>r.finca_id===f._id).slice(0,5).map(r=>clean(r,['fecha','horas','metodo','observaciones'])), productos:D.rp.filter(r=>r.finca_id===f._id).slice(0,5).map(r=>clean(r,['fecha','nombre','tipo','cantidad','observaciones'])) })), stock:(stockItems||[]).slice(0,30).map(it=>{ const dias=it.consumo_diario>0&&it.cantidad>0?Math.floor(it.cantidad/it.consumo_diario):null return { nombre:it.nombre, categoria:it.categoria, cantidad:parseFloat(it.cantidad||0), unidad:it.unidad, consumo_diario:parseFloat(it.consumo_diario||0), dias_restantes:dias, precio_unitario:parseFloat(it.precio_unitario||0), valor_total_eur:Math.round(parseFloat(it.cantidad||0)*parseFloat(it.precio_unitario||0)), proveedor:it.proveedor, finca:it.finca_nombre } }) } // Añadir dispositivos con sus lecturas actuales (sin credenciales — no aportan y ocupan tokens) if(typeof dispositivos!=='undefined'&&dispositivos.length){ snap.dispositivos=dispositivos.map(d=>{ const fi=D.fi.find(f=>f._id===d.finca_id) const o={nombre:d.nombre,tipo:d.tipo||'ecowitt',finca:fi?fi.nombre:'sin asignar',activo:!!d.activo} const dt=d.datos if(dt&&!dt.error){ const lect={} if(dt.temp!=null)lect.temp_exterior_C=+parseFloat(dt.temp).toFixed(1) if(dt.hum!=null)lect.humedad_exterior_pct=+parseFloat(dt.hum).toFixed(0) if(dt.tempIn!=null)lect.temp_interior_C=+parseFloat(dt.tempIn).toFixed(1) if(dt.humIn!=null)lect.humedad_interior_pct=+parseFloat(dt.humIn).toFixed(0) if(dt.wind!=null)lect.viento_kmh=+parseFloat(dt.wind).toFixed(1) if(dt.gust!=null)lect.rachas_kmh=+parseFloat(dt.gust).toFixed(1) if(dt.windDir!=null){const dirs=['N','NE','E','SE','S','SO','O','NO'];lect.direccion_viento=dirs[Math.round(dt.windDir/45)%8]} if(dt.rainHour!=null)lect.lluvia_hora_mm=+parseFloat(dt.rainHour).toFixed(1) if(dt.rainDay!=null)lect.lluvia_dia_mm=+parseFloat(dt.rainDay).toFixed(1) if(dt.rainWeek!=null&&dt.rainWeek>0)lect.lluvia_semana_mm=+parseFloat(dt.rainWeek).toFixed(1) if(dt.rainMonth!=null&&dt.rainMonth>0)lect.lluvia_mes_mm=+parseFloat(dt.rainMonth).toFixed(1) if(dt.pressure!=null)lect.presion_hPa=+parseFloat(dt.pressure).toFixed(0) if(dt.uvi!=null)lect.indice_uv=+parseFloat(dt.uvi).toFixed(0) if(dt.solar!=null)lect.solar_W_m2=+parseFloat(dt.solar).toFixed(0) if(dt.co2In!=null)lect.co2_ppm=+parseFloat(dt.co2In).toFixed(0) if(dt.lightningCount!=null)lect.rayos_hoy=dt.lightningCount if(dt.lightningDist!=null)lect.km_ultimo_rayo=dt.lightningDist // DPV / VPD calculado — estado fisiológico if(dt.temp!=null&&dt.hum!=null){ const c=calcDPV(dt.temp,dt.hum) if(c){ const cl=clasificarDPV(c.dpv) lect.dpv_kPa=+c.dpv.toFixed(2) lect.dpv_estado=cl?cl.label:'' lect.dpv_recomendacion=cl?cl.accion:'' } } if(dt.soilChannels?.length)lect.suelo=dt.soilChannels.map(s=>({canal:s.ch,humedad_pct:s.moist!=null?Math.round(s.moist):null,temp_C:s.temp!=null?+parseFloat(s.temp).toFixed(1):null})) if(dt.tempHumChannels?.length)lect.temp_hum_extra=dt.tempHumChannels.map(c=>({canal:c.ch,temp_C:c.temp!=null?+parseFloat(c.temp).toFixed(1):null,hum_pct:c.hum!=null?Math.round(c.hum):null})) if(dt.leafChannels?.length)lect.humedad_foliar=dt.leafChannels.map(c=>({canal:c.ch,wet_pct:c.wet})) if(dt.pm25Channels?.length)lect.pm25=dt.pm25Channels.map(c=>({canal:c.ch,ugm3:c.pm25})) if(dt.ts)lect.ultima_lectura=new Date(dt.ts).toLocaleString('es-ES',{dateStyle:'short',timeStyle:'short'}) o.lecturas=lect }else if(dt&&dt.error){ o.estado='desconectado: '+dt.error }else{ o.estado='sin datos todavía' } return o }) } // Si el usuario es admin, añadir estadísticas globales y lista resumida de usuarios let adminContext='' if(isAdmin()){ try{ const stats=adminGlobalStats() const ss=adminSessionStats() const usersForIA=(stats.users_all||stats.users_top||[]).slice(0,12).map(u=>({ email:u.email||((u.id||u.user_id||'').slice(0,8)+'…'), registro:u.created_at?new Date(u.created_at).toISOString().slice(0,10):null, ultimo_login:u.last_sign_in_at?new Date(u.last_sign_in_at).toISOString().slice(0,10):null, fincas:u.fincas||0,ventas:u.ventas||0,gastos:u.gastos||0,jornadas:u.jornadas||0, ingresos_eur:Math.round(u.total_ingresos||0), costes_eur:Math.round(u.total_costes||0), ultima_actividad:u.ultima?new Date(u.ultima).toISOString().slice(0,10):'sin_actividad' })) // Estado real del backend (HONESTIDAD: si RLS no aplicado, decirlo claro) const estadoBackend=stats.rls_aplicado ?'OK — datos completos de todos los usuarios cargados desde Supabase' :'⚠️ INCOMPLETO — el SQL de RLS no está aplicado todavía, así que solo veo los datos del propio admin (1 usuario aparente). Para ver TODOS los usuarios reales, el admin debe ir a la pestaña ⚙️ Admin > Configurar y ejecutar el SQL en Supabase.' adminContext=` ESTÁS HABLANDO CON EL ADMINISTRADOR (${(currentUser?.email||'').toLowerCase()}). ESTADO DEL BACKEND DE ADMIN: ${estadoBackend} ESTADÍSTICAS GLOBALES DE LA PLATAFORMA MI HOSTELERÍA: ${JSON.stringify({ usuarios_registrados_real:stats.n_users_real, usuarios_con_actividad:stats.n_users_con_datos, total_fincas:stats.n_fincas, total_gastos:stats.n_gastos, total_ventas:stats.n_ventas, total_jornadas:stats.n_jornadas, total_jornaleros:stats.n_jornaleros, ingresos_totales_eur:Math.round(stats.ingresos_total), costes_totales_eur:Math.round(stats.costes_total), margen_total_eur:Math.round(stats.margen), })} ANALÍTICA DE VISITAS (sesiones autenticadas en la app): ${ss?JSON.stringify({ visitas_hoy:ss.visitas_hoy, visitas_ultimos_7d:ss.visitas_7d, visitas_ultimos_30d:ss.visitas_30d, usuarios_unicos_hoy:ss.usuarios_hoy, usuarios_unicos_7d:ss.usuarios_7d, total_sesiones_historico:ss.total_sesiones, ultimas_dos_semanas_por_dia:ss.serie_14d }):'(no hay datos de sesiones — la tabla mc_sessions aún no existe o está vacía. Aplicar SQL del panel ⚙️ Configurar)'} USUARIOS (lista resumida): ${JSON.stringify(usersForIA)} REGLAS DE HONESTIDAD CRÍTICAS: - Si "estado_backend" indica INCOMPLETO, DEBES avisar al admin de que los números de usuarios pueden no reflejar la realidad y dirigirlo al panel ⚙️ Configurar. - NUNCA digas "hay 1 usuario en total" cuando el estado sea INCOMPLETO. En su lugar di: "Solo puedo ver tu propia cuenta porque el RLS de admin no está aplicado todavía. Aplica el SQL en Supabase para que aparezcan los demás usuarios." - Para preguntas sobre VISITAS A LA WEB PÚBLICA (gente que entra sin estar registrada), responde que Mi Hostelería tiene Google Analytics 4 activo (propiedad G-QMG1YFJ0CF, dominio micampoconia.com) y Google Tag Manager (GTM-5MRXGJ6B). Las estadísticas completas (países, dispositivos, embudos, conversiones) se ven en analytics.google.com. En la app solo registramos sesiones de usuarios autenticados. - Para preguntas sobre USO DE LA APP (sesiones de usuarios autenticados), usa los números de "analítica de visitas" arriba. - NUNCA inventes números. Si un campo es null o no aparece, di que no lo tienes. Como admin puedes: - Responder estadísticas globales con los números EXACTOS arriba - Identificar usuarios inactivos (los que tienen ultima_actividad="sin_actividad") - Detectar el más activo - Sugerir acciones (animar inactivos, etc.) - Recomendar al admin que use la pestaña Admin > Inspeccionar para ver los datos crudos de un usuario.` }catch(e){console.warn('admin context error:',e)} } return `Eres el asistente de gestión de un hostelero. Eres conciso y cercano. 🌐 IDIOMA: El usuario tiene la app en ${LANG_NAMES[userSettings.lang]||'español'}. RESPONDE SIEMPRE EN ${(LANG_NAMES[userSettings.lang]||'español').toUpperCase()}, salvo si el propio usuario te escribe en otro idioma — en ese caso, responde en el idioma en que te haya escrito. Los datos del usuario están en español (nombres de cultivos, productos, etc.) — déjalos como están y solo traduce TUS textos de respuesta.${adminContext} INFORMACIÓN DEL CREADOR DE LA APP: Mi Hostelería ha sido creada por Ali Aauicha Azghouli (aliaauicha@gmail.com, tel. 678902270). - Especialista en operaciones y analítica. - Trabajó en el campo durante sus años universitarios en la costa almeriense. - Grado en Ciencias Políticas (Universidad de Granada), Máster en Análisis Económico (Universidad de Málaga), Máster en SAP BTP y AI (en curso, EuropeanBTech 2026). - Carrera profesional: Analista de Operaciones en Fluiconnecto (UK), Responsable de Operaciones en Paack (UK), Analista de Cadena de Suministro en Grupo Sesé (Barcelona). - Especializado en optimización de procesos, gestión de stock, análisis de datos, Lean y Six Sigma. - LinkedIn: https://www.linkedin.com/in/ali-aauicha/ Si alguien pregunta sobre el autor, el creador o quién ha hecho la app, responde con esta información. DATOS ACTUALES: ${JSON.stringify(snap)} MÓDULOS: - ga (gasto): alimentos, bebidas, limpieza, menaje, energía, suministros, mantenimiento, otros. Puede incluir finca_nombre (establecimiento). - ve (venta): tickets / cierre de caja — producto (menú del día, carta, desayunos, bebidas, etc.), kilos_kg (=número de tickets o cubiertos), precio_kg (=ticket medio €), importe_total, cliente (sala/barra/terraza/take-away), numero_albaran (número de ticket Z), finca_nombre (establecimiento) - jo (jornada): días trabajados — jornalero (nombre del trabajador), horas, tarea (cocina, sala, barra, limpieza, encargado, desayunos…), fecha, incidencia, finca_nombre (establecimiento) - jornalero_nuevo: AÑADIR un trabajador a la plantilla — nombre, tarifa (€/hora), tarea (puesto: camarero, cocinero, ayudante, encargado, limpieza, recepción). Usar cuando digan "añade a Fernando", "nuevo camarero Fernando 9€/h sala", "apunta a X al equipo". - riego: servicios o aperturas por establecimiento — finca (nombre del establecimiento), horas (de servicio), metodo (mediodía/cena/desayuno/continuo/turno doble), fecha, observaciones - campana: crear temporada — nombre (Verano 2026, Navidad, Semana Santa…), fecha_inicio (YYYY-MM-DD), fecha_fin (YYYY-MM-DD), descripcion, fincas (array de establecimientos) - evento: añadir cita al calendario — titulo, tipo (reserva grupo/evento privado/mantenimiento/inspección/proveedor/reunion/otro), fecha (YYYY-MM-DD), hora (HH:MM), descripcion - producto_finca: productos consumidos en local — finca (establecimiento), nombre (del producto), tipo (limpieza/menaje/desinfección/lavandería/otro), cantidad, fecha, observaciones - stock_nuevo: AÑADIR producto al stock/almacén — nombre, categoria (alimentos/bebidas/limpieza/menaje/energia/mantenimiento/otros), cantidad (número), unidad (kg/L/cajas/botellas/barril/pack/unidades/rollos), consumo_diario (número, 0 si no consume), precio_unitario (€/unidad, número), proveedor (Makro, Mahou, Coosur, Selecta, distribuidor local…), finca_nombre opcional, observaciones. Usar cuando digan "tengo X de producto Y", "he comprado Z cajas de…", "añade al stock", "apunta en almacén", "quedan N botellas de…". SOBRE LOS DISPOSITIVOS/SENSORES: - En "dispositivos" tienes el listado de sensores conectados del hostelero (sondas de temperatura de cámaras frigoríficas, sensores de terraza, estaciones meteo), con el establecimiento donde están instalados y sus lecturas en vivo (temperatura cámara, congelador, ambiente terraza, viento, humedad, etc.). - Si te preguntan por los dispositivos, sensores, estaciones o por la temperatura de una cámara concreta, USA estos datos y responde con las lecturas reales. - Si preguntan "¿qué dispositivos tengo?" o "¿qué sensores hay en el local X?", responde listando los que hay con su establecimiento asignado. - Si preguntan por la temperatura de cámaras o el tiempo en la terraza, busca el sensor asignado a ese local y da las cifras exactas. Avisa si una cámara está fuera de rango (carnes/lácteos: 0-4 °C, congelador: -18 °C o menos, vinos: 10-14 °C). - Si un dispositivo tiene estado "desconectado" o "sin datos", indícalo amablemente y sugiere pulsar 🔄 Actualizar. INSTRUCCIONES: - "Fernando 9€ hora sala" o "añade a Fernando al equipo" → tipo jornalero_nuevo con nombre, tarifa y tarea (puesto). - "Fernando trabajó 8h cocina hoy" → tipo jo (jornada trabajada ese día). - "He comprado 24 cajas de cerveza a 13€" o "tengo 50L de aceite" → tipo stock_nuevo con nombre, categoria, cantidad, unidad, precio_unitario. - Para jornadas múltiples "Antonio 8h sala, Carmen 6h cocina" → una entrada jo por persona. - Para establecimientos: "Bar Centro: 42 menús del día, ticket medio 14,80€" → UNA venta (ve). - Si menciona un establecimiento que no existe, créalo automáticamente. - En consultas de totales → calcula y responde con precisión. - Para consultas sobre sensores/dispositivos/temperaturas/cámaras → usa la sección "dispositivos" y "guardar" queda vacío. Responde con JSON puro: {"respuesta":"texto","guardar":[{"tipo":"gasto|venta|jo|jornalero_nuevo|riego|producto_finca|stock_nuevo|campana|evento","datos":{...}}]} Sin guardar → "guardar":[]` } function addUserMsg(t){ const b=document.getElementById('chat-msgs') const d=document.createElement('div');d.className='msg user' d.innerHTML=`
${esc(t)}
`;b.appendChild(d);b.scrollTop=b.scrollHeight } function addBotMsg(html,pill=''){ const b=document.getElementById('chat-msgs') const d=document.createElement('div');d.className='msg bot' d.innerHTML=`
🏪
${html}${pill?'
'+pill+'':''}
` b.appendChild(d);b.scrollTop=b.scrollHeight } function showTyping(){ const b=document.getElementById('chat-msgs') const d=document.createElement('div');d.id='typ';d.className='msg bot' d.innerHTML='
🏪
' b.appendChild(d);b.scrollTop=b.scrollHeight } function rmTyping(){document.getElementById('typ')?.remove()} async function sendMsg(){ const inp=document.getElementById('chat-input') const txt=inp.value.trim();if(!txt)return inp.value='';inp.style.height='auto' document.getElementById('send-btn').disabled=true addUserMsg(txt);convHistory.push({role:'user',content:txt});showTyping() try{ const res=await fetch('https://5bffv7il4g.execute-api.eu-south-2.amazonaws.com/',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({model:'claude-haiku-4-5-20251001',max_tokens:900,system:buildSys(),messages:convHistory.slice(-10)}) }) if(!res.ok){ const errText=await res.text() console.error('Chat error:',res.status,errText) if(res.status===429){ rmTyping() addBotMsg('⏳ Se ha alcanzado el límite de peticiones por minuto. Espera unos 30-60 segundos y vuelve a preguntar.

Consejo: preguntas más cortas y sin repetir todo el historial consumen menos.') document.getElementById('send-btn').disabled=false return } throw new Error('API error '+res.status) } const raw=await res.json() const t2=(raw.content||[]).map(i=>i.text||'').join('') let p={respuesta:t2,guardar:[]} try{p=JSON.parse(t2.replace(/```json|```/g,'').trim())}catch{} const saved=[] for(const item of(p.guardar||[])){ if(!item.tipo||!item.datos)continue await saveEntry(item.tipo,item.datos) saved.push({gasto:'💸 Gastos',venta:'📦 Ventas',jo:'👨‍🍳 Personal',jornalero_nuevo:'👨‍🍳 Personal',riego:'🏪 Establecimientos',producto_finca:'🏪 Establecimientos'}[item.tipo]||'✓') } rmTyping() const pill=saved.length?'✓ Guardado en: '+[...new Set(saved)].join(', '):'' addBotMsg(esc(p.respuesta),pill) convHistory.push({role:'assistant',content:p.respuesta}) if(convHistory.length>20)convHistory=convHistory.slice(-20) if(saved.length){ const tabMap={'Gastos':'g','Ventas':'v','Personal':'j','Establecimientos':'fc'} const dest=Object.entries(tabMap).find(([k])=>saved[0]?.includes(k)) if(dest&&dest[1]!==curTab)setTimeout(()=>goTab(dest[1],document.querySelector(`[data-tab="${dest[1]}"]`)),600) } }catch(err){ rmTyping() console.error('Chat error:',err) addBotMsg('⚠️ Error: '+err.message+'. Abre la consola del navegador (F12) para ver más detalles.') } document.getElementById('send-btn').disabled=false } // ═══════════════ INIT ════════════════════════ document.getElementById('shell').style.display='none' document.getElementById('bottom-nav').style.display='none' async function initAuth(){ try{ // 0 — Si la URL es de fichaje (#fichar=ID), mostrar la página de fichaje sin pasar por login if(typeof checkFichajeRoute==='function'&&checkFichajeRoute())return; if(typeof checkReservaRoute==='function'&&checkReservaRoute())return; if(typeof checkPedidoRoute==='function'&&checkPedidoRoute())return // 1 — Handle OAuth/email callback codes in URL const url=new URL(window.location.href) const code=url.searchParams.get('code') const accessToken=url.hash.match(/access_token=([^&]+)/)?.[1] const errorDesc=url.searchParams.get('error_description') if(errorDesc){ showAuthScreen() authErr('Error: '+decodeURIComponent(errorDesc)) window.history.replaceState({},document.title,window.location.pathname) return } if(code){ // PKCE flow — exchange code for session try{ const {data,error}=await sb.auth.exchangeCodeForSession(code) window.history.replaceState({},document.title,window.location.pathname) if(data?.session?.user){ await showApp(data.session.user); return } }catch(e){ console.warn('Code exchange failed:',e) } } if(accessToken){ // Implicit flow — set session from hash try{ await sb.auth.setSession({ access_token:accessToken, refresh_token:url.hash.match(/refresh_token=([^&]+)/)?.[1]||'' }) window.history.replaceState({},document.title,window.location.pathname) }catch(e){ console.warn('Set session failed:',e) } } // 2 — Check for existing session const {data:{session}}=await sb.auth.getSession() if(session?.user){ await showApp(session.user); return } // 3 — No session showAuthScreen() // 4 — Listen for future changes sb.auth.onAuthStateChange(async(event,session)=>{ if(event==='SIGNED_IN'&&session?.user){ await showApp(session.user) } else if(event==='SIGNED_OUT'){ showAuthScreen() } }) }catch(e){ console.warn('Auth init error:',e) showAuthScreen() } } initAuth() // ─── Red de seguridad: exponer handlers de onclick en window ─── // Los handlers inline (onclick="foo()") sólo pueden encontrar funciones // que estén en `window`. Esto lo garantiza aunque cambie el ámbito. try{ if(typeof guardarDispositivo==='function')window.guardarDispositivo=guardarDispositivo if(typeof editarDispositivo==='function')window.editarDispositivo=editarDispositivo if(typeof eliminarDispositivo==='function')window.eliminarDispositivo=eliminarDispositivo if(typeof refreshDispositivo==='function')window.refreshDispositivo=refreshDispositivo if(typeof refreshAllDispositivos==='function')window.refreshAllDispositivos=refreshAllDispositivos if(typeof showDispInfoGuide==='function')window.showDispInfoGuide=showDispInfoGuide if(typeof startDispQR==='function')window.startDispQR=startDispQR if(typeof stopDispQR==='function')window.stopDispQR=stopDispQR if(typeof onDispPhotoUpload==='function')window.onDispPhotoUpload=onDispPhotoUpload if(typeof toggleDispVoice==='function')window.toggleDispVoice=toggleDispVoice if(typeof processDispIA==='function')window.processDispIA=processDispIA }catch(e){console.warn('Expose handlers error:',e)} inp.value='';inp.style.height='auto' document.getElementById('send-btn').disabled=true addUserMsg(txt);convHistory.push({role:'user',content:txt});showTyping() try{ const res=await fetch('https://5bffv7il4g.execute-api.eu-south-2.amazonaws.com/',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({model:'claude-haiku-4-5-20251001',max_tokens:900,system:buildSys(),messages:convHistory.slice(-10)}) }) if(!res.ok){ const errText=await res.text() console.error('Chat error:',res.status,errText) if(res.status===429){ rmTyping() addBotMsg('⏳ Se ha alcanzado el límite de peticiones por minuto. Espera unos 30-60 segundos y vuelve a preguntar.

Consejo: preguntas más cortas y sin repetir todo el historial consumen menos.') document.getElementById('send-btn').disabled=false return } throw new Error('API error '+res.status) } const raw=await res.json() const t2=(raw.content||[]).map(i=>i.text||'').join('') let p={respuesta:t2,guardar:[]} try{p=JSON.parse(t2.replace(/```json|```/g,'').trim())}catch{} const saved=[] for(const item of(p.guardar||[])){ if(!item.tipo||!item.datos)continue await saveEntry(item.tipo,item.datos) saved.push({gasto:'💸 Gastos',venta:'📦 Ventas',jo:'👨‍🍳 Personal',jornalero_nuevo:'👨‍🍳 Personal',riego:'🏪 Establecimientos',producto_finca:'🏪 Establecimientos'}[item.tipo]||'✓') } rmTyping() const pill=saved.length?'✓ Guardado en: '+[...new Set(saved)].join(', '):'' addBotMsg(esc(p.respuesta),pill) convHistory.push({role:'assistant',content:p.respuesta}) if(convHistory.length>20)convHistory=convHistory.slice(-20) if(saved.length){ const tabMap={'Gastos':'g','Ventas':'v','Personal':'j','Establecimientos':'fc'} const dest=Object.entries(tabMap).find(([k])=>saved[0]?.includes(k)) if(dest&&dest[1]!==curTab)setTimeout(()=>goTab(dest[1],document.querySelector(`[data-tab="${dest[1]}"]`)),600) } }catch(err){ rmTyping() console.error('Chat error:',err) addBotMsg('⚠️ Error: '+err.message+'. Abre la consola del navegador (F12) para ver más detalles.') } document.getElementById('send-btn').disabled=false } // ═══════════════ INIT ════════════════════════ document.getElementById('shell').style.display='none' document.getElementById('bottom-nav').style.display='none' async function initAuth(){ try{ // 0 — Si la URL es de fichaje (#fichar=ID), mostrar la página de fichaje sin pasar por login if(typeof checkFichajeRoute==='function'&&checkFichajeRoute())return; if(typeof checkReservaRoute==='function'&&checkReservaRoute())return; if(typeof checkPedidoRoute==='function'&&checkPedidoRoute())return // 1 — Handle OAuth/email callback codes in URL const url=new URL(window.location.href) const code=url.searchParams.get('code') const accessToken=url.hash.match(/access_token=([^&]+)/)?.[1] const errorDesc=url.searchParams.get('error_description') if(errorDesc){ showAuthScreen() authErr('Error: '+decodeURIComponent(errorDesc)) window.history.replaceState({},document.title,window.location.pathname) return } if(code){ // PKCE flow — exchange code for session try{ const {data,error}=await sb.auth.exchangeCodeForSession(code) window.history.replaceState({},document.title,window.location.pathname) if(data?.session?.user){ await showApp(data.session.user); return } }catch(e){ console.warn('Code exchange failed:',e) } } if(accessToken){ // Implicit flow — set session from hash try{ await sb.auth.setSession({ access_token:accessToken, refresh_token:url.hash.match(/refresh_token=([^&]+)/)?.[1]||'' }) window.history.replaceState({},document.title,window.location.pathname) }catch(e){ console.warn('Set session failed:',e) } } // 2 — Check for existing session const {data:{session}}=await sb.auth.getSession() if(session?.user){ await showApp(session.user); return } // 3 — No session showAuthScreen() // 4 — Listen for future changes sb.auth.onAuthStateChange(async(event,session)=>{ if(event==='SIGNED_IN'&&session?.user){ await showApp(session.user) } else if(event==='SIGNED_OUT'){ showAuthScreen() } }) }catch(e){ console.warn('Auth init error:',e) showAuthScreen() } } initAuth() // ─── Red de seguridad: exponer handlers de onclick en window ─── // Los handlers inline (onclick="foo()") sólo pueden encontrar funciones // que estén en `window`. Esto lo garantiza aunque cambie el ámbito. try{ if(typeof guardarDispositivo==='function')window.guardarDispositivo=guardarDispositivo if(typeof editarDispositivo==='function')window.editarDispositivo=editarDispositivo if(typeof eliminarDispositivo==='function')window.eliminarDispositivo=eliminarDispositivo if(typeof refreshDispositivo==='function')window.refreshDispositivo=refreshDispositivo if(typeof refreshAllDispositivos==='function')window.refreshAllDispositivos=refreshAllDispositivos if(typeof showDispInfoGuide==='function')window.showDispInfoGuide=showDispInfoGuide if(typeof startDispQR==='function')window.startDispQR=startDispQR if(typeof stopDispQR==='function')window.stopDispQR=stopDispQR if(typeof onDispPhotoUpload==='function')window.onDispPhotoUpload=onDispPhotoUpload if(typeof toggleDispVoice==='function')window.toggleDispVoice=toggleDispVoice if(typeof processDispIA==='function')window.processDispIA=processDispIA }catch(e){console.warn('Expose handlers error:',e)} // ═══════════════════════════════════════════════ // HOSTELERIA — Local config, Reservas, Pedidos QR // Capacidad/aforo, sillas, mesas, dispositivos // Reservas por QR, pedidos desde la mesa por QR // ═══════════════════════════════════════════════ // ---- Configuración del local (localStorage por id) ---- function getLocalCfg(id){ // Defaults extraídos del propio local en D.fi (datos demo o creados manualmente) const fi=(D.fi||[]).find(x=>(x._id||x.id)===id) const def={ aforo:fi?.aforo||0, sillas:fi?.sillas||0, mesas:fi?.mesas||0, m2:fi?.m2||0, tipo:fi?.tipo||'', horario:fi?.horario||'', direccion:fi?.direccion||'', caja:false,fichajes:true,camaras:0 } try{ const raw=localStorage.getItem('mihos_localcfg_'+id) if(raw)return Object.assign(def,JSON.parse(raw)) }catch(e){} return def } function saveLocalCfg(id,cfg){ try{ const cur=getLocalCfg(id) const next=Object.assign(cur,cfg||{}) localStorage.setItem('mihos_localcfg_'+id,JSON.stringify(next)) return next }catch(e){console.warn('saveLocalCfg',e);return cfg} } window.getLocalCfg=getLocalCfg window.saveLocalCfg=saveLocalCfg // ---- Reservas (localStorage por local + D.reservas demo) ---- function getReservas(fincaId){ let arr=[] // 1) Reservas del modo demo en D.reservas if(Array.isArray(D.reservas)){ arr=D.reservas.filter(r=>r.finca_id===fincaId).map(r=>({ id:r._id||r.id, nombre:r.cliente||r.nombre||'', fecha:r.fecha||'', hora:r.hora||'', comensales:r.comensales||1, telefono:r.telefono||'', mesa:r.mesa||'', tipo:r.tipo||'', observaciones:r.observaciones||'', estado:r.estado||'confirmada', origen:'demo' })) } // 2) Reservas guardadas en localStorage (creadas manualmente o por QR) try{ const raw=localStorage.getItem('mihos_reservas_'+fincaId) if(raw){ const ls=JSON.parse(raw) arr=arr.concat(ls) } }catch(e){} return arr } function saveReservas(fincaId,arr){ try{localStorage.setItem('mihos_reservas_'+fincaId,JSON.stringify(arr||[]))}catch(e){console.warn('saveReservas',e)} } function addReserva(fincaId,r){ const arr=getReservas(fincaId) r.id=r.id||('r_'+Date.now()+'_'+Math.random().toString(36).slice(2,7)) r.ts=r.ts||new Date().toISOString() arr.push(r) saveReservas(fincaId,arr) return r } window.getReservas=getReservas window.saveReservas=saveReservas window.addReserva=addReserva // ---- Pedidos desde la mesa (localStorage por finca) ---- function getPedidos(fincaId){ try{ const raw=localStorage.getItem('mihos_pedidos_'+fincaId) if(raw)return JSON.parse(raw) }catch(e){} return [] } function savePedidos(fincaId,arr){ try{localStorage.setItem('mihos_pedidos_'+fincaId,JSON.stringify(arr||[]))}catch(e){console.warn('savePedidos',e)} } function addPedido(fincaId,p){ const arr=getPedidos(fincaId) p.id=p.id||('p_'+Date.now()+'_'+Math.random().toString(36).slice(2,7)) p.ts=p.ts||new Date().toISOString() p.estado=p.estado||'nuevo' arr.push(p) savePedidos(fincaId,arr) return p } window.addPedido=addPedido window.getPedidos=getPedidos // ---- Carta del local (artículos para pedir) ---- function getCarta(fincaId){ try{ const raw=localStorage.getItem('mihos_carta_'+fincaId) if(raw)return JSON.parse(raw) }catch(e){} // Carta por defecto return [ {id:'i1',cat:'Bebidas',nombre:'Caña',precio:2.5}, {id:'i2',cat:'Bebidas',nombre:'Refresco',precio:2.8}, {id:'i3',cat:'Bebidas',nombre:'Vino tinto',precio:3.0}, {id:'i4',cat:'Comida',nombre:'Tortilla',precio:3.5}, {id:'i5',cat:'Comida',nombre:'Bocadillo',precio:5.0}, {id:'i6',cat:'Comida',nombre:'Ensalada',precio:7.5}, ] } function saveCarta(fincaId,arr){ try{localStorage.setItem('mihos_carta_'+fincaId,JSON.stringify(arr||[]))}catch(e){} } window.getCarta=getCarta window.saveCarta=saveCarta // ---- Renderers de las nuevas pestañas ---- window.renderLocalTab=function(id,fi){ const cfg=getLocalCfg(id) const ocupacionPct=cfg.aforo>0?Math.min(100,Math.round((getReservasOcupacionDia(id,new Date().toISOString().slice(0,10))/cfg.aforo)*100)):0 const ocupColor=ocupacionPct>=80?'#dc2626':ocupacionPct>=50?'#d97706':'#16a34a' return `
🏪 Configuración del local
Hoy ${ocupacionPct}%
Stock se gestiona en la pestaña 🧴 Stock del local. El tiempo y la localización están en ⛅ Tiempo.
` } window.renderReservasTab=function(id,fi){ const cfg=getLocalCfg(id) const reservas=getReservas(id).sort((a,b)=>(a.fecha+' '+a.hora).localeCompare(b.fecha+' '+b.hora)) const hoy=new Date().toISOString().slice(0,10) const futuras=reservas.filter(r=>r.fecha>=hoy) let h=`
📅 Reservas en tiempo real · ${futuras.length} próximas · aforo ${cfg.aforo||'?'} pax · usa 📅 QR Reservar mesa en la pestaña 🏪 Local para imprimir el QR público.
` // Botón añadir manual h+=`` if(!futuras.length){ h+=`
Sin reservas próximas.
Imprime el QR de reservas y compártelo en redes o en la entrada del local.
` return h } // Agrupar por fecha const porDia={} for(const r of futuras){if(!porDia[r.fecha])porDia[r.fecha]=[];porDia[r.fecha].push(r)} for(const f of Object.keys(porDia).sort()){ const total=porDia[f].reduce((s,r)=>s+(parseInt(r.comensales)||0),0) const pct=cfg.aforo>0?Math.min(100,Math.round((total/cfg.aforo)*100)):0 const col=pct>=80?{bg:'#fee2e2',bd:'#fca5a5',tx:'#991b1b'}:pct>=50?{bg:'#fef3c7',bd:'#fcd34d',tx:'#92400e'}:{bg:'#dcfce7',bd:'#86efac',tx:'#166534'} const dt=new Date(f+'T12:00:00').toLocaleDateString('es-ES',{weekday:'long',day:'2-digit',month:'long'}) h+=`
${dt} ${total}/${cfg.aforo||'?'} pax · ${pct}%
` for(const r of porDia[f].sort((a,b)=>a.hora.localeCompare(b.hora))){ h+=`
${esc(r.nombre||'(anónimo)')} · ${r.comensales||1} pax
🕐 ${esc(r.hora)} ${r.mesa?'· 🍽️ Mesa '+esc(r.mesa):''} ${r.telefono?'· 📞 '+esc(r.telefono):''}
` } h+=`
` } return h } window.addReservaManual=function(id){ const nombre=prompt('Nombre del cliente:');if(nombre===null)return const fecha=prompt('Fecha (YYYY-MM-DD):',new Date().toISOString().slice(0,10));if(fecha===null)return const hora=prompt('Hora (HH:MM):','21:00');if(hora===null)return const comensales=parseInt(prompt('Comensales:','2'))||1 const telefono=prompt('Teléfono (opcional):','')||'' const mesa=prompt('Mesa (opcional):','')||'' addReserva(id,{nombre:nombre.trim(),fecha,hora,comensales,telefono,mesa,origen:'manual'}) if(typeof render==='function')render() } window.borrarReserva=function(fincaId,resId){ if(!confirm('¿Eliminar esta reserva?'))return const arr=getReservas(fincaId).filter(r=>r.id!==resId) saveReservas(fincaId,arr) if(typeof render==='function')render() } window.renderPedidosQRTab=function(id,fi){ const cfg=getLocalCfg(id) const pedidos=getPedidos(id).sort((a,b)=>new Date(b.ts)-new Date(a.ts)) const carta=getCarta(id) let h=`
📲 Pedido desde la mesa · ${pedidos.length} pedidos · ${cfg.mesas||'?'} mesas · imprime el QR 📲 QR Pedido en mesa por mesa y los clientes podrán pedir directamente desde su móvil.
` // Carta editable h+=`
🍽️ Carta (${carta.length} artículos) — editar
` for(const it of carta){ h+=`
` } h+=`
` // Lista de pedidos h+=`
📥 Últimos pedidos
` if(!pedidos.length){ h+=`
Sin pedidos aún. Imprime el QR de pedido y los clientes podrán hacer pedidos desde la mesa.
` } else { for(const p of pedidos.slice(0,30)){ const cls={nuevo:{bg:'#fef3c7',bd:'#fcd34d',tx:'#92400e'},preparado:{bg:'#dbeafe',bd:'#93c5fd',tx:'#1d4ed8'},servido:{bg:'#dcfce7',bd:'#86efac',tx:'#166534'}}[p.estado]||{bg:'#f1f5f9',bd:'#cbd5e1',tx:'#475569'} const lineas=(p.items||[]).map(it=>`
${it.cant||1}× ${esc(it.nombre)} ${(it.precio*(it.cant||1)).toFixed(2)}€
`).join('') const total=(p.items||[]).reduce((s,it)=>s+(it.precio||0)*(it.cant||1),0) h+=`
🍽️ Mesa ${esc(p.mesa||'?')} · ${p.estado} ${total.toFixed(2)}€
${lineas}
` } } return h } // ═══════════════════════════════════════════════════════════════ // PLANTILLA / TURNOS — Recomendación basada en preferencias + reservas // ═══════════════════════════════════════════════════════════════ function _diaSemanaCorto(d){return ['D','L','M','X','J','V','S'][new Date(d+'T12:00:00').getDay()]} function _formatoFecha(d){return new Date(d+'T12:00:00').toLocaleDateString('es-ES',{weekday:'long',day:'2-digit',month:'short'})} // Calcula la plantilla recomendada para un local en una fecha concreta // Devuelve {comensales, turnos:{mañana, mediodia, tarde, noche}, recomendados:[{turno, puesto, persona, score}]} function recommendPlantilla(fincaId, fecha){ const cfg=getLocalCfg(fincaId) const aforo=cfg.aforo||60 const reservas=getReservas(fincaId).filter(r=>r.fecha===fecha) // Comensales totales y por franja let cMañana=0, cMediodia=0, cTarde=0, cNoche=0 for(const r of reservas){ const h=parseInt((r.hora||'13:00').slice(0,2)) const c=parseInt(r.comensales||1)||1 if(h<11) cMañana+=c else if(h<16) cMediodia+=c else if(h<19) cTarde+=c else cNoche+=c } const total=cMañana+cMediodia+cTarde+cNoche // Si no hay reservas, usar histórico de ventas para estimar (sencillo) const dia=_diaSemanaCorto(fecha) // Patrón típico de carga por día de semana (V/S más cargados) const factorDia={D:0.7, L:0.5, M:0.55, X:0.6, J:0.7, V:1.0, S:1.0}[dia]||0.7 const cargaEstimada = total>0 ? total : Math.round(aforo*0.4*factorDia) // Reglas de plantilla recomendada por carga (ajustables) const calcStaff=(comensales)=>{ const cocina=Math.max(1, Math.ceil(comensales/22)) const sala=Math.max(1, Math.ceil(comensales/14)) const limpieza=comensales>=20?1:0 const encargado=comensales>=30?1:0 return {cocina, sala, limpieza, encargado} } const turnos={} if(cMañana>0||(total===0&&factorDia>=0.6)) turnos.mañana=calcStaff(cMañana||Math.round(cargaEstimada*0.15)) if(cMediodia>0||(total===0&&factorDia>=0.5)) turnos.mediodia=calcStaff(cMediodia||Math.round(cargaEstimada*0.45)) if(cTarde>0) turnos.tarde=calcStaff(cTarde||Math.round(cargaEstimada*0.15)) if(cNoche>0||(total===0&&factorDia>=0.6)) turnos.noche=calcStaff(cNoche||Math.round(cargaEstimada*0.40)) // Asignación: para cada puesto necesario, buscar el empleado con preferencia compatible y mejor scoring const prefsLocal=(D.preferencias||[]).filter(p=>p.finca_id===fincaId) const recomendados=[] const usadosHoy=new Set() for(const turno of Object.keys(turnos)){ const need=turnos[turno] const puestos=[] for(let i=0;i{ let score=0 // Coincidencia de horario_pref con turno const pref=p.horario_pref||'flex' if(pref==='flex') score+=2 else if(pref===turno) score+=4 else if(pref==='mañana_noche'&&(turno==='mañana'||turno==='noche')) score+=3 // Día preferido if((p.dias_pref||[]).includes(dia)) score+=2 if((p.dias_libre||[]).includes(dia)) score-=10 // Penalizar si ya está asignado if(usadosHoy.has(p.jornalero_id)) score-=5 return {p, score} }).sort((a,b)=>b.score-a.score) if(candidatos.length && candidatos[0].score>0){ const elegido = candidatos[0].p recomendados.push({turno, puesto, persona:elegido.jornalero_nombre, jornalero_id:elegido.jornalero_id, score:candidatos[0].score}) usadosHoy.add(elegido.jornalero_id) } else { recomendados.push({turno, puesto, persona:null, jornalero_id:null, score:0, vacante:true}) } } } return {fecha, dia, comensales:total, cargaEstimada, turnos, recomendados, reservas:reservas.length} } // Vista del tab "🎯 Plantilla" window.renderPlantillaTab=function(id,fi){ const cfg=getLocalCfg(id) const prefs=(D.preferencias||[]).filter(p=>p.finca_id===id) // Generar 7 días desde hoy const hoy=new Date() const dias=[] for(let i=0;i<7;i++){ const d=new Date(hoy); d.setDate(d.getDate()+i) dias.push(d.toISOString().slice(0,10)) } let h=`
🎯 Plantilla recomendada — la app calcula los turnos óptimos cruzando preferencias horarias de tu equipo con las reservas confirmadas y el patrón histórico del día. Confirma o edita en cada celda.
` // Resumen de preferencias if(prefs.length){ h+=`
⚙️ Preferencias del equipo en este local
` for(const p of prefs){ const horarioLbl={mañana:'🌅 Mañana',mediodia:'🌞 Mediodía',tarde:'☕ Tarde',noche:'🌙 Noche',mañana_noche:'🌅+🌙',flex:'🔄 Flex'}[p.horario_pref]||p.horario_pref const libres=(p.dias_libre||[]).join('')||'—' h+=`
${esc(p.jornalero_nombre)}
${horarioLbl} Libre: ${libres} ≤${p.max_horas_dia}h/día
` } h+=`
` } else { h+=`
⚠️ Aún no has configurado preferencias horarias para tu equipo de este local. La recomendación funciona mejor si añades qué horario prefiere cada persona. (En modo demo ya hay preferencias precargadas para Bar Centro, Restaurante Marisol y Cafetería Plaza.)
` } // Iterar 7 días const TURNO_LBL={mañana:'🌅 Mañana',mediodia:'🌞 Mediodía',tarde:'☕ Tarde',noche:'🌙 Noche'} const PUESTO_ICO={cocina:'🍳',sala:'🍽️',limpieza:'🧴',encargado:'⭐'} for(const d of dias){ const r=recommendPlantilla(id,d) const fechaLbl=_formatoFecha(d) const cargaColor=r.cargaEstimada>=40?'#dc2626':r.cargaEstimada>=20?'#d97706':'#16a34a' const isHoy=d===new Date().toISOString().slice(0,10) h+=`
${fechaLbl} ${isHoy?'HOY':''}
${r.reservas} ${r.reservas===1?'reserva':'reservas'} ~${r.cargaEstimada} pax
` if(r.recomendados.length===0){ h+=`
Día sin actividad esperada (puede ser día de cierre).
` } else { // Agrupar por turno const porTurno={} for(const x of r.recomendados){if(!porTurno[x.turno])porTurno[x.turno]=[];porTurno[x.turno].push(x)} for(const turno of Object.keys(porTurno)){ h+=`
${TURNO_LBL[turno]||turno}
` for(const x of porTurno[turno]){ const ico=PUESTO_ICO[x.puesto]||'•' if(x.vacante){ h+=`${ico} ${x.puesto} · vacante` } else { h+=`${ico} ${esc(x.persona)} (${x.puesto})` } } h+=`
` } } h+=`
` } return h } window.updCarta=function(fincaId,itemId,key,val){ const carta=getCarta(fincaId) const it=carta.find(x=>x.id===itemId) if(it){it[key]=val;saveCarta(fincaId,carta)} } window.addCartaItem=function(fincaId){ const carta=getCarta(fincaId) carta.push({id:'i_'+Date.now().toString(36),cat:'Nuevo',nombre:'Artículo',precio:0}) saveCarta(fincaId,carta) if(typeof render==='function')render() } window.delCartaItem=function(fincaId,itemId){ const carta=getCarta(fincaId).filter(x=>x.id!==itemId) saveCarta(fincaId,carta) if(typeof render==='function')render() } window.setPedidoEstado=function(fincaId,pid,estado){ const arr=getPedidos(fincaId) const p=arr.find(x=>x.id===pid) if(p){p.estado=estado;savePedidos(fincaId,arr);if(typeof render==='function')render()} } window.delPedido=function(fincaId,pid){ if(!confirm('¿Eliminar pedido?'))return const arr=getPedidos(fincaId).filter(x=>x.id!==pid) savePedidos(fincaId,arr) if(typeof render==='function')render() } // ---- Ocupación de un día (suma de comensales) ---- function getReservasOcupacionDia(fincaId,fechaISO){ return getReservas(fincaId).filter(r=>r.fecha===fechaISO).reduce((s,r)=>s+(parseInt(r.comensales)||0),0) } window.getReservasOcupacionDia=getReservasOcupacionDia // Agregado de reservas del día a través de TODOS los locales (con filtro opcional) function getReservasDelDia(fechaISO, fincaIdFiltro){ const locales=(D.fi||[]) let total=0 const porFinca=[] let totalRes=0 for(const fi of locales){ const fid=fi._id||fi.id if(fincaIdFiltro && fid!==fincaIdFiltro) continue const cfg=getLocalCfg(fid) const resDia=getReservas(fid).filter(r=>r.fecha===fechaISO) if(!resDia.length) continue const com=resDia.reduce((s,r)=>s+(parseInt(r.comensales)||0),0) const aforo=cfg.aforo||fi.aforo||0 const pct=aforo>0?Math.min(100,Math.round((com/aforo)*100)):0 total+=com totalRes+=resDia.length porFinca.push({fincaId:fid,fincaNombre:fi.nombre,comensales:com,aforo,pct,reservas:resDia.length}) } return {fecha:fechaISO,total,porFinca,reservas:totalRes} } window.getReservasDelDia=getReservasDelDia // ---- QR Reservar (cliente externo) ---- window.imprimirQRReserva=function(fincaId,fincaNombre){ const url=`${location.origin}${location.pathname}#reservar=${fincaId}` const qrUrl=buildQRImageUrl(url,600) const qrUrlFb=buildQRImageUrlFallback(url,600) let modal=document.getElementById('qr-cartel-modal');if(modal)modal.remove() modal=document.createElement('div') modal.id='qr-cartel-modal' modal.style.cssText='position:fixed;inset:0;background:rgba(15,34,6,.75);z-index:99998;display:flex;align-items:center;justify-content:center;padding:18px;overflow-y:auto;font-family:DM Sans,sans-serif' const safeNombre=esc(fincaNombre) const safeFile=fincaNombre.replace(/[^a-z0-9]/gi,'_') modal.innerHTML=`
📅 QR Reservar mesa
Imprime y coloca a la entrada o publica en redes
RESERVA TU MESA
Escanea el código y reserva en segundos
📍 ${safeNombre}
QR
1
Apunta tu cámara al QR
2
Elige fecha, hora y comensales
3
Confirma con tu nombre y teléfono
${url}
` document.body.appendChild(modal) } // ---- QR Pedido desde la mesa ---- window.imprimirQRPedido=function(fincaId,fincaNombre){ const cfg=getLocalCfg(fincaId) const numMesas=cfg.mesas||1 const mesaNum=prompt('¿Para qué número de mesa? (1 - '+numMesas+'). Deja vacío para QR genérico.','') const mesa=mesaNum?mesaNum.trim():'' const url=`${location.origin}${location.pathname}#pedir=${fincaId}${mesa?'&mesa='+encodeURIComponent(mesa):''}` const qrUrl=buildQRImageUrl(url,600) const qrUrlFb=buildQRImageUrlFallback(url,600) let modal=document.getElementById('qr-cartel-modal');if(modal)modal.remove() modal=document.createElement('div') modal.id='qr-cartel-modal' modal.style.cssText='position:fixed;inset:0;background:rgba(15,34,6,.75);z-index:99998;display:flex;align-items:center;justify-content:center;padding:18px;overflow-y:auto;font-family:DM Sans,sans-serif' const safeNombre=esc(fincaNombre) const safeFile=fincaNombre.replace(/[^a-z0-9]/gi,'_') modal.innerHTML=`
📲 QR Pedido en mesa
Imprime un QR por mesa para pedidos directos
PIDE DESDE TU MESA
Escanea, elige y nosotros lo llevamos
📍 ${safeNombre}${mesa?' · 🍽️ Mesa '+esc(mesa):''}
QR
1
Escanea el QR de tu mesa
2
Elige los artículos de la carta
3
Confirma — el pedido llega al instante
${url}
` document.body.appendChild(modal) } // ---- Rutas públicas: #reservar=fincaId y #pedir=fincaId&mesa=N ---- function checkReservaRoute(){ const m=location.hash.match(/^#reservar=([^&]+)/) if(!m)return false showReservaPage(m[1]) return true } function checkPedidoRoute(){ const m=location.hash.match(/^#pedir=([^&]+)(?:&mesa=([^&]+))?/) if(!m)return false showPedidoPage(m[1],m[2]?decodeURIComponent(m[2]):'') return true } window.checkReservaRoute=checkReservaRoute window.checkPedidoRoute=checkPedidoRoute async function showReservaPage(fincaId){ let fincaNombre=null try{if(D&&D.fi){const fi=D.fi.find(f=>f._id===fincaId);if(fi)fincaNombre=fi.nombre}}catch(e){} if(!fincaNombre){ try{const {data}=await sb.rpc('get_finca_publica',{p_finca_id:fincaId});if(data&&data.length)fincaNombre=data[0].nombre}catch(e){} } if(!fincaNombre)fincaNombre='Local '+fincaId.slice(0,8) for(const el of document.body.children){if(el.tagName!=='SCRIPT'&&el.tagName!=='NOSCRIPT')el.style.display='none'} let cont=document.getElementById('reserva-page') if(!cont){cont=document.createElement('div');cont.id='reserva-page';cont.style.cssText='position:fixed;inset:0;background:linear-gradient(135deg,#7f1d1d,#0f2206);display:flex;align-items:flex-start;justify-content:center;padding:18px;font-family:DM Sans,sans-serif;z-index:99999;overflow-y:auto';document.body.appendChild(cont)} const hoy=new Date().toISOString().slice(0,10) cont.innerHTML=`
📅🍽️
Reserva tu mesa
📍 ${esc(fincaNombre)}
Reserva sujeta a disponibilidad
` } window.enviarReserva=function(fincaId,fincaNombre){ const nombre=(document.getElementById('rv-nombre')?.value||'').trim() const tel=(document.getElementById('rv-tel')?.value||'').trim() const fecha=document.getElementById('rv-fecha')?.value const hora=document.getElementById('rv-hora')?.value const comensales=parseInt(document.getElementById('rv-com')?.value||0)||1 const msg=document.getElementById('rv-msg') if(!nombre){if(msg){msg.style.color='#dc2626';msg.textContent='Necesitamos tu nombre'}return} if(!tel){if(msg){msg.style.color='#dc2626';msg.textContent='Necesitamos un teléfono de contacto'}return} if(!fecha||!hora){if(msg){msg.style.color='#dc2626';msg.textContent='Indica fecha y hora'}return} addReserva(fincaId,{nombre,telefono:tel,fecha,hora,comensales,origen:'qr'}) if(msg){msg.style.color='#16a34a';msg.innerHTML=`✓ Reserva confirmada
${esc(nombre)} · ${esc(fecha)} ${esc(hora)} · ${comensales} pax`} } async function showPedidoPage(fincaId,mesa){ let fincaNombre=null try{if(D&&D.fi){const fi=D.fi.find(f=>f._id===fincaId);if(fi)fincaNombre=fi.nombre}}catch(e){} if(!fincaNombre){try{const {data}=await sb.rpc('get_finca_publica',{p_finca_id:fincaId});if(data&&data.length)fincaNombre=data[0].nombre}catch(e){}} if(!fincaNombre)fincaNombre='Local '+fincaId.slice(0,8) for(const el of document.body.children){if(el.tagName!=='SCRIPT'&&el.tagName!=='NOSCRIPT')el.style.display='none'} let cont=document.getElementById('pedido-page') if(!cont){cont=document.createElement('div');cont.id='pedido-page';cont.style.cssText='position:fixed;inset:0;background:linear-gradient(135deg,#78350f,#0f2206);display:block;font-family:DM Sans,sans-serif;z-index:99999;overflow-y:auto;padding:18px';document.body.appendChild(cont)} if(!window._pedidoCarrito)window._pedidoCarrito={} window._pedidoFincaId=fincaId window._pedidoMesa=mesa const carta=getCarta(fincaId) const cats=[...new Set(carta.map(it=>it.cat))] let h=`
🍽️📲
Pide desde tu mesa
📍 ${esc(fincaNombre)}${mesa?' · 🍽️ Mesa '+esc(mesa):''}
` if(!mesa){ h+=`` } for(const cat of cats){ h+=`
${esc(cat)}
` for(const it of carta.filter(x=>x.cat===cat)){ h+=`
${esc(it.nombre)}
${(it.precio||0).toFixed(2)}€
${(window._pedidoCarrito[it.id]||0)}
` } } h+=`
Total: 0.00€
` cont.innerHTML=h pdActualizarTotal() } window.pdQty=function(itemId,delta){ if(!window._pedidoCarrito)window._pedidoCarrito={} const cur=window._pedidoCarrito[itemId]||0 const next=Math.max(0,cur+delta) window._pedidoCarrito[itemId]=next const el=document.getElementById('pd-q-'+itemId) if(el)el.textContent=next pdActualizarTotal() } function pdActualizarTotal(){ const carta=getCarta(window._pedidoFincaId||'') let tot=0 for(const id in (window._pedidoCarrito||{})){ const q=window._pedidoCarrito[id]||0 const it=carta.find(x=>x.id===id) if(it)tot+=q*(it.precio||0) } const el=document.getElementById('pd-total') if(el)el.textContent='Total: '+tot.toFixed(2)+'€' } window.enviarPedido=function(){ const fincaId=window._pedidoFincaId let mesa=window._pedidoMesa if(!mesa){const i=document.getElementById('pd-mesa');mesa=(i?.value||'').trim()} const msg=document.getElementById('pd-msg') if(!mesa){if(msg){msg.style.color='#dc2626';msg.textContent='Indica el número de mesa'}return} const carta=getCarta(fincaId) const items=[] for(const id in (window._pedidoCarrito||{})){ const q=window._pedidoCarrito[id]||0 if(q<=0)continue const it=carta.find(x=>x.id===id) if(it)items.push({id:it.id,nombre:it.nombre,precio:it.precio,cant:q}) } if(!items.length){if(msg){msg.style.color='#dc2626';msg.textContent='Tu pedido está vacío'}return} addPedido(fincaId,{mesa,items,estado:'nuevo',origen:'qr'}) window._pedidoCarrito={} if(msg){msg.style.color='#16a34a';msg.innerHTML='✓ Pedido enviado
El equipo lo está preparando'} // Reset cantidades visibles for(const it of carta){const el=document.getElementById('pd-q-'+it.id);if(el)el.textContent='0'} pdActualizarTotal() } // ---- Hook into hash routes (early) ---- try{ if(checkReservaRoute&&checkReservaRoute())console.log('Reserva route') else if(checkPedidoRoute&&checkPedidoRoute())console.log('Pedido route') }catch(e){console.warn('hash route:',e)} // ---- Calendario: overlay de reservas con color por aforo ---- window.evReservaColor=function(pct){ if(pct>=80)return '#dc2626' if(pct>=50)return '#d97706' if(pct>0)return '#16a34a' return '' } window.getReservasDelDia=function(fechaISO){ // Devuelve {total, porFinca:[{fincaId,fincaNombre,total,aforo,pct}]} const out={total:0,porFinca:[]} try{ if(!D||!D.fi)return out for(const fi of D.fi){ const id=fi._id const total=getReservasOcupacionDia(id,fechaISO) if(total<=0)continue const cfg=getLocalCfg(id) const aforo=cfg.aforo||0 const pct=aforo>0?Math.min(100,Math.round((total/aforo)*100)):0 out.porFinca.push({fincaId:id,fincaNombre:fi.nombre,total,aforo,pct}) out.total+=total } }catch(e){} return out }