const { useState, useEffect, useRef, useMemo, createContext, useContext } = React; const IFR_ROUTES = JSON.parse(document.getElementById('ifr-data').textContent); const AIRPORTS = JSON.parse(document.getElementById('airports-data').textContent); const FL_OPTIONS = ['F080','F090','F100','F110','F120','F130','F140','F150','F160','F170','F180']; const WAKE_CAT = [{v:'L',label:'Light'},{v:'M',label:'Medium'},{v:'H',label:'Heavy'}]; const RULES_FULL = {I:'IFR',V:'VFR',Y:'IFR→VFR',Z:'VFR→IFR'}; const SEED_PLANS = [ {id:'pl_001',name:'Ibiza — sábados',favorite:true,lastUsed:'2026-04-19',from:'LEMU',to:'LEIB',alt1:'LEVC',alt2:'LEMT',rules:'Z',type:'S',route:'RUXET IFR',fl:'F100',dur:{h:'01',m:'30'},obt:{h:'07',m:'00'},aircraft:'C172',reg:'ECOBE',wake:'L',equipment:'SGY',ssr:'C',colour:'BLANCA',operator:'EUROPEANFLYER',pic:'JESUS SOLER ROMERO',remarks:'VUELO DE ESCUELA',pob:'002',endurance:{h:'05',m:'00'},radio:'E',survival:'',jackets:'F',dinghies:'',tlf:'679165883'}, {id:'pl_002',name:'Castellón IFR',favorite:true,lastUsed:'2026-04-17',from:'LEMU',to:'LECH',alt1:'LEVC',alt2:'LEMT',rules:'Z',type:'S',route:'VLC IFR M985 SOPET',fl:'F100',dur:{h:'01',m:'30'},obt:{h:'08',m:'30'},aircraft:'C172',reg:'ECOBE',wake:'L',equipment:'SGY',ssr:'C',colour:'BLANCA',operator:'EUROPEANFLYER',pic:'JESUS SOLER ROMERO',remarks:'VUELO DE ESCUELA',pob:'002',endurance:{h:'05',m:'00'},radio:'E',survival:'',jackets:'F',dinghies:'',tlf:'679165883'}, {id:'pl_003',name:'Granada sur',favorite:false,lastUsed:'2026-04-10',from:'LEMU',to:'LEGR',alt1:'LEAM',alt2:'LEMT',rules:'Z',type:'S',route:'ASTRO IFR M985 VIBAS',fl:'F100',dur:{h:'02',m:'30'},obt:{h:'09',m:'00'},aircraft:'C172',reg:'ECOBE',wake:'L',equipment:'SGY',ssr:'C',colour:'BLANCA',operator:'EUROPEANFLYER',pic:'JESUS SOLER ROMERO',remarks:'VUELO DE ESCUELA',pob:'002',endurance:{h:'06',m:'00'},radio:'E',survival:'',jackets:'F',dinghies:'',tlf:'679165883'}, {id:'pl_004',name:'Nav. local LEMU',favorite:false,lastUsed:'2026-04-05',from:'LEMU',to:'LEMU',alt1:'LEVC',alt2:'LEMT',rules:'V',type:'S',route:'DCT',fl:'F050',dur:{h:'01',m:'00'},obt:{h:'10',m:'00'},aircraft:'C172',reg:'ECOBE',wake:'L',equipment:'SGY',ssr:'C',colour:'BLANCA',operator:'EUROPEANFLYER',pic:'JESUS SOLER ROMERO',remarks:'VUELO LOCAL',pob:'002',endurance:{h:'04',m:'00'},radio:'E',survival:'',jackets:'F',dinghies:'',tlf:'679165883'} ]; // Build 60 days of history with weekly patterns so stats are meaningful function buildSeedHistory(){ const base = [ {plan:'pl_001',name:'Ibiza — sábados',from:'LEMU',to:'LEIB',rules:'Z',fl:'F100',route:'RUXET IFR',dur:'01:30',durMin:90}, {plan:'pl_002',name:'Castellón IFR',from:'LEMU',to:'LECH',rules:'Z',fl:'F100',route:'VLC IFR M985 SOPET',dur:'01:30',durMin:90}, {plan:'pl_003',name:'Granada sur',from:'LEMU',to:'LEGR',rules:'Z',fl:'F100',route:'ASTRO IFR M985 VIBAS',dur:'02:30',durMin:150}, {plan:'pl_004',name:'Nav. local LEMU',from:'LEMU',to:'LEMU',rules:'V',fl:'F050',route:'DCT',dur:'01:00',durMin:60} ]; const out = []; const today = new Date('2026-04-20T10:00:00'); // pattern: Wed/Sat/Sun are flight days, with higher Sat for (let d=0; d<65; d++){ const date = new Date(today); date.setDate(today.getDate()-d); const dow = date.getDay(); // 0=Sun..6=Sat let n = 0; if (dow===6) n = Math.random()<0.85 ? 2 : 1; else if (dow===0) n = Math.random()<0.7 ? 1 : 0; else if (dow===3) n = Math.random()<0.6 ? 1 : 0; else if (dow===5) n = Math.random()<0.4 ? 1 : 0; for (let k=0; k0.06; out.push({ id:`h_${d}_${k}`, planId:b.plan, planName:b.name, from:b.from, to:b.to, when:date.toISOString(), status: ok?'ok':'error', fpId: ok?`FYS${20+Math.floor(Math.random()*80)}${String.fromCharCode(65+Math.floor(Math.random()*26))}${String.fromCharCode(65+Math.floor(Math.random()*26))}`:null, error: ok?null:'Timeout ENAIRE', dur:b.dur, durMin:b.durMin, rules:b.rules, fl:b.fl, aircraft:'C172', reg:'ECOBE', route:b.route, pob:'002', pic:'JESUS SOLER ROMERO' }); } } return out.sort((a,b)=>b.when.localeCompare(a.when)); } const SEED_HISTORY = buildSeedHistory(); // Flota European Flyers — extraído de la tabla de pesos 2026 // European Flyers fleet — actualizado 06/05/2024 const EF_FLEET = [ // C-172 R/S (Gasolina TAE125) {reg:'EC-NVG',type:'C172S',ssr:'C',com:'SFGY',wake:'L',weight:1675,arm:42,moment:70.35,letra:'M'}, {reg:'EC-KOG',type:'C172R',ssr:'C',com:'SDFGY',wake:'L',weight:1722.5,arm:40.72,moment:70.15,letra:'E'}, {reg:'EC-KPO',type:'C172R',ssr:'C',com:'SFGY',wake:'L',weight:1731.5,arm:40.75,moment:70.56,letra:'F'}, {reg:'EC-KLA',type:'C172R',ssr:'C',com:'SDGY',wake:'L',weight:1748.9,arm:41.3,moment:72.18,letra:'F'}, {reg:'EC-KVX',type:'C172S',ssr:'C',com:'SFGY',wake:'L',weight:1761.2,arm:41.57,moment:73.21,letra:'N'}, {reg:'EC-KBL',type:'C172S',ssr:'C',com:'SDGY',wake:'L',weight:1757.1,arm:41.66,moment:73.204,letra:'G'}, {reg:'EC-LJD',type:'C172S',ssr:'C',com:'SFGY',wake:'L',weight:1728.4,arm:42.78,moment:71.41,letra:'H'}, // C-172 Diesel {reg:'EC-ITX',type:'C172R',ssr:'C',com:'SDFY',wake:'L',weight:1778.64,arm:41.15,moment:73.18,letra:'A',diesel:true}, {reg:'EC-KNI',type:'C172R',ssr:'C',com:'SDFGY',wake:'L',weight:1825,arm:41.08,moment:74.96,letra:'D',diesel:true}, {reg:'EC-MOB',type:'C172S',ssr:'C',com:'SGY',wake:'L',weight:1848.5,arm:43.81,moment:80.97,letra:'I',diesel:true}, {reg:'EC-NAK',type:'C172R',ssr:'C',com:'SDFGY',wake:'L',weight:1763,arm:41.31,moment:72.83,letra:'J',diesel:true}, {reg:'EC-NAL',type:'C172S',ssr:'C',com:'SDFGY',wake:'L',weight:1782,arm:40.58,moment:72.31,letra:'K',diesel:true}, {reg:'EC-NMG',type:'C172S',ssr:'C',com:'SGY',wake:'L',weight:1787.01,arm:44.43,moment:78.509,letra:'L',diesel:true}, {reg:'EC-KNC',type:'C172R',ssr:'C',com:'SGY',wake:'L',weight:1813,arm:40.35,moment:73.15,letra:'C',diesel:true}, // DA-42 NG {reg:'EC-MND',type:'DA42',ssr:'C',com:'SDFGY',wake:'L',weight:3292,arm:96.1,moment:316.3,letra:null}, {reg:'EC-MNC',type:'DA42',ssr:'C',com:'SDFGY',wake:'L',weight:3286.5,arm:96.1,moment:315.7,letra:null}, {reg:'EC-IIY',type:'A34',ssr:'C',com:'SD',wake:'L',weight:3084,arm:83.9,moment:258,letra:null}, // Tecnam P-Mentor {reg:'EC-OBE',type:'P2008',ssr:'C',com:'SGY',wake:'L',weight:996.60,arm:68.50,moment:68.27,letra:'P'}, {reg:'EC-OGQ',type:'P2008',ssr:'C',com:'SGY',wake:'L',weight:1001.07,arm:67.91,moment:67.98,letra:'Q'}, ]; // Demo fallback (only used when backend unavailable) const USERS_DB = { 'demo@aeroplan.es':{name:'Piloto Demo',email:'demo@aeroplan.es',pass:'demo1234',enaireUser:'',enairePassSet:false,plan:'free',monthlyUsed:0,monthlyLimit:3,memberSince:'2026-01-01',nextRenewal:'—',isAdmin:false} }; const SEED_ADMIN = {mrr:2480,mrrLastMonth:1960,activeSubs:62,totalUsers:89,signupsMonth:14,submissionsMonth:1287,revenueSpark:[620,680,720,820,940,1080,1280,1520,1780,2040,2260,2480],usersSpark:[20,28,34,42,48,54,58,64,70,76,82,89],submissionsSpark:[120,180,240,320,410,520,640,780,890,1010,1170,1287]}; const SEED_USERS = [ {id:'u1',name:'Jesús Soler Romero',email:'jesus.soler@europeanflyers.com',plan:'owner',status:'active',since:'2026-03-01',sends:142,lastActive:'hace 2 min'}, {id:'u9',name:'Tucho',email:'tucho',plan:'free-forever',status:'active',since:'2026-04-01',sends:5,lastActive:'ahora',note:'Pruebas'}, {id:'u2',name:'Alberto Alonso',email:'alberto.alonso@ef.com',plan:'premium',status:'active',since:'2026-03-12',sends:87,lastActive:'hace 14 min'}, {id:'u3',name:'María Pérez',email:'maria.p@ef.com',plan:'premium',status:'active',since:'2026-03-14',sends:62,lastActive:'hace 1h'}, {id:'u4',name:'Carlos Ruiz',email:'carlos.r@gmail.com',plan:'free',status:'active',since:'2026-04-02',sends:3,lastActive:'hace 3h'}, {id:'u5',name:'Laura Vega',email:'laura.vega@ef.com',plan:'premium',status:'active',since:'2026-03-20',sends:45,lastActive:'hace 5h'}, {id:'u6',name:'Diego Torres',email:'d.torres@ef.com',plan:'free-forever',status:'active',since:'2026-03-30',sends:28,lastActive:'hace 1 día',note:'Amigo'}, {id:'u7',name:'Sara Molina',email:'sara.m@outlook.com',plan:'premium',status:'blocked',since:'2026-03-08',sends:0,lastActive:'hace 10 días'}, {id:'u8',name:'Pablo Gómez',email:'pablo.g@ef.com',plan:'premium',status:'active',since:'2026-04-01',sends:19,lastActive:'hace 2 días'} ]; const SEED_LOGS = [ {id:'l1',when:'14:32:11',user:'Alberto Alonso',action:'SUBMIT_FP',detail:'LEMU → LEIB · 200 OK · FYS24BE',level:'info'}, {id:'l2',when:'14:28:47',user:'Jesús Soler',action:'LOGIN',detail:'IP 88.14.22.1 · Madrid',level:'info'}, {id:'l3',when:'14:20:03',user:'Sara Molina',action:'LOGIN_BLOCKED',detail:'Cuenta bloqueada',level:'warn'}, {id:'l4',when:'14:15:58',user:'María Pérez',action:'SUBMIT_FP',detail:'LEMU → LECH · 200 OK',level:'info'}, {id:'l5',when:'14:02:11',user:'Laura Vega',action:'UPDATE_PROFILE',detail:'Cambio contraseña ENAIRE',level:'info'}, {id:'l6',when:'13:48:22',user:'Carlos Ruiz',action:'PAYWALL_HIT',detail:'Free: 3/3 envíos',level:'warn'}, {id:'l7',when:'13:40:09',user:'Tucho',action:'LOGIN',detail:'Cuenta de pruebas',level:'info'}, {id:'l8',when:'13:25:33',user:'Pablo Gómez',action:'UNUSUAL_LOCATION',detail:'IP nueva',level:'warn'} ]; // Map a submission DB row + parsed request_json to full history object function ap_mapSub(s, req) { const dia = req['dateOfFlight.dia']||''; const mes = req['dateOfFlight.mes']||''; const anio = req['dateOfFlight.anio']||''; const flightDate = dia && mes ? (dia.toString().padStart(2,'0')+'/'+mes.toString().padStart(2,'0')+(anio?'/'+anio:'')) : ''; const eobt = (req['eobtTime.hora']||'') + ':' + (req['eobtTime.minutos']||''); const endH = req['fp19.endurance.hora']||''; const endM = req['fp19.endurance.minutos']||''; return { id: 'sub_' + s.id, planId: s.plan_id, planName: s.plan_name || (req['data.departureAerodrome']||'') + ' - ' + (req['data.destinationAerodrome']||''), from: s.adep || req['data.departureAerodrome'] || '?', to: s.ades || req['data.destinationAerodrome'] || '?', when: new Date(s.created_at * 1000).toISOString(), status: s.status === 'ok' ? 'ok' : 'error', fpId: s.icaro_ref || null, error: s.error_message || null, durationMs: s.duration_ms || 0, // Identificacion arcid: req['data.flightId'] || '', rules: req['data.flightRules'] || '', flightType: req['data.flightType'] || '', flightDate: flightDate, // Aeronave aircraft: req['data.aircraftName'] || '', wake: req['data.wakeTurbulenceCategory'] || '', reg: req['data.aircraftRegistrationMarkings'] || '', comEquipment: req['data.aidEquipment'] || '', ssr: req['ssrData'] || '', // Ruta eobt: eobt !== ':' ? eobt : '', speed: req['data.crusingSpeed'] || '', fl: req['data.crusingLevel'] || '', route: req['data.route'] || '', dur: (req['estimatedElapsedTime.hora']||'00') + ':' + (req['estimatedElapsedTime.minutos']||'00'), durMin: parseInt(req['estimatedElapsedTime.hora']||'0')*60 + parseInt(req['estimatedElapsedTime.minutos']||'0'), altn: req['data.alternateAerodrome1'] || '', altn2: req['data.alternateAerodrome2'] || '', // Suplementario endurance: endH ? (endH+':'+(endM||'00')) : '', pob: req['fp19.personsOnBoard'] || '', pic: req['fp19.pilot'] || '', colors: req['fp19.aircraftColourAndMarkings'] || '', emergencyRadio: req['fp19.radio'] || '', survivalEquipment: req['fp19.survivalEquipment'] || '', lifeJackets: req['fp19.lifeJackets'] || '', // Observaciones rmk: req['data.remarks'] || '', opr: req['data.operator'] || '', }; } const AppCtx = createContext(null); const AppProvider = ({children}) => { const [route, setRoute] = useState({screen:'landing', params:{}}); const loadTweaks = () => { try { const s=localStorage.getItem('ap_tweaks'); if(s) return {...window.TWEAK_DEFAULTS,...JSON.parse(s),theme:'light'}; } catch(e){} return {...window.TWEAK_DEFAULTS,theme:'light'}; }; const [tweaks, setTweaks] = useState(loadTweaks); const [user, setUser] = useState(null); const [plans, setPlans] = useState([]); const [history, setHistory] = useState([]); const [toasts, setToasts] = useState([]); const go = (screen, params={}) => { setRoute({screen, params}); try { localStorage.setItem('ap_route', JSON.stringify({screen, params})); } catch(e){} window.scrollTo({top:0}); }; useEffect(()=>{ try { const s=localStorage.getItem('ap_route'); if(s) setRoute(JSON.parse(s)); const u=localStorage.getItem('ap_user'); if(u){ const parsed=JSON.parse(u); if(parsed.monthlyLimit==null) parsed.monthlyLimit=Infinity; setUser(parsed);} } catch(e){} }, []); useEffect(()=>{ document.body.setAttribute('data-theme', 'light'); // Always light document.documentElement.style.setProperty('--radius', (tweaks.radius||14)+'px'); try { localStorage.setItem('ap_tweaks', JSON.stringify(tweaks)); } catch(e){} }, [tweaks]); const [backendOK, setBackendOK] = useState(false); useEffect(()=>{ (async()=>{ try { setBackendOK(!!(await window.isBackendReady())); } catch(e){ setBackendOK(false); } })(); }, []); // Handle ?verify=TOKEN useEffect(()=>{ const params = new URLSearchParams(window.location.search); const vToken = params.get('verify'); if (vToken && backendOK && window.api) { window.api.verifyEmail(vToken).then(()=>{ setUser(u => u ? {...u, email_verified: true} : u); try { const s=JSON.parse(localStorage.getItem('ap_user')||'{}'); s.email_verified=true; localStorage.setItem('ap_user',JSON.stringify(s)); } catch(e){} toast(t('auth.verified_ok') || 'Email verificado correctamente'); }).catch(()=>{ toast(t('auth.verify_fail') || 'Enlace de verificación inválido o expirado'); }); window.history.replaceState({}, '', window.location.pathname); } }, [backendOK]); const hasBackend = () => backendOK && !!window.api; // Al recargar pagina, si ya hay sesion y backend, cargar planes reales useEffect(()=>{ if (!backendOK || !window.api) return; (async()=>{ try { const me = await window.api.me(); if (me && me.user) { const d = await window.api.plans(); setPlans(Array.isArray(d.plans) ? d.plans : []); try { const subs = await window.api.submissions(); if (Array.isArray(subs.submissions)) { const hist = subs.submissions.map(s => { let req = {}; try { req = JSON.parse(s.request_json || '{}'); } catch(e){} return ap_mapSub(s, req); }); setHistory(hist); } } catch(e){} } } catch(e){ // No hay sesion activa o backend caido — cargar seed si hay user en localStorage try { if (localStorage.getItem('ap_user')) { setPlans(SEED_PLANS); setHistory(SEED_HISTORY); } } catch(ex){} } })(); }, [backendOK]); const login = async (email, pass) => { // Si hay backend PHP, usa el endpoint real. Si no, caída demo con USERS_DB. if (await window.isBackendReady()) { try { const r = await window.api.login({email, password: pass}); const u = r.user || {}; const authed = { ...u, name: u.name, email: u.email, enaireUser: u.icaro_user_masked || '', enairePassSet: !!u.has_icaro_credentials, plan: u.plan, monthlyUsed: 0, monthlyLimit: u.plan === 'free' ? 3 : Infinity, isAdmin: u.role === 'admin', memberSince: new Date((u.created_at||0)*1000).toLocaleDateString('es-ES'), nextRenewal: u.plan_until ? new Date(u.plan_until*1000).toLocaleDateString('es-ES') : '—' }; setUser(authed); try { localStorage.setItem('ap_user', JSON.stringify(authed)); } catch(e){} // Cargar planes reales (siempre reemplaza, incluso si vacio) try { const d = await window.api.plans(); setPlans(Array.isArray(d.plans) ? d.plans : []); } catch(e){ setPlans([]); } // Cargar historial de submissions try { const subs = await window.api.submissions(); if (Array.isArray(subs.submissions)) { const hist = subs.submissions.map(s => { let req = {}; try { req = JSON.parse(s.request_json || '{}'); } catch(e){} return ap_mapSub(s, req); }); setHistory(hist); } } catch(e){} go('dashboard'); return {ok:true}; } catch (err) { return {ok:false, error: err.error || 'login_failed'}; } } // No demo fallback — backend is required return {ok:false, error:'server_unavailable'}; }; const logout = async () => { if (await window.isBackendReady()) { try { await window.api.logout(); } catch(e){} } setUser(null); try { localStorage.removeItem('ap_user'); } catch(e){} go('landing'); }; const toast = (msg) => { const id = Math.random().toString(36).slice(2); setToasts(t=>[...t,{id,msg}]); setTimeout(()=>setToasts(t=>t.filter(x=>x.id!==id)), 2600); }; // Custom confirm modal (replaces browser confirm()) const [confirmState, setConfirmState] = useState(null); const confirm = (message, {title, yes, no} = {}) => new Promise(resolve => { setConfirmState({message, title: title || '', yes: yes || 'Eliminar', no: no || 'Cancelar', resolve}); }); return {children} {confirmState &&
{if(e.target===e.currentTarget){confirmState.resolve(false);setConfirmState(null);}}} style={{position:'fixed',inset:0,background:'rgba(0,0,0,.45)',backdropFilter:'blur(4px)',zIndex:400,display:'flex',justifyContent:'center',alignItems:'center',padding:20}}>
{confirmState.title &&
{confirmState.title}
}

