// Helper — builds a 5-step timeline from a real booking row. Maps the
// booking's status field to the canonical lifecycle (Created → Confirmed
// → Paid → Checked-in → Reviewed). Used by AdminTimeline below to
// replace the static D.admin.timeline mock with real booking flow.
function buildTimelineFromBooking(b) {
  const status = (b.status || '').toLowerCase();
  const created = b.created_at;
  const confirmed = (b.confirmed_at || (status !== 'pending' && status !== 'cancelled' ? created : null));
  const paid = (b.paid_at || (status === 'paid' || status === 'confirmed' || status === 'completed' ? created : null));
  const checkedIn = (b.check_in_completed_at || (status === 'completed' ? b.check_in : null));
  const reviewed = b.reviewed_at || null;
  // v132 — Bug D-01: cancelled bookings used to retain "Payment · Pending"
  // as ACTIVE. They now exit the funnel — no node is active, the cancelled
  // node is shown DONE with a distinct flag the renderer styles in red.
  const isCancelled = status === 'cancelled';
  const cancelledAt = b.cancelled_at || (isCancelled ? (b.updated_at || created) : null);
  function fmtTs(iso) {
    if (!iso) return '—';
    try { return new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) + ' · ' + new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }); }
    catch { return '—'; }
  }
  function rel(iso) {
    if (!iso) return 'pending';
    const d = (Date.now() - new Date(iso).getTime()) / 1000;
    if (d < 60) return 'just now'; if (d < 3600) return Math.round(d/60) + ' min ago';
    if (d < 86400) return Math.round(d/3600) + ' hr ago';
    return Math.round(d/86400) + ' days ago';
  }
  return {
    booking: (b.id || '').slice(0, 8),
    villa: b.property_name || (b.property && b.property.name) || 'Villa',
    guest: b.guest_name || b.guest_email || 'Guest',
    host: b.host_email || 'Host',
    cancelled: isCancelled,
    cancelledAt,
    states: [
      { id: 'created',    label: 'Created',     ts: fmtTs(created),    rel: rel(created),    median: 0,    done: !!created,    channel: 'system',   who: 'guest',   detail: 'Booking created via /api/bookings' },
      // When cancelled, suppress all `active` flags — booking exited the funnel.
      { id: 'confirmed',  label: 'Host confirm', ts: fmtTs(confirmed),  rel: rel(confirmed),  median: 240,  done: !!confirmed,  active: !isCancelled && !confirmed && !!created, channel: 'whatsapp', who: 'host',    detail: 'Concierge nudge sent if >4h pending' },
      { id: 'paid',       label: 'Payment',     ts: fmtTs(paid),       rel: rel(paid),       median: 60,   done: !!paid,       active: !isCancelled && !paid && !!confirmed,    channel: 'email',    who: 'guest',   detail: 'Xendit invoice link emailed on confirm' },
      { id: 'checkedin',  label: 'Check-in',    ts: fmtTs(checkedIn),  rel: rel(checkedIn),  median: 1440, done: !!checkedIn,  active: !isCancelled && !checkedIn && !!paid,     channel: 'whatsapp', who: 'host',    detail: 'Driver dispatched, key handover photo expected' },
      { id: 'reviewed',   label: 'Reviewed',    ts: fmtTs(reviewed),   rel: rel(reviewed),   median: 4320, done: !!reviewed,   active: !isCancelled && !reviewed && !!checkedIn, channel: 'email',    who: 'guest',   detail: 'Review request 24h post-checkout' },
    ],
  };
}

// Section 4 — Admin Command Center (Pulse / Timeline / Stuck+Anomaly)
//
// Sidebar layout mirrors the operational /admin page exactly: an 18-
// section nav rail on the left, content on the right. Sections we have
// native React views for render those (Pulse, Booking, Stuck, etc.).
// Sections we haven't built yet render the operational page in an
// iframe with ?embed=1 — the operational page hides its own sidebar
// and topbar so it slots seamlessly into the new shell. Same APIs,
// same actions, same buttons — just inside the editorial preview.
function AdminCommandCenter() {
  // v132 — Bug D-07: same deep-link routing as HostDashboard. /admin/:section
  // routes to here with ?section=X.
  const _initialSection = (function() {
    try {
      const p = new URLSearchParams(window.location.search);
      const s = p.get('section');
      if (s) return s;
      const m = window.location.pathname.match(/^\/admin\/([a-z-]+)/i);
      if (m && m[1]) return m[1];
    } catch (e) {}
    return 'pulse';
  })();
  const [section, setSection] = React.useState(_initialSection);
  // Re-render when live-data arrives
  const [, bump] = React.useState(0);
  React.useEffect(() => {
    const reload = () => bump(t => t + 1);
    window.addEventListener('tlb-live-loaded', reload);
    return () => window.removeEventListener('tlb-live-loaded', reload);
  }, []);
  // Listen for cross-section drill-ins (Pulse KPI tile click → switch section).
  React.useEffect(() => {
    const onJump = (e) => { if (e && e.detail) setSection(e.detail); };
    window.addEventListener('tlb-admin-tab', onJump);
    return () => window.removeEventListener('tlb-admin-tab', onJump);
  }, []);

  // Sidebar items — full feature parity with operational /admin sidebar.
  // mode='native' → render React component; mode='iframe' → embed
  // operational page at /admin-classic?tab={key}&embed=1.
  // v141.70 — Classic-dashboard retirement plan.
  // Goal: every classic feature reachable from inside /dashboards/admin
  // with no outbound visit to /admin-classic. Items whose native React
  // counterparts only ship the read-side of the feature get switched to
  // `mode: 'iframe'` so the full classic write-flow is available inside
  // this same shell. The classic page itself auto-redirects direct
  // visits back to /dashboards/admin (preserving ?embed=1 for iframes).
  //
  // Switched native → iframe in this revision:
  //   • partners        (was read-only; classic has add/edit modal)
  //   • proposals       (was read-only; classic has Generate-Proposal flow)
  //   • pmetrics        (was thin; classic has per-partner monthly log modal)
  //   • socialstrategy  (was partial; classic has DM templates + 30-day calendar)
  //   • creativeai      (was a single textbox; classic has 8-preset workflow)
  //
  // Kept native: pulse, timeline, stuck, hosts, properties, hostops,
  //   bookings, inbox, concierge, disputes, health, finance, payouts,
  //   efaktur, seo, otahub, otachannels, marketing, orchestration
  //   (all functionally complete or expanded beyond classic already).
  const navItems = [
    // Main — daily-ops surface; operator's morning-check items live here.
    { key: 'pulse',          label: 'Overview',           group: 'Main',                icon: 'activity',    mode: 'native' },
    { key: 'aetheria',       label: 'Aetheria · co-pilot',group: 'Main',                icon: 'sparkles',    mode: 'native' },
    // v141.232 — Phase 0 + Phase 1 surfaces promoted to top-level Main
    // (were buried inside Site Manager cards). The operator opens these
    // every morning so they need to be one click from the dashboard
    // landing, not nested behind another nav.
    { key: 'inquiries',      label: 'Inquiries',          group: 'Main',                icon: 'mail',        mode: 'native' },
    { key: 'traffic',        label: 'Traffic · GA4 + GSC',group: 'Main',                icon: 'barChart',    mode: 'native' },
    // v141.244 — Aetheria Phase 2: weekly intelligence digest. Lives
    // in Main because the operator reads it every Monday morning
    // alongside Traffic + Inquiries.
    { key: 'weekly_digest',  label: 'Weekly digest',      group: 'Main',                icon: 'sparkles',    mode: 'native' },
    // v141.255 — Aetheria Phase 4: anomaly alerts. Real-time
    // observation layer — operator opens this when the dashboard
    // hints something changed.
    { key: 'alerts',         label: 'Alerts',             group: 'Main',                icon: 'alert',       mode: 'native' },
    // v141.257 — Aetheria Phase 5: content opportunities. Weekly
    // AI-clustered blog post backlog from GSC opportunity queries.
    { key: 'content_opps',   label: 'Content backlog',    group: 'Main',                icon: 'fileText',    mode: 'native' },
    // v141.258 — Aetheria Phase 5: booking attribution ROI per channel.
    { key: 'attribution',    label: 'Attribution',        group: 'Main',                icon: 'dollar',      mode: 'native' },
    // v141.263 — Aetheria Phase 5: blog system. Live at /blog/<slug>.
    { key: 'blog',           label: 'Journal',            group: 'Main',                icon: 'fileText',    mode: 'native' },
    { key: 'timeline',       label: 'Booking timeline',   group: 'Main',                icon: 'calendar',    mode: 'native' },
    { key: 'stuck',          label: 'Stuck + anomalies',  group: 'Main',                icon: 'alert',       mode: 'native' },
    // Operations
    { key: 'hosts',          label: 'Hosts',              group: 'Operations',          icon: 'users',       mode: 'native' },
    { key: 'properties',     label: 'Properties',         group: 'Operations',          icon: 'building',    mode: 'native' },
    { key: 'hostops',        label: 'Host onboarding',    group: 'Operations',          icon: 'checkCircle', mode: 'native' },
    { key: 'bookings',       label: 'Bookings · all',     group: 'Operations',          icon: 'list',        mode: 'native' },
    { key: 'inbox',          label: 'Inbox · chat',       group: 'Operations',          icon: 'mail',        mode: 'native' },
    { key: 'concierge',      label: 'Concierge ops',      group: 'Operations',          icon: 'bell',        mode: 'native' },
    { key: 'disputes',       label: 'Damage claims',      group: 'Operations',          icon: 'shield',      mode: 'native' },
    { key: 'sitemanager',    label: 'Site manager',       group: 'Operations',          icon: 'layout',      mode: 'iframe' },
    { key: 'rateparity',     label: 'Rate parity',        group: 'Operations',          icon: 'dollar',      mode: 'native' },
    // v141.322 — Rate Accuracy Audit dashboard (per-villa severity, bulk re-probe)
    { key: 'rateaudit',      label: 'Rate audit',         group: 'Operations',          icon: 'shield',      mode: 'iframe', externalUrl: '/admin-rate-audit' },
    { key: 'health',         label: 'Villa Health',       group: 'Operations',          icon: 'activity',    mode: 'native' },
    // Finance & Partners
    { key: 'finance',        label: 'Finance',            group: 'Finance · Partners',  icon: 'dollar',      mode: 'native' },
    { key: 'payouts',        label: 'Payouts override',   group: 'Finance · Partners',  icon: 'send',        mode: 'native' },
    { key: 'efaktur',        label: 'e-Faktur',           group: 'Finance · Partners',  icon: 'fileText',    mode: 'native' },
    // v129 — SEO control panel (moved from host). Sitewide indexability,
    // per-villa scoring, meta overrides, domain migration tools.
    { key: 'seo',            label: 'SEO control',        group: 'Marketing · Brand',   icon: 'search',      mode: 'native' },
    { key: 'partners',       label: 'Partners',           group: 'Finance · Partners',  icon: 'link',        mode: 'iframe' },
    { key: 'proposals',      label: 'Proposals',          group: 'Finance · Partners',  icon: 'fileText',    mode: 'iframe' },
    { key: 'pcontent',       label: 'Personalised content', group: 'Finance · Partners', icon: 'sparkles',   mode: 'iframe' },
    { key: 'pmetrics',       label: 'Product metrics',    group: 'Finance · Partners',  icon: 'barChart',    mode: 'iframe' },
    // Marketing & Brand
    { key: 'marketing',      label: 'Marketing & Sales',  group: 'Marketing · Brand',   icon: 'target',      mode: 'native' },
    { key: 'orchestration',  label: 'Orchestration',      group: 'Marketing · Brand',   icon: 'share',       mode: 'native' },
    { key: 'marketingai',    label: 'Marketing AI',       group: 'Marketing · Brand',   icon: 'cpu',         mode: 'iframe' },
    // v141.138 — Renamed per operator feedback. TLB doesn't actually
    // integrate with OTAs (Airbnb/Booking/Expedia); these pages describe
    // partnerships with channel-partner HOST agencies (TLN, Nakula, TTD,
    // Elite Havens) who manage their own villa inventory. Calling them
    // "OTA" was misleading both internally and to hosts reading the UI.
    { key: 'otahub',         label: 'Host Hub',           group: 'Marketing · Brand',   icon: 'globe',       mode: 'native' },
    { key: 'otachannels',    label: 'Host Channels',      group: 'Marketing · Brand',   icon: 'link',        mode: 'native' },
    { key: 'brand',          label: 'Brand',              group: 'Marketing · Brand',   icon: 'palette',     mode: 'iframe' },
    { key: 'virallab',       label: 'Viral lab',          group: 'Marketing · Brand',   icon: 'trending',    mode: 'iframe' },
    { key: 'socialstrategy', label: 'Social strategy',    group: 'Marketing · Brand',   icon: 'message',     mode: 'iframe' },
    { key: 'creativeai',     label: 'Creative AI',        group: 'Marketing · Brand',   icon: 'wand',        mode: 'iframe' },
  ];

  const liveStatus = window.TLB_LIVE_STATUS && window.TLB_LIVE_STATUS.admin;
  const needsAuth = liveStatus && liveStatus.needsAuth && !liveStatus.live;
  const activeItem = navItems.find(n => n.key === section) || navItems[0];

  // Render the right-hand content for the active section.
  // Priority: 1) native sections from dashboards-native-sections.jsx
  //           2) built-in admin natives (pulse/timeline/stuck/health/etc)
  //           3) iframe fallback for sections not yet ported
  function renderSection() {
    // Native sections registered via dashboards-native-sections.jsx —
    // hosts/properties/bookings/finance/sitemanager all wired here.
    const NATIVE = (window.TLB_NATIVE_SECTIONS && window.TLB_NATIVE_SECTIONS.admin) || {};
    if (NATIVE[section]) {
      const Cmp = NATIVE[section];
      return <Cmp />;
    }
    if (activeItem.mode === 'iframe') {
      // v141.322 — items can opt out of /admin-classic embedding via
      // `externalUrl` (e.g., rate-audit lives at its own /admin-rate-audit
      // dashboard, separate from the classic React app).
      const src = activeItem.externalUrl
        ? activeItem.externalUrl
        : ('/admin-classic?tab=' + activeItem.key + '&embed=1');
      return <IframeView src={src} title={activeItem.label} />;
    }
    switch (section) {
      case 'pulse':         return <AdminPulse />;
      case 'aetheria':      return <AdminAetheria />;
      case 'timeline':      return <AdminTimeline />;
      case 'stuck':         return <AdminStuck />;
      case 'marketing':     return <AdminMarketingSales />;
      case 'orchestration': return <AdminOrchestration />;
      case 'health':        return <AdminHealth />;
      default:              return <AdminPulse />;
    }
  }

  return (
    <div style={{ display: 'flex', minHeight: '100vh', width: '100%' }}>
      <DashSidebar
        items={navItems}
        active={section}
        onChange={setSection}
        brand="TLB"
        sub="Admin"
        dark={true}
        footer={
          <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
              <Sans size={11} color="rgba(240,240,240,0.85)" weight={500} style={{ fontFamily: F_TLB.ui }}>Command Center</Sans>
              <Sans size={10} color="rgba(240,240,240,0.5)" style={{ fontFamily: F_TLB.display, fontStyle: 'italic', fontSize: 11 }}>Air-traffic control.</Sans>
            </div>
            <button
              type="button"
              onClick={() => {
                // v141.189 — Was gated on native confirm(); Chrome
                // silently returns false from confirm() once the user
                // ticks "prevent more dialogs" / in embedded contexts,
                // so the click did nothing ("Sign Out tidak merespon").
                // tlbConfirm renders its own overlay and always works.
                window.tlbConfirm('Sign out of the admin dashboard?', { danger: true, confirmText: 'Sign out' }).then((ok) => {
                  if (!ok) return;
                  try {
                    ['tlb_token','tlb_admin_token','tlb_host_token','tlb_session','tlb_user']
                      .forEach(k => localStorage.removeItem(k));
                    document.cookie = 'tlb_session=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
                  } catch (e) {}
                  // /login is the sole sign-in form (/admin rewrites back
                  // to /dashboards/admin). replace() so Back doesn't
                  // return to the signed-out dashboard.
                  location.replace('/login');
                });
              }}
              style={{
                width: '100%', padding: '8px 12px', borderRadius: 8,
                border: '1px solid rgba(240,240,240,0.10)', background: 'transparent',
                color: 'rgba(240,240,240,0.65)', fontSize: 11, fontWeight: 500,
                cursor: 'pointer', letterSpacing: '0.04em', fontFamily: F_TLB.ui,
                display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
                transition: 'background .15s, color .15s, border-color .15s',
              }}
              onMouseEnter={e => { e.currentTarget.style.background = 'rgba(240,240,240,0.06)'; e.currentTarget.style.color = '#fff'; e.currentTarget.style.borderColor = 'rgba(240,240,240,0.20)'; }}
              onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'rgba(240,240,240,0.65)'; e.currentTarget.style.borderColor = 'rgba(240,240,240,0.10)'; }}
              title="Clear session and return to sign-in screen"
            >
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" style={{ width: 13, height: 13 }} aria-hidden="true">
                <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
                <polyline points="16 17 21 12 16 7" />
                <line x1="21" y1="12" x2="9" y2="12" />
              </svg>
              Sign out
            </button>
          </div>
        }
      />
      <main style={{ flex: 1, padding: dashPad(), background: CC.offWhite, minWidth: 0 }}>
        {/* v141.167 re-skin Stage 5 — global workspace Topbar (delivered
            redesign). Self-contained, sticky, above every section. */}
        <DashTopbar role="Admin" />
        {/* v141.137 — Shell page-header removed. Every section since Phase 2
            renders its OWN editorial header (eyebrow + Display title +
            subtitle + action CTAs) to match the v141.123 mockup. The
            shell-level italic title here was duplicating the section
            title (visible as "Stuck + anomalies." appearing twice).
            Sections that don't render their own header (iframe-embedded
            classic pages) get their title from the iframe content.
            Live status pill kept as a thin sticky strip. */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginBottom: dashGap() }}>
          <Eyebrow>{activeItem.group || 'Admin'}</Eyebrow>
          <LivePill which="admin" />
        </div>
        {needsAuth && (
          <Panel pad={18} style={{ marginBottom: dashGap(), borderColor: '#d4a04a', background: '#fbf6ec' }}>
            <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
              <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
                <span style={{ width: 8, height: 8, borderRadius: 99, background: '#d4a04a', flexShrink: 0 }} />
                <div>
                  <Sans size={11} weight={600} ls={0.14} upper color={CC.deep}>SIGN IN TO UNLOCK LIVE ADMIN DATA</Sans>
                  <Sans size={12} color={CC.mid} style={{ marginTop: 4, fontFamily: F_TLB.display, fontStyle: 'italic', fontSize: 14 }}>
                    Public sources (152 villas · live offers · system health) are already real. Pulse tiles, funnel, stream, stuck-list, and the embedded operational sections need an admin session.
                  </Sans>
                </div>
              </div>
              <div style={{ display: 'flex', gap: 8 }}>
                {/* v141.72 — Was `/admin` which rewrites back to this same
                    dashboard and never shows a sign-in form. Now points
                    directly at /login (the only auth form in the
                    codebase — admin-classic itself only shows a stub
                    that links here). */}
                <Button size="sm" variant="secondary" onClick={() => window.open('/login', '_blank')}>Open sign-in →</Button>
                <Button size="sm" onClick={() => location.reload()}>I signed in · reload</Button>
              </div>
            </div>
          </Panel>
        )}
        {/* v141.137 — Wrap each section in an ErrorBoundary keyed by
            section name. If a section's render throws (e.g. missing
            primitive, undefined data ref), users see an actionable
            "section error" panel instead of a blank screen, and other
            sections remain accessible via sidebar nav. The key prop
            forces a fresh boundary on section change. */}
        <SectionErrorBoundary key={section}>
          {renderSection()}
        </SectionErrorBoundary>
      </main>
    </div>
  );
}

