/* ==================================================================
   Admin Cockpit — shared primitives
   ------------------------------------------------------------------
   Every cockpit overview screen (Dashboard, Academics, Hostel, Parents,
   KinetiX, Finance, Staff Attendance) is built from the pieces in this
   file, so the reference design's stat card / pill / meter / row is
   defined exactly once. Screens below import them off window.CX.

   Loaded before the screens that use it — see index.html.

   Naming: everything here is prefixed Cx (or lives under window.CX) so it
   cannot collide with the two dozen pre-existing screen globals that share
   this scope. Babel-standalone gives every <script type="text/babel"> the
   same top-level scope, so a bare `const Card` here would be a redeclaration
   error the moment another screen does the same.
   ================================================================== */

/* ---------- Data fetching ---------- */

// One hook behind every cockpit screen. Returns { data, error, loading,
// reload } and re-fetches when `path` changes.
//
// `loading` is only true on the FIRST load for a given path. A refresh keeps
// the previous data on screen and flips `refreshing` instead — a dashboard
// that blanks itself to skeletons every 60 seconds is unusable, and the
// numbers rarely move enough to be worth the flash.
const useCockpitData = (path, { pollMs = 0 } = {}) => {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);
  const [refreshing, setRefreshing] = useState(false);
  const seen = useRef(false);

  const load = useCallback(async (quiet) => {
    if (quiet) setRefreshing(true); else setLoading(!seen.current);
    try {
      const d = await window.KXApi.get(path);
      setData(d);
      setError(null);
      seen.current = true;
    } catch (e) {
      // Keep whatever is on screen; surface the failure alongside it rather
      // than replacing real numbers with an error page.
      setError(e?.message || "Could not load this screen.");
    } finally {
      setLoading(false);
      setRefreshing(false);
    }
  }, [path]);

  useEffect(() => { seen.current = false; load(false); }, [load]);

  useEffect(() => {
    if (!pollMs) return;
    const t = setInterval(() => load(true), pollMs);
    return () => clearInterval(t);
  }, [pollMs, load]);

  return { data, error, loading, refreshing, reload: () => load(true) };
};

/* ---------- Formatting ---------- */

const cxNum = (n) => (n == null ? "—" : Number(n).toLocaleString("en-IN"));

// Indian-format currency, abbreviated at lakh/crore because a dashboard tile
// cannot hold "₹51,97,700" at 46px without wrapping.
const cxMoney = (n, { full = false } = {}) => {
  if (n == null) return "—";
  const v = Number(n);
  if (full) return "₹" + v.toLocaleString("en-IN", { maximumFractionDigits: 0 });
  const abs = Math.abs(v);
  if (abs >= 1e7) return "₹" + (v / 1e7).toFixed(abs >= 1e8 ? 0 : 2).replace(/\.00$/, "") + " Cr";
  if (abs >= 1e5) return "₹" + (v / 1e5).toFixed(abs >= 1e6 ? 1 : 2).replace(/\.00$/, "") + " L";
  if (abs >= 1e3) return "₹" + (v / 1e3).toFixed(0) + "k";
  return "₹" + v.toLocaleString("en-IN", { maximumFractionDigits: 0 });
};

const cxPct = (n) => (n == null ? "—" : `${Number(n)}%`);

const cxInitials = (name) =>
  String(name || "?")
    .trim().split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase() || "?";

// Deterministic avatar tint, so the same person is the same colour every
// render and across screens.
const CX_AV_PALETTE = [
  ["#E7F0FE", "#2B62D9"],
  ["#FDE9F3", "#D31C74"],
  ["#E8F8EE", "#1F9D4D"],
  ["#FFF3E0", "#C97B00"],
  ["#EDEAFB", "#5A49C4"],
];
const cxAvatarTint = (seed) => {
  const s = String(seed || "");
  let h = 0;
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
  return CX_AV_PALETTE[h % CX_AV_PALETTE.length];
};

const cxRelTime = (iso) => {
  if (!iso) return "—";
  const ms = Date.now() - new Date(iso).getTime();
  if (Number.isNaN(ms)) return "—";
  const s = Math.floor(ms / 1000);
  if (s < 60) return "just now";
  if (s < 3600) return `${Math.floor(s / 60)}m ago`;
  if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
  if (s < 7 * 86400) return `${Math.floor(s / 86400)}d ago`;
  return new Date(iso).toLocaleDateString("en-IN", { day: "numeric", month: "short" });
};