{confirmState.message}

}
; }; const useApp = () => useContext(AppCtx); const Toasts = () => { const {toasts} = useApp(); return
{toasts.map(t=>
{t.msg}
)}
; }; // Custom date picker matching app style const DatePicker = ({value, onChange, style: wrapStyle}) => { const [open, setOpen] = useState(false); const ref = useRef(null); const t = window.t || (k => k); // Parse current value (YYYY-MM-DD) const parsed = value ? new Date(value + 'T00:00:00') : new Date(); const [viewYear, setViewYear] = useState(parsed.getFullYear()); const [viewMonth, setViewMonth] = useState(parsed.getMonth()); // Close on outside click useEffect(() => { if (!open) return; const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open]); // When value changes externally, sync view useEffect(() => { if (value) { const d = new Date(value + 'T00:00:00'); setViewYear(d.getFullYear()); setViewMonth(d.getMonth()); } }, [value]); const DAYS_ES = ['Lu','Ma','Mi','Ju','Vi','Sa','Do']; const DAYS_EN = ['Mo','Tu','We','Th','Fr','Sa','Su']; const MONTHS_ES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre']; const MONTHS_EN = ['January','February','March','April','May','June','July','August','September','October','November','December']; const lang = (window.TWEAK_DEFAULTS?.lang || 'es'); const dayNames = lang === 'en' ? DAYS_EN : DAYS_ES; const monthNames = lang === 'en' ? MONTHS_EN : MONTHS_ES; // Build calendar grid const firstDay = new Date(viewYear, viewMonth, 1); let startDow = firstDay.getDay() - 1; // Mon=0 if (startDow < 0) startDow = 6; const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); const prevDays = new Date(viewYear, viewMonth, 0).getDate(); const cells = []; for (let i = startDow - 1; i >= 0; i--) cells.push({d: prevDays - i, cur: false}); for (let i = 1; i <= daysInMonth; i++) cells.push({d: i, cur: true}); const rem = 42 - cells.length; for (let i = 1; i <= rem; i++) cells.push({d: i, cur: false}); const today = new Date(); const todayStr = today.toISOString().slice(0, 10); const selDay = value || ''; const pick = (day) => { const m = String(viewMonth + 1).padStart(2, '0'); const d = String(day).padStart(2, '0'); onChange(`${viewYear}-${m}-${d}`); setOpen(false); }; const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); } else setViewMonth(m => m - 1); }; const nextMonth = () => { if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); } else setViewMonth(m => m + 1); }; // Format display value const displayVal = value ? (() => { const d = new Date(value + 'T00:00:00'); return `${d.getDate()} ${monthNames[d.getMonth()].slice(0, 3)} ${d.getFullYear()}`; })() : '—'; return
setOpen(!open)} style={{cursor:'pointer',display:'flex',alignItems:'center',justifyContent:'space-between',userSelect:'none'}}> {displayVal}
{open &&
{/* Month nav */}
{monthNames[viewMonth]} {viewYear}
{/* Day headers */}
{dayNames.map(d =>
{d}
)}
{/* Days grid */}
{cells.map((c, i) => { const dateStr = c.cur ? `${viewYear}-${String(viewMonth+1).padStart(2,'0')}-${String(c.d).padStart(2,'0')}` : ''; const isToday = dateStr === todayStr; const isSel = dateStr === selDay; return ; })}
{/* Today shortcut */}
}
; }; const LogoMark = ({size=24}) => ( ); const NAV_ITEMS = [ {id:'dashboard',label:'nav.plans',icon:'list'}, {id:'history',label:'nav.history',icon:'clock'}, {id:'ifr-table',label:'nav.ifr',icon:'map'}, {id:'profile',label:'nav.profile',icon:'user'} ]; const TopNav = () => { const t = window.t || (k => k); const {route, go, user} = useApp(); if (!user) return null; const items = user.isAdmin ? NAV_ITEMS.concat([{id:'admin',label:'nav.admin',icon:'shield'}]) : NAV_ITEMS; return ; }; const MobileTabBar = () => { const t = window.t || (k => k); const {route, go, user} = useApp(); if (!user) return null; const items = (user.isAdmin ? NAV_ITEMS.concat([{id:'admin',label:'nav.admin',icon:'shield'}]) : NAV_ITEMS).slice(0,5); return ; }; Object.assign(window,{useState,useEffect,useRef,useMemo,AppCtx,AppProvider,useApp,Toasts,TopNav,MobileTabBar,LogoMark,IFR_ROUTES,AIRPORTS,FL_OPTIONS,WAKE_CAT,RULES_FULL,EF_FLEET,SEED_PLANS,SEED_HISTORY,SEED_ADMIN,SEED_USERS,SEED_LOGS,USERS_DB});