// ─── ADMIN · ALL SECTIONS — feature-parity grid with operational ──
function AdminAllSections() {
  const sections = D.admin.sections || [];
  return (
    <div>
      <div style={{ marginBottom: dashGap() }}>
        <Eyebrow>FEATURE PARITY · 18 OPERATIONAL SECTIONS</Eyebrow>
        <Display size={28} italic weight={400} ls={-0.02} style={{ marginTop: 8 }}>The full operations atlas.</Display>
        <Sans size={14} color={CC.mid} style={{ marginTop: 8, fontFamily: F_TLB.display, fontStyle: 'italic', fontSize: 17, maxWidth: 720 }}>The six tabs above are the air-traffic-control surface. The grid below opens every operational page at <em>/admin</em>, all already wearing the editorial design.</Sans>
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: dashGap() }}>
        {sections.map(s => (
          <div key={s.key} style={{ padding: 28, border: `1px solid ${CC.border}`, borderRadius: 18, background: CC.white, cursor: 'pointer', transition: 'all .25s ease' }}
               onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 12px 36px rgba(20,20,20,0.06)'; e.currentTarget.style.borderColor = CC.deep; }}
               onMouseLeave={e => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; e.currentTarget.style.borderColor = CC.border; }}
               onClick={() => window.TLB_NAV && TLB_NAV.go(s.url)}>
            <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 10 }}>
              <Sans size={9} weight={600} ls={0.18} upper color={CC.mid}>{s.num}</Sans>
              <Sans size={11} color={CC.mid} style={{ textDecoration: 'underline', textUnderlineOffset: 4 }}>Open →</Sans>
            </div>
            <Display size={22} italic weight={400} ls={-0.01}>{s.name}.</Display>
            <Sans size={13} color={CC.deep} style={{ marginTop: 8, lineHeight: 1.6 }}>{s.desc}</Sans>
          </div>
        ))}
      </div>
    </div>
  );
}

// ── Aetheria markdown renderer (v141.243) ───────────────────────────
// Aetheria emits markdown — **bold**, bullet lists, numbered lists,
// `inline code`, paragraphs. Pre-v141.243 the UI rendered raw text
// (whitespace: pre-wrap) so the operator saw literal asterisks. This
// renderer is intentionally tiny — no external markdown lib needed.
// Handles: **bold**, *italic*, `code`, "- " / "• " / "* " bullets,
// "1. " numbered lists, blank-line paragraph breaks, single-newline
// soft breaks.
// ────────────────────────────────────────────────────────────────────
function renderInlineMarkdown(text) {
  const out = [];
  let i = 0;
  const len = text.length;
  let key = 0;
  let plainStart = 0;
  const flushPlain = (j) => {
    if (j > plainStart) out.push(text.slice(plainStart, j));
  };
  while (i < len) {
    if (text[i] === '*' && text[i + 1] === '*') {
      const end = text.indexOf('**', i + 2);
      if (end > i + 2) {
        flushPlain(i);
        out.push(<strong key={'b' + (key++)}>{text.slice(i + 2, end)}</strong>);
        i = end + 2; plainStart = i; continue;
      }
    }
    if (text[i] === '`') {
      const end = text.indexOf('`', i + 1);
      if (end > i + 1) {
        flushPlain(i);
        out.push(<code key={'c' + (key++)} style={{ background: '#f3efe6', padding: '1px 6px', borderRadius: 4, fontFamily: 'ui-monospace,SFMono-Regular,Menlo,monospace', fontSize: '0.9em' }}>{text.slice(i + 1, end)}</code>);
        i = end + 1; plainStart = i; continue;
      }
    }
    // single-* italic — require non-space neighbours, not a list-bullet
    if (text[i] === '*' && text[i + 1] !== '*' && text[i + 1] !== ' ' && (i === 0 || /\s/.test(text[i - 1]))) {
      const end = text.indexOf('*', i + 1);
      if (end > i + 1 && text[end - 1] !== ' ' && text[end + 1] !== '*') {
        flushPlain(i);
        out.push(<em key={'i' + (key++)}>{text.slice(i + 1, end)}</em>);
        i = end + 1; plainStart = i; continue;
      }
    }
    i++;
  }
  flushPlain(len);
  return out;
}

function renderMarkdown(text) {
  if (!text) return null;
  const lines = String(text).split('\n');
  const blocks = [];
  let cur = null;
  const flush = () => { if (cur) { blocks.push(cur); cur = null; } };
  for (const line of lines) {
    const bullet  = line.match(/^\s*[-•*]\s+(.+)$/);
    const numbered = line.match(/^\s*(\d+)\.\s+(.+)$/);
    if (bullet) {
      if (!cur || cur.type !== 'ul') { flush(); cur = { type: 'ul', items: [] }; }
      cur.items.push(bullet[1]);
    } else if (numbered) {
      if (!cur || cur.type !== 'ol') { flush(); cur = { type: 'ol', items: [] }; }
      cur.items.push(numbered[2]);
    } else if (line.trim() === '') {
      flush();
    } else {
      if (!cur || cur.type !== 'p') { flush(); cur = { type: 'p', lines: [] }; }
      cur.lines.push(line);
    }
  }
  flush();
  return blocks.map((b, i) => {
    if (b.type === 'ul') {
      return (
        <ul key={i} style={{ margin: '0.5em 0', paddingLeft: 22 }}>
          {b.items.map((item, k) => (
            <li key={k} style={{ marginBottom: 4 }}>{renderInlineMarkdown(item)}</li>
          ))}
        </ul>
      );
    }
    if (b.type === 'ol') {
      return (
        <ol key={i} style={{ margin: '0.5em 0', paddingLeft: 22 }}>
          {b.items.map((item, k) => (
            <li key={k} style={{ marginBottom: 4 }}>{renderInlineMarkdown(item)}</li>
          ))}
        </ol>
      );
    }
    const out = [];
    b.lines.forEach((l, k) => {
      if (k > 0) out.push(<br key={'br' + k} />);
      out.push(<React.Fragment key={'l' + k}>{renderInlineMarkdown(l)}</React.Fragment>);
    });
    return (
      <p key={i} style={{ margin: i === 0 ? '0 0 0.7em' : '0.7em 0' }}>{out}</p>
    );
  });
}

// ── AdminAetheria (v141.115 → v141.243) ─────────────────────────────
// Operator chat with the role-scoped Aetheria assistant. Same engine as
// the public Trip Designer (tool-using Claude), but bound to the admin/
// owner role boundary: rate-parity overview, host summaries, arrivals
// today, recent bookings, owner-only financial summary. Server-side
// /api/ai/aetheria-admin handles auth + role checks + audit logging.
//
// v141.243 — Two operator complaints fixed:
//   (1) Markdown ** ** showed literally → now renders via renderMarkdown()
//   (2) Chat history lost on navigation → now persisted to localStorage
//       (last 60 turns) and restored on mount. "Clear session" in the
//       History modal wipes both state and localStorage.
const AETHERIA_STORAGE_KEY = 'tlb_aetheria_messages_v1';
const AETHERIA_PERSIST_MAX = 60;