const cxDate = (iso, opts) => {
  if (!iso) return "—";
  const d = new Date(iso.length === 10 ? iso + "T00:00:00" : iso);
  if (Number.isNaN(d.getTime())) return "—";
  return d.toLocaleDateString("en-IN", opts || { day: "numeric", month: "short" });
};

const cxTime = (iso) => {
  if (!iso) return "—";
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return "—";
  return d.toLocaleTimeString("en-IN", { hour: "numeric", minute: "2-digit" });
};

const cxTitle = (s) =>
  String(s || "").replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());

// student_health_logs.temperature_c is nominally Celsius, but wardens type
// what the thermometer shows and some of those are Fahrenheit — the column has
// values like 101.0 alongside 39.5. Rendering a bare "°C" on all of them
// prints "101.0°C" on a health record, which is nonsense a nurse would act on.
// Anything above 45 cannot be a body temperature in Celsius, so label it °F.
const cxTemp = (v) => {
  if (v == null || v === "") return null;
  const n = Number(v);
  if (Number.isNaN(n)) return null;
  return n > 45 ? `${n}°F` : `${n}°C`;
};

/* ---------- Icons (reference set: 24-box, 1.8 stroke) ---------- */

const CX_PATHS = {
  staff: <><path d="M17 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="10" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></>,
  building: <><path d="M3 21h18"/><path d="M5 21V7l7-4 7 4v14"/><path d="M9 21v-6h6v6"/></>,
  pulse: <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>,
  cap: <><path d="M22 10 12 5 2 10l10 5 10-5z"/><path d="M6 12v5c0 1.7 2.7 3 6 3s6-1.3 6-3v-5"/></>,
  clock: <><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></>,
  heart: <path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l8.8 8.8 8.8-8.8a5.5 5.5 0 0 0 0-7.8z"/>,
  chat: <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>,
  alert: <><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></>,
  bolt: <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>,
  smile: <><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></>,
  rupee: <><path d="M6 3h12"/><path d="M6 8h12"/><path d="M6 13h4a5 5 0 0 0 0-10"/><path d="M6 13l8 8"/></>,
  wallet: <><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4z"/></>,
  box: <><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></>,
  bell: <><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></>,
  arrowUpRight: <><line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/></>,
  arrowLeft: <><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></>,
  download: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></>,
  activity: <><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></>,
  plus: <><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></>,
  plug: <><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v3a6 6 0 0 1-12 0V8z"/></>,
  book: <><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></>,
  bus: <><path d="M4 17h16"/><rect x="3" y="4" width="18" height="13" rx="2"/><circle cx="7.5" cy="19" r="1.5"/><circle cx="16.5" cy="19" r="1.5"/><path d="M3 10h18"/></>,
  shield: <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>,
  users: <><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/></>,
  file: <><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></>,
};

const CxIcon = ({ name, size = 18, strokeWidth = 1.8, style }) => (
  <svg viewBox="0 0 24 24" width={size} height={size} fill="none"
       stroke="currentColor" strokeWidth={strokeWidth}
       strokeLinecap="round" strokeLinejoin="round" style={style} aria-hidden="true">
    {CX_PATHS[name] || CX_PATHS.pulse}
  </svg>
);

/* ---------- Building blocks ---------- */

const CxCard = ({ children, className = "", style, ...rest }) => (
  <div className={`cx-card ${className}`} style={style} {...rest}>{children}</div>
);

// The big-number tile from the reference: coloured circular icon with a halo,
// a label, a 46px figure, and a footer line.
const CxStatCard = ({
  icon, color, label, value, delta, deltaTone = "up", suffix,
  footNote, action, onAction, actionLabel,
}) => (
  <div className="cx-card cx-stat">
    <div className="cx-stat-head">
      <div style={{ display: "flex", alignItems: "center", gap: 14, minWidth: 0 }}>
        <div className="cx-stat-icon"
             style={{ background: color, boxShadow: `0 0 0 5px ${color}26` }}>
          <CxIcon name={icon} size={20}/>
        </div>
        <div className="cx-stat-label">{label}</div>
      </div>
      {onAction && (
        <button className="cx-round-btn sm" onClick={onAction}
                title={actionLabel || "Open"} aria-label={actionLabel || "Open"}>
          <CxIcon name="arrowUpRight" size={16}/>
        </button>
      )}
    </div>
    <div className="cx-stat-row">
      <span className="cx-stat-value">{value}</span>
      {suffix && <span style={{ fontSize: 15, fontWeight: 600, color: "var(--ink-3)" }}>{suffix}</span>}
      {delta && <span className={`cx-delta ${deltaTone}`}>{delta}</span>}
    </div>
    <div className="cx-stat-foot">
      <span style={{ fontSize: 13, color: "var(--ink-3)" }}>{footNote}</span>
      {action}
    </div>
  </div>
);

