const Admin = () => { const t = window.t || (k => k); const {toast, confirm} = useApp(); const [tab, setTab] = useState('overview'); const [tickets, setTickets] = useState([]); const [replyText, setReplyText] = useState({}); const [supportFilter, setSupportFilter] = useState('open'); const [expandedTk, setExpandedTk] = useState({}); const [users, setUsers] = useState([]); const [metrics, setMetrics] = useState(null); const [logs, setLogs] = useState([]); const [logLevel, setLogLevel] = useState(''); const [search, setSearch] = useState(''); const [selectedUser, setSelectedUser] = useState(null); const [userDetail, setUserDetail] = useState(null); const [detailTab, setDetailTab] = useState('activity'); const [loadingDetail, setLoadingDetail] = useState(false); const [expandedSub, setExpandedSub] = useState(null); const [subViewMode, setSubViewMode] = useState({}); // {subId: 'summary'|'post'} const [giftMonths, setGiftMonths] = useState(1); const [showGiftFor, setShowGiftFor] = useState(null); const [showCreateUser, setShowCreateUser] = useState(false); const [newUser, setNewUser] = useState({email:'',password:'',name:'',license:'PPL',plan:'free'}); const [showChangePass, setShowChangePass] = useState(null); const [newPass, setNewPass] = useState(''); const [ifrRoutes, setIfrRoutes] = useState(() => JSON.parse(document.getElementById('ifr-data')?.textContent || '[]')); const [editingRoute, setEditingRoute] = useState(null); const [routeForm, setRouteForm] = useState({from:'',to:'',rules:'Z',fl:'',route:'',dur:'',notes:''}); const [userFilter, setUserFilter] = useState('all'); // all|premium|gifted|free|blocked|instructor // Load data from API useEffect(() => { api.adminMetrics().then(d => setMetrics(d)).catch(() => {}); api.adminUsers('').then(d => setUsers(d.users || [])).catch(() => {}); }, []); const logsLoaded = useRef(''); const supportLoaded = useRef(false); useEffect(() => { if (tab === 'logs' && logsLoaded.current !== logLevel) { logsLoaded.current = logLevel; api.adminLogs(logLevel).then(d => setLogs(d.logs || [])).catch(() => {}); } }, [tab, logLevel]); useEffect(() => { if (tab === 'support' && !supportLoaded.current) { supportLoaded.current = true; api.adminSupport().then(d => setTickets(d.tickets || [])).catch(() => {}); } }, [tab]); const searchUsers = (q) => { setSearch(q); api.adminUsers(q).then(d => setUsers(d.users || [])).catch(() => {}); }; const openUserDetail = async (u) => { setSelectedUser(u); setDetailTab('activity'); setLoadingDetail(true); try { const d = await api.adminUserDetail(u.id); setUserDetail(d); } catch (e) { toast('Error cargando detalle'); setUserDetail(null); } setLoadingDetail(false); }; const closeDetail = () => { setSelectedUser(null); setUserDetail(null); }; const toggleBlock = async (id, currentStatus) => { const block = currentStatus !== 'blocked'; try { await api.adminBlock(id, block); setUsers(u => u.map(x => x.id === id ? {...x, status: block ? 'blocked' : 'active'} : x)); if (userDetail && userDetail.user.id === id) { setUserDetail(d => ({...d, user: {...d.user, status: block ? 'blocked' : 'active'}})); } toast(block ? 'Usuario bloqueado' : 'Usuario desbloqueado'); } catch (e) { toast('Error: ' + (e.error || 'desconocido')); } }; const giftPremium = async (id, months) => { try { await api.adminGift(id, months); setUsers(u => u.map(x => x.id === id ? {...x, plan: 'gifted'} : x)); if (userDetail && userDetail.user.id === id) { setUserDetail(d => ({...d, user: {...d.user, plan: 'gifted', stripe_subscription_id: null}})); } toast(months === 0 ? 'Premium vitalicio regalado' : `Premium regalado (${months} ${months===1?'mes':'meses'})`); setShowGiftFor(null); setGiftMonths(1); } catch (e) { toast('Error: ' + (e.error || 'desconocido')); } }; const doCreateUser = async () => { if (!newUser.email || !newUser.password || !newUser.name) { toast('Rellena email, contraseña y nombre'); return; } try { await api.adminCreateUser(newUser); toast('Usuario creado'); setShowCreateUser(false); setNewUser({email:'',password:'',name:'',license:'PPL',plan:'free'}); api.adminUsers(search).then(d => setUsers(d.users || [])).catch(() => {}); } catch (e) { toast('Error: ' + (e.error || 'desconocido')); } }; const doChangePass = async () => { if (!newPass || newPass.length < 8) { toast('Mínimo 8 caracteres'); return; } try { await api.adminChangePassword(showChangePass, newPass); toast('Contraseña cambiada'); setShowChangePass(null); setNewPass(''); } catch (e) { toast('Error: ' + (e.error || 'desconocido')); } }; const saveIfrRoutes = (routes) => { setIfrRoutes(routes); // Persist to DOM script block + localStorage const el = document.getElementById('ifr-data'); if (el) el.textContent = JSON.stringify(routes); try { localStorage.setItem('aeroplan_ifr_routes', JSON.stringify(routes)); } catch(e){} }; const addOrUpdateRoute = () => { if (!routeForm.from || !routeForm.to || !routeForm.route) { toast('From, To y Ruta son obligatorios'); return; } let updated; if (editingRoute !== null) { updated = ifrRoutes.map((r, i) => i === editingRoute ? {...routeForm} : r); } else { updated = [...ifrRoutes, {...routeForm}]; } saveIfrRoutes(updated); setEditingRoute(null); setRouteForm({from:'',to:'',rules:'Z',fl:'',route:'',dur:'',notes:''}); toast(editingRoute !== null ? 'Ruta actualizada' : 'Ruta añadida'); }; const deleteRoute = (idx) => { saveIfrRoutes(ifrRoutes.filter((_, i) => i !== idx)); toast('Ruta eliminada'); }; const editRoute = (idx) => { setEditingRoute(idx); setRouteForm({...ifrRoutes[idx]}); }; const revokePremium = async (id) => { try { await api.adminRevoke(id); setUsers(u => u.map(x => x.id === id ? {...x, plan: 'free', plan_until: null} : x)); if (userDetail && userDetail.user.id === id) { setUserDetail(d => ({...d, user: {...d.user, plan: 'free', plan_until: null}})); } toast('Premium revocado'); } catch (e) { toast('Error: ' + (e.error || 'desconocido')); } }; const planBadge = (p) => { const labels = {admin: 'Admin', premium: 'Premium', gifted: 'Gifted', free: 'Free'}; const accent = p === 'premium' || p === 'gifted' || p === 'admin'; return {labels[p] || p}; }; const fmtDate = (ts) => { if (!ts) return '—'; const d = new Date(ts * 1000); return d.toLocaleDateString('es', {day: '2-digit', month: 'short', year: 'numeric'}) + ' ' + d.toLocaleTimeString('es', {hour: '2-digit', minute: '2-digit'}); }; const fmtAgo = (ts) => { if (!ts) return 'nunca'; const diff = Math.floor(Date.now() / 1000) - ts; if (diff < 60) return 'ahora'; if (diff < 3600) return `hace ${Math.floor(diff / 60)} min`; if (diff < 86400) return `hace ${Math.floor(diff / 3600)}h`; return `hace ${Math.floor(diff / 86400)}d`; }; const levelColor = (l) => l === 'error' ? 'var(--err)' : l === 'warn' ? 'var(--warn)' : 'var(--ok)'; const TABS = [{id:'overview',l:t('admin.overview')},{id:'users',l:t('admin.users')},{id:'ifr',l:t('admin.ifr')},{id:'logs',l:t('admin.logs')},{id:'support',l:t('admin.support')}]; return
{t('admin.panel')}

{t('admin.title')}{' '} {t('admin.title2')}{' '} {t('admin.title3')}

{t('admin.subtitle')}

{TABS.map(t=>)}
{/* ===== INSTRUCTOR REQUESTS BANNER (all tabs) ===== */} {metrics && metrics.instructor_requests > 0 &&
{setTab('users');setUserFilter('instructor');searchUsers('');}}>
{metrics.instructor_requests} solicitud{metrics.instructor_requests>1?'es':''} de instructor pendiente{metrics.instructor_requests>1?'s':''}
Haz clic para revisar y asignar licencia
} {/* ===== OVERVIEW ===== */} {tab==='overview' && metrics && <>
{[ {k:'MRR',v:`${metrics.mrr_eur}€`,sub:metrics.paying_users?`${metrics.paying_users} suscriptores`:null}, {k:'Usuarios',v:metrics.users_total}, {k:'Premium',v:metrics.users_premium,sub:metrics.gifted_total?`${metrics.gifted_total} gifted (${metrics.gifted_lifetime} vitalicio${metrics.gifted_temp?`, ${metrics.gifted_temp} temporal`:''})`:null}, {k:'Envíos OK',v:metrics.submissions_ok}, {k:'Envíos Error',v:metrics.submissions_error}, {k:'Bloqueados',v:metrics.users_blocked}, ].map((m,i)=>(
{m.k}
{m.v}
{m.sub &&
{m.sub}
}
))}
{metrics.series &&

Envíos (14 días)

{metrics.series.map((s,i)=>{const max=Math.max(...metrics.series.map(x=>x.count),1);const h=(s.count/max)*100;return
{s.date.slice(5)}
})}
} } {/* User detail overlay — rendered at component level so it works from any tab */} {selectedUser &&
{if(e.target===e.currentTarget) closeDetail();}}>
{/* Header */}
{(userDetail?.user?.name || selectedUser.name || '?')[0]}
{userDetail?.user?.name || selectedUser.name} {planBadge(userDetail?.user?.plan || selectedUser.plan)} {(userDetail?.user?.status || selectedUser.status) === 'blocked' && Bloqueado}
{userDetail?.user?.email || selectedUser.email}{(userDetail?.user?.phone || selectedUser.phone) ? ` · ${userDetail?.user?.phone || selectedUser.phone}` : ''} · {userDetail?.user?.license || ''} · Registro {fmtDate(userDetail?.user?.created_at || selectedUser.created_at)}
{userDetail?.user &&
{userDetail.user.plan === 'premium' && userDetail.user.stripe_subscription_id && Stripe activo} {userDetail.user.plan === 'premium' && !userDetail.user.stripe_subscription_id && Premium (sin Stripe)} {userDetail.user.plan === 'gifted' && userDetail.user.plan_until && Gift hasta {fmtDate(userDetail.user.plan_until)}} {userDetail.user.plan === 'gifted' && !userDetail.user.plan_until && Gift vitalicio ♾} {userDetail.user.plan_until && userDetail.user.plan === 'premium' && · Renueva {fmtDate(userDetail.user.plan_until)}}
}
{/* Actions bar */} {userDetail &&
{(userDetail.user.plan === 'free' || userDetail.user.plan === 'premium') && } {(userDetail.user.plan === 'premium' || userDetail.user.plan === 'gifted') && } {userDetail.user.stripe_subscription_id && } {userDetail.user.role !== 'admin' && }
} {/* License selector — dedicated row */} {userDetail &&
Licencia: {userDetail.user.license==='INSTRUCTOR_PENDING' && Solicitud pendiente}
} {/* Stats row */} {userDetail &&
{[ {k:'Planes',v:userDetail.stats.total_plans}, {k:'Envíos OK',v:userDetail.stats.ok_submissions}, {k:'Errores',v:userDetail.stats.error_submissions}, {k:'Aeronaves',v:userDetail.stats.total_aircraft}, {k:'Último login',v:fmtAgo(userDetail.user.last_login_at)}, ].map((s,i)=>
{s.k}
{s.v}
)}
} {/* Detail tabs */} {userDetail && <>
{[{id:'activity',l:'Envíos'},{id:'plans',l:'Planes'},{id:'logs',l:'Logs'},{id:'aircraft',l:'Aeronaves'},{id:'tickets',l:'Soporte'}].map(tb=> )}
{/* Submissions */} {detailTab==='activity' &&
{userDetail.submissions.length === 0 &&
Sin envíos
} {userDetail.submissions.map((s,i)=>{ const isOpen = expandedSub === s.id; let reqFields = null; try { reqFields = JSON.parse(s.request_json || '{}'); } catch(e){} return
setExpandedSub(isOpen?null:s.id)}>
{s.plan_name || s.op} {s.adep && s.ades && {s.adep} → {s.ades}} {s.icaro_ref && {s.icaro_ref}} {s.error_message && {s.error_message}}
{fmtDate(s.created_at)} {s.status.toUpperCase()}
{isOpen && (() => { const vm = subViewMode[s.id] || 'summary'; const setVm = (m) => setSubViewMode(prev => ({...prev, [s.id]: m})); // Extract friendly fields from request_json const r = reqFields || {}; const adep = r['data.departureAerodrome'] || s.adep || ''; const ades = r['data.destinationAerodrome'] || s.ades || ''; const arcid = r['data.flightId'] || ''; const rules = r['data.flightRules'] || ''; const ftype = r['data.flightType'] || ''; const dia = r['dateOfFlight.dia']||''; const mes = r['dateOfFlight.mes']||''; const anio = r['dateOfFlight.anio']||''; const flightDate = dia && mes ? (dia.toString().padStart(2,'0')+'/'+mes.toString().padStart(2,'0')+(anio?'/'+anio:'')) : ''; const eobtH = r['eobtTime.hora']||''; const eobtM = r['eobtTime.minutos']||''; const eobt = eobtH ? (eobtH+':'+(eobtM||'00')) : ''; const acType = r['data.aircraftName'] || ''; const wake = r['data.wakeTurbulenceCategory'] || ''; const reg = r['data.aircraftRegistrationMarkings'] || ''; const comEq = r['data.aidEquipment'] || ''; const ssr = r['ssrData'] || ''; const speed = r['data.crusingSpeed'] || ''; const fl = r['data.crusingLevel'] || ''; const route = r['data.route'] || ''; const eetH = r['estimatedElapsedTime.hora']||''; const eetM = r['estimatedElapsedTime.minutos']||''; const eet = eetH ? (eetH+':'+(eetM||'00')) : ''; const altn = r['data.alternateAerodrome1'] || ''; const altn2 = r['data.alternateAerodrome2'] || ''; const endH = r['fp19.endurance.hora']||''; const endM = r['fp19.endurance.minutos']||''; const endurance = endH ? (endH+':'+(endM||'00')) : ''; const pob = r['fp19.personsOnBoard'] || ''; const pic = r['fp19.pilot'] || ''; const colors = r['fp19.aircraftColourAndMarkings'] || ''; const eRadio = r['fp19.radio'] || ''; const survival = r['fp19.survivalEquipment'] || ''; const jackets = r['fp19.lifeJackets'] || ''; const rmk = r['data.remarks'] || ''; const opr = r['data.operator'] || ''; const RULES_MAP = {I:'IFR',V:'VFR',Y:'VFR→IFR',Z:'IFR→VFR'}; const FTYPE_MAP = {S:'Regular',N:'No regular',G:'Aviacion general',M:'Militar',X:'Otros'}; const kv = (k,v) => v ?
{k}{v}
: null; const secHead = (t) =>
{t}
; return
ID: {s.id} Duracion: {s.duration_ms}ms Op: {s.op}
{vm === 'summary' && reqFields && <> {/* Route header */}
{adep} {ades}
{rules && {RULES_MAP[rules]||rules}} {fl && {fl}} {acType && {acType}} {eet && {eet}} {flightDate && {flightDate}}
{/* Identificacion */}
{secHead('Identificacion')} {kv('ARCID', arcid)} {kv('Reglas', RULES_MAP[rules]||rules)} {kv('Tipo vuelo', FTYPE_MAP[ftype]||ftype)} {kv('Fecha', flightDate)}
{/* Aeronave */}
{secHead('Aeronave')} {kv('Tipo', acType)} {kv('Estela', wake)} {kv('REG', reg)} {kv('Equipo COM', comEq)} {kv('SSR', ssr)}
{/* Ruta */}
{secHead('Ruta')} {kv('EOBT', eobt ? eobt+'Z' : '')} {kv('Velocidad', speed)} {kv('Nivel', fl)} {kv('Ruta', route)} {kv('EET', eet)} {kv('ALTN', altn)} {kv('ALTN2', altn2)}
{/* Suplementario */} {(endurance || pob || pic || colors || eRadio || survival) &&
{secHead('Suplementario')} {kv('Autonomia', endurance)} {kv('POB', pob)} {kv('PIC', pic)} {kv('Color/Marcas', colors)} {kv('Radio emergencia', eRadio)} {kv('Supervivencia', survival)} {kv('Chalecos', jackets)}
} {/* Observaciones */} {(rmk || opr) &&
{secHead('Observaciones')} {kv('RMK', rmk)} {kv('OPR', opr)}
}
} {vm === 'summary' && !reqFields &&
Sin datos de peticion
} {vm === 'post' && <>
Peticion POST enviada a ENAIRE
{reqFields &&
{Object.entries(reqFields).filter(([k,v])=>v).sort(([a],[b])=>a.localeCompare(b)).map(([k,v])=>
{k} {v}
)}
} {!reqFields &&
Sin datos de peticion
}
} {/* Respuesta ENAIRE */} {s.response_json &&
Respuesta ENAIRE (HTML)
{(() => {
                            try { const r2 = JSON.parse(s.response_json); return r2.raw || JSON.stringify(r2, null, 2); } catch(e) { return s.response_json; }
                          })()}
}
; })()}
; })}
} {/* Plans */} {detailTab==='plans' &&
{userDetail.plans.length === 0 &&
Sin planes
} {userDetail.plans.map((p,i)=>
{p.favorite ? : }
{p.name} {p.adep} → {p.ades} {p.flight_rules} · {p.aircraft_type}
{fmtDate(p.updated_at)}
)}
} {/* Logs */} {detailTab==='logs' &&
{userDetail.logs.length === 0 &&
Sin logs
} {userDetail.logs.map((l,i)=>
{l.event} {l.message && {l.message}} {l.ip && {l.ip}}
{fmtDate(l.created_at)} {l.level.toUpperCase()}
)}
} {/* Aircraft */} {detailTab==='aircraft' &&
{userDetail.aircraft.length === 0 &&
Sin aeronaves
} {userDetail.aircraft.map((a,i)=>
{a.registration} {a.type} Estela {a.wake} {a.is_default ? Default : null}
)}
} {/* User tickets */} {detailTab==='tickets' &&
{(!userDetail.tickets || userDetail.tickets.length === 0) &&
Sin tickets de soporte
} {(userDetail.tickets || []).map((tk,i)=>
{tk.status === 'open' ? 'Abierto' : tk.status === 'replied' ? 'Respondido' : 'Cerrado'} {fmtDate(tk.created_at)}
{tk.message}
{tk.admin_reply &&
Admin:{tk.admin_reply}
}
)}
}
} {loadingDetail &&
Cargando...
}
} {/* ===== USERS ===== */} {tab==='users' && (() => { const filtered = users.filter(u => { if (userFilter === 'all') return true; if (userFilter === 'premium') return u.plan === 'premium'; if (userFilter === 'gifted') return u.plan === 'gifted'; if (userFilter === 'free') return u.plan === 'free'; if (userFilter === 'blocked') return u.status === 'blocked'; if (userFilter === 'instructor') return u.license === 'INSTRUCTOR_PENDING'; return true; }); return <> {/* User list */}

Usuarios ({filtered.length}{userFilter!=='all'?` / ${users.length}`:''})

{users.filter(u=>u.status==='active').length} activos · {users.filter(u=>u.plan==='premium'||u.plan==='gifted').length} premium · {users.filter(u=>u.status==='blocked').length} bloqueados
searchUsers(e.target.value)} placeholder="Buscar…" style={{paddingLeft:32,height:36,fontSize:13}}/>
{/* Filter bar */}
{[ {id:'all',l:'Todos',count:users.length}, {id:'free',l:'Free',count:users.filter(u=>u.plan==='free').length}, {id:'premium',l:'Premium',count:users.filter(u=>u.plan==='premium').length}, {id:'gifted',l:'Gifted',count:users.filter(u=>u.plan==='gifted').length}, {id:'blocked',l:'Bloqueados',count:users.filter(u=>u.status==='blocked').length}, {id:'instructor',l:'📋 Pendientes',count:users.filter(u=>u.license==='INSTRUCTOR_PENDING').length}, ].filter(f=>f.id==='all'||f.count>0).map(f=>)}
{filtered.map((u,i)=>
openUserDetail(u)} onMouseEnter={e=>e.currentTarget.style.background='var(--bg-inset)'} onMouseLeave={e=>e.currentTarget.style.background=''} >
{u.name[0]}
{u.name}
{u.email}{u.phone ? ` · ${u.phone}` : ''} · {u.license || '—'} · {fmtAgo(u.last_login_at)}
{planBadge(u.plan)} {u.license === 'INSTRUCTOR_PENDING' && Instructor pendiente} {u.status === 'blocked' && Bloqueado}
e.stopPropagation()}> {u.plan === 'free' && } {(u.plan === 'premium' || u.plan === 'gifted') && } {u.role !== 'admin' && }
)}
; })()} {/* ===== IFR ROUTES ===== */} {tab==='ifr' && <>

{editingRoute !== null ? 'Editar ruta' : 'Añadir ruta IFR'}

setRouteForm(f=>({...f,from:e.target.value.toUpperCase()}))} placeholder="LELL" maxLength={4}/>
setRouteForm(f=>({...f,to:e.target.value.toUpperCase()}))} placeholder="LEIB" maxLength={4}/>
setRouteForm(f=>({...f,fl:e.target.value.toUpperCase()}))} placeholder="F090"/>
setRouteForm(f=>({...f,dur:e.target.value}))} placeholder="01:30"/>
setRouteForm(f=>({...f,route:e.target.value.toUpperCase()}))} placeholder="OKABI T10 BCN"/>
setRouteForm(f=>({...f,notes:e.target.value}))} placeholder="Opcional"/>
{editingRoute !== null && }