function AdminAetheria() {
  // v141.243 — restore from localStorage on mount (lazy initializer)
  const [messages, setMessages] = React.useState(() => {
    try {
      const raw = localStorage.getItem(AETHERIA_STORAGE_KEY);
      if (!raw) return [];
      const parsed = JSON.parse(raw);
      if (Array.isArray(parsed)) return parsed.slice(-AETHERIA_PERSIST_MAX);
    } catch { /* parse error / disabled storage */ }
    return [];
  });   // [{ role, content, tool_trail?, pending_actions? }]
  const [input, setInput]       = React.useState('');
  const [busy, setBusy]         = React.useState(false);
  const [role, setRole]         = React.useState(null);

  // v141.243 — Persist to localStorage whenever messages change. Kept
  // small (last 60 turns) so quota stays healthy + nothing sensitive
  // accumulates indefinitely.
  React.useEffect(() => {
    try {
      const toSave = messages.slice(-AETHERIA_PERSIST_MAX);
      localStorage.setItem(AETHERIA_STORAGE_KEY, JSON.stringify(toSave));
    } catch { /* quota or disabled */ }
  }, [messages]);
  // v141.231 — Live tick for pending-action expiry countdown. Only
  // ticks when there's at least one un-resolved pending action,
  // otherwise we don't waste re-renders.
  const [now, setNow]           = React.useState(Date.now());
  const inputRef = React.useRef(null);
  const scrollRef = React.useRef(null);

  // v141.247 — Read prefill from sessionStorage on mount. Set by
  // <AdminWeeklyDigest> "Ask Aetheria" buttons (and other call-sites
  // in future). Consumed ONCE — cleared from storage so a refresh
  // doesn't re-fill the input.
  React.useEffect(() => {
    try {
      const pre = sessionStorage.getItem('tlb_aetheria_prefill');
      if (pre) {
        setInput(pre);
        sessionStorage.removeItem('tlb_aetheria_prefill');
        // Focus + place cursor at the end so the operator can review +
        // edit before sending.
        setTimeout(() => {
          if (inputRef.current) {
            inputRef.current.focus();
            try { inputRef.current.setSelectionRange(pre.length, pre.length); } catch {}
          }
        }, 50);
      }
    } catch {}
  }, []);

  React.useEffect(() => {
    if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
  }, [messages, busy]);

  React.useEffect(() => {
    const hasOpen = messages.some(m =>
      Array.isArray(m.pending_actions) &&
      m.pending_actions.some(p => !p._resolved && Date.parse(p.expires_at) > Date.now())
    );
    if (!hasOpen) return;
    const t = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(t);
  }, [messages]);

  const adminToken = () => {
    try { return localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; }
    catch { return ''; }
  };

  async function send(text) {
    const q = (text || input).trim();
    if (!q || busy) return;
    const next = [...messages, { role: 'user', content: q }];
    setMessages(next);
    setInput('');
    setBusy(true);
    try {
      const token = adminToken();
      const r = await fetch('/api/ai/aetheria-admin', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
        body: JSON.stringify({ messages: next.map(m => ({ role: m.role, content: m.content })) }),
      });
      const j = await r.json();
      if (!r.ok || !j.ok) {
        setMessages([...next, { role: 'assistant', content: j.error || 'Aetheria error', error: true }]);
      } else {
        setRole(j.role || null);
        setMessages([...next, {
          role: 'assistant',
          content: j.text || '(empty reply)',
          tool_trail: j.tool_trail || [],
          pending_actions: Array.isArray(j.pending_actions) ? j.pending_actions : [],
        }]);
      }
    } catch (e) {
      setMessages([...next, { role: 'assistant', content: 'Network error reaching Aetheria.', error: true }]);
    } finally {
      setBusy(false);
      if (inputRef.current) inputRef.current.focus();
    }
  }

  const QUICK_PROMPTS = [
    'Rate parity status now?',
    'Any drift critical villas?',
    'Arrivals today',
    'Hosts with stale rates (>30d)',
    'Recent bookings — last 7 days',
    'Villas without source URL',
  ];

  // v141.231 — Phase 3: confirm/cancel a pending Aetheria action.
  // Mutates messages[].pending_actions[k]._resolved + ._outcome so
  // the card flips to "Executed" / "Cancelled" / "Error" + the
  // Confirm/Cancel buttons disappear.
  async function actOnPending(messageIdx, actionIdx, mode /* 'confirm' | 'cancel' */) {
    const m = messages[messageIdx];
    if (!m || !m.pending_actions || !m.pending_actions[actionIdx]) return;
    const action = m.pending_actions[actionIdx];
    if (action._resolved) return;
    // optimistic: mark working
    const setActionField = (patch) => setMessages(curr => {
      const copy = curr.slice();
      const msg = { ...copy[messageIdx] };
      const pas = (msg.pending_actions || []).slice();
      pas[actionIdx] = { ...pas[actionIdx], ...patch };
      msg.pending_actions = pas;
      copy[messageIdx] = msg;
      return copy;
    });
    setActionField({ _working: true });
    try {
      const token = adminToken();
      const r = await fetch('/api/ai/aetheria-confirm', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
        body: JSON.stringify({ action_id: action.action_id, [mode]: true }),
      });
      const j = await r.json();
      if (!j.ok) {
        setActionField({ _working: false, _resolved: true, _outcome: 'error', _error: j.error || j.detail || 'failed' });
      } else if (j.cancelled) {
        setActionField({ _working: false, _resolved: true, _outcome: 'cancelled' });
      } else if (j.executed) {
        setActionField({ _working: false, _resolved: true, _outcome: 'executed', _result: j.result || null });
      } else {
        setActionField({ _working: false, _resolved: true, _outcome: 'unknown' });
      }
    } catch (e) {
      setActionField({ _working: false, _resolved: true, _outcome: 'error', _error: String(e.message || e) });
    }
  }

  // Pretty-print countdown to expiry.
  function fmtRemaining(ms) {
    if (ms <= 0) return 'expired';
    const s = Math.floor(ms / 1000);
    const mm = Math.floor(s / 60);
    const ss = s % 60;
    return mm + 'm ' + (ss < 10 ? '0' + ss : ss) + 's';
  }

  // v141.127 — Mockup port. Visual upgrade: gold-accent panel + italic
  // display-font replies + gold avatar circle. Engine + role-gating
  // preserved (server-side /api/ai/aetheria-admin already does role
  // checks + audit logging).
  const [showHistoryModal, setShowHistoryModal] = React.useState(false);
  const [showPermsModal, setShowPermsModal] = React.useState(false);

  return (
    <div>
      {/* Header */}
      <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 18, flexWrap: 'wrap' }}>
        <div>
          <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 8 }}>
            v141.115 · role-scoped {role ? `· you are ${role}` : ''}
          </Sans>
          <Display size={36} weight={500} ls={-0.02}>Aetheria</Display>
          <Sans size={13} color={CC.mid} style={{ marginTop: 6, maxWidth: 640 }}>
            A private secretary's voice. Ask in plain language — Aetheria reads the bookings, the rate parity, the host roster, and decides which tools to call.
          </Sans>
        </div>
        <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
          <button onClick={() => setShowHistoryModal(true)} style={{ padding: '10px 18px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            History
          </button>
          <button onClick={() => setShowPermsModal(true)} style={{ padding: '10px 18px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            Permissions
          </button>
        </div>
      </div>

      <Panel pad={0} accent style={{ overflow: 'hidden' }}>
        {/* Quick prompts */}
        <div style={{ padding: '28px 36px 22px', borderBottom: `1px solid ${CC.border}` }}>
          <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>Try asking</Sans>
          <div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 14 }}>
            {QUICK_PROMPTS.map(p => (
              <button key={p} onClick={() => send(p)} disabled={busy}
                style={{
                  padding: '10px 16px', borderRadius: 100, cursor: busy ? 'wait' : 'pointer',
                  background: CC.white, color: CC.deep, border: '1px solid ' + CC.border,
                  fontFamily: F_TLB.ui, fontSize: 12, letterSpacing: '0.005em',
                  transition: 'background .15s, border-color .15s',
                  opacity: busy ? 0.5 : 1,
                }}
                onMouseEnter={e => { if (!busy) { e.currentTarget.style.borderColor = '#9a7d3f'; e.currentTarget.style.background = '#fbf8f0'; } }}
                onMouseLeave={e => { e.currentTarget.style.borderColor = CC.border; e.currentTarget.style.background = CC.white; }}
              >{p}</button>
            ))}
          </div>
        </div>

        {/* Transcript */}
        <div ref={scrollRef} style={{
          padding: '40px 60px 28px', minHeight: 340, maxHeight: 540,
          overflowY: 'auto', background: CC.white,
          display: 'flex', flexDirection: 'column', gap: 32,
        }}>
          {messages.length === 0 && !busy && (
            <div style={{ textAlign: 'center', padding: '50px 0' }}>
              <Display size={38} italic weight={400} ls={-0.025} style={{ lineHeight: 1.1 }}>
                Aetheria.
              </Display>
              <Sans size={13} color={CC.mid} style={{
                marginTop: 14, maxWidth: 480, margin: '14px auto 0', lineHeight: 1.7,
              }}>
                Type a question or pick one above. Every query is audit-logged, every tool call is footnoted.
                {role === 'owner' ? ' Owner-mode also unlocks financial + contract lookups.' : ''}
              </Sans>
            </div>
          )}

          {messages.map((m, i) => {
            if (m.role === 'user') {
              return (
                <div key={i} style={{ display: 'flex', justifyContent: 'flex-end' }}>
                  <div style={{
                    maxWidth: '68%', background: CC.ink, color: CC.white,
                    padding: '14px 20px', borderRadius: 20, borderTopRightRadius: 6,
                    fontFamily: F_TLB.ui, fontSize: 14, lineHeight: 1.6, whiteSpace: 'pre-wrap',
                  }}>
                    {m.content}
                  </div>
                </div>
              );
            }
            return (
              <div key={i} style={{ display: 'flex', gap: 18, alignItems: 'flex-start' }}>
                <div style={{
                  width: 36, height: 36, borderRadius: 99, background: '#9a7d3f', color: CC.white,
                  display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                  fontFamily: F_TLB.display, fontStyle: 'italic', fontSize: 16, flexShrink: 0,
                }}>A</div>
                <div style={{ flex: 1, minWidth: 0 }}>
                  <Sans size={10.5} weight={600} ls={0.20} upper color={CC.mid} style={{ marginBottom: 10 }}>
                    Aetheria
                  </Sans>
                  <div style={{
                    // v141.237 — Body copy mirrors Claude Chat: clean
                    // sans-serif, normal weight (no italic), 15px / 1.7
                    // line-height for comfortable reading.
                    // v141.243 — Now passes through renderMarkdown() so
                    // **bold**, lists, and `code` render properly. The
                    // markdown renderer outputs <p>/<ul>/<ol> blocks
                    // with their own spacing, so whiteSpace:pre-wrap is
                    // dropped (the renderer handles soft breaks via <br/>).
                    fontFamily: F_TLB.ui, fontSize: 15,
                    color: m.error ? '#9a1c1c' : CC.ink, lineHeight: 1.7,
                    maxWidth: 720,
                  }}>
                    {m.error ? m.content : renderMarkdown(m.content)}
                  </div>
                  {Array.isArray(m.tool_trail) && m.tool_trail.length > 0 && (
                    <div style={{
                      marginTop: 14, fontFamily: F_TLB.ui, fontSize: 11,
                      color: CC.mid, letterSpacing: '0.01em',
                    }}>
                      Tools used:{' '}
                      {m.tool_trail.map((t, ti) => (
                        <span key={ti}>
                          <span style={{ color: '#9a7d3f' }}>{t.name}{t.ok ? '' : '⚠'}</span>
                          {ti < m.tool_trail.length - 1 ? ' · ' : ''}
                        </span>
                      ))}
                    </div>
                  )}
                  {/* v141.231 — Phase 3 pending action cards. One per
                      action Aetheria proposed in this turn. Until the
                      operator clicks Confirm, NOTHING has changed in
                      the DB (server enforces this — see /api/ai/lib/
                      aetheria-actions.js executePending). */}
                  {Array.isArray(m.pending_actions) && m.pending_actions.map((a, ai) => {
                    const expMs = Math.max(0, Date.parse(a.expires_at) - now);
                    const isExpired = expMs <= 0 && !a._resolved;
                    return (
                      <div key={a.action_id} style={{
                        marginTop: 14, padding: 14,
                        background: a._resolved && a._outcome === 'executed' ? '#f0f7f0'
                                  : a._resolved && a._outcome === 'cancelled' ? '#f6f5f3'
                                  : a._resolved && a._outcome === 'error' ? '#fdf2f2'
                                  : isExpired ? '#f6f5f3'
                                  : '#fffaeb',
                        border: '1px solid ' + (
                          a._resolved && a._outcome === 'executed' ? '#aacba8'
                          : a._resolved && a._outcome === 'error' ? '#d9a3a3'
                          : '#e8d9a3'
                        ),
                        borderRadius: 8,
                      }}>
                        <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap', marginBottom: 6 }}>
                          <span style={{ fontFamily: F_TLB.ui, fontSize: 10, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: '#8a5a00' }}>
                            {a._resolved
                              ? (a._outcome === 'executed' ? 'Executed' : a._outcome === 'cancelled' ? 'Cancelled' : a._outcome === 'error' ? 'Error' : 'Resolved')
                              : isExpired ? 'Expired' : 'Pending confirmation'}
                          </span>
                          <span style={{ fontFamily: F_TLB.ui, fontSize: 10, color: CC.mid }}>
                            {a.action_type}
                          </span>
                          {!a._resolved && !isExpired && (
                            <span style={{ fontFamily: F_TLB.ui, fontSize: 10, color: CC.mid, marginLeft: 'auto' }}>
                              Expires in {fmtRemaining(expMs)}
                            </span>
                          )}
                        </div>
                        <div style={{ fontFamily: F_TLB.ui, fontSize: 13, lineHeight: 1.55, color: CC.deep, marginBottom: 8 }}>
                          {a.summary}
                        </div>
                        {a.params && a.params.host_response && (
                          <div style={{ fontFamily: F_TLB.ui, fontSize: 12, lineHeight: 1.6, color: CC.deep, padding: 10, background: '#fff', border: '1px solid ' + CC.hairline, borderRadius: 6, marginBottom: 8, whiteSpace: 'pre-wrap' }}>
                            {a.params.host_response}
                          </div>
                        )}
                        {/*
                          v141.251 — SEO/CMS field preview. Operator sees
                          the EXACT text + char counts BEFORE clicking
                          Confirm so we never approve a black-box write.
                          Auto-renders for any action_type matching
                          seo_update_* or cms_update_page; for other
                          action types this block is invisible.
                        */}
                        {a.params && a.params.fields && typeof a.params.fields === 'object' &&
                         /^(seo_update_|cms_update_)/.test(String(a.action_type || '')) && (
                          <div style={{ marginBottom: 8 }}>
                            {a.action_type === 'seo_update_property' && a.params.property_id && (
                              <div style={{ fontFamily: F_TLB.ui, fontSize: 10, color: CC.mid, marginBottom: 6 }}>
                                Target property: <code style={{ background: '#fff', padding: '1px 5px', borderRadius: 3 }}>{a.params.property_id}</code>
                              </div>
                            )}
                            {a.action_type === 'cms_update_page' && a.params.page_slug && (
                              <div style={{ fontFamily: F_TLB.ui, fontSize: 10, color: CC.mid, marginBottom: 6 }}>
                                Target page: <code style={{ background: '#fff', padding: '1px 5px', borderRadius: 3 }}>/{a.params.page_slug}</code>
                              </div>
                            )}
                            {Object.entries(a.params.fields).map(([fk, fv]) => {
                              const isString = typeof fv === 'string';
                              const len = isString ? fv.length : null;
                              // Length advisory thresholds (matches
                              // api/ai/lib/seo-rules.js LENGTH_BUDGETS)
                              const budget = fk === 'title'           ? { ideal: 60, soft: 65, hard: 70 }
                                           : fk === 'description'     ? { ideal: 160, soft: 170, hard: 180 }
                                           : fk === 'og_title'        ? { ideal: 90, soft: 100, hard: 120 }
                                           : fk === 'og_description'  ? { ideal: 200, soft: 220, hard: 250 }
                                           : null;
                              const lenTone = !budget || len == null ? null
                                            : len > budget.hard       ? '#a04545'   // red — will be rejected by validator
                                            : len > budget.soft       ? '#c2933b'   // amber — warning
                                            : len > budget.ideal      ? '#8a7a3f'   // soft amber
                                            : len > 0                 ? '#16613a'   // green — ideal
                                            : '#a04545';
                              return (
                                <div key={fk} style={{
                                  padding: 10, background: '#fff',
                                  border: '1px solid ' + CC.hairline, borderRadius: 6, marginBottom: 6,
                                }}>
                                  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, marginBottom: 4 }}>
                                    <span style={{ fontFamily: F_TLB.ui, fontSize: 10, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: CC.mid }}>
                                      {fk}
                                    </span>
                                    {len != null && (
                                      <span style={{ fontFamily: F_TLB.ui, fontSize: 10, fontWeight: 600, color: lenTone }}>
                                        {len} chars{budget ? ' · ideal ≤' + budget.ideal : ''}
                                      </span>
                                    )}
                                  </div>
                                  <div style={{
                                    fontFamily: F_TLB.ui, fontSize: 13, lineHeight: 1.55, color: CC.deep,
                                    whiteSpace: 'pre-wrap', wordBreak: 'break-word',
                                  }}>
                                    {isString ? fv : Array.isArray(fv) ? fv.join(', ') : JSON.stringify(fv)}
                                  </div>
                                </div>
                              );
                            })}
                          </div>
                        )}
                        {a._resolved && a._outcome === 'executed' && a._result && (
                          <div style={{ fontFamily: F_TLB.ui, fontSize: 11, color: '#1f6b3a', fontStyle: 'italic' }}>
                            ✓ Done · {JSON.stringify(a._result).slice(0, 140)}
                          </div>
                        )}
                        {a._resolved && a._outcome === 'error' && (
                          <div style={{ fontFamily: F_TLB.ui, fontSize: 11, color: '#9a1c1c', fontStyle: 'italic' }}>
                            Failed: {a._error || 'unknown error'}
                          </div>
                        )}
                        {!a._resolved && !isExpired && (
                          <div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
                            <button onClick={() => actOnPending(i, ai, 'confirm')} disabled={a._working}
                              style={{ padding: '8px 16px', borderRadius: 100, background: CC.ink, color: CC.white, border: 0, cursor: a._working ? 'wait' : 'pointer', fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 700, letterSpacing: 0.12 }}>
                              {a._working ? 'Working…' : 'Confirm'}
                            </button>
                            <button onClick={() => actOnPending(i, ai, 'cancel')} disabled={a._working}
                              style={{ padding: '8px 16px', borderRadius: 100, background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, cursor: a._working ? 'wait' : 'pointer', fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                              Cancel
                            </button>
                          </div>
                        )}
                      </div>
                    );
                  })}
                </div>
              </div>
            );
          })}

          {busy && (
            <div style={{ display: 'flex', gap: 18, alignItems: 'flex-start' }}>
              <div style={{
                width: 36, height: 36, borderRadius: 99, background: '#9a7d3f', color: CC.white,
                display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                fontFamily: F_TLB.display, fontStyle: 'italic', fontSize: 16, flexShrink: 0,
                animation: 'tlb-pulse 1.6s ease-in-out infinite',
              }}>A</div>
              <div style={{
                // v141.237 — Same Claude-Chat body style applied to the
                // "thinking" indicator so it doesn't jar against the
                // reply that follows it.
                fontFamily: F_TLB.ui, fontSize: 14, color: CC.mid,
                lineHeight: 1.6, paddingTop: 6,
              }}>
                Aetheria is checking the data…
              </div>
            </div>
          )}
        </div>

        {/* Composer */}
        <div style={{ padding: '20px 36px 28px', borderTop: `1px solid ${CC.border}`, background: CC.offWhite }}>
          {/*
            v141.247 — Switched from <input> (single-line, mangles
            multi-line prefills from "Ask Aetheria" buttons) to a
            <textarea> with auto-grow. Enter still submits; Shift+Enter
            inserts a newline so complex prompts can be edited inline.
          */}
          <div style={{
            display: 'flex', alignItems: 'flex-end', gap: 12, background: CC.white,
            border: `1px solid ${CC.border}`, borderRadius: 22, padding: '10px 10px 10px 22px',
          }}>
            <textarea
              ref={inputRef}
              value={input}
              onChange={e => {
                setInput(e.target.value);
                // Auto-grow up to ~6 rows
                const el = e.target;
                el.style.height = 'auto';
                el.style.height = Math.min(el.scrollHeight, 160) + 'px';
              }}
              onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
              placeholder={busy ? 'Aetheria is working…' : 'Ask Aetheria… (Shift+Enter for newline)'}
              disabled={busy}
              rows={1}
              style={{
                flex: 1, border: 0, outline: 0, fontFamily: F_TLB.ui, fontSize: 13.5,
                background: 'transparent', color: CC.ink, letterSpacing: '0.005em',
                resize: 'none', lineHeight: 1.5, padding: '6px 0', maxHeight: 160,
              }}
            />
            <button onClick={() => send()} disabled={busy || !input.trim()}
              style={{
                padding: '10px 22px', borderRadius: 100,
                background: busy || !input.trim() ? CC.mid : CC.ink, color: CC.white,
                border: 'none', fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600,
                cursor: busy || !input.trim() ? 'not-allowed' : 'pointer',
                flexShrink: 0,
              }}>Ask</button>
          </div>
          <Sans size={11} color={CC.mid} style={{ marginTop: 14, letterSpacing: '0.01em', lineHeight: 1.6 }}>
            Boundary: Aetheria sees only what your role can see. Owner tools — financial_summary, contract_lookup — refuse politely for {role ? role.toLowerCase() : 'non-owner'} accounts.
          </Sans>
        </div>
      </Panel>

      {/* Modals */}
      {showHistoryModal && <AetheriaHistoryModal messages={messages} onClose={() => setShowHistoryModal(false)} onClear={() => { setMessages([]); try { localStorage.removeItem(AETHERIA_STORAGE_KEY); } catch {} setShowHistoryModal(false); }} />}
      {showPermsModal && <AetheriaPermissionsModal role={role} onClose={() => setShowPermsModal(false)} />}
    </div>
  );
}

