o usa tu correo
🌿

Asistente del Campo

IA agrícola · datos en tiempo real

👨‍🌾 Jornada 📦 Venta 🌱 Gasto 💧 Finca 💰 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 CAMPO: ${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 Campo 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 agricultor. 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 Campo ha sido creada por Ali Aauicha Azghouli (aliaauicha@gmail.com, tel. 678902270). - Almería, 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): abonos, fitosanitarios, herbicidas, agua/riego, maquinaria, combustible, semillas. Puede incluir finca_nombre. - ve (venta): cosechas vendidas — producto, kilos_kg, precio_kg, importe_total, cliente, numero_albaran, finca_nombre - jo (jornada): días trabajados — jornalero (nombre), horas, tarea, fecha, incidencia, finca_nombre - jornalero_nuevo: AÑADIR un nuevo trabajador a la cuadrilla — nombre, tarifa (€/hora), tarea (especialidad). Usar cuando digan "añade a Fernando", "nuevo jornalero Fernando 9€/h poda", "apunta a X en la cuadrilla". - riego: riegos por finca — finca (nombre), horas, metodo (goteo/aspersión/gravedad), fecha, observaciones - campana: crear campaña — nombre, fecha_inicio (YYYY-MM-DD), fecha_fin (YYYY-MM-DD), descripcion, fincas (array de nombres) - evento: añadir cita al calendario — titulo, tipo (tratamiento/cosecha/riego/veterinario/reunion/otro), fecha (YYYY-MM-DD), hora (HH:MM), descripcion - producto_finca: productos aplicados — finca (nombre), nombre (del producto), tipo (herbicida/fertilizante/fitosanitario/insecticida/fungicida/otro), cantidad, fecha, observaciones - stock_nuevo: AÑADIR insumo al stock/almacén — nombre, categoria (abono/herbicida/sulfato/fitosanitario/fungicida/insecticida/semilla/combustible/maquinaria/riego/otro), cantidad (número), unidad (kg/L/g/ml/unid/m³/sacos/cajas), consumo_diario (número, 0 si no consume), precio_unitario (€/unidad, número), proveedor, finca_nombre opcional, observaciones. Usar cuando digan "tengo X de producto Y", "he comprado Z kg de…", "añade al stock", "apunta en almacén", "quedan N sacos de…". SOBRE LOS DISPOSITIVOS/SENSORES: - En "dispositivos" tienes el listado de sensores conectados del agricultor, con la finca donde están instalados y sus lecturas meteorológicas actuales (temperatura, humedad, viento, lluvia, etc.). - Si te preguntan por los dispositivos, sensores, estaciones o qué tiempo hace en una finca concreta, USA estos datos directamente y responde con las lecturas reales. - Si preguntan "¿qué dispositivos tengo?" o "¿qué sensores hay en la finca X?", responde listando los que hay con su finca asignada. - Si preguntan por el tiempo/viento/lluvia de una finca, busca el sensor asignado a esa finca y da las cifras exactas. - Si un dispositivo tiene estado "desconectado" o "sin datos", indícalo amablemente y sugiere pulsar 🔄 Actualizar. INSTRUCCIONES: - "Fernando 9€ hora poda" o "añade a Fernando a la cuadrilla" → tipo jornalero_nuevo con nombre, tarifa y tarea. - "Fernando trabajó 8h poda hoy" → tipo jo (jornada trabajada ese día). - "He comprado 50kg de abono NPK a 0.85€" o "tengo 200L de glifosato" → tipo stock_nuevo con nombre, categoria, cantidad, unidad, precio_unitario. - Para jornadas múltiples "Antonio 8h poda, Carmen 6h" → una entrada jo por persona. - Para fincas: "Finca Norte: 3h goteo, herbicida Roundup 2L" → UN riego Y UN producto separados. - Si menciona una finca que no existe, créala automáticamente. - En consultas de totales → calcula y responde con precisión. - Para consultas sobre sensores/dispositivos/tiempo en fincas → 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://micampoconia.aliaauicha.workers.dev/',{ 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:'👨‍🌾 Jornaleros',jornalero_nuevo:'👨‍🌾 Cuadrilla',riego:'💧 Fincas',producto_finca:'💧 Fincas'}[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','Jornaleros':'j','Cuadrilla':'j','Fincas':'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 // 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://micampoconia.aliaauicha.workers.dev/',{ 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:'👨‍🌾 Jornaleros',jornalero_nuevo:'👨‍🌾 Cuadrilla',riego:'💧 Fincas',producto_finca:'💧 Fincas'}[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','Jornaleros':'j','Cuadrilla':'j','Fincas':'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 // 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)}