const CxPill = ({ tone = "", children, className = "", ...rest }) => (
  <span className={`cx-pill ${tone ? "tint " + tone : ""} ${className}`} {...rest}>{children}</span>
);

const CxMeter = ({ pct, color = "var(--blue-solid)", className = "" }) => (
  <div className={`cx-meter ${className}`}>
    <div style={{ width: `${Math.max(0, Math.min(100, Number(pct) || 0))}%`, background: color }}/>
  </div>
);

const CxAvatar = ({ name, size = "" }) => {
  const [bg, fg] = cxAvatarTint(name);
  return <div className={`cx-av ${size}`} style={{ background: bg, color: fg }}>{cxInitials(name)}</div>;
};

const CxSeg = ({ value, options, onChange }) => (
  <div className="cx-seg" role="tablist">
    {options.map((o) => (
      <button key={o.value} role="tab" aria-selected={value === o.value}
              className={value === o.value ? "active" : ""}
              onClick={() => onChange(o.value)}>{o.label}</button>
    ))}
  </div>
);

const CxPageHead = ({ eyebrow, title, actions, onBack }) => (
  <div className="cx-page-head">
    <div style={{ display: "flex", alignItems: "center", gap: 16, minWidth: 0 }}>
      {onBack && (
        <button className="cx-round-btn" onClick={onBack} title="Back" aria-label="Back">
          <CxIcon name="arrowLeft"/>
        </button>
      )}
      <div style={{ minWidth: 0 }}>
        {eyebrow && <div className="cx-eyebrow">{eyebrow}</div>}
        <h1 className="cx-h1">{title}</h1>
      </div>
    </div>
    {actions && <div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>{actions}</div>}
  </div>
);

// Shown where a card has nothing to draw. Deliberately the same white card as
// everything else, so a quiet section reads as "nothing here yet" rather than
// as a rendering failure.
const CxEmpty = ({ icon = "box", title, hint }) => (
  <div className="cx-empty">
    <div className="cx-empty-mark"><CxIcon name={icon} size={20}/></div>
    <div><strong>{title}</strong>{hint && <span>{hint}</span>}</div>
  </div>
);

// The "system not connected" card. This exists so an unwired subsystem looks
// deliberate and tells you what to do, instead of showing a zero that reads as
// a real measurement.
const CxNotConnected = ({ title, what, why }) => (
  <div className="cx-card">
    <div className="cx-empty">
      <div className="cx-empty-mark" style={{ background: "var(--amber-bg)", color: "var(--amber)" }}>
        <CxIcon name="plug" size={20}/>
      </div>
      <div>
        <strong>{title}</strong>
        <span style={{ display: "block", marginTop: 4 }}>{what}</span>
      </div>
      {why && <div style={{ fontSize: 12, color: "var(--ink-4)", maxWidth: 420 }}>{why}</div>}
    </div>
  </div>
);

// Card-shaped skeletons, sized to the layout they replace so the page doesn't
// jump when the data lands.
//
// The column count is a CSS custom property rather than an inline
// grid-template-columns, so the stylesheet can collapse it to one column on a
// phone. Inline, it stayed at three columns at 390px and rendered as three
// slivers with the rest of the screen blank — which read as "the app has
// finished and there is nothing here" rather than "still loading".
const CxSkeleton = ({ rows = 3, height = 150 }) => (
  <div className="cx-skelgrid" style={{ "--cx-skel-cols": rows }}>
    {Array.from({ length: rows }).map((_, i) => (
      <div key={i} className="cx-skel" style={{ height, borderRadius: "var(--radius-card)" }}/>
    ))}
  </div>
);