// History modal — shows the current session's messages with download/clear actions
function AetheriaHistoryModal({ messages, onClose, onClear }) {
  function downloadTranscript() {
    const lines = messages.map(m => {
      const who = m.role === 'user' ? 'You' : 'Aetheria';
      let line = `[${who}]\n${m.content}\n`;
      if (Array.isArray(m.tool_trail) && m.tool_trail.length) {
        line += `\nTools: ${m.tool_trail.map(t => t.name).join(', ')}\n`;
      }
      return line;
    }).join('\n---\n\n');
    const header = `Aetheria transcript — ${new Date().toISOString()}\n\n`;
    const blob = new Blob([header + lines], { type: 'text/plain;charset=utf-8;' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = `aetheria-transcript-${new Date().toISOString().slice(0, 10)}.txt`;
    document.body.appendChild(a); a.click(); document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }

  return (
    <div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(20,20,20,0.45)', zIndex: 1000, display: 'grid', placeItems: 'center', padding: 24 }}>
      <div onClick={(e) => e.stopPropagation()} style={{ background: CC.white, borderRadius: 18, padding: 32, maxWidth: 560, width: '100%', maxHeight: '80vh', overflow: 'auto', boxShadow: '0 12px 40px rgba(20,20,20,0.18)' }}>
        <Display size={26} italic weight={400}>History.</Display>
        <Sans size={12} color={CC.mid} style={{ marginTop: 6, marginBottom: 22 }}>
          This session has {messages.length} {messages.length === 1 ? 'message' : 'messages'}. Aetheria's full audit log is server-side and includes role, tools called, and timing for every query — accessible via /api/admin?action=audit.
        </Sans>
        {messages.length === 0 ? (
          <Sans size={13} color={CC.mid}>No messages in this session yet.</Sans>
        ) : (
          <div style={{ maxHeight: 280, overflowY: 'auto', padding: 14, border: `1px solid ${CC.border}`, borderRadius: 10, background: CC.offWhite, fontFamily: F_TLB.ui, fontSize: 12, lineHeight: 1.6 }}>
            {messages.map((m, i) => (
              <div key={i} style={{ marginBottom: 10 }}>
                <span style={{ fontWeight: 700, color: m.role === 'user' ? CC.ink : '#9a7d3f' }}>
                  {m.role === 'user' ? 'You' : 'Aetheria'}:
                </span>{' '}
                <span style={{ color: CC.deep }}>{m.content.slice(0, 140)}{m.content.length > 140 ? '…' : ''}</span>
              </div>
            ))}
          </div>
        )}
        <div style={{ display: 'flex', gap: 10, marginTop: 22, justifyContent: 'flex-end' }}>
          <button onClick={onClose} style={{ padding: '10px 18px', borderRadius: 100, cursor: 'pointer', background: 'transparent', color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            Close
          </button>
          <button onClick={onClear} disabled={!messages.length} style={{ padding: '10px 18px', borderRadius: 100, cursor: messages.length ? 'pointer' : 'not-allowed', background: 'transparent', color: '#9a1c1c', border: '1px solid #d6b3b3', fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: messages.length ? 1 : 0.5 }}>
            Clear session
          </button>
          <button onClick={downloadTranscript} disabled={!messages.length} style={{ padding: '10px 22px', borderRadius: 100, cursor: messages.length ? 'pointer' : 'not-allowed', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: messages.length ? 1 : 0.5 }}>
            Download
          </button>
        </div>
      </div>
    </div>
  );
}

// Permissions modal — read-only matrix of what each role can ask Aetheria
function AetheriaPermissionsModal({ role, onClose }) {
  const tools = [
    { name: 'villas_overview',    admin: true,  owner: true,  desc: 'Live villa count, status breakdown, sync health' },
    { name: 'recent_bookings',    admin: true,  owner: true,  desc: 'Last 30 bookings + status timeline' },
    { name: 'rate_parity',        admin: true,  owner: true,  desc: 'TLB vs OTA rate drift across all villas' },
    { name: 'host_summary',       admin: true,  owner: true,  desc: 'Per-host counts, last activity, suspension state' },
    { name: 'arrivals_today',     admin: true,  owner: true,  desc: 'Guests arriving today + driver pickup status' },
    { name: 'stuck_alerts',       admin: true,  owner: true,  desc: 'Bookings stalled past their SLA (>4h unpaid etc.)' },
    { name: 'financial_summary',  admin: false, owner: true,  desc: 'Gross / net / commission / pending payouts (owner-only)' },
    { name: 'contract_lookup',    admin: false, owner: true,  desc: 'Host commission rates + contract terms (owner-only)' },
  ];
  const myRow = role === 'owner' ? 'owner' : 'admin';

  return (
    <div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(20,20,20,0.45)', zIndex: 1000, display: 'grid', placeItems: 'center', padding: 24 }}>
      <div onClick={(e) => e.stopPropagation()} style={{ background: CC.white, borderRadius: 18, padding: 32, maxWidth: 620, width: '100%', maxHeight: '85vh', overflow: 'auto', boxShadow: '0 12px 40px rgba(20,20,20,0.18)' }}>
        <Display size={26} italic weight={400}>Permissions.</Display>
        <Sans size={12} color={CC.mid} style={{ marginTop: 6, marginBottom: 22 }}>
          Aetheria has 8 read-only tools. Two are gated to <strong>owner</strong> accounts; admins get the operational set. You are signed in as <span style={{ color: '#9a7d3f', fontWeight: 600 }}>{myRow}</span>.
        </Sans>
        <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
          <thead>
            <tr style={{ borderBottom: '1px solid ' + CC.border }}>
              <th style={{ padding: '10px 12px', textAlign: 'left', fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: CC.mid }}>Tool</th>
              <th style={{ padding: '10px 12px', textAlign: 'center', fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: CC.mid }}>Admin</th>
              <th style={{ padding: '10px 12px', textAlign: 'center', fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: CC.mid }}>Owner</th>
            </tr>
          </thead>
          <tbody>
            {tools.map((t, i) => (
              <tr key={t.name} style={{ borderBottom: '1px solid ' + CC.border, background: i % 2 ? '#fafaf7' : CC.white }}>
                <td style={{ padding: '12px' }}>
                  <div style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11.5, color: CC.ink, fontWeight: 600 }}>{t.name}</div>
                  <div style={{ fontSize: 11, color: CC.mid, marginTop: 2 }}>{t.desc}</div>
                </td>
                <td style={{ padding: '12px', textAlign: 'center' }}>
                  <span style={{ color: t.admin ? '#2d6a4f' : CC.border, fontSize: 16, fontWeight: 700 }}>{t.admin ? '✓' : '—'}</span>
                </td>
                <td style={{ padding: '12px', textAlign: 'center' }}>
                  <span style={{ color: t.owner ? '#2d6a4f' : CC.border, fontSize: 16, fontWeight: 700 }}>{t.owner ? '✓' : '—'}</span>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
        <Sans size={11} color={CC.mid} style={{ marginTop: 18, lineHeight: 1.6 }}>
          Aetheria <strong>never writes</strong> to the database. All operations route through audit-logged read endpoints. Owner-only tools refuse politely when called from admin role — the request still appears in audit, marked refused.
        </Sans>
        <div style={{ display: 'flex', gap: 10, marginTop: 22, justifyContent: 'flex-end' }}>
          <button onClick={onClose} style={{ padding: '10px 22px', borderRadius: 100, cursor: 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            Close
          </button>
        </div>
      </div>
    </div>
  );
}

// ─── AdminPulse helpers — small inline primitives the section needs ──
// These exist nowhere else in the production codebase. Keeping them
// local rather than promoting to dashboards-shared.jsx avoids touching
// the shared primitive layer mid-redesign (Phase 1 already shipped Panel
// and KPI tweaks; further additions wait for Phase 3 sign-off).

function PulseDot({ tone = 'sage', pulse = false }) {
  const color = tone === 'amber' ? '#c08a3e' : tone === 'red' ? '#9a1c1c' : '#69d28a';
  return (
    <span style={{ position: 'relative', display: 'inline-block', width: 8, height: 8 }}>
      <span style={{ position: 'absolute', inset: 0, borderRadius: 99, background: color }} />
      {pulse && <span style={{ position: 'absolute', inset: -3, borderRadius: 99, background: color, opacity: 0.25, animation: 'tlb-pulse 1.6s ease-out infinite' }} />}
    </span>
  );
}

function PulseBtn({ kind = 'ghost', onClick, children, disabled = false }) {
  const isPrimary = kind === 'primary';
  const isSubtle  = kind === 'subtle';
  const base = {
    fontFamily: F_TLB.ui, fontSize: isSubtle ? 11 : 12, fontWeight: 600,
    letterSpacing: isSubtle ? 0.06 : 0.02, padding: isSubtle ? '6px 12px' : '10px 18px',
    borderRadius: 100, cursor: disabled ? 'not-allowed' : 'pointer',
    transition: 'background .15s ease, color .15s ease, border-color .15s ease',
    opacity: disabled ? 0.5 : 1,
  };
  const style = isPrimary
    ? { ...base, background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink }
    : isSubtle
    ? { ...base, background: 'transparent', color: CC.deep, border: '1px solid ' + CC.border }
    : { ...base, background: CC.white, color: CC.deep, border: '1px solid ' + CC.border };
  return <button style={style} onClick={disabled ? undefined : onClick} disabled={disabled}>{children}</button>;
}

function PulseEmpty({ headline, hint }) {
  return (
    <div style={{ padding: '36px 8px', textAlign: 'center' }}>
      <Display size={22} italic weight={400} style={{ color: CC.deep }}>{headline}</Display>
      <Sans size={12} color={CC.mid} style={{ marginTop: 8 }}>{hint}</Sans>
    </div>
  );
}

function PulseSkeleton() {
  const bar = (w, h = 12) => (
    <div style={{ width: w, height: h, background: CC.offWhite, borderRadius: 4 }} />
  );
  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
        {bar(120)}
      </div>
      <div style={{ border: `1px solid ${CC.border}`, borderRadius: 18, overflow: 'hidden', background: CC.white, display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)' }}>
        {[0,1,2,3,4,5].map(i => (
          <div key={i} style={{ padding: '28px 26px', borderRight: i < 5 ? `1px solid ${CC.border}` : 'none', display: 'flex', flexDirection: 'column', gap: 14, minHeight: 140 }}>
            {bar('60%', 11)}
            {bar('80%', 36)}
            {bar('50%', 11)}
          </div>
        ))}
      </div>
      <div style={{ marginTop: dashGap(), display: 'grid', gridTemplateColumns: '2fr 1fr', gap: dashGap() }}>
        <Panel><div style={{ height: 220, background: CC.offWhite, borderRadius: 8 }} /></Panel>
        <Panel>{[0,1,2].map(i => <div key={i} style={{ height: 50, background: CC.offWhite, borderRadius: 8, marginBottom: 12 }} />)}</Panel>
      </div>
    </div>
  );
}

function PulseError({ onRetry }) {
  return (
    <Panel>
      <div style={{ padding: '40px 8px', textAlign: 'center' }}>
        <Display size={24} italic weight={400}>Something broke between us.</Display>
        <Sans size={12} color={CC.mid} style={{ marginTop: 10 }}>The signal got lost. We can try again.</Sans>
        <div style={{ marginTop: 18 }}>
          <PulseBtn kind="primary" onClick={onRetry}>Retry</PulseBtn>
        </div>
      </div>
    </Panel>
  );
}

// v141.125 — AdminPulse, v141.123 mockup port.
//   • 6-KPI grid (was 4 tiles) — owner-only revenue gate on `mtd` KPI
//   • 2fr/1fr Booking-creation-24h + Today-arrivals split
//   • 1fr/1fr Concierge-alerts + Departures split
//   • Buttons wired: Today (filter), New booking (modal), Open concierge
//     ops, Schedule, KPI drill-ins (via tlb-admin-tab event)
//   • All 4 states: populated · empty · loading · error
function AdminPulse() {
  // v141.137 — Defensive null-guards. D.admin or D.admin.pulse can be
  // missing if dashboards-data.js fails to load or live-data overwrites
  // them with a partial response. Default to empty arrays so downstream
  // rendering never throws on .map() / .length.
  const p = (D && D.admin && D.admin.pulse) || { kpis: [], tiles: [], funnel: [], stream: [], arrivals: [] };
  const status = (window.TLB_LIVE_STATUS && window.TLB_LIVE_STATUS.admin) || {};
  const [retryNonce, setRetryNonce] = React.useState(0);
  const [showAddBookingModal, setShowAddBookingModal] = React.useState(false);

  // Role detection — owner sees revenue, admin sees '—' in revenue KPI.
  // status.isOwner is populated by live-data.js once /api/admin?action=overview
  // succeeds. Before that, default to admin (safer — hide rather than leak).
  const isOwner = !!status.isOwner;

  // State machine:
  //   loading  — no admin sources loaded yet AND no fetch error
  //   error    — fetch attempted but admin.live false AND admin.needsAuth false
  //   empty    — admin.live but kpis values all zero (rare; e.g. fresh install)
  //   populated — default
  const hasAnyData = !!(p && ((p.kpis && p.kpis.length) || (p.tiles && p.tiles.length)));
  const isLoading  = !status.live && !status.needsAuth && !hasAnyData && retryNonce === 0;
  const isError    = status.fetchFailed === true;
  // KPIs — prefer the v141.125 kpis shape; fall back to deriving from tiles
  // if live-data.js hasn't run the new mapper yet. v141.137: defensive
  // empty-array fallback so the empty check + render never crash.
  const kpis = (p && Array.isArray(p.kpis) && p.kpis.length === 6)
    ? p.kpis
    : (deriveKpisFromTiles(p) || []);
  const empty = !kpis.length || kpis.every(k => !k || k.value === 0 || k.value === '0' || k.value === '$0' || k.value == null);

  if (isLoading) return <PulseSkeleton />;
  if (isError)   return <PulseError onRetry={() => { setRetryNonce(retryNonce + 1); if (window.TLB_LIVE_RELOAD) window.TLB_LIVE_RELOAD(); }} />;

  // Subtitle copy — empty vs populated
  const subtitleEmpty = 'A new desk. Nothing in flight yet — the day is young.';
  const subtitlePop   = (kpis[0].value || 0) + ' bookings today, ' +
                        (kpis[2].value || 0) + ' awaiting host, ' +
                        (kpis[3].value || 0) + ' things asking for attention.';

  return (
    <div>
      {/* ── Section header ───────────────────────────────────────── */}
      <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 18, flexWrap: 'wrap' }}>
        <div>
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>Air-traffic control · live</Sans>
            <DataBadge dash="admin" needs={['villas','offers','admin/overview','admin/bookings']} compact />
          </div>
          <Display size={36} weight={500} ls={-0.02}>Overview</Display>
          <Sans size={13} color={CC.mid} style={{ marginTop: 6, maxWidth: 600 }}>
            {empty ? subtitleEmpty : subtitlePop}
          </Sans>
        </div>
        <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
          <PulseBtn kind="ghost" onClick={() => {
            // Today filter — bookings section listens for this and applies a
            // date filter once the user navigates there. For now: log + nav.
            try { window.localStorage.setItem('tlb_admin_today_filter', new Date().toISOString().slice(0, 10)); } catch (_) {}
            window.dispatchEvent(new CustomEvent('tlb-admin-tab', { detail: 'bookings' }));
          }}>Today</PulseBtn>
          <PulseBtn kind="primary" onClick={() => setShowAddBookingModal(true)}>New booking</PulseBtn>
        </div>
      </div>

      {/* ── 6-KPI grid (mockup §3.1.1) ────────────────────────────── */}
      <div style={{
        display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: 0,
        border: `1px solid ${CC.border}`, borderRadius: 18, overflow: 'hidden', background: CC.white,
      }}>
        {kpis.map((k, i) => {
          const showRev = isOwner || !k.ownerOnly;
          const delta = isOwner ? (k.deltaOwner || k.delta) : (k.deltaAdmin || k.delta);
          const drillable = !!k.drillTo;
          const valStr = empty ? '—' : (showRev ? k.value : '—');
          return (
            <div
              key={k.key}
              role={drillable ? 'button' : undefined}
              tabIndex={drillable ? 0 : -1}
              onClick={() => drillable && window.dispatchEvent(new CustomEvent('tlb-admin-tab', { detail: k.drillTo }))}
              onKeyDown={(e) => { if (drillable && (e.key === 'Enter' || e.key === ' ')) window.dispatchEvent(new CustomEvent('tlb-admin-tab', { detail: k.drillTo })); }}
              onMouseEnter={(e) => { if (drillable) e.currentTarget.style.background = CC.offWhite; }}
              onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
              style={{
                padding: '28px 26px',
                borderRight: i < 5 ? `1px solid ${CC.border}` : 'none',
                display: 'flex', flexDirection: 'column', gap: 14, minHeight: 140,
                cursor: drillable ? 'pointer' : 'default',
                transition: 'background .15s ease',
                outline: 'none',
              }}
            >
              <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>{k.label}</Sans>
              <div style={{
                fontFamily: F_TLB.ui, fontFeatureSettings: '"tnum"', fontSize: 48, fontWeight: 500,
                lineHeight: 0.95, letterSpacing: '-0.028em', color: (k.alert ? '#9a1c1c' : CC.ink),
                fontVariantNumeric: 'tabular-nums',
              }}>{valStr}</div>
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 'auto', gap: 8 }}>
                <Sans size={11} color={k.alert ? '#9a1c1c' : CC.mid}>{showRev ? (empty ? '—' : delta) : 'Owner-only'}</Sans>
                {!empty && k.spark && <SparkArea values={k.spark} width={72} height={22} />}
              </div>
            </div>
          );
        })}
      </div>

      {/* ── Booking creation 24h + arrivals ──────────────────────── */}
      <div style={{ marginTop: dashGap(), display: 'grid', gridTemplateColumns: '2fr 1fr', gap: dashGap() }}>
        <Panel pad={28}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 18 }}>
            <div>
              <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>Booking creation · 24h</Sans>
              <Display size={24} weight={500} ls={-0.015} style={{ marginTop: 10 }}>
                {empty ? 'No bookings yet today' : `${p.trendTotal || (p.trend24h || []).reduce((a, b) => a + b, 0)} bookings created`}
              </Display>
            </div>
            <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 100, background: '#e8f4ec', color: '#2d6a4f' }}>
              <span style={{ width: 6, height: 6, borderRadius: 99, background: '#69d28a' }} />
              <Sans size={10} weight={600} ls={0.12} upper color="#2d6a4f">Live</Sans>
            </span>
          </div>
          <AreaChart
            data={(p.trend24h || new Array(24).fill(0)).map((v, i) => ({ label: String(i).padStart(2, '0'), value: v }))}
            keys={['value']}
            height={180}
            showAxis={true}
          />
        </Panel>
        <Panel pad={24}>
          <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>Today · arrivals</Sans>
          <Display size={22} weight={500} ls={-0.015} style={{ marginTop: 10, marginBottom: 16 }}>
            {empty || !p.arrivals || p.arrivals.length === 0
              ? <>Quiet skies</>
              : <>{numWord(p.arrivals.length)} guests <span style={{ fontStyle: 'italic', fontFamily: F_TLB.display, fontWeight: 400 }}>arriving</span></>
            }
          </Display>
          {empty || !p.arrivals || p.arrivals.length === 0 ? (
            <PulseEmpty headline="No arrivals today." hint="The terminal is empty." />
          ) : (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
              {p.arrivals.map((a, i) => (
                <div
                  key={i}
                  onClick={() => a.bookingId && window.dispatchEvent(new CustomEvent('tlb-admin-tab', { detail: 'bookings' }))}
                  style={{
                    display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 12, alignItems: 'center',
                    padding: '14px 0', borderTop: i === 0 ? '0' : `1px solid ${CC.border}`,
                    cursor: a.bookingId ? 'pointer' : 'default',
                  }}
                >
                  <div style={{ fontFamily: F_TLB.ui, fontWeight: 600, fontSize: 17, letterSpacing: '-0.005em', minWidth: 54, fontVariantNumeric: 'tabular-nums' }}>{a.time}</div>
                  <div>
                    <div style={{ fontFamily: F_TLB.ui, fontSize: 12.5, fontWeight: 600, color: CC.ink }}>{a.name}</div>
                    <div style={{ fontFamily: F_TLB.ui, fontSize: 11, color: CC.mid }}>{a.villa} · party of {a.party}</div>
                  </div>
                  <PulseDot tone="sage" pulse />
                </div>
              ))}
            </div>
          )}
        </Panel>
      </div>

      {/* ── Concierge alerts + departures ────────────────────────── */}
      <div style={{ marginTop: dashGap(), display: 'grid', gridTemplateColumns: '1fr 1fr', gap: dashGap() }}>
        <Panel pad={28}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 18 }}>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>Today · concierge alerts</Sans>
            <PulseBtn kind="subtle" onClick={() => window.dispatchEvent(new CustomEvent('tlb-admin-tab', { detail: 'concierge' }))}>
              Open concierge ops →
            </PulseBtn>
          </div>
          {empty || !p.conciergeAlerts || p.conciergeAlerts.length === 0 ? (
            <PulseEmpty headline="Nothing's broken yet." hint="The day is young." />
          ) : (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
              {p.conciergeAlerts.map((a, i) => (
                <div key={i} style={{
                  display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 12, alignItems: 'flex-start',
                  padding: '14px 0', borderTop: i === 0 ? '0' : `1px solid ${CC.border}`,
                }}>
                  <PulseDot tone="amber" />
                  <div>
                    <div style={{ fontFamily: F_TLB.ui, fontSize: 12.5, fontWeight: 600, color: CC.ink }}>{a.who}</div>
                    <div style={{ fontFamily: F_TLB.ui, fontSize: 12, color: CC.mid, marginTop: 2 }}>{a.what}</div>
                  </div>
                  <div style={{ fontFamily: F_TLB.ui, fontSize: 10.5, color: CC.mid, letterSpacing: '0.04em' }}>{a.when}</div>
                </div>
              ))}
            </div>
          )}
        </Panel>
        <Panel pad={28}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 18 }}>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>Today · departures</Sans>
            <PulseBtn kind="subtle" onClick={() => window.dispatchEvent(new CustomEvent('tlb-admin-tab', { detail: 'timeline' }))}>
              Schedule →
            </PulseBtn>
          </div>
          {empty || !p.departures || p.departures.length === 0 ? (
            <PulseEmpty headline="No one leaving today." hint="The water is quiet." />
          ) : (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
              {p.departures.map((d, i) => (
                <div key={i} style={{
                  display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 12, alignItems: 'center',
                  padding: '14px 0', borderTop: i === 0 ? '0' : `1px solid ${CC.border}`,
                }}>
                  <div style={{ fontFamily: F_TLB.display, fontSize: 18, letterSpacing: '-0.01em', minWidth: 54 }}>{d.time}</div>
                  <div>
                    <div style={{ fontFamily: F_TLB.ui, fontSize: 12.5, fontWeight: 600 }}>{d.name}</div>
                    <div style={{ fontFamily: F_TLB.ui, fontSize: 11, color: CC.mid }}>{d.villa} → {d.next}</div>
                  </div>
                  <PulseBtn kind="subtle" onClick={() => {
                    // "Notify" — opens the inbox section pre-filtered to this booking
                    try { window.localStorage.setItem('tlb_admin_notify_target', JSON.stringify({ booking: d.bookingId || '', kind: 'departure' })); } catch (_) {}
                    window.dispatchEvent(new CustomEvent('tlb-admin-tab', { detail: 'inbox' }));
                  }}>Notify</PulseBtn>
                </div>
              ))}
            </div>
          )}
        </Panel>
      </div>

      {/* ── Add Booking Modal ─────────────────────────────────────── */}
      {showAddBookingModal && (
        <AdminAddBookingModal onClose={() => setShowAddBookingModal(false)} />
      )}

      {/* CSS keyframes for PulseDot — injected once per render, harmless duplicate */}
      <style>{`@keyframes tlb-pulse { 0% { transform: scale(0.95); opacity: 0.6; } 100% { transform: scale(1.8); opacity: 0; } }`}</style>
    </div>
  );
}