Rutas IFR ({ifrRoutes.length})

{ifrRoutes.length === 0 &&
Sin rutas
} {ifrRoutes.map((r,i)=>
{r.from} - {r.to} {r.rules} {r.fl} {r.route} {r.dur}
)}
} {/* ===== LOGS ===== */} {tab==='logs' &&

Actividad

{logs.length} registros
{['','info','warn','error'].map(l=>)}
{logs.map((l,i)=>
{fmtDate(l.created_at)} {l.event} {l.message && {l.message}} {l.ip && {l.ip}} {l.level.toUpperCase()}
)} {logs.length === 0 &&
Sin logs
}
} {/* ===== SUPPORT ===== */} {tab==='support' && (() => { const openTk = tickets.filter(x => x.status !== 'closed'); const closedTk = tickets.filter(x => x.status === 'closed'); return <>

{t('admin.support')}

{supportFilter==='open' && <> {openTk.length === 0 &&
Sin tickets abiertos
}
{openTk.map(tk => { const isOpen = expandedTk[tk.id] !== false; // open by default for open tickets const lastMsg = (tk.messages||[]).slice(-1)[0]; return
{/* Collapsible header */}
setExpandedTk(s=>({...s,[tk.id]:!isOpen}))}>
{(tk.user_name||'?')[0]}
{tk.user_name || 'Usuario'}
{!isOpen && lastMsg &&
{lastMsg.sender==='admin'?'Tú: ':''}{lastMsg.body}
}
{tk.status === 'replied' ? 'Respondido' : 'Abierto'} {(tk.messages||[]).length} msg
{/* Expanded content */} {isOpen &&
{/* User info bar */}
{e.stopPropagation();openUserDetail({id:tk.user_id,name:tk.user_name,email:tk.user_email,plan:tk.user_plan,license:tk.user_license,status:tk.user_status});}}>{tk.user_email} · {tk.user_license || '—'} · {planBadge(tk.user_plan)} {fmtDate(tk.created_at)}
{/* Conversation messages */}
{(tk.messages || []).map((m, i) =>
{m.sender === 'admin' &&
AeroPlan
} {m.body}
{new Date(m.created_at * 1000).toLocaleString()}
)}
{/* Reply + actions */}