// Standard wrapper: skeleton while loading, an inline banner if the fetch
// failed, then the screen. The banner sits ABOVE the content rather than
// replacing it, because a stale dashboard is more useful than an error page.
const CxScreen = ({ state, children, skeleton }) => (
  <>
    {state.error && (
      <div className="cx-card" style={{
        marginBottom: 20, padding: "14px 20px",
        display: "flex", alignItems: "center", gap: 12,
        borderColor: "rgba(229,72,77,0.3)", background: "var(--red-bg)",
      }}>
        <CxIcon name="alert" size={18} style={{ color: "var(--red)", flexShrink: 0 }}/>
        <span style={{ fontSize: 13, color: "var(--red)" }}>{state.error}</span>
        <button className="btn sm" style={{ marginLeft: "auto" }} onClick={state.reload}>Retry</button>
      </div>
    )}
    {state.loading && !state.data ? (skeleton || <CxSkeleton/>) : children}
  </>
);

/* ---------- Donut ---------- */

// Conic-gradient ring with a white hole, as in the reference. Segments are
// [{ value, color }]; anything left over is drawn in the track colour.
const CxDonut = ({ segments, total, centreValue, centreLabel, size = 170 }) => {
  const sum = segments.reduce((a, s) => a + (Number(s.value) || 0), 0);
  const denom = total || sum || 1;
  let acc = 0;
  const stops = segments.map((s) => {
    const from = (acc / denom) * 100;
    acc += Number(s.value) || 0;
    const to = (acc / denom) * 100;
    return `${s.color} ${from}% ${to}%`;
  });
  stops.push(`var(--bg-3) ${(acc / denom) * 100}% 100%`);
  const hole = Math.round(size * 0.74);
  return (
    <div style={{
      width: size, height: size, borderRadius: "50%",
      background: `conic-gradient(${stops.join(", ")})`,
      display: "grid", placeItems: "center", flexShrink: 0,
    }}>
      <div style={{
        width: hole, height: hole, borderRadius: "50%", background: "#fff",
        display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
      }}>
        <span style={{ fontSize: 34, fontWeight: 600, letterSpacing: "-1.5px", lineHeight: 1, color: "var(--ink-0)" }}>
          {centreValue}
        </span>
        <span style={{ fontSize: 12, color: "var(--ink-3)" }}>{centreLabel}</span>
      </div>
    </div>
  );
};

/* ---------- Labelled bar list ---------- */

const CxBarList = ({ items, showValue = true }) => (
  <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
    {items.map((it, i) => (
      <div key={i} style={{ display: "flex", alignItems: "center", gap: 10, fontSize: 13 }}>
        <span style={{ width: 62, fontWeight: 600, color: "var(--ink-0)", flexShrink: 0 }}>{it.label}</span>
        <div style={{ flex: 1, minWidth: 0 }}>
          <CxMeter pct={it.pct} color={it.color || "var(--blue-solid)"}/>
        </div>
        {showValue && (
          <span style={{ width: 44, textAlign: "right", color: "var(--ink-3)", flexShrink: 0 }}>{it.value}</span>
        )}
      </div>
    ))}
  </div>
);

/* ---------- Column chart (the rounded bars from the reference) ---------- */

const CxColumns = ({ items, height = 170 }) => (
  <div>
    <div style={{ display: "flex", alignItems: "flex-end", gap: 12, height }}>
      {items.map((it, i) => (
        <div key={i} title={it.title || `${it.label}: ${it.value}`}
             style={{
               flex: 1, maxWidth: 86,
               height: `${Math.max(6, Number(it.heightPct) || 0)}%`,
               background: it.color, borderRadius: 18,
               position: "relative", overflow: "hidden", minWidth: 0,
             }}>
          <span style={{
            position: "absolute", top: 10, left: 10,
            background: "rgba(255,255,255,0.92)", borderRadius: 999,
            padding: "3px 9px", fontSize: 12, fontWeight: 700, color: "var(--ink-0)",
          }}>{it.value}</span>
        </div>
      ))}
    </div>
    <div style={{ display: "flex", gap: 12, marginTop: 8 }}>
      {items.map((it, i) => (
        <div key={i} style={{
          flex: 1, maxWidth: 86, textAlign: "center",
          fontSize: 11.5, fontWeight: 600, color: "var(--ink-4)",
          textTransform: "uppercase", letterSpacing: "0.4px",
        }}>{it.label}</div>
      ))}
    </div>
  </div>
);

/* ---------- Month calendar ---------- */