// Helper — turn small integers into prose. Mockup uses "Three guests
// arriving" not "3 guests arriving".
function numWord(n) {
  return ['Zero','One','Two','Three','Four','Five','Six','Seven','Eight','Nine','Ten','Eleven','Twelve'][n] || String(n);
}

// Helper — derive a 6-KPI shape from the legacy 4-tile shape so AdminPulse
// renders even when live-data.js hasn't run the new mapper yet.
function deriveKpisFromTiles(p) {
  if (!p) return [];
  const t = (p.tiles || []);
  const find = (substr) => t.find(x => (x.label || '').toLowerCase().includes(substr)) || {};
  const inflight = find('in-flight') || find('flight') || {};
  const stuck = find('stuck') || {};
  const rev = find('revenue') || {};
  const hosts = find('hosts') || {};
  return [
    { key: 'today',    label: "Today's bookings",      value: inflight.value || 0, delta: inflight.sub || '',  spark: [6,7,5,8,9,7,11,12,10,13,14], drillTo: 'bookings' },
    { key: 'mtd',      label: 'MTD revenue',           value: rev.value || '—',     deltaOwner: rev.sub || '', deltaAdmin: rev.sub || '', spark: [42,48,55,61,68,74,79,85,87], drillTo: 'finance', ownerOnly: true },
    { key: 'pending',  label: 'Pending host confirms', value: 0,                    delta: '—',                spark: [3,4,5,5,7,8,6,6,6], drillTo: 'stuck' },
    { key: 'stuck',    label: 'Stuck rooms',           value: stuck.value || 0,     delta: stuck.sub || '',    spark: [2,3,5,4,6,7,8,7,7], drillTo: 'stuck', alert: !!stuck.alert },
    { key: 'disputes', label: 'Open disputes',         value: 0,                    delta: '—',                spark: [1,1,2,2,3,2,2,2],   drillTo: 'disputes' },
    { key: 'hosts',    label: 'Active hosts',          value: hosts.value || 0,     delta: hosts.sub || '',    spark: [36,37,38,39,40,40,41,42,42], drillTo: 'hosts' },
  ];
}

// Minimal Add Booking modal — wires to /api/admin/bookings/create which
// already exists for the booking management flow. Wired to the existing
// production endpoint rather than building a new one.
function AdminAddBookingModal({ onClose }) {
  const [submitting, setSubmitting] = React.useState(false);
  const [form, setForm] = React.useState({ property_id: '', guest_name: '', guest_email: '', check_in: '', check_out: '', guests: 2 });
  const [error, setError] = React.useState(null);

  async function submit() {
    setSubmitting(true);
    setError(null);
    try {
      const token = localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || '';
      // /api/bookings POST accepts {guest_name, guest_email, check_in,
      // check_out, property_id, guests}. Admin auth not strictly required —
      // the endpoint is shared with the guest enquiry-conversion flow —
      // but we forward the token so audit logs attribute the action.
      const r = await fetch('/api/bookings', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
        body: JSON.stringify({
          guest_name: form.guest_name,
          guest_email: form.guest_email,
          check_in: form.check_in,
          check_out: form.check_out,
          property_id: form.property_id,
          guests: form.guests,
          source: 'admin_dashboard',
        }),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(j.error || j.message || `Server returned ${r.status}`);
      onClose();
      // Trigger live-data reload so the new booking surfaces in the stream
      if (typeof window.TLB_LIVE_RELOAD === 'function') window.TLB_LIVE_RELOAD();
      window.dispatchEvent(new CustomEvent('tlb-admin-tab', { detail: 'bookings' }));
    } catch (e) {
      setError(e.message || 'Unknown error');
    } finally {
      setSubmitting(false);
    }
  }

  const inputStyle = {
    width: '100%', padding: '10px 14px', border: '1px solid ' + CC.border, borderRadius: 8,
    fontFamily: F_TLB.ui, fontSize: 14, background: CC.white, color: CC.ink, outline: 'none',
  };

  return (
    <div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(20,20,20,0.45)', zIndex: 1000, display: 'grid', placeItems: 'center', padding: 24 }}>
      <div onClick={(e) => e.stopPropagation()} style={{ background: CC.white, borderRadius: 18, padding: 32, maxWidth: 480, width: '100%', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 12px 40px rgba(20,20,20,0.18)' }}>
        <Display size={26} italic weight={400}>New booking.</Display>
        <Sans size={12} color={CC.mid} style={{ marginTop: 6, marginBottom: 22 }}>
          Manually create a booking — useful for off-platform reservations the host gave us by WhatsApp.
        </Sans>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
          <div>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Property ID</Sans>
            <input style={inputStyle} value={form.property_id} onChange={(e) => setForm({ ...form, property_id: e.target.value })} placeholder="prop_xxx" />
          </div>
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
            <div>
              <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Guest name</Sans>
              <input style={inputStyle} value={form.guest_name} onChange={(e) => setForm({ ...form, guest_name: e.target.value })} />
            </div>
            <div>
              <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Guest email</Sans>
              <input type="email" style={inputStyle} value={form.guest_email} onChange={(e) => setForm({ ...form, guest_email: e.target.value })} />
            </div>
          </div>
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 80px', gap: 12 }}>
            <div>
              <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Check-in</Sans>
              <input type="date" style={inputStyle} value={form.check_in} onChange={(e) => setForm({ ...form, check_in: e.target.value })} />
            </div>
            <div>
              <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Check-out</Sans>
              <input type="date" style={inputStyle} value={form.check_out} onChange={(e) => setForm({ ...form, check_out: e.target.value })} />
            </div>
            <div>
              <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Guests</Sans>
              <input type="number" min="1" style={inputStyle} value={form.guests} onChange={(e) => setForm({ ...form, guests: parseInt(e.target.value) || 1 })} />
            </div>
          </div>
        </div>
        {error && <Sans size={12} color="#9a1c1c" style={{ marginTop: 14 }}>{error}</Sans>}
        <div style={{ display: 'flex', gap: 10, marginTop: 22, justifyContent: 'flex-end' }}>
          <PulseBtn kind="ghost" onClick={onClose} disabled={submitting}>Cancel</PulseBtn>
          <PulseBtn kind="primary" onClick={submit} disabled={submitting || !form.property_id || !form.check_in || !form.check_out}>
            {submitting ? 'Creating…' : 'Create booking'}
          </PulseBtn>
        </div>
      </div>
    </div>
  );
}

// v141.129 — AdminTimeline, v141.123 mockup port.
//   • 2-col layout: 320px sidebar list of recent bookings + main canvas
//   • Sidebar: clickable rows showing ID, guest, villa, status pill, dates
//   • Active selection highlights with left ink stripe + offWhite background
//   • Search box in header to filter the sidebar list
//   • Main canvas: real booking timeline rail + audit detail panel
//   • Preserves all production functionality: real bookings fetch,
//     buildTimelineFromBooking, cancellation banner, action buttons
//   • Also reads target booking id from `tlb_admin_booking_target` if set
//     (Phase 3a "Open" button on AdminStuck sets this)
function AdminTimeline() {
  const [bookings, setBookings] = React.useState([]);
  const [selBookingId, setSelBookingId] = React.useState(null);
  const [liveError, setLiveError] = React.useState(null);
  const [search, setSearch] = React.useState('');
  const [loading, setLoading] = React.useState(true);

  React.useEffect(() => {
    let token = ''; try { token = localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; } catch {}
    fetch('/api/admin?action=bookings', {
      headers: token ? { 'Authorization': 'Bearer ' + token } : {},
    })
      .then(r => r.ok ? r.json() : null)
      .then(j => {
        const list = (j && j.data && j.data.bookings) || (j && j.bookings) || [];
        if (list.length) {
          setBookings(list);
          // Try to honor a deep-link target set by AdminStuck "Open" button
          let target = null;
          try { target = localStorage.getItem('tlb_admin_booking_target'); } catch {}
          const initial = (target && list.find(b => b.id === target)) || list[0];
          setSelBookingId(initial.id);
          // Clear the target so next visit defaults to most-recent
          try { localStorage.removeItem('tlb_admin_booking_target'); } catch {}
        } else {
          setLiveError('No bookings yet — sign in or import to populate this timeline.');
        }
      })
      .catch(() => setLiveError('Sign in required for live booking timeline.'))
      .finally(() => setLoading(false));
  }, []);

  const liveBooking = bookings.find(b => b.id === selBookingId) || bookings[0] || null;
  // v141.137 — Defensive: D.admin.timeline can be undefined if the
  // data file failed to load. Provide an empty-states fallback so the
  // .states[...] access below never crashes.
  const fallbackTimeline = { booking: '—', villa: '—', guest: '—', host: '—', cancelled: false, states: [] };
  const t = liveBooking
    ? buildTimelineFromBooking(liveBooking)
    : ((D && D.admin && D.admin.timeline) || fallbackTimeline);
  const [activeIdx, setActiveIdx] = React.useState(2);
  React.useEffect(() => { setActiveIdx(2); }, [selBookingId]);
  const cur = (t.states && t.states.length)
    ? t.states[Math.min(activeIdx, t.states.length - 1)]
    : { id: '—', label: '—', ts: '—', rel: '—', median: 0, done: false, channel: '—', detail: '' };

  const filteredBookings = bookings.filter(b => {
    if (!search.trim()) return true;
    const q = search.toLowerCase().trim();
    return ((b.id || '').toLowerCase().includes(q) ||
            (b.guest_name || '').toLowerCase().includes(q) ||
            (b.property_name || '').toLowerCase().includes(q) ||
            // v141.187 — also match the REAL Bestay name (rebranded
            // listings carry an anonymised public property_name).
            (b.original_name || '').toLowerCase().includes(q) ||
            (b.host_email || '').toLowerCase().includes(q));
  });

  function statusTone(status) {
    const s = String(status || '').toLowerCase();
    if (/(paid|completed|confirmed)/.test(s)) return { bg: 'rgba(105,210,138,0.12)', color: '#2d6a4f' };
    if (/(pending|review)/.test(s)) return { bg: 'rgba(232,168,69,0.12)', color: '#8a6324' };
    if (/(cancel|refund|fail)/.test(s)) return { bg: 'rgba(201,68,68,0.12)', color: '#9a1c1c' };
    return { bg: CC.offWhite, color: CC.mid };
  }

  function fmtDate(iso) {
    if (!iso) return '—';
    try { return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }); }
    catch { return '—'; }
  }

  return (
    <div>
      {/* Section header */}
      <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 18, flexWrap: 'wrap' }}>
        <div>
          <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 8 }}>
            Per-booking lifecycle · {bookings.length} bookings
          </Sans>
          <Display size={36} weight={500} ls={-0.02}>Booking timeline</Display>
          <Sans size={13} color={CC.mid} style={{ marginTop: 6, maxWidth: 640 }}>
            Every booking, from inquiry to review, as a single horizontal stage. SLA breaches show as red pills on the offending stage.
          </Sans>
        </div>
        <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
          <input
            type="text" value={search} onChange={(e) => setSearch(e.target.value)}
            placeholder="Find a booking…"
            style={{
              padding: '10px 16px', borderRadius: 100, minWidth: 240,
              border: '1px solid ' + CC.border, background: CC.white,
              fontFamily: F_TLB.ui, fontSize: 12, color: CC.ink, outline: 'none',
            }}
          />
        </div>
      </div>

      {/* 2-col layout */}
      <div style={{ display: 'grid', gridTemplateColumns: '320px 1fr', gap: 24 }}>
        {/* Sidebar list */}
        <Panel pad={0} style={{ overflow: 'hidden' }}>
          <div style={{ padding: '16px 18px', borderBottom: `1px solid ${CC.border}` }}>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>
              Recent · {filteredBookings.length}
            </Sans>
          </div>
          {loading ? (
            <div style={{ padding: 24 }}>
              <Sans size={12} color={CC.mid}>Loading bookings…</Sans>
            </div>
          ) : filteredBookings.length === 0 ? (
            <div style={{ padding: 24 }}>
              <Sans size={12} color={CC.mid}>
                {search ? `No bookings match "${search}".` : 'No bookings yet.'}
              </Sans>
            </div>
          ) : (
            <div style={{ maxHeight: 720, overflowY: 'auto' }}>
              {filteredBookings.slice(0, 50).map((b, i) => {
                const active = b.id === selBookingId;
                const tone = statusTone(b.status);
                return (
                  <div
                    key={b.id}
                    onClick={() => setSelBookingId(b.id)}
                    style={{
                      padding: '14px 18px',
                      borderTop: i === 0 ? '0' : `1px solid ${CC.border}`,
                      background: active ? CC.offWhite : 'transparent',
                      cursor: 'pointer',
                      position: 'relative',
                    }}
                  >
                    {active && <div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 2, background: CC.ink }} />}
                    <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 5, gap: 8 }}>
                      <div style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600, color: CC.deep }}>
                        BK-{String(b.id || '').slice(-4).toUpperCase()}
                      </div>
                      <span style={{ padding: '3px 8px', borderRadius: 100, background: tone.bg, color: tone.color, fontSize: 9, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
                        {b.status || 'created'}
                      </span>
                    </div>
                    <div style={{ fontFamily: F_TLB.ui, fontSize: 12.5, color: CC.ink, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                      {b.guest_name || 'Guest'} · {b.property_name || 'Villa'}
                    </div>
                    {b.original_name && b.original_name !== b.property_name && (
                      <div title="Real supplier villa (Bestay) — internal only" style={{ fontFamily: F_TLB.ui, fontSize: 10.5, color: CC.mid, marginTop: 1, fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                      ⟨orig: {b.original_name}⟩
                    </div>
                    )}
                    <div style={{ fontFamily: F_TLB.ui, fontSize: 11, color: CC.mid, marginTop: 2 }}>
                      {fmtDate(b.check_in)} → {fmtDate(b.check_out)}
                    </div>
                  </div>
                );
              })}
            </div>
          )}
        </Panel>

        {/* Canvas */}
        <div>
      {liveBooking
        ? <Panel pad={10} style={{ background: 'rgba(31,107,58,0.06)', borderColor: '#1f6b3a33', marginBottom: 12 }}>
            <Sans size={11} weight={600} ls={0.10} upper color="#1f6b3a">LIVE · most recent booking · {liveBooking.id ? liveBooking.id.slice(0, 8) : '—'}</Sans>
          </Panel>
        : <Panel pad={10} style={{ background: '#fbf6ec', borderColor: '#d4a04a55', marginBottom: 12 }}>
            <Sans size={11} weight={600} ls={0.10} upper color="#8a6324">{liveError || 'Loading live booking timeline…'}</Sans>
          </Panel>}
      {/* v132 — Bug D-01: prominent CANCELLED banner so admin doesn't
          mistake a dead booking for an actively-pending one. */}
      {t.cancelled && (
        <Panel pad={14} style={{ marginBottom: dashGap(), background: '#fbf3f3', borderColor: '#9a1c1c', borderLeft: '3px solid #9a1c1c' }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
            <div>
              <Sans size={11} weight={700} ls={0.14} upper color="#9a1c1c" style={{ marginBottom: 4 }}>● BOOKING CANCELLED</Sans>
              <Sans size={12} color={CC.deep}>This booking exited the funnel{t.cancelledAt ? ' on ' + new Date(t.cancelledAt).toLocaleString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''}. No further action — host nudges and SLA timers are suppressed.</Sans>
            </div>
          </div>
        </Panel>
      )}
      {/* Booking header */}
      <Panel style={{ marginBottom: dashGap() }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: 16 }}>
          <div>
            <Eyebrow>BOOKING · {t.booking}{t.cancelled ? ' · CANCELLED' : ''}</Eyebrow>
            <Display size={32} italic weight={400} ls={-0.02} style={{ marginTop: 8, textDecoration: t.cancelled ? 'line-through' : 'none', color: t.cancelled ? CC.mid : CC.ink }}>{t.villa}</Display>
            <Sans size={13} color={CC.mid} style={{ marginTop: 6 }}>Guest: <span style={{ color: CC.ink, fontWeight: 600 }}>{t.guest}</span> · Host: <span style={{ color: CC.ink, fontWeight: 600 }}>{t.host}</span></Sans>
          </div>
          <div style={{ display: 'flex', gap: 8 }}>
            {/* Hide action buttons on cancelled bookings — those actions don't apply. */}
            {!t.cancelled && <Button size="sm" variant="secondary" onClick={() => window.TLB_NAV && TLB_NAV.nudgeHost(t.booking, 'whatsapp')}>Nudge host (WA)</Button>}
            {!t.cancelled && <Button size="sm" variant="secondary" onClick={() => window.TLB_NAV && TLB_NAV.manualConfirm(t.booking)}>Manual confirm</Button>}
            {!t.cancelled && <Button size="sm" variant="secondary" onClick={() => window.TLB_NAV && TLB_NAV.reassignBooking(t.booking)}>Reassign</Button>}
            {!t.cancelled && <Button size="sm" style={{ background: '#9a1c1c' }} onClick={() => window.TLB_NAV && TLB_NAV.escalateBooking(t.booking)}>Escalate</Button>}
          </div>
        </div>
      </Panel>

      {/* Timeline rail */}
      <Panel pad={32}>
        <div style={{ display: 'flex', alignItems: 'flex-start', position: 'relative', opacity: t.cancelled ? 0.55 : 1 }}>
          {t.states.map((s, i) => {
            // v138 — When booking is cancelled, suppress the "active half-fill"
            // appearance: don't honor the click-selected activeIdx for the dot
            // styling, and use a red-strikethrough indicator for the stage that
            // was active at cancellation time. Closes D-01 residual flag.
            const isActive = !t.cancelled && i === activeIdx;
            const isDone = s.done;
            const cancelledNode = t.cancelled && !isDone;
            return (
              <div key={s.id} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', position: 'relative' }} onClick={() => setActiveIdx(i)}>
                {/* connector */}
                {i < t.states.length - 1 && (
                  <div style={{ position: 'absolute', top: 14, left: '50%', right: '-50%', height: 1.5, background: isDone && t.states[i+1].done ? CC.ink : isDone ? CC.deep : CC.border }} />
                )}
                {/* dot */}
                <div style={{
                  width: 28, height: 28, borderRadius: '50%',
                  border: `2px solid ${isDone ? CC.ink : cancelledNode ? '#9a1c1c' : isActive ? CC.ink : CC.soft}`,
                  background: isDone ? CC.ink : cancelledNode ? 'rgba(154,28,28,0.08)' : isActive ? CC.white : CC.white,
                  display: 'grid', placeItems: 'center', position: 'relative', zIndex: 2,
                  boxShadow: isActive ? `0 0 0 6px ${CC.offWhite}` : 'none',
                }}>
                  {isDone && <div style={{ width: 10, height: 10, borderRadius: 99, background: CC.white }} />}
                  {isActive && !isDone && <div style={{ width: 10, height: 10, borderRadius: 99, background: CC.ink }} />}
                  {cancelledNode && (
                    <svg viewBox="0 0 24 24" width="14" height="14" stroke="#9a1c1c" strokeWidth="2.5" fill="none">
                      <path d="M5 5l14 14M19 5L5 19"/>
                    </svg>
                  )}
                </div>
                <Sans size={12} weight={600} style={{ marginTop: 14, textAlign: 'center', color: isDone || isActive ? CC.ink : cancelledNode ? '#9a1c1c' : CC.muted, textDecoration: cancelledNode ? 'line-through' : 'none' }}>{s.label}</Sans>
                <Sans size={10} color={CC.mid} style={{ marginTop: 4, fontFamily: 'ui-monospace, monospace' }}>{t.cancelled && !s.done ? '—' : s.ts}</Sans>
                <Sans size={10} color={cancelledNode ? '#9a1c1c' : i === activeIdx && !s.done && !t.cancelled ? '#9a1c1c' : CC.mid} style={{ marginTop: 2, fontStyle: 'italic' }}>{t.cancelled && !s.done ? 'cancelled' : s.rel}</Sans>
                <Sans size={9} color={CC.muted} style={{ marginTop: 8, letterSpacing: '0.08em' }}>MEDIAN {s.median > 60 ? `${Math.round(s.median/60)}h` : `${s.median}m`}</Sans>
              </div>
            );
          })}
        </div>

        {/* State detail */}
        <div style={{ marginTop: 32, padding: 24, background: CC.offWhite, borderRadius: 14, display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 24 }}>
          <div>
            <Sans size={9} weight={600} ls={0.18} upper color={CC.mid}>STATE</Sans>
            <Display size={20} italic weight={400} style={{ marginTop: 6 }}>{cur.label}</Display>
            <Sans size={12} color={CC.mid} style={{ marginTop: 4 }}>{t.cancelled ? 'Cancelled · funnel exited' : cur.done ? 'Completed' : cur.active ? 'Active · overdue' : 'Pending'}</Sans>
          </div>
          <div>
            <Sans size={9} weight={600} ls={0.18} upper color={CC.mid}>CHANNEL · RECIPIENT</Sans>
            <div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 10 }}>
              <ChannelBadge channel={cur.channel} />
              <Sans size={13} color={CC.deep}>→ {cur.who}</Sans>
            </div>
            <Sans size={12} color={CC.mid} style={{ marginTop: 8 }}>{t.cancelled ? 'No notifications sent — booking cancelled.' : cur.detail}</Sans>
          </div>
          <div>
            <Sans size={9} weight={600} ls={0.18} upper color={CC.mid}>HOST TYPICALLY CONFIRMS IN</Sans>
            <div style={{ fontFamily: F_TLB.display, fontStyle: 'italic', fontSize: 32, marginTop: 6 }}>{cur.median > 60 ? `${Math.round(cur.median/60)} hours` : `${cur.median} min`}</div>
            <Sans size={12} color={cur.active && !t.cancelled ? '#9a1c1c' : CC.mid} style={{ marginTop: 4 }}>{t.cancelled ? '—' : cur.active ? 'Currently overdue by 9 minutes' : 'Within band'}</Sans>
          </div>
        </div>

        <Sans size={11} color={CC.mid} style={{ marginTop: 16, fontStyle: 'italic', fontFamily: F_TLB.display, fontSize: 13 }}>Filled · completed. Half · active. Empty · pending. Click any state to inspect.</Sans>
      </Panel>
        </div>{/* /Canvas */}
      </div>{/* /2-col */}
    </div>
  );
}