// Renders the month containing `anchor` (an ISO date string), dotting any day
// that appears in `events` and ringing today.
const CxCalendar = ({ anchor, today, events, onMonthChange, offset = 0 }) => {
  const base = new Date((anchor || today) + "T00:00:00");
  const view = new Date(base.getFullYear(), base.getMonth() + offset, 1);
  const year = view.getFullYear(), month = view.getMonth();
  const daysInMonth = new Date(year, month + 1, 0).getDate();
  // Monday-first grid: JS getDay() is Sunday-first, so rotate.
  const lead = (new Date(year, month, 1).getDay() + 6) % 7;

  const byDay = new Map();
  for (const e of events || []) {
    const d = new Date(e.date + "T00:00:00");
    if (d.getFullYear() === year && d.getMonth() === month) {
      if (!byDay.has(d.getDate())) byDay.set(d.getDate(), []);
      byDay.get(d.getDate()).push(e);
    }
  }

  const todayD = new Date(today + "T00:00:00");
  const isThisMonth = todayD.getFullYear() === year && todayD.getMonth() === month;

  const cells = [];
  for (let i = 0; i < lead; i++) cells.push(null);
  for (let d = 1; d <= daysInMonth; d++) cells.push(d);

  return (
    <>
      <div className="cx-card-head" style={{ marginBottom: 14 }}>
        <div className="cx-card-title">
          {view.toLocaleDateString("en-IN", { month: "long", year: "numeric" })}
        </div>
        <div style={{ display: "flex", gap: 6 }}>
          <button className="cx-round-btn xs" onClick={() => onMonthChange(offset - 1)} aria-label="Previous month">‹</button>
          <button className="cx-round-btn xs" onClick={() => onMonthChange(offset + 1)} aria-label="Next month">›</button>
        </div>
      </div>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(7,1fr)", gap: 2, marginBottom: 4 }}>
        {["MO", "TU", "WE", "TH", "FR", "SA", "SU"].map((w) => (
          <div key={w} style={{ textAlign: "center", fontSize: 11, fontWeight: 600, color: "var(--ink-3)", padding: "4px 0" }}>{w}</div>
        ))}
      </div>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(7,1fr)", gap: 2, marginBottom: 14 }}>
        {cells.map((d, i) => {
          if (d == null) return <div key={i} style={{ height: 34 }}/>;
          const evs = byDay.get(d) || [];
          const isToday = isThisMonth && d === todayD.getDate();
          return (
            <div key={i}
                 title={evs.map((e) => e.title).join(" · ") || undefined}
                 style={{
                   height: 34, display: "flex", alignItems: "center", justifyContent: "center",
                   fontSize: 13, fontWeight: isToday ? 700 : 500, borderRadius: 10,
                   position: "relative",
                   background: isToday ? "var(--ink-btn)" : "transparent",
                   color: isToday ? "#fff" : "var(--ink-0)",
                 }}>
              {d}
              {evs.length > 0 && (
                <span style={{
                  position: "absolute", bottom: 3, left: "50%", marginLeft: -2,
                  width: 4, height: 4, borderRadius: "50%",
                  background: isToday ? "#fff" : evs[0].color,
                }}/>
              )}
            </div>
          );
        })}
      </div>
    </>
  );
};

/* ---------- CSV export ---------- */

// Client-side CSV. Kept here because three screens offer an export and the
// quoting rule (double every quote, wrap every field) must not be re-derived.
const cxExportCsv = (filename, rows) => {
  const csv = rows
    .map((r) => r.map((v) => `"${String(v ?? "").replace(/"/g, '""')}"`).join(","))
    .join("\n");
  const a = document.createElement("a");
  a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
  a.download = filename;
  a.click();
  URL.revokeObjectURL(a.href);
};

window.CX = {
  useCockpitData,
  num: cxNum, money: cxMoney, pct: cxPct, initials: cxInitials,
  avatarTint: cxAvatarTint, relTime: cxRelTime, date: cxDate, time: cxTime,
  title: cxTitle, temp: cxTemp,
  Icon: CxIcon, Card: CxCard, StatCard: CxStatCard, Pill: CxPill, Meter: CxMeter,
  Avatar: CxAvatar, Seg: CxSeg, PageHead: CxPageHead, Empty: CxEmpty,
  NotConnected: CxNotConnected, Skeleton: CxSkeleton, Screen: CxScreen,
  Donut: CxDonut, BarList: CxBarList, Columns: CxColumns, Calendar: CxCalendar,
  exportCsv: cxExportCsv,
};