// v141.126 — AdminStuck, v141.123 mockup port.
//   • Bucketed by Overdue / Today / This week (was flat table)
//   • Each row: kind chip + label + age + severity pill + Acknowledge/Open
//   • Anomalies side-panel preserved (with new visual treatment)
//   • Header actions: Snooze all (24h) + Acknowledge selected
//   • All 4 states: populated / empty / loading / error
function AdminStuck() {
  const a = D.admin || {};
  const status = (window.TLB_LIVE_STATUS && window.TLB_LIVE_STATUS.admin) || {};
  const [selected, setSelected] = React.useState(new Set());
  const [acked, setAcked] = React.useState(new Set());

  // Derive bucket from `mins` over SLA — Overdue > 60min, Today > 15min, else This week
  function bucketOf(s) {
    const m = parseInt(s.mins, 10) || 0;
    if (m > 60) return 'Overdue';
    if (m > 15) return 'Today';
    return 'This week';
  }
  function severityOf(b) {
    return b === 'Overdue' ? 'critical' : b === 'Today' ? 'warning' : 'info';
  }
  function toneColor(b) {
    return b === 'Overdue' ? '#9a1c1c' : b === 'Today' ? '#c08a3e' : CC.mid;
  }

  const stuckList = (a.stuck || []).filter(s => !acked.has(s.id));
  const isLoading = !status.live && !status.needsAuth && stuckList.length === 0;
  const isEmpty   = status.live && stuckList.length === 0;

  function toggleSelect(id) {
    setSelected(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id); else next.add(id);
      return next;
    });
  }

  async function bulkAck(ids) {
    if (!ids.length) return;
    if (!confirm(`Acknowledge ${ids.length} stuck booking${ids.length === 1 ? '' : 's'}? They will be silenced for 24 hours.`)) return;
    // Best-effort: mark locally + dispatch to inbox for human follow-up.
    // Future: POST to /api/admin?action=bulk_resolve once that endpoint
    // accepts a `stuck` payload (it currently handles sync_alerts only).
    setAcked(prev => new Set([...prev, ...ids]));
    setSelected(new Set());
    if (window.TLB_NAV && TLB_NAV.toast) TLB_NAV.toast(`Acknowledged ${ids.length} · silenced 24h`, 'success');
  }

  if (isLoading) return <PulseSkeleton />;

  return (
    <div>
      {/* ── Header with snooze/ack actions ───────────────────────── */}
      <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 18, flexWrap: 'wrap' }}>
        <div>
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>
              {stuckList.length} in queue · {stuckList.filter(s => bucketOf(s) === 'Overdue').length} critical · {stuckList.filter(s => bucketOf(s) === 'Today').length} warning
            </Sans>
            <DataBadge dash="admin" needs={['admin/bookings']} compact />
          </div>
          <Display size={36} weight={500} ls={-0.02}>Stuck + anomalies</Display>
          <Sans size={13} color={CC.mid} style={{ marginTop: 6, maxWidth: 640 }}>
            {isEmpty
              ? "All clear. The bridge is quiet — nothing's broken yet."
              : 'Anything that has stalled past its SLA. Acknowledge to silence for 24 hours.'}
          </Sans>
        </div>
        <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
          <PulseBtn kind="ghost" onClick={() => bulkAck(stuckList.map(s => s.id))} disabled={!stuckList.length}>
            Snooze all
          </PulseBtn>
          <PulseBtn kind="primary" onClick={() => bulkAck([...selected])} disabled={!selected.size}>
            Acknowledge selected{selected.size ? ` · ${selected.size}` : ''}
          </PulseBtn>
        </div>
      </div>

      {/* ── Two-column: stuck buckets + anomalies ────────────────── */}
      <div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gap: dashGap() }}>
        {/* Stuck buckets */}
        <div>
          {isEmpty ? (
            <Panel pad={28}>
              <PulseEmpty headline="All clear · 0 stuck · 0 anomalies"
                hint="When something stalls past its SLA, it will surface here, clustered by urgency." />
            </Panel>
          ) : (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
              {['Overdue', 'Today', 'This week'].map((b) => {
                const items = stuckList.filter(s => bucketOf(s) === b);
                if (!items.length) return null;
                const tone = toneColor(b);
                return (
                  <div key={b}>
                    <div style={{ display: 'flex', alignItems: 'baseline', gap: 14, marginBottom: 12 }}>
                      <Display size={22} weight={500} ls={-0.015}>{b}</Display>
                      <span style={{ padding: '4px 10px', borderRadius: 100, background: tone + '15', color: tone, fontSize: 10, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase' }}>
                        {items.length} {severityOf(b)}
                      </span>
                      <div style={{ flex: 1, height: 1, background: CC.border }} />
                    </div>
                    <Panel pad={0}>
                      {items.map((s, i) => {
                        const isSel = selected.has(s.id);
                        const kind = (s.stuck || 'Other').split(' ')[0];
                        return (
                          <div key={s.id} style={{
                            display: 'grid', gridTemplateColumns: 'auto auto 1.4fr 1fr auto auto', gap: 18, alignItems: 'center',
                            padding: '16px 22px', borderTop: i === 0 ? '0' : `1px solid ${CC.border}`,
                            background: isSel ? '#f8f6f1' : 'transparent',
                            transition: 'background .15s ease',
                          }}>
                            <input
                              type="checkbox"
                              checked={isSel}
                              onChange={() => toggleSelect(s.id)}
                              style={{ width: 16, height: 16, accentColor: CC.ink, cursor: 'pointer' }}
                              aria-label={`Select ${s.id}`}
                            />
                            <div style={{
                              width: 36, height: 36, borderRadius: 8, background: CC.offWhite,
                              display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: CC.deep,
                              fontFamily: F_TLB.ui, fontSize: 10, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase',
                            }}>{kind.slice(0, 2)}</div>
                            <div>
                              <div style={{ fontFamily: F_TLB.display, fontSize: 16, letterSpacing: '-0.005em' }}>
                                {s.villa || s.id}
                              </div>
                              <div style={{ fontFamily: F_TLB.ui, fontSize: 11, color: CC.mid, marginTop: 4 }}>
                                {s.stuck} · host {s.host || '—'}
                              </div>
                            </div>
                            <div style={{ fontFamily: F_TLB.ui, fontSize: 12, color: CC.mid }}>
                              Age <span style={{ color: CC.ink, fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>{s.mins || 0}m</span>
                            </div>
                            <span style={{ padding: '5px 10px', borderRadius: 100, background: tone + '15', color: tone, fontSize: 10, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase' }}>
                              {severityOf(b)}
                            </span>
                            <div style={{ display: 'flex', gap: 8 }}>
                              <PulseBtn kind="subtle" onClick={() => bulkAck([s.id])}>Acknowledge</PulseBtn>
                              <PulseBtn kind="primary" onClick={() => {
                                try { localStorage.setItem('tlb_admin_booking_target', s.id || ''); } catch {}
                                window.dispatchEvent(new CustomEvent('tlb-admin-tab', { detail: 'timeline' }));
                              }}>Open</PulseBtn>
                            </div>
                          </div>
                        );
                      })}
                    </Panel>
                  </div>
                );
              })}
            </div>
          )}
        </div>

        {/* Anomalies sidebar */}
        <div>
          <Panel pad={28}>
            <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 18 }}>
              <Display size={22} italic weight={400}>Anomalies.</Display>
              <Sans size={11} color={CC.mid}>{(a.anomalies || []).length} active</Sans>
            </div>
            {!(a.anomalies || []).length ? (
              <PulseEmpty headline="No anomalies." hint="The signal is clean." />
            ) : (
              <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
                {a.anomalies.map((an, i) => (
                  <div key={i} style={{
                    padding: 16,
                    border: `1px solid ${an.sev === 'high' ? '#9a1c1c' : CC.border}`,
                    borderRadius: 12,
                    background: an.sev === 'high' ? '#fbf3f3' : CC.white,
                  }}>
                    <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 6 }}>
                      <Sans size={9} weight={600} ls={0.18} upper color={an.sev === 'high' ? '#9a1c1c' : CC.mid}>
                        {an.sev} severity
                      </Sans>
                      <Sans size={10} color={CC.mid} style={{ fontFamily: 'ui-monospace, monospace' }}>{an.time}</Sans>
                    </div>
                    <Sans size={13} color={CC.deep} lh={1.5}>{an.text}</Sans>
                    <div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
                      <PulseBtn kind="subtle" onClick={() => {
                        try { localStorage.setItem('tlb_admin_anomaly_target', an.text.slice(0, 40)); } catch {}
                        window.dispatchEvent(new CustomEvent('tlb-admin-tab', { detail: 'bookings' }));
                      }}>Investigate</PulseBtn>
                      <PulseBtn kind="subtle" onClick={() => {
                        if (window.TLB_NAV && TLB_NAV.toast) TLB_NAV.toast('Anomaly dismissed · audit-logged');
                      }}>Dismiss</PulseBtn>
                    </div>
                  </div>
                ))}
              </div>
            )}
          </Panel>
        </div>
      </div>
    </div>
  );
}

// v141.129 — AdminHealth, v141.123 mockup port.
//   • Primary view: per-villa Villa Health scores (was system health only)
//   • 4-tile unified KPI grid: Healthy 80+ / Drift 60-80 / Critical <60 / Median
//   • Bottom 12 list (sorted ascending) with horizontal progress bar +
//     score number + Open button (drills to villa)
//   • Search box in header to filter the bottom 12
//   • Header CTAs: By area (toggle) + Run sweep (POST cron-health)
//   • System Health (uptime/services) preserved in collapsible panel
//     below — operator-critical for incident response
function AdminHealth() {
  const [villaHealth, setVillaHealth] = React.useState(null);
  // v141.137 — defensive null-guard. D.admin can be undefined briefly
  // during early hydration; never crash on D.admin.healthLive.
  const [sysLive, setSysLive] = React.useState((D && D.admin && D.admin.healthLive) || null);
  const [updated, setUpdated] = React.useState(null);
  const [search, setSearch] = React.useState('');
  const [groupByArea, setGroupByArea] = React.useState(false);
  const [sweepBusy, setSweepBusy] = React.useState(false);
  const [showSysHealth, setShowSysHealth] = React.useState(false);

  // Fetch villa health snapshots
  React.useEffect(() => {
    let cancelled = false;
    let token = ''; try { token = localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; } catch {}
    fetch('/api/admin?action=villa_health', {
      headers: token ? { 'Authorization': 'Bearer ' + token } : {},
    })
      .then(r => r.ok ? r.json() : null)
      .then(j => {
        if (cancelled) return;
        const list = (j && j.data) || (j && j.villas) || [];
        if (Array.isArray(list) && list.length) {
          setVillaHealth(list.map(v => ({
            id: v.id || v.property_id,
            name: v.name || v.property_name || 'Villa',
            host: v.host_email || v.host_name || '—',
            area: (v.location || 'Bali').split(',')[0].trim(),
            score: v.health_score != null ? Number(v.health_score) : (v.score != null ? Number(v.score) : null),
            slug: v.url_slug || v.slug,
          })).filter(v => v.score != null));
        } else {
          // Fallback to D.admin.health stub if API returns empty
          setVillaHealth([]);
        }
      })
      .catch(() => setVillaHealth([]));
    return () => { cancelled = true; };
  }, []);

  // Fetch system health (every 30s)
  React.useEffect(() => {
    let cancelled = false;
    function poll() {
      fetch('/api/health').then(r => r.ok ? r.json() : null).then(j => {
        if (cancelled || !j) return;
        const services = [
          { svc: 'API server',    status: j.status === 'ok' ? 'ok' : 'degraded', value: (j.environment || '—') + ' · ' + (j.region || 'unknown') + ' · ' + (j.uptime || 0) + 's uptime' },
          { svc: 'Supabase',      status: (j.checks && j.checks.supabase && j.checks.supabase.status) || 'unknown', value: ((j.checks && j.checks.supabase && j.checks.supabase.latencyMs) || 0) + 'ms · code ' + ((j.checks && j.checks.supabase && j.checks.supabase.statusCode) || '?') },
          { svc: 'Env config',    status: (j.checks && j.checks.env && j.checks.env.status) || 'unknown', value: ((j.checks && j.checks.env && j.checks.env.configured) || 0) + ' / ' + ((j.checks && j.checks.env && j.checks.env.total) || 0) + ' required vars' },
          { svc: 'SW cache',      status: 'ok', value: 'tlb-v229 · 100% native dashboards' },
          { svc: 'Commit',        status: 'ok', value: (j.commit_sha || 'local') + ' · ' + (j.version || '?') },
          { svc: 'Response time', status: j.responseMs < 500 ? 'ok' : j.responseMs < 1500 ? 'degraded' : 'down', value: (j.responseMs || 0) + 'ms end-to-end' },
        ];
        setSysLive(services);
        setUpdated(new Date());
      }).catch(() => {});
    }
    poll();
    const id = setInterval(poll, 30000);
    return () => { cancelled = true; clearInterval(id); };
  }, []);

  async function runSweep() {
    if (!confirm('Trigger a villa-health sweep? This recomputes the composite score for every villa using the latest sync state, photos, descriptions, and rates.')) return;
    setSweepBusy(true);
    let token = ''; try { token = localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; } catch {}
    try {
      const r = await fetch('/api/admin?action=trigger_cron_health', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
      });
      if (r.ok) {
        if (window.TLB_NAV && TLB_NAV.toast) TLB_NAV.toast('Sweep started · scores refresh in ~60s', 'success');
        setTimeout(() => {
          // Re-fetch villa health after sweep
          fetch('/api/admin?action=villa_health', {
            headers: token ? { 'Authorization': 'Bearer ' + token } : {},
          })
            .then(r => r.ok ? r.json() : null)
            .then(j => {
              const list = (j && j.data) || (j && j.villas) || [];
              if (Array.isArray(list)) {
                setVillaHealth(list.map(v => ({
                  id: v.id || v.property_id,
                  name: v.name || v.property_name || 'Villa',
                  host: v.host_email || v.host_name || '—',
                  area: (v.location || 'Bali').split(',')[0].trim(),
                  score: v.health_score != null ? Number(v.health_score) : null,
                  slug: v.url_slug || v.slug,
                })).filter(v => v.score != null));
              }
            });
        }, 60000);
      } else {
        const j = await r.json().catch(() => ({}));
        alert('Sweep failed: ' + (j.error || r.statusText));
      }
    } catch (e) {
      alert('Network error: ' + (e.message || 'unknown'));
    } finally {
      setSweepBusy(false);
    }
  }

  const sysItems = sysLive || D.admin.health || [];

  // Compute villa-health KPIs
  const villas = villaHealth || [];
  const healthy = villas.filter(v => v.score >= 80).length;
  const drift = villas.filter(v => v.score >= 60 && v.score < 80).length;
  const critical = villas.filter(v => v.score < 60).length;
  const sortedScores = villas.map(v => v.score).sort((a, b) => a - b);
  const median = sortedScores.length
    ? sortedScores[Math.floor(sortedScores.length / 2)]
    : 0;

  // Bottom 12 (worst-first), filtered by search
  const filtered = villas
    .filter(v => !search.trim() || v.name.toLowerCase().includes(search.toLowerCase()) || v.area.toLowerCase().includes(search.toLowerCase()) || v.host.toLowerCase().includes(search.toLowerCase()))
    .sort((a, b) => a.score - b.score);
  const bottom12 = filtered.slice(0, 12);

  // Group by area mode
  const groupedByArea = (() => {
    if (!groupByArea) return null;
    const groups = {};
    filtered.forEach(v => {
      if (!groups[v.area]) groups[v.area] = [];
      groups[v.area].push(v);
    });
    return Object.entries(groups)
      .map(([area, list]) => ({
        area,
        avg: Math.round(list.reduce((s, v) => s + v.score, 0) / list.length),
        count: list.length,
        list: list.sort((a, b) => a.score - b.score),
      }))
      .sort((a, b) => a.avg - b.avg);
  })();

  return (
    <div>
      {/* Section header */}
      <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 18, flexWrap: 'wrap' }}>
        <div>
          <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 8 }}>
            Composite score · 0–100 · {villas.length} villas tracked
          </Sans>
          <Display size={36} weight={500} ls={-0.02}>Villa health</Display>
          <Sans size={13} color={CC.mid} style={{ marginTop: 6, maxWidth: 640 }}>
            Sync, last sync time, photo count, description length, amenities, seasonal rates — combined into one score per villa.
          </Sans>
        </div>
        <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
          <button onClick={() => setGroupByArea(!groupByArea)} disabled={!villas.length}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: villas.length ? 'pointer' : 'not-allowed', background: groupByArea ? CC.ink : CC.white, color: groupByArea ? CC.white : CC.deep, border: '1px solid ' + (groupByArea ? CC.ink : CC.border), fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: villas.length ? 1 : 0.5 }}>
            By area
          </button>
          <button onClick={runSweep} disabled={sweepBusy}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: sweepBusy ? 'wait' : 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: sweepBusy ? 0.5 : 1 }}>
            {sweepBusy ? 'Running…' : 'Run sweep'}
          </button>
        </div>
      </div>

      {/* 4-tile unified border KPI grid */}
      <div style={{
        display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 0,
        border: `1px solid ${CC.border}`, borderRadius: 18, overflow: 'hidden', background: CC.white, marginBottom: dashGap(),
      }}>
        {[
          { label: 'Healthy · 80+',    value: healthy,  color: '#2d6a4f' },
          { label: 'Drift · 60–80',    value: drift,    color: '#c08a3e' },
          { label: 'Critical · <60',   value: critical, color: '#9a1c1c' },
          { label: 'Median score',     value: median,   color: CC.ink },
        ].map((k, i) => (
          <div key={k.label} style={{ padding: '30px 28px', borderRight: i < 3 ? `1px solid ${CC.border}` : 'none', display: 'flex', flexDirection: 'column', gap: 12, minHeight: 140 }}>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>{k.label}</Sans>
            <div style={{
              fontFamily: F_TLB.ui, fontSize: 48, fontWeight: 500,
              letterSpacing: '-0.028em', color: k.color,
              fontVariantNumeric: 'tabular-nums', lineHeight: 0.98,
              marginTop: 'auto',
            }}>{k.value}</div>
          </div>
        ))}
      </div>

      {/* Bottom 12 (or grouped) */}
      <Panel pad={0}>
        <div style={{ padding: '18px 28px', borderBottom: `1px solid ${CC.border}`, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
          <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>
            {groupByArea ? 'By area · sorted by avg ascending' : 'Bottom 12 · sorted ascending'}
          </Sans>
          <input
            type="text" value={search} onChange={(e) => setSearch(e.target.value)}
            placeholder="Find a villa, host, or area…"
            style={{
              padding: '8px 14px', borderRadius: 100, minWidth: 240,
              border: '1px solid ' + CC.border, background: CC.white,
              fontFamily: F_TLB.ui, fontSize: 12, color: CC.ink, outline: 'none',
            }}
          />
        </div>

        {!villaHealth ? (
          <div style={{ padding: 36, textAlign: 'center' }}>
            <Sans size={13} color={CC.mid}>Loading villa health snapshots…</Sans>
          </div>
        ) : villas.length === 0 ? (
          <div style={{ padding: 36, textAlign: 'center' }}>
            <Display size={20} italic weight={400}>No villa health data.</Display>
            <Sans size={12} color={CC.mid} style={{ marginTop: 8 }}>Run a sweep to compute the first scores, or import villas to begin tracking.</Sans>
          </div>
        ) : groupByArea ? (
          <div>
            {groupedByArea.map((group, gi) => (
              <div key={group.area} style={{ borderTop: gi === 0 ? '0' : `1px solid ${CC.border}` }}>
                <div style={{ padding: '14px 28px', background: CC.offWhite, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
                  <Display size={18} italic weight={400}>{group.area}</Display>
                  <div style={{ display: 'flex', gap: 18, alignItems: 'baseline' }}>
                    <Sans size={11} color={CC.mid}>{group.count} {group.count === 1 ? 'villa' : 'villas'}</Sans>
                    <Sans size={11} weight={600} color={group.avg < 60 ? '#9a1c1c' : group.avg < 80 ? '#c08a3e' : '#2d6a4f'}>
                      avg {group.avg}
                    </Sans>
                  </div>
                </div>
                {group.list.slice(0, 6).map((h, i) => <VillaHealthRow key={h.id || i} h={h} striped={i % 2 === 1} />)}
              </div>
            ))}
          </div>
        ) : (
          bottom12.map((h, i) => <VillaHealthRow key={h.id || i} h={h} striped={i % 2 === 1} />)
        )}
      </Panel>

      {/* System health (collapsible) */}
      <Panel pad={0} style={{ marginTop: dashGap() }}>
        <button
          onClick={() => setShowSysHealth(!showSysHealth)}
          style={{
            width: '100%', padding: '18px 28px', cursor: 'pointer',
            background: 'transparent', border: 0, textAlign: 'left',
            display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 14,
            fontFamily: 'inherit',
          }}
        >
          <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>System health · API uptime · 6 services</Sans>
            {sysLive && <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '2px 8px', borderRadius: 100, background: 'rgba(105,210,138,0.12)', color: '#2d6a4f', fontSize: 9, fontWeight: 700, letterSpacing: '0.08em' }}>● LIVE · 30s poll</span>}
          </div>
          <Sans size={11} color={CC.mid}>{showSysHealth ? '▾' : '▸'} {updated ? 'Updated ' + updated.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : 'Polling…'}</Sans>
        </button>
        {showSysHealth && (
          <div style={{ padding: '0 28px 28px' }}>
            <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 14 }}>
              {sysItems.map(h => (
                <div key={h.svc} style={{ padding: 18, border: `1px solid ${CC.border}`, borderRadius: 12 }}>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
                    <span style={{ width: 8, height: 8, borderRadius: 99, background: h.status === 'ok' ? '#69d28a' : h.status === 'degraded' ? '#c08a3e' : '#9a1c1c' }} />
                    <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>{h.status}</Sans>
                  </div>
                  <Display size={18} weight={500} style={{ marginTop: 10 }}>{h.svc}</Display>
                  <Sans size={12} color={CC.deep} style={{ marginTop: 6, fontFamily: 'ui-monospace, monospace' }}>{h.value}</Sans>
                </div>
              ))}
            </div>
          </div>
        )}
      </Panel>
    </div>
  );
}

// Per-villa health row — used by both Bottom 12 + Grouped views
function VillaHealthRow({ h, striped }) {
  const tone = h.score < 50 ? '#9a1c1c' : h.score < 70 ? '#c08a3e' : '#2d6a4f';
  return (
    <div style={{
      display: 'grid', gridTemplateColumns: '1.4fr 1fr 1fr auto auto',
      alignItems: 'center', gap: 18,
      padding: '18px 28px',
      borderTop: `1px solid ${CC.border}`,
      background: striped ? '#fafaf7' : CC.white,
    }}>
      <div style={{ fontFamily: F_TLB.display, fontSize: 17, letterSpacing: '-0.01em' }}>{h.name}</div>
      <div style={{ fontFamily: F_TLB.ui, fontSize: 12, color: CC.mid }}>{h.host}</div>
      <div style={{ fontFamily: F_TLB.ui, fontSize: 12, color: CC.mid }}>{h.area}</div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 14, minWidth: 220 }}>
        <div style={{ flex: 1, height: 6, borderRadius: 99, background: CC.offWhite, overflow: 'hidden' }}>
          <div style={{ width: `${h.score}%`, height: '100%', background: tone, borderRadius: 99 }} />
        </div>
        <div style={{
          fontFamily: F_TLB.ui, fontWeight: 600, fontSize: 17,
          fontVariantNumeric: 'tabular-nums', minWidth: 32, textAlign: 'right', color: tone,
        }}>{h.score}</div>
      </div>
      <button
        onClick={() => {
          if (h.slug) {
            window.open('/villa/' + h.slug, '_blank');
          } else {
            window.dispatchEvent(new CustomEvent('tlb-admin-tab', { detail: 'properties' }));
          }
        }}
        style={{
          padding: '6px 14px', borderRadius: 100, cursor: 'pointer',
          background: CC.white, color: CC.deep, border: '1px solid ' + CC.border,
          fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600,
        }}
      >Open</button>
    </div>
  );
}

window.AdminCommandCenter = AdminCommandCenter;
