/* =========================================================
   CONNIE — panels (left + right column cards)
   ========================================================= */
const { useEffect: pEffect, useRef: pRef, useState: pState } = React;

// ----- Inline icon set (Lucide-styled outline, 1.75 stroke) -----
const ICON_PATHS = {
  menu:     <><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></>,
  mic:      <><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></>,
  square:   <rect x="6" y="6" width="12" height="12" rx="2"/>,
  search:   <><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/></>,
  mail:     <><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></>,
  sun:      <><circle cx="12" cy="12" r="4"/><line x1="12" y1="2" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="6.34" y2="6.34"/><line x1="17.66" y1="17.66" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="6.34" y2="17.66"/><line x1="17.66" y1="6.34" x2="19.07" y2="4.93"/></>,
  presentation: <><path d="M3 4h18"/><path d="M5 4v12h14V4"/><line x1="12" y1="16" x2="12" y2="20"/><line x1="8" y1="20" x2="16" y2="20"/></>,
  "arrow-up":  <><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></>,
  check:    <polyline points="20 6 9 17 4 12"/>,
  "loader-2": <><path d="M21 12a9 9 0 1 1-6.219-8.56"/></>,
  bot:      <><rect x="3" y="8" width="18" height="12" rx="3"/><circle cx="9" cy="14" r="1.4" fill="currentColor"/><circle cx="15" cy="14" r="1.4" fill="currentColor"/><line x1="12" y1="4" x2="12" y2="8"/><circle cx="12" cy="3.5" r="1.2" fill="currentColor"/></>,
  // dashboard-v3 controls
  settings: <><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></>,
  "eye-off": <><path d="M9.88 9.88a3 3 0 0 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" y1="2" x2="22" y2="22"/></>,
  "chevron-up":   <polyline points="18 15 12 9 6 15"/>,
  "chevron-down": <polyline points="6 9 12 15 18 9"/>,
  "chevron-left":  <polyline points="15 18 9 12 15 6"/>,
  "chevron-right": <polyline points="9 18 15 12 9 6"/>,
  "minimize-2": <><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></>,
  "maximize-2": <><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></>,
  x:        <><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></>,
  "volume-2": <><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></>,
  "volume-x": <><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></>,
  film:     <><rect x="2" y="2" width="20" height="20" rx="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="17" x2="22" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/></>,
  upload:   <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></>,
  image:    <><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></>,
  eye:      <><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></>,
  // dashboard-v3.1: drag handle (6-dot grip) + media controls for voice preview
  grip:     <><circle cx="9" cy="6" r="1.4" fill="currentColor" stroke="none"/><circle cx="15" cy="6" r="1.4" fill="currentColor" stroke="none"/><circle cx="9" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="9" cy="18" r="1.4" fill="currentColor" stroke="none"/><circle cx="15" cy="18" r="1.4" fill="currentColor" stroke="none"/></>,
  play:     <polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none"/>,
  pause:    <><rect x="6" y="5" width="4" height="14" rx="1" fill="currentColor" stroke="none"/><rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor" stroke="none"/></>,
  // per-user-visibility UI: calendar section title icon
  calendar: <><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></>,
  // Standort (S2a) section title icon
  "map-pin": <><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></>,
  // Aktien-Portfolio: card title + per-line trend + add/remove controls
  "trending-up":   <><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></>,
  "trending-down": <><polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/><polyline points="16 17 22 17 22 11"/></>,
  "line-chart":    <><path d="M3 3v18h18"/><path d="M7 14l4-4 3 3 5-6"/></>,
  plus:     <><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></>,
  trash:    <><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></>,
  // Wissen (Knowledge-Input, S4) section title icon
  "book-open": <><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></>,
  // Auth-Login slice (WB-Auth, 2026-06-04): logout button in topbar.
  "log-out": <><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"/></>,
};

// Group the flat knowledge-fact list for display (File-Upload extension): facts
// that share a ``source_id`` (the chunks of one uploaded file) collapse into a
// single entry ("N Fakten" + a preview); manually-typed facts (no source_id)
// stay individual. Order is preserved from the (newest-first) input. Pure
// function (exported on window for the JSX-parse + a future logic test).
function groupKnowledge(facts) {
  const out = [];
  const fileGroups = {};  // source_id -> out-index
  for (const f of Array.isArray(facts) ? facts : []) {
    if (!f || typeof f !== "object") continue;
    const sid = typeof f.source_id === "string" && f.source_id ? f.source_id : null;
    if (sid && f.source === "upload") {
      if (fileGroups[sid] === undefined) {
        fileGroups[sid] = out.length;
        out.push({
          key: "file:" + sid,
          isFile: true,
          sourceId: sid,
          title: f.title || null,
          topic: f.topic || null,
          created_by: f.created_by || null,
          count: 1,
          // Preview = the first chunk (chunk_index 0 sorts first server-side).
          preview: typeof f.body === "string" ? f.body : "",
        });
      } else {
        out[fileGroups[sid]].count += 1;
      }
    } else {
      out.push({
        key: "fact:" + (f.id || Math.random().toString(36).slice(2)),
        isFile: false,
        id: f.id || null,
        title: f.title || null,
        topic: f.topic || null,
        created_by: f.created_by || null,
        count: 1,
        preview: typeof f.body === "string" ? f.body : "",
      });
    }
  }
  return out;
}

function Icon({ name, size = 16, className = "", strokeWidth = 1.75 }) {
  return (
    <svg
      className={`icn ${className}`}
      width={size} height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={strokeWidth}
      strokeLinecap="round"
      strokeLinejoin="round"
      style={{ display: "inline-block", flexShrink: 0 }}
    >
      {ICON_PATHS[name] || null}
    </svg>
  );
}

// ============================================================
// HEUTE — schedule card (live: items from GET /web/context)
// ============================================================
const WEEKDAYS = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];

// Small dim placeholder used by the live cards while loading or on error.
function CardPlaceholder({ text }) {
  return (
    <div style={{
      padding: "18px 8px", textAlign: "center",
      color: "var(--fg-3)", fontSize: 12,
      fontFamily: "var(--font-mono)", letterSpacing: "0.12em",
    }}>
      {text}
    </div>
  );
}

function ScheduleCard({ items }) {
  const weekday = WEEKDAYS[new Date().getDay()];
  const loading = items === null;          // not loaded yet
  const list = Array.isArray(items) ? items : [];
  const meta = loading
    ? "lade ..."
    : `${list.length} ${list.length === 1 ? "Termin" : "Termine"}`;
  return (
    <div className="card">
      <div className="card__head">
        <div className="card__title">Heute · {weekday}</div>
        <div className="card__meta">{meta}</div>
      </div>
      <div className="schedule">
        {loading && <CardPlaceholder text="lade Termine ..." />}
        {!loading && list.length === 0 && <CardPlaceholder text="Keine Termine heute." />}
        {!loading && list.map((a, i) => (
          <div key={i} className={`appt appt--${a.tone || "teal"}`}>
            <div className="appt__time">{a.time}</div>
            <div>
              <div className="appt__title">{a.title}</div>
              {a.sub && <div className="appt__sub">{a.sub}</div>}
            </div>
            <div className="appt__dot" />
          </div>
        ))}
      </div>
    </div>
  );
}

// ============================================================
// PIPELINE — HubSpot stats
// ============================================================
function Sparkline({ data, color = "#05E6A5" }) {
  const w = 100, h = 28;
  const max = Math.max(...data), min = Math.min(...data);
  const range = max - min || 1;
  const points = data
    .map((v, i) => {
      const x = (i / (data.length - 1)) * w;
      const y = h - ((v - min) / range) * (h - 4) - 2;
      return `${x.toFixed(1)},${y.toFixed(1)}`;
    })
    .join(" ");
  return (
    <svg className="spark" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none">
      <defs>
        <linearGradient id={`g-${color}`} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0%" stopColor={color} stopOpacity="0.4" />
          <stop offset="100%" stopColor={color} stopOpacity="0" />
        </linearGradient>
      </defs>
      <polyline points={points} fill="none" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
      <polygon points={`0,${h} ${points} ${w},${h}`} fill={`url(#g-${color})`} />
    </svg>
  );
}

// One open deal as a row. With a backend-built url it's a clickable deep-link
// (opens the HubSpot record in a new tab); without url it's plain text (the
// backend drops url when portal_id is unresolved - graceful, F2 / SR-DV-11).
function DealRow({ deal }) {
  const label = deal.name || "(kein Name)";
  const stage = deal.stage && deal.stage !== "-" ? deal.stage : "";
  const inner = (
    <>
      <span className="deal__name">{label}</span>
      {stage && <span className="deal__stage">{stage}</span>}
    </>
  );
  if (deal.url) {
    return (
      <a className="deal deal--link" href={deal.url}
         target="_blank" rel="noopener noreferrer" title="In HubSpot oeffnen">
        {inner}
      </a>
    );
  }
  return <div className="deal">{inner}</div>;
}

function PipelineCard({ data }) {
  const [modal, setModal] = pState(false);   // Wiedervorlage layover
  const loading = data === undefined || data === null;
  const openN = loading ? "–" : (data.open_deals ?? 0);
  const followN = loading ? "–" : (data.needs_followup ?? 0);
  const headline = loading
    ? "lade Pipeline ..."
    : (data.headline || "Keine Pipeline-Daten.");
  const topDeals = (!loading && Array.isArray(data.top_deals)) ? data.top_deals : [];
  // #6: deals parked on Wiedervorlage (future next-activity date) are NOT in
  // topDeals; they live here and open in a layover when the stat is clicked.
  const followups = (!loading && Array.isArray(data.followups)) ? data.followups : [];
  const canOpen = followups.length > 0;

  // Escape closes the Wiedervorlage layover.
  pEffect(() => {
    if (!modal) return;
    const onKey = (e) => { if (e.key === "Escape") setModal(false); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [modal]);

  const rowStyle = {
    display: "flex", justifyContent: "space-between", gap: 14, alignItems: "baseline",
    padding: "7px 0", borderBottom: "1px solid var(--line)",
  };
  const dateStyle = { fontFamily: "var(--font-mono)", fontSize: 12, color: "#FFB020", whiteSpace: "nowrap", flexShrink: 0 };
  const stageStyle = { color: "var(--fg-3)", fontSize: 12 };

  return (
    <div className="card">
      <div className="card__head">
        <div className="card__title">Pipeline · HubSpot</div>
        <div className="card__meta">{loading ? "lade ..." : "letzte 30 Tage"}</div>
      </div>
      <div className="pipe">
        <div className="pipe__stat">
          <div className="pipe__num v">{openN}</div>
          <div className="pipe__label">Offene Deals</div>
          {/* Decorative spark - no historical series is exposed by the API. */}
          <Sparkline data={[2, 3, 3, 4, 4, 5, 5]} color="#05E6A5" />
        </div>
        <div
          className="pipe__stat"
          onClick={canOpen ? () => setModal(true) : undefined}
          role={canOpen ? "button" : undefined}
          tabIndex={canOpen ? 0 : undefined}
          onKeyDown={canOpen ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setModal(true); } } : undefined}
          title={canOpen ? "Wiedervorlagen ansehen" : "Keine Wiedervorlagen"}
          style={{ cursor: canOpen ? "pointer" : "default" }}
        >
          <div className="pipe__num" style={{ color: "#FFB020" }}>{followN}</div>
          <div className="pipe__label">Wiedervorlage{canOpen ? " ›" : ""}</div>
          <Sparkline data={[1, 1, 0, 1, 2, 1, 2]} color="#FFB020" />
        </div>
      </div>
      {topDeals.length > 0 && (
        <div className="deals">
          {topDeals.map((d) => <DealRow key={d.id || d.name} deal={d} />)}
        </div>
      )}
      <div style={{
        marginTop: 12,
        fontFamily: "var(--font-mono)", fontSize: 10, letterSpacing: "0.04em",
        color: "var(--fg-3)", lineHeight: 1.5,
        paddingTop: 12, borderTop: "1px solid var(--line)",
      }}>
        {headline}
      </div>

      {modal && ReactDOM.createPortal(
        <div className="wf-modal" onClick={() => setModal(false)} role="dialog" aria-modal="true">
          <div className="wf-modal__panel" onClick={(e) => e.stopPropagation()}>
            <div className="wf-modal__head">
              <div className="wf-modal__title">Wiedervorlage</div>
              <button className="wf-modal__close" onClick={() => setModal(false)} aria-label="Schließen">
                <Icon name="x" size={16} strokeWidth={2} />
              </button>
            </div>
            <div className="wf-modal__note">
              Deals mit einem Folgetermin in der Zukunft - bis dahin geparkt, daher
              nicht in der Live-Liste.
            </div>
            <div className="wf-modal__body">
              {followups.map((f) => (
                <div style={rowStyle} key={f.id || f.name}>
                  <div>
                    {f.url
                      ? <a href={f.url} target="_blank" rel="noopener noreferrer"
                           style={{ color: "var(--fg-1)", textDecoration: "none" }}>{f.name}</a>
                      : <span>{f.name}</span>}
                    {f.stage && f.stage !== "-" && <span style={stageStyle}> · {f.stage}</span>}
                  </div>
                  <span style={dateStyle}>{f.date || ""}</span>
                </div>
              ))}
            </div>
            <div className="wf-modal__foot">
              <button className="wf-btn wf-btn--approve" onClick={() => setModal(false)}>Schließen</button>
            </div>
          </div>
        </div>,
        document.body
      )}
    </div>
  );
}

// ============================================================
// GEDÄCHTNIS — knowledge graph card
// ============================================================
// Truncate a node title for the small SVG label (keeps whole words where easy).
function shortLabel(s, max = 14) {
  const t = String(s || "");
  return t.length > max ? t.slice(0, max - 1) + "…" : t;
}

// Colour a node by its kind. Focus node = lime (CONVENTIC primary), neighbours
// teal (secondary). Tokens mirror styles.css (#E1F528 / #05E6A5).
function nodeColor(isFocus) {
  return isFocus ? "#E1F528" : "#05E6A5";
}

// Data-driven, hand-rolled radial layout (no library, SR-VM-10). The focus node
// sits at the centre; neighbours are placed on a circle at deterministic angles
// (no animation -> reduced-motion-friendly). Clicking a neighbour re-centres via
// onFocusNode(focusString). Labels are React text nodes (no innerHTML, SR-DV-9).
function MemoryGraph({ graph, onFocusNode }) {
  const W = 100, H = 72, cx = 50, cy = 36;
  const nodes = (graph && Array.isArray(graph.nodes)) ? graph.nodes : [];
  const focusId = graph ? graph.focus_id : null;

  if (!nodes.length) {
    // No subgraph yet (no focus / empty result): keep a calm placeholder.
    return (
      <div className="memory__graph memory__graph--empty">
        <CardPlaceholder text="kein Fokus gewaehlt" />
      </div>
    );
  }

  const focusNode = nodes.find((n) => n.id === focusId) || nodes[0];
  const neighbours = nodes.filter((n) => n.id !== focusNode.id);
  // Build an opaque "<label>:<key>:<value>" focus for a clicked neighbour, using
  // the same contract the backend allowlist expects. Client-side we only have
  // id/label/title - so only the name-keyed labels (Deal/Topic) are drillable
  // by title. Company is domain-keyed (title is the display name, not the
  // domain) and Person is email-keyed (we don't carry email) -> those nodes are
  // shown but not clickable. Returns null when a node can't be re-centred.
  function focusStringFor(n) {
    if (!n.title) return null;
    if (n.label === "Deal" || n.label === "Topic") {
      return `${n.label}:name:${n.title}`;
    }
    return null;
  }

  const R = 26; // neighbour ring radius in viewBox units
  const placed = neighbours.map((n, i) => {
    const ang = (2 * Math.PI * i) / Math.max(1, neighbours.length) - Math.PI / 2;
    return {
      ...n,
      x: cx + R * Math.cos(ang),
      y: cy + R * Math.sin(ang) * 0.82, // squash vertically to fit the card
    };
  });

  return (
    <svg className="memory__graph" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet"
         style={{ width: "100%" }} role="img" aria-label="Wissensgraph-Ausschnitt">
      <defs>
        <radialGradient id="node-core" cx="50%" cy="50%" r="50%">
          <stop offset="0%" stopColor="#FFFFFF" stopOpacity="1" />
          <stop offset="40%" stopColor="#05E6A5" stopOpacity="0.9" />
          <stop offset="100%" stopColor="#05E6A5" stopOpacity="0" />
        </radialGradient>
      </defs>
      {/* edges: focus -> each neighbour (radial spokes) */}
      {placed.map((n, i) => (
        <line key={`e-${i}`} x1={cx} y1={cy} x2={n.x} y2={n.y}
          stroke="rgba(5,230,165,0.28)" strokeWidth="0.4" />
      ))}
      {/* focus node (centre) */}
      <circle cx={cx} cy={cy} r="7" fill="url(#node-core)" opacity="0.6" />
      <circle cx={cx} cy={cy} r="3" fill={nodeColor(true)}
        style={{ filter: "drop-shadow(0 0 3px #E1F528)" }} />
      <text x={cx} y={cy + 9} textAnchor="middle" className="graph__label graph__label--focus">
        {shortLabel(focusNode.title, 16)}
      </text>
      {/* neighbour nodes */}
      {placed.map((n, i) => {
        const fs = focusStringFor(n);
        const clickable = !!fs && !!onFocusNode;
        return (
          <g key={`n-${n.id}`}
             style={{ cursor: clickable ? "pointer" : "default" }}
             onClick={clickable ? () => onFocusNode(fs) : undefined}>
            <circle cx={n.x} cy={n.y} r="2"
              fill={nodeColor(false)}
              style={{ filter: "drop-shadow(0 0 2px #05E6A5)" }} />
            <text x={n.x} y={n.y - 3} textAnchor="middle" className="graph__label">
              {shortLabel(n.title, 12)}
            </text>
          </g>
        );
      })}
    </svg>
  );
}

function MemoryCard({ data, syncLabel, graph, onFocusNode }) {
  const loading = data === undefined || data === null;
  const reachable = !loading && data.bolt === "reachable";
  const nodes = (!loading && typeof data.nodes === "number") ? data.nodes : null;
  const nodesLabel = loading
    ? "lade ..."
    : (nodes === null ? "nicht erreichbar" : nodes.toLocaleString("de-DE"));
  const meta = loading ? "lade ..." : (reachable ? "Live" : "offline");
  return (
    <div className="card">
      <div className="card__head">
        <div className="card__title">Gedächtnis · Graph</div>
        <div className="card__meta">{meta}</div>
      </div>
      <div className="memory">
        <MemoryGraph graph={graph} onFocusNode={onFocusNode} />
        <div className="memory__note">
          <span className="source">Memgraph · Connies Gedaechtnis</span>
          {loading
            ? "Verbinde mit dem Wissensgraphen ..."
            : reachable
              ? "Strukturiertes Wissen aus HubSpot, Gespraechen und Connies Notizen."
              : "Der Wissensgraph ist gerade nicht erreichbar."}
        </div>
        <div className="memory__stats">
          <span>Knoten <strong>{nodesLabel}</strong></span>
          <span>Sync <strong>{loading ? "lade ..." : (syncLabel || "offline")}</strong></span>
        </div>
      </div>
    </div>
  );
}

// ============================================================
// WETTER — local weather + next rain (frontend-only, Open-Meteo)
// ============================================================
// WMO weather-code -> short German description (local map, no library).
const WMO_DESC = {
  0: "Klar",
  1: "Ueberwiegend klar", 2: "Teils bewoelkt", 3: "Bedeckt",
  45: "Nebel", 48: "Reifnebel",
  51: "Leichter Niesel", 53: "Niesel", 55: "Starker Niesel",
  56: "Gefrierender Niesel", 57: "Starker gefrierender Niesel",
  61: "Leichter Regen", 63: "Regen", 65: "Starker Regen",
  66: "Gefrierender Regen", 67: "Starker gefrierender Regen",
  71: "Leichter Schneefall", 73: "Schneefall", 75: "Starker Schneefall",
  77: "Schneegriesel",
  80: "Leichte Regenschauer", 81: "Regenschauer", 82: "Starke Regenschauer",
  85: "Leichte Schneeschauer", 86: "Starke Schneeschauer",
  95: "Gewitter", 96: "Gewitter mit Hagel", 99: "Schweres Gewitter mit Hagel",
};
function wmoText(code) {
  return WMO_DESC[code] != null ? WMO_DESC[code] : "Wetterlage unbekannt";
}

// Derive the next-rain sentence from the hourly arrays. "Regen jetzt" when the
// current slot is already wet; "Regen ab HH:MM" for the first future wet slot;
// otherwise "Heute kein Regen erwartet." A slot counts as wet when
// precipitation > 0 OR precipitation_probability >= 50.
function nextRainText(hourly) {
  if (!hourly || !Array.isArray(hourly.time)) return "Heute kein Regen erwartet.";
  const times = hourly.time;
  const precip = hourly.precipitation || [];
  const prob = hourly.precipitation_probability || [];
  const now = Date.now();
  const isWet = (i) => (Number(precip[i]) > 0) || (Number(prob[i]) >= 50);
  // Find the slot index for the current hour (first slot whose time >= now-1h).
  let nowIdx = -1;
  for (let i = 0; i < times.length; i++) {
    const t = new Date(times[i]).getTime();
    if (!Number.isNaN(t) && t >= now - 3600 * 1000) { nowIdx = i; break; }
  }
  if (nowIdx === -1) nowIdx = 0;
  if (isWet(nowIdx)) return "Regen jetzt.";
  for (let i = nowIdx + 1; i < times.length; i++) {
    if (isWet(i)) {
      const hhmm = new Date(times[i]).toLocaleTimeString("de-DE",
        { hour: "2-digit", minute: "2-digit" });
      return `Regen ab ${hhmm}.`;
    }
  }
  return "Heute kein Regen erwartet.";
}

function WeatherCard() {
  // state: idle | locating | loading | ok | denied | error
  const [status, setStatus] = pState("idle");
  const [weather, setWeather] = pState(null);
  // The display label of the location the forecast is for (the saved Standort
  // name/label when present, else null = "lokal" via geolocation).
  const [placeLabel, setPlaceLabel] = pState(null);

  // Fetch the open-meteo forecast for given coordinates + set state. Returns
  // true on success. Coordinates are rounded to 2 decimals (~1km, SR-DV-8 /
  // SR-ST-2b) before they leave the browser.
  async function fetchForecast(lat, lon) {
    const url = `https://api.open-meteo.com/v1/forecast?latitude=${(+lat).toFixed(2)}`
      + `&longitude=${(+lon).toFixed(2)}`
      + `&current=temperature_2m,weather_code,precipitation`
      + `&hourly=precipitation,precipitation_probability`
      + `&timezone=auto&forecast_days=1`;
    const resp = await fetch(url);
    if (!resp.ok) return false;
    const data = await resp.json();
    setWeather(data);
    return true;
  }

  // The existing browser-geolocation path (now the FALLBACK, S2b): used when no
  // per-user Standort is saved or the location lookup fails.
  function loadFromGeolocation() {
    if (!("geolocation" in navigator)) { setStatus("denied"); return; }
    setPlaceLabel(null);
    setStatus("locating");
    navigator.geolocation.getCurrentPosition(
      async (pos) => {
        // SR-DV-8: round coordinates to 2 decimals (~1km) - enough for weather,
        // far less precise. Coordinates live only in RAM here, never stored/logged.
        const lat = pos.coords.latitude.toFixed(2);
        const lon = pos.coords.longitude.toFixed(2);
        setStatus("loading");
        try {
          if (await fetchForecast(lat, lon)) setStatus("ok");
          else setStatus("error");
        } catch (_) {
          setStatus("error");
        }
      },
      (_err) => { setStatus("denied"); }, // permission denied / unavailable: no IP fallback
      { timeout: 10000, maximumAge: 600000, enableHighAccuracy: false }
    );
  }

  // S2b: prefer the per-user saved Standort (GET /web/location). If it has
  // coordinates, use them; otherwise fall back to navigator.geolocation. The
  // owner is forced server-side by connie-app - the browser never sends it.
  async function load() {
    const apiBase = (window.CONNIE_API_BASE || "http://127.0.0.1:8081").replace(/\/+$/, "");
    const token = window.CONNIE_WEB_TOKEN || "";
    if (!token) { loadFromGeolocation(); return; }
    setStatus("loading");
    try {
      const resp = await fetch(`${apiBase}/web/location`, {
        headers: { Authorization: `Bearer ${token}` },
      });
      if (resp.ok) {
        const loc = await resp.json();
        if (loc && loc.source === "manual"
            && typeof loc.lat === "number" && typeof loc.lon === "number") {
          setPlaceLabel(loc.name || loc.label || null);
          try {
            if (await fetchForecast(loc.lat, loc.lon)) { setStatus("ok"); return; }
            setStatus("error"); return;
          } catch (_) { setStatus("error"); return; }
        }
      }
    } catch (_) {
      // fall through to geolocation
    }
    // No saved Standort (or lookup failed) -> existing geolocation path.
    loadFromGeolocation();
  }

  pEffect(() => { load(); }, []);

  const cur = (status === "ok" && weather && weather.current) ? weather.current : null;
  const temp = cur && cur.temperature_2m != null
    ? `${Math.round(cur.temperature_2m)}°C` : "--";
  const desc = cur ? wmoText(cur.weather_code) : "";
  const rain = (status === "ok" && weather) ? nextRainText(weather.hourly) : "";

  return (
    <div className="card">
      <div className="card__head">
        <div className="card__title">Wetter · Heute</div>
        <div className="card__meta">
          {status === "ok" ? (placeLabel || "lokal")
            : status === "locating" ? "Standort ..."
            : status === "loading" ? "lade ..."
            : status === "denied" ? "kein Standort"
            : status === "error" ? "Fehler" : "lade ..."}
        </div>
      </div>

      {(status === "idle" || status === "locating" || status === "loading") && (
        <CardPlaceholder text="lade Wetter ..." />
      )}

      {status === "ok" && (
        <div className="weather">
          <div className="weather__main">
            <span className="weather__icon"><Icon name="sun" size={26} /></span>
            <span className="weather__temp">{temp}</span>
            <span className="weather__desc">{desc}</span>
          </div>
          <div className="weather__rain">{rain}</div>
        </div>
      )}

      {(status === "denied" || status === "error") && (
        <div className="weather weather--empty">
          <div className="weather__note">
            {status === "denied"
              ? "Standort nicht freigegeben."
              : "Wetterdienst gerade nicht erreichbar."}
          </div>
          <button className="weather__retry" onClick={load}>Erneut versuchen</button>
        </div>
      )}

      {/* SR-DV-8 MUST: tell the user where the location goes. */}
      <div className="weather__hint">Standort geht an Open-Meteo (Drittanbieter, Schweiz).</div>
    </div>
  );
}

// ============================================================
// AKTIEN — per-user portfolio tile (GET /web/portfolio)
// ============================================================
// Header shows the EUR day-difference in EUR and %, green (teal) for +, red for
// -. Expandable to the per-stock list. Facts only - NO buy/sell badges in V1
// (Haftung, SR-SP-11). Disclaimer line at the bottom (from the API `disclaimer`).
// `data` is the tile shape from /web/portfolio: { available, per_stock, totals,
// disclaimer, currency } or null while loading.
function _fmtEur(n) {
  if (n === null || n === undefined || typeof n !== "number" || !isFinite(n)) return "--";
  // German format, 2 decimals, with a sign for non-zero day-diffs handled by callers.
  return n.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function _fmtPct(n) {
  if (n === null || n === undefined || typeof n !== "number" || !isFinite(n)) return "--";
  return n.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function _signCls(n) {
  if (typeof n !== "number" || !isFinite(n) || n === 0) return "flat";
  return n > 0 ? "up" : "down";
}

// Tiny per-line trend sparkline for the Aktien tile (feature 3). Inline SVG
// polyline (build-free, no library) over the cleaned daily-close series the
// backend already finite-filtered + length-capped (SR-Y-6). Pure number list,
// never provider text - rendered as a path, never as markup. Colour: teal when
// the last point is above the first, red when below, neutral grey when equal.
// Edge cases: empty -> null (nothing rendered); 1 point -> a flat mid-line;
// all-equal -> a horizontal line (range guarded to 1).
function StockSparkline({ data }) {
  const arr = (Array.isArray(data) ? data : []).filter(
    (v) => typeof v === "number" && isFinite(v)
  );
  if (!arr.length) return null;
  const w = 64, h = 22, pad = 2;
  if (arr.length === 1) {
    return (
      <svg className="stocks__spark" viewBox={`0 0 ${w} ${h}`} role="img" aria-label="Kursverlauf 3 Monate">
        <line x1="0" y1={h / 2} x2={w} y2={h / 2} stroke="var(--fg-3)" strokeWidth="1.2" />
      </svg>
    );
  }
  const max = Math.max(...arr), min = Math.min(...arr);
  const range = (max - min) || 1;
  const up = arr[arr.length - 1] >= arr[0];
  const color = arr[arr.length - 1] === arr[0]
    ? "var(--fg-2)"
    : (up ? "var(--teal)" : "var(--danger)");
  const pts = arr
    .map((v, i) =>
      `${((i / (arr.length - 1)) * w).toFixed(1)},`
      + `${(h - ((v - min) / range) * (h - 2 * pad) - pad).toFixed(1)}`
    )
    .join(" ");
  return (
    <svg className="stocks__spark" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none"
         role="img" aria-label="Kursverlauf 3 Monate">
      <polyline points={pts} fill="none" stroke={color} strokeWidth="1.3"
                strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function StockPortfolioCard({ data }) {
  const [open, setOpen] = pState(false);
  const loading = data === null || data === undefined;
  const available = !loading && data.available === true;
  const totals = (!loading && data.totals) || {};
  const list = (!loading && Array.isArray(data.per_stock)) ? data.per_stock : [];
  const disclaimer = (!loading && typeof data.disclaimer === "string") ? data.disclaimer : "";

  const diffEur = typeof totals.diff_eur === "number" ? totals.diff_eur : null;
  const diffPct = typeof totals.diff_pct === "number" ? totals.diff_pct : null;
  const sign = _signCls(diffEur);
  const arrow = sign === "down" ? "trending-down" : "trending-up";
  const signChar = (typeof diffEur === "number" && diffEur > 0) ? "+" : "";
  const staleCount = typeof totals.stale_count === "number" ? totals.stale_count : 0;
  const valueEur = typeof totals.value_eur === "number" ? totals.value_eur : null;
  // total_partial (feature 2): the shown sum omits N stale lines -> label it so
  // the figure is not read as the full portfolio value.
  const totalPartial = totals.total_partial === true;

  return (
    <div className="card">
      <div className="card__head">
        <div className="card__title">Aktien · Heute</div>
        <div className="card__meta">
          {loading ? "lade ..."
            : !available ? "leer"
            : `${list.length} ${list.length === 1 ? "Position" : "Positionen"}`}
        </div>
      </div>

      {loading && <CardPlaceholder text="lade Aktien ..." />}

      {!loading && !available && (
        <CardPlaceholder text="Noch keine Aktien hinterlegt - im Einstellungs-Menü ergänzen." />
      )}

      {!loading && available && (
        <div className="stocks">
          <button
            className="stocks__head"
            onClick={() => setOpen((o) => !o)}
            aria-expanded={open}
            title={open ? "Einzelaktien ausblenden" : "Einzelaktien anzeigen"}
          >
            <span className={`stocks__diff stocks__diff--${sign}`}>
              <Icon name={arrow} size={20} />
              <span className="stocks__diff-eur">{signChar}{_fmtEur(diffEur)} €</span>
              <span className="stocks__diff-pct">({signChar}{_fmtPct(diffPct)} %)</span>
            </span>
            <Icon name={open ? "chevron-up" : "chevron-down"} size={16} />
          </button>

          {/* Feature 2: prominent portfolio total. When the sum omits stale
              lines it is labelled "Teil-Summe (ohne N veraltete)" so it is not
              mistaken for the full portfolio value. */}
          <div className="stocks__total">
            <span className="stocks__total-label">
              {totalPartial
                ? `Teil-Summe (ohne ${staleCount} veraltete)`
                : "Portfolio-Wert"}
            </span>
            <span className="stocks__total-value">{_fmtEur(valueEur)} €</span>
          </div>
          {/* A stale note only when there are stale lines but no partial label
              already says so (e.g. all positions stale -> value 0). */}
          {staleCount > 0 && !totalPartial && (
            <div className="stocks__value stocks__stale">
              {staleCount} Kurs(e) evtl. veraltet
            </div>
          )}

          {open && (
            <div className="stocks__list">
              {list.map((s, i) => {
                const lSign = _signCls(s.diff_eur);
                const lSignChar = (typeof s.diff_eur === "number" && s.diff_eur > 0) ? "+" : "";
                const lValue = typeof s.value_eur === "number" ? s.value_eur : null;
                return (
                  <div key={s.wkn || i} className="stocks__row">
                    <div className="stocks__info">
                      {/* Name (longName) shown ALWAYS - confusion protection
                          against a wrong-but-valid symbol (SR-Y-4). */}
                      <span className="stocks__name">{s.name || s.wkn}</span>
                      <span className="stocks__sub">
                        {s.wkn}{typeof s.qty === "number" ? ` · ${_fmtPct(s.qty)} Stk` : ""}
                        {s.exchange ? ` · ${s.exchange}` : ""}
                        {s.currency ? ` · ${s.currency}` : ""}
                      </span>
                    </div>
                    {/* Feature 3: per-line trend sparkline (hidden for stale
                        lines - a missing/old series would mislead). */}
                    {!s.stale && <StockSparkline data={s.spark} />}
                    {s.stale ? (
                      <span className="stocks__row-stale" title="Kurs nicht verfügbar">
                        Kurs n/a
                      </span>
                    ) : (
                      <span className={`stocks__row-diff stocks__diff--${lSign}`}>
                        {/* Feature 1: EUR position value (qty x price x fx). */}
                        <span className="stocks__row-value">{_fmtEur(lValue)} €</span>
                        <span className="stocks__row-day">
                          {lSignChar}{_fmtEur(s.diff_eur)} € ({lSignChar}{_fmtPct(s.diff_pct)} %)
                        </span>
                      </span>
                    )}
                  </div>
                );
              })}
            </div>
          )}
        </div>
      )}

      {/* SR-SP-11: facts only, no advice. Disclaimer always visible. */}
      {disclaimer && <div className="weather__hint">{disclaimer}</div>}
    </div>
  );
}

// ============================================================
// ARBEITSGEDÄCHTNIS — recent cross-channel cues (GET /web/recent)
// ============================================================
// Channel -> short label + an icon name (reuses the inline ICON_PATHS set;
// falls back to "bot" for unknown channels). No new CDN, JSX text only (SR-DV-9).
const WM_CHANNEL = {
  telegram:  { label: "Telegram", icon: "mail" },
  whatsapp:  { label: "WhatsApp", icon: "mail" },
  web:       { label: "Dashboard", icon: "bot" },
  web:       { label: "Web", icon: "bot" },
};

// Relative time from an ISO/epoch ts, e.g. "vor 4 Min" / "vor 2 Std" / "gerade".
function relTime(ts) {
  if (!ts) return "";
  let then;
  if (typeof ts === "number") then = ts * 1000;
  else then = new Date(String(ts).endsWith("Z") ? ts : ts).getTime();
  if (!then || Number.isNaN(then)) return "";
  const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
  if (secs < 60) return "gerade";
  const mins = Math.round(secs / 60);
  if (mins < 60) return `vor ${mins} Min`;
  const hrs = Math.round(mins / 60);
  if (hrs < 48) return `vor ${hrs} Std`;
  const days = Math.round(hrs / 24);
  return `vor ${days} Tg`;
}

// The hot working-memory timeline. `items` come from /web/recent already shaped
// + peer-MASKED by the backend ({ts, channel, peer_display, summary}); we only
// render. peer_display is the masked form (e.g. "+49 170 ***") - no full PII
// ever reaches here (SR-DV-4/§5.8). All fields are JSX text nodes (no innerHTML).
function WorkingMemoryCard({ items }) {
  const loading = items === null || items === undefined;
  const list = Array.isArray(items) ? items : [];
  const meta = loading ? "lade ..." : `${list.length} ${list.length === 1 ? "Eintrag" : "Einträge"}`;
  return (
    <div className="card card--flex" style={{ flex: "0 0 auto" }}>
      <div className="card__head">
        <div className="card__title">Arbeitsgedächtnis · Live</div>
        <div className="card__meta">{meta}</div>
      </div>
      <div className="worklog">
        {loading && <CardPlaceholder text="lade Kontext ..." />}
        {!loading && list.length === 0 && (
          <CardPlaceholder text="Noch keine jüngsten Kommunikationen." />
        )}
        {!loading && list.map((it, i) => {
          const ch = WM_CHANNEL[it.channel] || { label: it.channel || "", icon: "bot" };
          return (
            <div key={i} className="wlog">
              <span className="wlog__icon" title={ch.label}>
                <Icon name={ch.icon} size={14} />
              </span>
              <div className="wlog__body">
                <div className="wlog__summary">{it.summary}</div>
                <div className="wlog__meta">
                  <span className="wlog__peer">{it.peer_display || "***"}</span>
                  {ch.label && <span className="wlog__chan">{ch.label}</span>}
                </div>
              </div>
              <div className="wlog__time">{relTime(it.ts)}</div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ============================================================
// AKTIVITÄT — live tool feed
// ============================================================
function ActivityCard({ items }) {
  return (
    <div className="card card--flex" style={{ flex: "0 0 auto" }}>
      <div className="card__head">
        <div className="card__title">Aktivität · Live</div>
        <div className="card__meta">{items.length} Schritte</div>
      </div>
      <div className="activity">
        {items.length === 0 && (
          <div style={{
            padding: "20px 8px", textAlign: "center",
            color: "var(--fg-3)", fontSize: 12,
            fontFamily: "var(--font-mono)", letterSpacing: "0.12em",
          }}>
            Keine aktiven Aufgaben.
          </div>
        )}
        {items.map((a, i) => (
          <div key={a.id} className="act" data-status={a.status}>
            <span className={`act__icon ${a.status === "running" ? "spinner" : ""}`}>
              {a.status === "running"
                ? <Icon name="loader-2" size={16} />
                : <Icon name="check" size={16} />}
            </span>
            <div>
              <div className="act__text">{a.text}</div>
              {a.sub && <div className="act__sub">{a.sub}</div>}
            </div>
            <div className="act__time">{a.time}</div>
          </div>
        ))}
      </div>
    </div>
  );
}

// ============================================================
// TRANSKRIPT — chat log
// ============================================================
function TranscriptCard({ messages }) {
  const scrollRef = pRef(null);
  pEffect(() => {
    if (scrollRef.current) {
      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
    }
  }, [messages]);

  return (
    <div className="card card--flex card--scroll">
      <div className="card__head">
        <div className="card__title">Transkript</div>
        <div className="card__meta">{messages.length} Nachrichten</div>
      </div>
      <div className="transcript" ref={scrollRef}>
        {messages.map((m, i) => (
          <div key={i} className={`msg msg--${m.who}`}>
            <div className="msg__who">{m.who === "user" ? "Bernd" : "Connie"}</div>
            <div>{m.text}</div>
          </div>
        ))}
      </div>
    </div>
  );
}

// ============================================================
// WORKFLOWS — approval queue + start + recent runs (GET /web/workflows|runs)
// ============================================================
// One card, three stacked areas:
//   1. Freigabe-Warteschlange - runs awaiting approval (status==awaiting_approval).
//      The most important part: Bernd approves/rejects Connie's pending actions.
//      Each row carries Freigeben (decision approve) / Ablehnen (reject); the
//      masked preview (approval.preview, redacted by the backend) can be loaded
//      on demand and shown collapsibly.
//   2. Workflows starten - the catalog from /web/workflows. Required inputs_spec
//      fields render as a tiny inline form; on success a short "Gestartet".
//   3. Letzte Laeufe - a compact list of non-awaiting runs with a status badge.
// All text is JSX text nodes (no innerHTML, SR-DV-9). The workflow names + the
// decision values come from the backend contract, never free-typed (SR side).

// Map a run status to {label, mod} - the modifier drives the badge colour in
// styles.css (.wf-badge--<mod>). Unknown -> neutral grey.
const WF_STATUS = {
  running:           { label: "läuft",     mod: "running" },
  awaiting_approval: { label: "Freigabe",  mod: "awaiting" },
  done:              { label: "fertig",    mod: "done" },
  failed:            { label: "Fehler",    mod: "failed" },
  partial:           { label: "teilweise", mod: "partial" },
  cancelled:         { label: "abgebrochen", mod: "cancelled" },
};
function wfStatus(s) {
  return WF_STATUS[s] || { label: s || "unbekannt", mod: "neutral" };
}

// Relative "seit" label for an ISO/epoch timestamp (reuses relTime's German
// wording but phrased as a duration, e.g. "seit 4 Min"). Falls back to "".
function wfSince(ts) {
  const rel = relTime(ts);
  if (!rel) return "";
  if (rel === "gerade") return "gerade eben";
  return rel.replace(/^vor /, "seit ");
}

// The approval preview can be HTML (e.g. a branded mail draft: <p>...</p>).
// Render it as READABLE TEXT, not raw markup, and WITHOUT innerHTML (the draft
// is LLM-produced and could carry an injected <script>/onerror - never inject
// it into Bernd's authenticated dashboard). <p>/<br> become line breaks, all
// other tags are dropped, the common entities are decoded.
function stripHtmlForPreview(s) {
  if (!s) return "";
  return String(s)
    .replace(/<\s*br\s*\/?>/gi, "\n")
    .replace(/<\/\s*(p|div|h[1-6]|li|tr)\s*>/gi, "\n")
    .replace(/<[^>]+>/g, "")
    .replace(/&nbsp;/gi, " ")
    .replace(/&amp;/gi, "&")
    .replace(/&lt;/gi, "<")
    .replace(/&gt;/gi, ">")
    .replace(/&quot;/gi, '"')
    .replace(/&#0*39;|&#x0*27;|&apos;/gi, "'")
    .replace(/\n{3,}/g, "\n\n")
    .trim();
}

// A single awaiting-approval row: name, since-when, approve/reject, and an
// optional collapsible masked preview loaded via onLoadPreview(run_id).
function ApprovalRow({ run, busy, onDecision, onLoadPreview }) {
  const [modal, setModal] = pState(false);    // preview/decision overlay open
  const [preview, setPreview] = pState(null);   // null = not loaded; ""/text once tried
  const [loading, setLoading] = pState(false);

  async function loadPreview() {
    if (preview !== null || !onLoadPreview) return;   // load once
    setLoading(true);
    try {
      const p = await onLoadPreview(run.run_id);
      setPreview(typeof p === "string" ? p : (p == null ? "" : String(p)));
    } catch (_) {
      setPreview("");   // keep the row usable even if the preview fails
    } finally {
      setLoading(false);
    }
  }

  function openModal() { setModal(true); loadPreview(); }
  function closeModal() { setModal(false); }
  function decide(decision) { onDecision(run.run_id, decision); closeModal(); }

  // Escape closes the overlay while it is open.
  pEffect(() => {
    if (!modal) return;
    const onKey = (e) => { if (e.key === "Escape") closeModal(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [modal]);

  return (
    <div className="wf-appr">
      <div className="wf-appr__head">
        <div className="wf-appr__main">
          <div className="wf-appr__name">{run.workflow || "(Workflow)"}</div>
          <div className="wf-appr__since">{wfSince(run.started_at)}</div>
        </div>
        <div className="wf-appr__actions">
          <button
            className="wf-btn wf-btn--preview"
            disabled={busy}
            onClick={openModal}
            title="Entwurf ansehen und entscheiden"
          >Prüfen</button>
          <button
            className="wf-btn wf-btn--approve"
            disabled={busy}
            onClick={() => onDecision(run.run_id, "approve")}
            title="Diese Aktion freigeben"
          >Freigeben</button>
          <button
            className="wf-btn wf-btn--reject"
            disabled={busy}
            onClick={() => onDecision(run.run_id, "reject")}
            title="Diese Aktion ablehnen"
          >Ablehnen</button>
        </div>
      </div>

      {modal && ReactDOM.createPortal(
        <div className="wf-modal" onClick={closeModal} role="dialog" aria-modal="true">
          <div className="wf-modal__panel" onClick={(e) => e.stopPropagation()}>
            <div className="wf-modal__head">
              <div className="wf-modal__title">{run.workflow || "(Workflow)"} - Freigabe</div>
              <button className="wf-modal__close" onClick={closeModal} aria-label="Schließen">
                <Icon name="x" size={16} strokeWidth={2} />
              </button>
            </div>
            <div className="wf-modal__note">
              Connie hat diese E-Mail für den neuen Lead entworfen. Mit Freigeben
              wird sie an den Lead versendet, mit Ablehnen verworfen.
            </div>
            <div className="wf-modal__body">
              {loading
                ? "lade Vorschau ..."
                : (preview ? stripHtmlForPreview(preview) : "Keine Vorschau verfügbar.")}
            </div>
            <div className="wf-modal__foot">
              <button className="wf-btn wf-btn--reject" disabled={busy}
                      onClick={() => decide("reject")}>Ablehnen</button>
              <button className="wf-btn wf-btn--approve" disabled={busy}
                      onClick={() => decide("approve")}>Freigeben</button>
            </div>
          </div>
        </div>,
        document.body
      )}
    </div>
  );
}

// A single catalog row: name + description + a Starten button, plus an inline
// form for any required inputs_spec fields. Local form state lives here; the
// parent only learns about it on submit (onStart(workflow, inputs)).
function WorkflowRow({ wf, busy, onStart }) {
  const spec = (wf.inputs_spec && typeof wf.inputs_spec === "object") ? wf.inputs_spec : {};
  const fields = Object.keys(spec);
  const requiredFields = fields.filter((k) => spec[k] && spec[k].required);
  const [values, setValues] = pState({});
  const [feedback, setFeedback] = pState(null);   // {kind:"ok"|"err", text}

  function setField(key, v) { setValues((s) => ({ ...s, [key]: v })); }

  async function submit(e) {
    e?.preventDefault?.();
    if (busy) return;
    // Only send fields the spec declares; trim text values.
    const inputs = {};
    fields.forEach((k) => {
      const v = values[k];
      if (v != null && String(v).trim() !== "") inputs[k] = String(v).trim();
    });
    // Client-side guard: required fields must be present (backend re-validates).
    const missing = requiredFields.filter((k) => !inputs[k]);
    if (missing.length) {
      setFeedback({ kind: "err", text: "Bitte Pflichtfelder ausfüllen." });
      return;
    }
    setFeedback(null);
    try {
      const res = await onStart(wf.workflow, inputs);
      if (res && res.ok) {
        setFeedback({ kind: "ok", text: "Gestartet" });
        setValues({});
      } else {
        setFeedback({ kind: "err", text: (res && res.detail) || "Start fehlgeschlagen." });
      }
    } catch (_) {
      setFeedback({ kind: "err", text: "Start fehlgeschlagen." });
    }
  }

  return (
    <form className="wf-row" onSubmit={submit}>
      <div className="wf-row__head">
        <div className="wf-row__info">
          <div className="wf-row__name">{wf.workflow || "(Workflow)"}</div>
          {wf.description && <div className="wf-row__desc">{wf.description}</div>}
        </div>
        <button className="wf-btn wf-btn--start" type="submit" disabled={busy}>
          Starten
        </button>
      </div>
      {fields.length > 0 && (
        <div className="wf-row__form">
          {fields.map((k) => {
            const f = spec[k] || {};
            return (
              <input
                key={k}
                className="wf-input"
                type="text"
                value={values[k] || ""}
                placeholder={(f.description || k) + (f.required ? " *" : "")}
                aria-label={f.description || k}
                onChange={(e) => setField(k, e.target.value)}
              />
            );
          })}
        </div>
      )}
      {feedback && (
        <div className={`wf-row__msg wf-row__msg--${feedback.kind}`}>
          {feedback.text}
        </div>
      )}
    </form>
  );
}

function WorkflowCard({ workflows, runs, available, onStart, onDecision, onLoadPreview }) {
  // busyRuns: a decision is in flight (disable buttons). decided: the run was
  // just approved/rejected - hide it AT ONCE, because the backend keeps it in
  // awaiting_approval until the next tick resumes it (up to ~2 min later).
  const [busyRuns, setBusyRuns] = pState({});
  const [decided, setDecided] = pState({});

  const loading = workflows === null || runs === null || workflows === undefined || runs === undefined;
  const wfList = Array.isArray(workflows) ? workflows : [];
  const runList = Array.isArray(runs) ? runs : [];

  // Service unreachable (explicit false from the backend `available` flag).
  const unreachable = available === false;

  const awaiting = runList.filter(
    (r) => r && r.status === "awaiting_approval" && !decided[r.run_id]
  );
  const recent = runList.filter((r) => r && r.status !== "awaiting_approval");

  // Once the backend has moved a decided run out of awaiting, drop it from the
  // local set so it cannot grow without bound (guarded: only set on change).
  pEffect(() => {
    const stillAwaiting = new Set(
      runList.filter((r) => r && r.status === "awaiting_approval").map((r) => r.run_id)
    );
    setDecided((s) => {
      let changed = false;
      const n = {};
      for (const id of Object.keys(s)) {
        if (stillAwaiting.has(id)) n[id] = true; else changed = true;
      }
      return changed ? n : s;
    });
  }, [runs]);

  // Wrap onDecision so the buttons disable during the request and the entry
  // disappears immediately on success (no stale, dead-looking buttons).
  async function decide(runId, decision) {
    if (busyRuns[runId]) return;
    setBusyRuns((s) => ({ ...s, [runId]: true }));
    try {
      const res = await onDecision(runId, decision);
      if (res && res.ok) setDecided((s) => ({ ...s, [runId]: true }));
    } finally {
      setBusyRuns((s) => { const n = { ...s }; delete n[runId]; return n; });
    }
  }

  const meta = loading
    ? "lade ..."
    : unreachable
      ? "offline"
      : `${awaiting.length} ${awaiting.length === 1 ? "Freigabe" : "Freigaben"}`;

  return (
    <div className="card card--flex" style={{ flex: "0 0 auto" }}>
      <div className="card__head">
        <div className="card__title">Workflows</div>
        <div className="card__meta">{meta}</div>
      </div>

      {loading && <CardPlaceholder text="lade Workflows ..." />}

      {!loading && unreachable && (
        <CardPlaceholder text="Workflow-Dienst nicht erreichbar" />
      )}

      {!loading && !unreachable && (
        <div className="wf">
          {/* 1. Freigabe-Warteschlange (prominent, top) */}
          <div className="wf-sec wf-sec--queue">
            <div className="wf-sec__title">Freigabe-Warteschlange</div>
            {awaiting.length === 0 ? (
              <div className="wf-empty">Keine Freigaben offen.</div>
            ) : (
              awaiting.map((r) => (
                <ApprovalRow
                  key={r.run_id}
                  run={r}
                  busy={!!busyRuns[r.run_id]}
                  onDecision={decide}
                  onLoadPreview={onLoadPreview}
                />
              ))
            )}
          </div>

          {/* 2. Workflows starten */}
          <div className="wf-sec">
            <div className="wf-sec__title">Workflow starten</div>
            {wfList.length === 0 ? (
              <div className="wf-empty">Keine Workflows hinterlegt.</div>
            ) : (
              wfList.map((wf) => (
                <WorkflowRow
                  key={wf.workflow}
                  wf={wf}
                  busy={false}
                  onStart={onStart}
                />
              ))
            )}
          </div>

          {/* 3. Recent runs */}
          <div className="wf-sec">
            <div className="wf-sec__title">Letzte Läufe</div>
            {recent.length === 0 ? (
              <div className="wf-empty">Noch keine Läufe.</div>
            ) : (
              <div className="wf-runs">
                {recent.slice(0, 8).map((r) => {
                  const st = wfStatus(r.status);
                  return (
                    <div key={r.run_id} className="wf-run">
                      <span className="wf-run__name">{r.workflow || "(Workflow)"}</span>
                      <span className={`wf-badge wf-badge--${st.mod}`}>{st.label}</span>
                      <span className="wf-run__time">
                        {wfSince(r.finished_at || r.started_at)}
                      </span>
                    </div>
                  );
                })}
              </div>
            )}
          </div>
        </div>
      )}
    </div>
  );
}

// ============================================================
// CARD SHELL - per-card controls (drag / resize / hide) - dashboard-v3.1
// ============================================================
// A thin wrapper that overlays a small control cluster onto the top-right of an
// existing card (the cards keep their own .card markup; .card is position:
// relative, so the overlay sits in its corner). Controls (revised v3.1 after
// Bernd's feedback that arrows + an invisible compact toggle felt bad):
//   - drag handle (grip)  -> pointerdown calls onDragHandleDown(cardId, e); the
//                            parent (App) owns the global pointer move/up logic,
//                            hit-tests the drop target (within AND across
//                            columns) and renders a drop-indicator line.
//   - size S / M / L      -> onSetSize(cardId, step); changes the rendered tile
//                            height for real (CSS .card-shell--size-*).
//   - hide (eye-off)      -> onHide(cardId)
//
// The wrapper carries data-card-id + data-col so the parent can map a pointer
// position to a card/column during a drag. ``dragging`` marks the source card
// (dimmed + lifted). All labels are echte Umlaute.
const SHELL_SIZES = ["S", "M", "L"];

function CardShell({
  cardId, col, size, dragging,
  onHide, onSetSize, onDragHandleDown, children,
}) {
  const sz = SHELL_SIZES.includes(size) ? size : "M";
  return (
    <div
      className={`card-shell card-shell--size-${sz} ${dragging ? "card-shell--dragging" : ""}`}
      data-card-id={cardId}
      data-col={col}
    >
      <div className="card-shell__ctrls" role="group" aria-label="Kachel-Steuerung">
        {/* Drag handle: pointer-based DnD owned by the parent. */}
        <button
          className="card-shell__btn card-shell__grip"
          title="Verschieben (ziehen)"
          aria-label="Kachel verschieben"
          onPointerDown={(e) => onDragHandleDown(cardId, e)}
        ><Icon name="grip" size={13} strokeWidth={2} /></button>

        {/* Discrete size step - visibly changes the tile height. */}
        <div className="card-shell__sizes" role="group" aria-label="Kachelgröße">
          {SHELL_SIZES.map((s) => (
            <button
              key={s}
              className={`card-shell__size ${sz === s ? "is-active" : ""}`}
              title={`Größe ${s}`}
              aria-label={`Kachelgröße ${s}`}
              aria-pressed={sz === s}
              onClick={() => onSetSize(cardId, s)}
            >{s}</button>
          ))}
        </div>

        <button
          className="card-shell__btn card-shell__btn--hide" title="Ausblenden"
          aria-label="Kachel ausblenden"
          onClick={() => onHide(cardId)}
        ><Icon name="eye-off" size={13} strokeWidth={2} /></button>
      </div>
      {children}
    </div>
  );
}

// ============================================================
// HELP / SHORTCUTS OVERLAY - opened from the topbar "menu" button (#8 + #10)
// ============================================================
// A small centred modal (reuses the wf-modal CI styling) that lists the
// keyboard shortcuts + a one-line "what is this". Closable via X, Escape, or a
// backdrop click. A function declaration (not const) so app.jsx sees it across
// the babel-script boundary, like SettingsPanel. Self-contained: no new CSS
// classes (key rows use inline styles with the existing CI vars).
function HelpOverlay({ open, onClose }) {
  // The shortcuts kept local so there is no cross-file binding to worry about.
  const SHORTCUTS = [
    { keys: "Leertaste halten", desc: "Sprechen (Push-to-Talk) - Loslassen sendet." },
    { keys: "g", desc: "Morgen-Briefing vorlesen (im Morgenfenster 05-11 Uhr)." },
    { keys: "Esc", desc: "Dialoge und Panels schliessen." },
  ];

  pEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open, onClose]);

  if (!open) return null;

  const rowStyle = { display: "flex", gap: 14, alignItems: "baseline", padding: "2px 0" };
  const keyStyle = {
    fontFamily: "var(--font-mono)", fontSize: 12, lineHeight: 1.6,
    padding: "4px 10px", borderRadius: 7, whiteSpace: "nowrap",
    background: "rgba(225,245,40,0.10)", border: "1px solid var(--line)",
    color: "var(--fg-1)", flexShrink: 0, minWidth: 132, textAlign: "center",
  };
  const descStyle = { color: "var(--fg-2)", fontSize: 13, lineHeight: 1.45 };

  return ReactDOM.createPortal(
    <div className="wf-modal" onClick={onClose} role="dialog" aria-modal="true">
      <div className="wf-modal__panel" onClick={(e) => e.stopPropagation()}>
        <div className="wf-modal__head">
          <div className="wf-modal__title">Tastaturkürzel & Hilfe</div>
          <button className="wf-modal__close" onClick={onClose} aria-label="Schließen">
            <Icon name="x" size={16} strokeWidth={2} />
          </button>
        </div>
        <div className="wf-modal__note">
          Connie - die KI-Assistentin von CONVENTIC. Sprich per Mikrofon oder
          tippe unten ins Feld. Die wichtigsten Kürzel:
        </div>
        <div className="wf-modal__body">
          <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
            {SHORTCUTS.map((s) => (
              <div style={rowStyle} key={s.keys}>
                <span style={keyStyle}>{s.keys}</span>
                <span style={descStyle}>{s.desc}</span>
              </div>
            ))}
          </div>
        </div>
        <div className="wf-modal__foot">
          <button className="wf-btn wf-btn--approve" onClick={onClose}>Verstanden</button>
        </div>
      </div>
    </div>,
    document.body
  );
}

// ============================================================
// SETTINGS PANEL - voice / text-size / hidden tiles - dashboard-v3.1
// ============================================================
// A right-side drawer in the conventic dark CI (glass + glow). Closable via the
// X, the Escape key, or a click on the backdrop. Sections:
//   1. Stimme      - language filter + a per-voice row (pick + pre-listen). The
//                    chosen voice -> onVoiceChange; the language filter ->
//                    onLanguageChange; a play button -> onPreview(voice).
//   2. Textgroesse - S / M / L segmented control -> onTextSizeChange
//   3. Ausgeblendete Kacheln - list of hidden cards with a "wieder anzeigen" btn
// Title-case helper for a label value (e.g. "german" -> "German"). Pure.
function _capLang(s) {
  const t = String(s || "").trim();
  return t ? t.charAt(0).toUpperCase() + t.slice(1) : t;
}

// Shorten a calendar id (a Gmail / group id) for the settings list so a long
// group id does not blow the row width. Keeps the local part recognisable.
function _shortCalId(id) {
  const s = String(id || "");
  if (s.length <= 34) return s;
  const at = s.indexOf("@");
  if (at > 0) {
    const local = s.slice(0, at);
    const domain = s.slice(at);
    const head = local.length > 16 ? local.slice(0, 16) + "…" : local;
    return head + domain;
  }
  return s.slice(0, 30) + "…";
}

function SettingsPanel({
  open, onClose,
  voices, voicesLoading, voiceId, onVoiceChange,
  language, onLanguageChange, onPreview, previewVoiceId,
  textSize, onTextSizeChange,
  hidden, cardTitles, onUnhide,
  agenda, agendaLoading, agendaAvailable, onLoadAgenda, onSaveAgenda,
  portfolio, portfolioLoading, onLoadPortfolio, onSavePortfolio, onResolveWkn,
  location, locationLoading, onLoadLocation, onSaveLocation,
  knowledge, knowledgeLoading, onLoadKnowledge, onSaveKnowledge, onDeleteKnowledge,
  onDeleteKnowledgeSource, onUploadKnowledge,
  // E-S6: voice-mode toggle + "Alle Voice-Sessions beenden" + active session info
  voiceMode, onVoiceModeChange, voiceSessionActive, voiceSessionInfo,
  onCloseAllVoiceSessions,
}) {
  // Local edit buffer for the calendar-visibility section: a map id -> "all"|
  // "owner", seeded from the loaded config. Kept separate from the loaded list
  // so toggles are instant and "Speichern" sends the whole edited set.
  const [agendaEdits, setAgendaEdits] = pState({});
  const [agendaSaveState, setAgendaSaveState] = pState("idle"); // idle|saving|saved|error
  const [agendaSaveMsg, setAgendaSaveMsg] = pState("");

  // Local edit buffer for the Aktien-Portfolio section: an array of {wkn, qty}
  // rows. Seeded from the loaded holdings; "Speichern" sends the whole set.
  const [pfRows, setPfRows] = pState(null);  // null = not seeded yet
  const [pfSaveState, setPfSaveState] = pState("idle"); // idle|saving|saved|error
  const [pfSaveMsg, setPfSaveMsg] = pState("");

  // WKN-resolve (S1b): per-row state for the "Aufloesen" flow. `pfResolveIdx` is
  // the row index currently resolving (or -1); `pfResults` holds {idx, results}
  // for the row whose candidate list is open; `pfResolveErr` is a short message.
  const [pfResolveIdx, setPfResolveIdx] = pState(-1);
  const [pfResults, setPfResults] = pState(null);   // {idx, results:[...]} | null
  const [pfResolveErr, setPfResolveErr] = pState("");

  // Local edit buffer for the Standort section: the free-text label. Seeded from
  // the loaded location; "Speichern" sends the whole label (empty = delete).
  const [locInput, setLocInput] = pState(null);  // null = not seeded yet
  const [locSaveState, setLocSaveState] = pState("idle"); // idle|saving|saved|error
  const [locSaveMsg, setLocSaveMsg] = pState("");

  // Local edit buffer for the Wissen (Knowledge-Input) section: the new-fact
  // form fields. The list itself comes from the `knowledge` prop. "Speichern"
  // sends one fact; the per-row trash icon deletes one by id.
  const [knBody, setKnBody] = pState("");
  const [knTitle, setKnTitle] = pState("");
  const [knTopic, setKnTopic] = pState("");
  const [knCompany, setKnCompany] = pState("");
  const [knSaveState, setKnSaveState] = pState("idle"); // idle|saving|saved|error
  const [knSaveMsg, setKnSaveMsg] = pState("");
  const [knBusyId, setKnBusyId] = pState("");  // id (or source_id) currently deleting
  // File-Upload extension: the chosen file + upload status + PII warning.
  const [knFile, setKnFile] = pState(null);          // File object or null
  const [knUpState, setKnUpState] = pState("idle");  // idle|uploading|done|error
  const [knUpMsg, setKnUpMsg] = pState("");
  const [knPiiWarn, setKnPiiWarn] = pState([]);      // PII classes from the last upload

  pEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open, onClose]);

  // Lazy-load the calendar-visibility config when the drawer opens (once).
  pEffect(() => {
    if (open && agenda === null && onLoadAgenda) onLoadAgenda();
  }, [open]);

  // Seed the edit buffer whenever a fresh config list arrives.
  pEffect(() => {
    if (!Array.isArray(agenda)) return;
    const seed = {};
    for (const c of agenda) {
      seed[c.id] = c.visibility === "owner" ? "owner" : "all";
    }
    setAgendaEdits(seed);
  }, [agenda]);

  // Lazy-load the portfolio holdings when the drawer opens (once).
  pEffect(() => {
    if (open && portfolio === null && onLoadPortfolio) onLoadPortfolio();
  }, [open]);

  // Seed the edit rows whenever a fresh holdings list arrives.
  pEffect(() => {
    if (!Array.isArray(portfolio)) return;
    setPfRows(portfolio.map((h) => ({
      wkn: h.wkn || "",
      qty: (h.qty === null || h.qty === undefined) ? "" : String(h.qty),
      yahoo_symbol: h.yahoo_symbol || "",
    })));
  }, [portfolio]);

  // Lazy-load the saved location when the drawer opens (once).
  pEffect(() => {
    if (open && location === null && onLoadLocation) onLoadLocation();
  }, [open]);

  // Seed the input whenever a fresh location arrives ({label} | null).
  pEffect(() => {
    if (location === null || location === undefined) return;
    setLocInput(typeof location.label === "string" ? location.label : "");
  }, [location]);

  // Lazy-load the shared knowledge facts when the drawer opens (once).
  pEffect(() => {
    if (open && knowledge === null && onLoadKnowledge) onLoadKnowledge();
  }, [open]);

  if (!open) return null;

  const list = Array.isArray(voices) ? voices : [];
  const hiddenList = Array.isArray(hidden) ? hidden : [];

  // Distinct, sorted languages present in the voice list (non-empty only).
  const languages = Array.from(
    new Set(list.map((v) => (v.language || "").trim()).filter(Boolean))
  ).sort();
  // Only show the filter when it actually helps (>= 2 distinct languages).
  const showLangFilter = languages.length >= 2;
  // Apply the filter (when set + meaningful) to the rendered voice rows.
  const activeLang = showLangFilter && language ? language : null;
  const shown = activeLang
    ? list.filter((v) => (v.language || "").trim() === activeLang)
    : list;

  return (
    <div className="settings-overlay" onClick={onClose}>
      <aside
        className="settings-drawer"
        role="dialog" aria-modal="true" aria-label="Einstellungen"
        onClick={(e) => e.stopPropagation()}
      >
        <div className="settings-drawer__head">
          <div className="settings-drawer__title">Einstellungen</div>
          <button
            className="settings-drawer__close" aria-label="Schließen"
            onClick={onClose}
          ><Icon name="x" size={18} strokeWidth={2} /></button>
        </div>

        <div className="settings-drawer__body">
          {/* 1. Stimme */}
          <section className="settings-sec">
            <div className="settings-sec__title">
              <Icon name="volume-2" size={14} /> Stimme
            </div>
            {voicesLoading ? (
              <div className="settings-hint">lade Stimmen ...</div>
            ) : list.length === 0 ? (
              <div className="settings-hint">
                Keine Stimmen verfügbar. Connie nutzt die Standard-Stimme.
              </div>
            ) : (
              <>
                {/* Language filter (only when several languages exist). */}
                {showLangFilter && (
                  <select
                    className="settings-select settings-select--lang"
                    aria-label="Sprache filtern"
                    value={activeLang || ""}
                    onChange={(e) => onLanguageChange(e.target.value || null)}
                  >
                    <option value="">Alle Sprachen</option>
                    {languages.map((lng) => (
                      <option key={lng} value={lng}>{_capLang(lng)}</option>
                    ))}
                  </select>
                )}

                {/* Voice rows: select (radio) + a pre-listen play button. The
                    "Standard-Stimme" row keeps the no-id default reachable. */}
                <div className="settings-voices" role="radiogroup" aria-label="Stimme wählen">
                  <button
                    className={`settings-voice ${!voiceId ? "is-active" : ""}`}
                    role="radio" aria-checked={!voiceId}
                    onClick={() => onVoiceChange(null)}
                  >
                    <span className="settings-voice__name">Standard-Stimme</span>
                  </button>
                  {shown.map((v) => {
                    const active = voiceId === v.voice_id;
                    const playing = previewVoiceId === v.voice_id;
                    return (
                      <div
                        key={v.voice_id}
                        className={`settings-voice ${active ? "is-active" : ""}`}
                      >
                        <button
                          className="settings-voice__pick"
                          role="radio" aria-checked={active}
                          onClick={() => onVoiceChange(v.voice_id)}
                        >
                          <span className="settings-voice__name">{v.name}</span>
                          {v.language && (
                            <span className="settings-voice__lang">{_capLang(v.language)}</span>
                          )}
                        </button>
                        <button
                          className={`settings-voice__play ${playing ? "is-playing" : ""}`}
                          title={playing ? "Vorschau stoppen" : "Vorhören"}
                          aria-label={playing ? `Vorschau ${v.name} stoppen` : `${v.name} vorhören`}
                          onClick={() => onPreview(v)}
                        >
                          <Icon name={playing ? "pause" : "play"} size={13} strokeWidth={2} />
                        </button>
                      </div>
                    );
                  })}
                  {shown.length === 0 && (
                    <div className="settings-hint">
                      Keine Stimme für diese Sprache. Filter zurücksetzen.
                    </div>
                  )}
                </div>
              </>
            )}
          </section>

          {/* 1b. Sprach-Modus (E-S6) - Cascade / Realtime + Notfall-Stopp */}
          <section className="settings-sec">
            <div className="settings-sec__title">
              <Icon name="mic" size={14} /> Sprach-Modus
            </div>
            <div className="settings-hint">
              Cascade ist heute live (ElevenLabs + Browser-STT).
              Realtime ist OpenAI EU-Frankfurt mit nativem Barge-in.
              Der Wechsel wirkt für die nächste Sitzung.
            </div>
            <div
              className="settings-seg"
              role="radiogroup"
              aria-label="Sprach-Modus wählen"
            >
              {[
                { key: "cascade", label: "Cascade" },
                { key: "realtime", label: "Realtime" },
              ].map((opt) => (
                <button
                  key={opt.key}
                  role="radio"
                  aria-checked={(voiceMode || "cascade") === opt.key}
                  className={`settings-seg__btn ${
                    (voiceMode || "cascade") === opt.key ? "is-active" : ""
                  }`}
                  onClick={() => onVoiceModeChange && onVoiceModeChange(opt.key)}
                >{opt.label}</button>
              ))}
            </div>
            {voiceSessionActive && voiceSessionInfo ? (
              <div className="settings-hint" style={{ marginTop: 6 }}>
                Aktive Sitzung: {voiceSessionInfo.providerId || "?"} ·
                {" "}{(voiceSessionInfo.sessionId || "").slice(0, 8)}
              </div>
            ) : null}
            <div className="settings-cals__foot" style={{ marginTop: 8 }}>
              <button
                className="settings-save"
                onClick={async () => {
                  if (onCloseAllVoiceSessions) await onCloseAllVoiceSessions();
                }}
              >
                Alle Voice-Sessions beenden
              </button>
            </div>
          </section>

          {/* 2. Textgroesse */}
          <section className="settings-sec">
            <div className="settings-sec__title">Textgröße</div>
            <div className="settings-seg" role="group" aria-label="Textgröße">
              {["S", "M", "L"].map((s) => (
                <button
                  key={s}
                  className={`settings-seg__btn ${textSize === s ? "is-active" : ""}`}
                  aria-pressed={textSize === s}
                  onClick={() => onTextSizeChange(s)}
                >{s}</button>
              ))}
            </div>
          </section>

          {/* 3. Ausgeblendete Kacheln */}
          <section className="settings-sec">
            <div className="settings-sec__title">Ausgeblendete Kacheln</div>
            {hiddenList.length === 0 ? (
              <div className="settings-hint">Alle Kacheln sind sichtbar.</div>
            ) : (
              <div className="settings-hidden">
                {hiddenList.map((id) => (
                  <div key={id} className="settings-hidden__row">
                    <span className="settings-hidden__name">
                      {cardTitles[id] || id}
                    </span>
                    <button
                      className="settings-hidden__show"
                      onClick={() => onUnhide(id)}
                    >
                      <Icon name="eye" size={13} /> wieder anzeigen
                    </button>
                  </div>
                ))}
              </div>
            )}
          </section>

          {/* 4. Kalender-Sichtbarkeit (per-user-visibility UI) */}
          <section className="settings-sec">
            <div className="settings-sec__title">
              <Icon name="calendar" size={14} /> Kalender-Sichtbarkeit
            </div>
            {agendaLoading ? (
              <div className="settings-hint">lade Kalender ...</div>
            ) : agendaAvailable === false ? (
              <div className="settings-hint">
                Kalender-Verwaltung nicht erreichbar.
              </div>
            ) : !Array.isArray(agenda) || agenda.length === 0 ? (
              <div className="settings-hint">
                Keine Kalender konfiguriert.
              </div>
            ) : (
              <>
                <div className="settings-cals">
                  {agenda.map((c) => {
                    const val = agendaEdits[c.id] || "all";
                    return (
                      <div key={c.id} className="settings-cal">
                        <div className="settings-cal__info">
                          <span className="settings-cal__name">
                            {c.label || _shortCalId(c.id)}
                          </span>
                          {c.label && (
                            <span className="settings-cal__id">{_shortCalId(c.id)}</span>
                          )}
                        </div>
                        <div
                          className="settings-seg settings-seg--cal"
                          role="group"
                          aria-label={`Sichtbarkeit ${c.label || c.id}`}
                        >
                          {[
                            { key: "all", label: "Alle" },
                            { key: "owner", label: "Nur ich" },
                          ].map((opt) => (
                            <button
                              key={opt.key}
                              className={`settings-seg__btn ${val === opt.key ? "is-active" : ""}`}
                              aria-pressed={val === opt.key}
                              onClick={() => {
                                setAgendaEdits((m) => ({ ...m, [c.id]: opt.key }));
                                setAgendaSaveState("idle");
                              }}
                            >{opt.label}</button>
                          ))}
                        </div>
                      </div>
                    );
                  })}
                </div>
                <div className="settings-cals__foot">
                  <button
                    className="settings-save"
                    disabled={agendaSaveState === "saving"}
                    onClick={async () => {
                      if (!onSaveAgenda) return;
                      setAgendaSaveState("saving");
                      setAgendaSaveMsg("");
                      const list = agenda.map((c) => ({
                        id: c.id,
                        label: c.label || undefined,
                        visibility: agendaEdits[c.id] || "all",
                      }));
                      const res = await onSaveAgenda(list);
                      if (res && res.ok) {
                        setAgendaSaveState("saved");
                        setAgendaSaveMsg("gespeichert");
                      } else {
                        setAgendaSaveState("error");
                        setAgendaSaveMsg((res && res.detail) || "Speichern fehlgeschlagen.");
                      }
                    }}
                  >
                    {agendaSaveState === "saving" ? "speichere ..." : "Speichern"}
                  </button>
                  {agendaSaveState === "saved" && (
                    <span className="settings-save__ok">
                      <Icon name="check" size={13} /> {agendaSaveMsg}
                    </span>
                  )}
                  {agendaSaveState === "error" && (
                    <span className="settings-save__err">{agendaSaveMsg}</span>
                  )}
                </div>
              </>
            )}
          </section>

          {/* 5. Aktien-Portfolio (per-user, owner-only by construction) */}
          <section className="settings-sec">
            <div className="settings-sec__title">
              <Icon name="line-chart" size={14} /> Aktien-Portfolio
            </div>
            <div className="settings-hint">
              Nur für Sie sichtbar. WKN eingeben und "Auflösen" - dann den
              Börsenplatz wählen (setzt das Yahoo-Symbol). Deutsche Plätze
              (.DE/.F/.SG) liefern EUR direkt. Das Symbol lässt sich auch manuell
              eintragen.
            </div>
            {portfolioLoading && !Array.isArray(pfRows) ? (
              <div className="settings-hint">lade Portfolio ...</div>
            ) : (
              <>
                <div className="settings-stocks">
                  {(pfRows || []).map((row, idx) => (
                    <div key={idx} className="settings-stock-group">
                    <div className="settings-stock">
                      <input
                        className="settings-input settings-input--wkn"
                        type="text"
                        inputMode="text"
                        autoCapitalize="characters"
                        spellCheck={false}
                        maxLength={6}
                        placeholder="WKN"
                        aria-label={`WKN Zeile ${idx + 1}`}
                        value={row.wkn}
                        onChange={(e) => {
                          const v = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 6);
                          setPfRows((rs) => rs.map((r, i) => (i === idx ? { ...r, wkn: v } : r)));
                          setPfSaveState("idle");
                          // A new WKN invalidates this row's open candidate list.
                          if (pfResults && pfResults.idx === idx) setPfResults(null);
                        }}
                      />
                      {/* "Aufloesen": resolve the WKN to exchange-place candidates
                          (S1b). Disabled until the WKN is exactly 6 chars; the
                          server does no egress on a malformed WKN either. */}
                      <button
                        className="settings-stock__resolve"
                        title="WKN aufloesen - Boersenplatz waehlen"
                        aria-label={`WKN Zeile ${idx + 1} aufloesen`}
                        disabled={!/^[A-Z0-9]{6}$/.test((row.wkn || "").toUpperCase()) || pfResolveIdx === idx || !onResolveWkn}
                        onClick={async () => {
                          if (!onResolveWkn) return;
                          setPfResolveIdx(idx);
                          setPfResolveErr("");
                          setPfResults(null);
                          const res = await onResolveWkn(row.wkn);
                          setPfResolveIdx(-1);
                          if (res && res.ok && Array.isArray(res.results) && res.results.length > 0) {
                            setPfResults({ idx, results: res.results });
                          } else {
                            setPfResolveErr("Keine Boersenplaetze gefunden - Symbol ggf. manuell eintragen.");
                            setPfResults({ idx, results: [] });
                          }
                        }}
                      >
                        {pfResolveIdx === idx ? "..." : "Auflösen"}
                      </button>
                      <input
                        className="settings-input settings-input--symbol"
                        type="text"
                        inputMode="text"
                        autoCapitalize="characters"
                        spellCheck={false}
                        maxLength={15}
                        placeholder="Yahoo-Symbol"
                        aria-label={`Yahoo-Symbol Zeile ${idx + 1}`}
                        value={row.yahoo_symbol}
                        onChange={(e) => {
                          // Keep only A-Z / 0-9 / dot / hyphen (the server's
                          // _YAHOO_SYMBOL_RE); uppercase; cap at 15.
                          const v = e.target.value.toUpperCase().replace(/[^A-Z0-9.\-]/g, "").slice(0, 15);
                          setPfRows((rs) => rs.map((r, i) => (i === idx ? { ...r, yahoo_symbol: v } : r)));
                          setPfSaveState("idle");
                        }}
                      />
                      <input
                        className="settings-input settings-input--qty"
                        type="number"
                        min="0"
                        step="any"
                        placeholder="Menge"
                        aria-label={`Menge Zeile ${idx + 1}`}
                        value={row.qty}
                        onChange={(e) => {
                          const v = e.target.value;
                          setPfRows((rs) => rs.map((r, i) => (i === idx ? { ...r, qty: v } : r)));
                          setPfSaveState("idle");
                        }}
                      />
                      <button
                        className="settings-stock__del"
                        title="Position entfernen"
                        aria-label={`Position ${idx + 1} entfernen`}
                        onClick={() => {
                          setPfRows((rs) => rs.filter((_, i) => i !== idx));
                          setPfSaveState("idle");
                          if (pfResults && pfResults.idx === idx) setPfResults(null);
                        }}
                      >
                        <Icon name="trash" size={13} strokeWidth={2} />
                      </button>
                    </div>
                    {/* Candidate list for THIS row (S1b). All fields are rendered
                        as JSX text children - React escapes them, and the server
                        already strip_control'd them (no dangerouslySetInnerHTML).
                        A "weak" match shows a visible warning. */}
                    {pfResults && pfResults.idx === idx && (
                      <div className="settings-stock__hits" role="listbox" aria-label="Boersenplaetze">
                        {pfResults.results.length === 0 ? (
                          <div className="settings-hint">{pfResolveErr || "Keine Treffer."}</div>
                        ) : (
                          pfResults.results.map((hit, hi) => (
                            <button
                              key={hi}
                              type="button"
                              role="option"
                              className={"settings-hit" + (hit.match === "weak" ? " settings-hit--weak" : "")}
                              title={hit.match === "weak" ? "Name weicht ab - bitte pruefen" : ""}
                              onClick={() => {
                                const sym = (hit.yahoo_symbol || "").toUpperCase();
                                setPfRows((rs) => rs.map((r, i) => (i === idx ? { ...r, yahoo_symbol: sym } : r)));
                                setPfSaveState("idle");
                                setPfResults(null);
                              }}
                            >
                              <span className="settings-hit__sym">{hit.yahoo_symbol}</span>
                              <span className="settings-hit__name">{hit.name || ""}</span>
                              <span className="settings-hit__place">
                                {[hit.exchange, hit.currency].filter(Boolean).join(" · ")}
                                {typeof hit.price === "number" ? ` · ${hit.price}` : ""}
                              </span>
                              {hit.match === "weak" && (
                                <span className="settings-hit__warn">Name weicht ab - bitte prüfen</span>
                              )}
                            </button>
                          ))
                        )}
                      </div>
                    )}
                    </div>
                  ))}
                  {(pfRows || []).length === 0 && (
                    <div className="settings-hint">Noch keine Positionen.</div>
                  )}
                </div>
                <div className="settings-stocks__foot">
                  <button
                    className="settings-stock__add"
                    onClick={() => {
                      setPfRows((rs) => [...(rs || []), { wkn: "", qty: "", yahoo_symbol: "" }]);
                      setPfSaveState("idle");
                    }}
                  >
                    <Icon name="plus" size={13} strokeWidth={2} /> Position hinzufügen
                  </button>
                  <button
                    className="settings-save"
                    disabled={pfSaveState === "saving"}
                    onClick={async () => {
                      if (!onSavePortfolio) return;
                      // Build the holdings set: skip empty WKN rows; the server
                      // validates WKN format + qty > 0 (a bad row fails the save).
                      const holdings = [];
                      for (const r of (pfRows || [])) {
                        const wkn = (r.wkn || "").trim().toUpperCase();
                        if (!wkn) continue;
                        // Validate the qty client-side: the server requires a
                        // float > 0 (a missing/0/invalid qty would be a 422). Show
                        // a friendly message instead of sending a bad request.
                        const qty = parseFloat(r.qty);
                        if (isNaN(qty) || qty <= 0) {
                          setPfSaveState("error");
                          setPfSaveMsg(`Bitte für ${wkn} eine Menge größer 0 eintragen.`);
                          return;
                        }
                        const h = { wkn, qty };
                        const sym = (r.yahoo_symbol || "").trim().toUpperCase();
                        if (sym) h.yahoo_symbol = sym;
                        holdings.push(h);
                      }
                      setPfSaveState("saving");
                      setPfSaveMsg("");
                      const res = await onSavePortfolio(holdings);
                      if (res && res.ok) {
                        setPfSaveState("saved");
                        setPfSaveMsg("gespeichert");
                      } else {
                        setPfSaveState("error");
                        // Guard: only ever set a string as the message (rendering a
                        // non-string as a React child blanks the screen).
                        setPfSaveMsg((res && typeof res.detail === "string" && res.detail) || "Speichern fehlgeschlagen.");
                      }
                    }}
                  >
                    {pfSaveState === "saving" ? "speichere ..." : "Speichern"}
                  </button>
                  {pfSaveState === "saved" && (
                    <span className="settings-save__ok">
                      <Icon name="check" size={13} /> {pfSaveMsg}
                    </span>
                  )}
                  {pfSaveState === "error" && (
                    <span className="settings-save__err">{pfSaveMsg}</span>
                  )}
                </div>
              </>
            )}
          </section>

          {/* 6. Standort (per-user, owner-only by construction; S2a) */}
          <section className="settings-sec">
            <div className="settings-sec__title">
              <Icon name="map-pin" size={14} /> Standort
            </div>
            <div className="settings-hint">
              Nur für Sie sichtbar. Geokodierung über Open-Meteo (Schweiz).
            </div>
            {locationLoading && locInput === null ? (
              <div className="settings-hint">lade Standort ...</div>
            ) : (
              <>
                <div className="settings-stocks">
                  <input
                    className="settings-input settings-input--loc"
                    type="text"
                    inputMode="text"
                    spellCheck={false}
                    maxLength={120}
                    placeholder="z.B. Son Servera, Mallorca"
                    aria-label="Aktueller Standort"
                    value={locInput || ""}
                    onChange={(e) => {
                      setLocInput(e.target.value.slice(0, 120));
                      setLocSaveState("idle");
                    }}
                  />
                </div>
                <div className="settings-stocks__foot">
                  <button
                    className="settings-save"
                    disabled={locSaveState === "saving"}
                    onClick={async () => {
                      if (!onSaveLocation) return;
                      // The server validates + geocodes; an empty label clears the
                      // saved Standort (the tile falls back to Geolocation).
                      setLocSaveState("saving");
                      setLocSaveMsg("");
                      const res = await onSaveLocation((locInput || "").trim());
                      if (res && res.ok) {
                        setLocSaveState("saved");
                        setLocSaveMsg(
                          res.geocoded === false
                            ? "gespeichert (ohne Koordinaten)"
                            : "gespeichert"
                        );
                      } else {
                        setLocSaveState("error");
                        setLocSaveMsg((res && res.detail) || "Speichern fehlgeschlagen.");
                      }
                    }}
                  >
                    {locSaveState === "saving" ? "speichere ..." : "Speichern"}
                  </button>
                  {locSaveState === "saved" && (
                    <span className="settings-save__ok">
                      <Icon name="check" size={13} /> {locSaveMsg}
                    </span>
                  )}
                  {locSaveState === "error" && (
                    <span className="settings-save__err">{locSaveMsg}</span>
                  )}
                </div>
              </>
            )}
          </section>

          {/* 7. Wissen (Knowledge-Input, S4). SHARED: the facts are global -
              Connie uses them for all users (SR-KI-9). Only the write path is
              owner-gated; created_by is set server-side. */}
          <section className="settings-sec">
            <div className="settings-sec__title">
              <Icon name="book-open" size={14} /> Wissen
            </div>
            <div className="settings-hint">
              Geteilt: dieses Wissen nutzt Connie für alle Nutzer. Trage hier
              verdichtete, PII-freie Kernfakten ein (keine vertraulichen Notizen).
            </div>
            <div className="settings-knowledge">
              <textarea
                className="settings-input settings-input--know"
                rows={3}
                maxLength={500}
                spellCheck={true}
                placeholder="Kernfakt, z.B. „Firma X bevorzugt PDF-Angebote ohne Anhänge.“"
                aria-label="Kernfakt"
                value={knBody}
                onChange={(e) => {
                  setKnBody(e.target.value.slice(0, 500));
                  setKnSaveState("idle");
                }}
              />
              <div className="settings-know__row">
                <input
                  className="settings-input settings-input--know-meta"
                  type="text"
                  maxLength={120}
                  spellCheck={false}
                  placeholder="Titel (optional)"
                  aria-label="Titel"
                  value={knTitle}
                  onChange={(e) => { setKnTitle(e.target.value.slice(0, 120)); setKnSaveState("idle"); }}
                />
                <input
                  className="settings-input settings-input--know-meta"
                  type="text"
                  maxLength={80}
                  spellCheck={false}
                  placeholder="Thema (optional)"
                  aria-label="Thema"
                  value={knTopic}
                  onChange={(e) => { setKnTopic(e.target.value.slice(0, 80)); setKnSaveState("idle"); }}
                />
                <input
                  className="settings-input settings-input--know-meta"
                  type="text"
                  maxLength={120}
                  spellCheck={false}
                  placeholder="Firma-Domain (optional)"
                  aria-label="Firma-Domain"
                  value={knCompany}
                  onChange={(e) => { setKnCompany(e.target.value.slice(0, 120)); setKnSaveState("idle"); }}
                />
              </div>
              <div className="settings-stocks__foot">
                <button
                  className="settings-save"
                  disabled={knSaveState === "saving" || !(knBody || "").trim()}
                  onClick={async () => {
                    if (!onSaveKnowledge) return;
                    const body = (knBody || "").trim();
                    if (!body) {
                      setKnSaveState("error");
                      setKnSaveMsg("Bitte einen Kernfakt eintragen.");
                      return;
                    }
                    const fact = { body };
                    const t = (knTitle || "").trim();
                    const tp = (knTopic || "").trim();
                    const c = (knCompany || "").trim();
                    if (t) fact.title = t;
                    if (tp) fact.topic = tp;
                    if (c) fact.company_domain = c;
                    setKnSaveState("saving");
                    setKnSaveMsg("");
                    const res = await onSaveKnowledge(fact);
                    if (res && res.ok) {
                      setKnSaveState("saved");
                      setKnSaveMsg("gespeichert");
                      // Clear the form for the next fact.
                      setKnBody(""); setKnTitle(""); setKnTopic(""); setKnCompany("");
                    } else {
                      setKnSaveState("error");
                      // Guard: only ever set a string as the message (a non-string
                      // React child blanks the screen).
                      setKnSaveMsg((res && typeof res.detail === "string" && res.detail) || "Speichern fehlgeschlagen.");
                    }
                  }}
                >
                  {knSaveState === "saving" ? "speichere ..." : "Speichern"}
                </button>
                {knSaveState === "saved" && (
                  <span className="settings-save__ok">
                    <Icon name="check" size={13} /> {knSaveMsg}
                  </span>
                )}
                {knSaveState === "error" && (
                  <span className="settings-save__err">{knSaveMsg}</span>
                )}
              </div>

              {/* ODER: eine verdichtete Datei hochladen (File-Upload extension).
                  Connie extrahiert den Text, chunkt ihn und speichert ihn als
                  mehrere Fakten. Server validiert verbindlich (Magic + Caps);
                  die Client-Prüfung hier ist nur Komfort. */}
              <div className="settings-know__upload">
                <div className="settings-hint settings-know__or">
                  ODER eine verdichtete Datei hochladen (PDF, DOCX, MD, TXT;
                  max. 10 MB). Titel/Thema/Firma gelten für alle Fakten der Datei.
                </div>
                <div className="settings-know__uprow">
                  <input
                    type="file"
                    className="settings-know__file"
                    accept=".pdf,.docx,.md,.txt"
                    aria-label="Datei wählen"
                    onChange={(e) => {
                      const f = e.target.files && e.target.files[0] ? e.target.files[0] : null;
                      setKnFile(f);
                      setKnUpState("idle");
                      setKnUpMsg("");
                      setKnPiiWarn([]);
                      // Cheap client-side guard (server validates verbindlich).
                      if (f && f.size > 10 * 1024 * 1024) {
                        setKnUpState("error");
                        setKnUpMsg("Datei größer als 10 MB.");
                      }
                    }}
                  />
                  <button
                    className="settings-save"
                    disabled={knUpState === "uploading" || !knFile}
                    onClick={async () => {
                      if (!onUploadKnowledge || !knFile) return;
                      if (knFile.size > 10 * 1024 * 1024) {
                        setKnUpState("error");
                        setKnUpMsg("Datei größer als 10 MB.");
                        return;
                      }
                      setKnUpState("uploading");
                      setKnUpMsg("");
                      setKnPiiWarn([]);
                      const meta = {};
                      const t = (knTitle || "").trim();
                      const tp = (knTopic || "").trim();
                      const c = (knCompany || "").trim();
                      if (t) meta.title = t;
                      if (tp) meta.topic = tp;
                      if (c) meta.company_domain = c;
                      const res = await onUploadKnowledge(knFile, meta);
                      if (res && res.ok) {
                        setKnUpState("done");
                        const n = res.chunk_count || 0;
                        setKnUpMsg(`gespeichert: ${n} ${n === 1 ? "Fakt" : "Fakten"}`);
                        setKnPiiWarn(Array.isArray(res.pii_warning) ? res.pii_warning : []);
                        setKnFile(null);
                        // Clear the form fields (they were applied to the file).
                        setKnTitle(""); setKnTopic(""); setKnCompany("");
                      } else {
                        setKnUpState("error");
                        setKnUpMsg((res && typeof res.detail === "string" && res.detail) || "Upload fehlgeschlagen.");
                      }
                    }}
                  >
                    {knUpState === "uploading" ? "lade hoch ..." : "Datei hochladen"}
                  </button>
                </div>
                {knUpState === "done" && (
                  <span className="settings-save__ok">
                    <Icon name="check" size={13} /> {knUpMsg}
                  </span>
                )}
                {knUpState === "error" && (
                  <span className="settings-save__err">{knUpMsg}</span>
                )}
                {knPiiWarn.length > 0 && (
                  <div className="settings-know__pii">
                    Achtung: möglicherweise personenbezogene Daten erkannt
                    ({knPiiWarn.join(", ")}). Prüfe die gespeicherten Fakten.
                  </div>
                )}
              </div>

              {/* Existing facts list (newest first). Uploaded files are GROUPED
                  into one entry (by source_id) with "N Fakten" + a preview;
                  deleting the group removes all its chunks. Manually-typed facts
                  stay individual. */}
              {knowledgeLoading && knowledge === null ? (
                <div className="settings-hint">lade Wissen ...</div>
              ) : (Array.isArray(knowledge) && knowledge.length > 0) ? (
                <ul className="settings-know__list">
                  {groupKnowledge(knowledge).map((g) => (
                    <li key={g.key} className="settings-know__item">
                      <div className="settings-know__text">
                        {g.isFile ? (
                          <div className="settings-know__t">
                            <Icon name="book-open" size={12} /> {g.title || "Hochgeladene Datei"}
                            <span className="settings-know__count"> · {g.count} {g.count === 1 ? "Fakt" : "Fakten"}</span>
                          </div>
                        ) : (g.title ? <div className="settings-know__t">{g.title}</div> : null)}
                        <div className="settings-know__b">{g.preview}</div>
                        {(g.topic || g.created_by) ? (
                          <div className="settings-know__meta">
                            {g.topic ? <span className="settings-know__tag">{g.topic}</span> : null}
                            {g.created_by ? <span className="settings-know__by">{g.created_by}</span> : null}
                          </div>
                        ) : null}
                      </div>
                      <button
                        className="settings-know__del"
                        aria-label={g.isFile ? "Datei löschen" : "Fakt löschen"}
                        title="Löschen"
                        disabled={knBusyId === g.key}
                        onClick={async () => {
                          setKnBusyId(g.key);
                          if (g.isFile) {
                            if (onDeleteKnowledgeSource && g.sourceId) await onDeleteKnowledgeSource(g.sourceId);
                          } else {
                            if (onDeleteKnowledge && g.id) await onDeleteKnowledge(g.id);
                          }
                          setKnBusyId("");
                        }}
                      >
                        <Icon name="trash" size={14} />
                      </button>
                    </li>
                  ))}
                </ul>
              ) : (
                <div className="settings-hint">Noch keine Fakten hinterlegt.</div>
              )}
            </div>
          </section>
        </div>
      </aside>
    </div>
  );
}

// ------------------------------------------------------------------
// BillomatCard (2026-06-02) - dashboard tile for open invoices + offers.
// Data comes from /web/billomat-summary; nullable while loading, the tile
// flips to "kein Zugriff" via available:false on any backend failure.
// ------------------------------------------------------------------
function BillomatCard({ data, period, onPeriodChange }) {
  const loading = data === null || data === undefined;
  const available = !loading && data && data.available !== false;

  const fmtEUR = (n) => (typeof n === "number" ? n : 0).toLocaleString("de-DE", {
    style: "currency", currency: "EUR", maximumFractionDigits: 0,
  });

  const blocks = available
    ? [
        { id: "overdue", title: "Überfällig",       data: data.overdue_invoices, color: "#FF6464" },
        { id: "open",    title: "Offene Rechnungen", data: data.open_invoices,    color: "#FFB020" },
        { id: "ooffer",  title: "Offene Angebote",   data: data.open_offers,      color: "#05E6A5" },
        { id: "draft",   title: "Angebot-Entwürfe",  data: data.draft_offers,     color: "#9BC8FF" },
      ]
    : [];

  const rowStyle = {
    display: "flex", justifyContent: "space-between", gap: 10,
    padding: "5px 0", borderBottom: "1px solid var(--line)",
    fontSize: 12, textDecoration: "none",
  };
  const labelStyle = { color: "var(--fg-2)", overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" };
  const amountStyle = { color: "var(--fg-2)", fontFamily: "var(--font-mono)", whiteSpace: "nowrap", flexShrink: 0 };

  // Filter pills (Alle / Jahr / Monat). The active one is highlighted; clicks
  // bubble up to the App via onPeriodChange so /web/billomat-summary is re-
  // fetched with the new query param.
  const PERIOD_OPTIONS = [
    { id: "all",   label: "Alle" },
    { id: "year",  label: "Jahr" },
    { id: "month", label: "Monat" },
  ];
  const pillsRowStyle = {
    display: "flex", gap: 6, marginTop: 8, marginBottom: 4,
  };
  const pillStyle = (active) => ({
    fontSize: 11, padding: "3px 9px", borderRadius: 999,
    border: active ? "1px solid var(--accent-1, #05E6A5)" : "1px solid var(--line)",
    background: active ? "rgba(5, 230, 165, 0.12)" : "transparent",
    color: active ? "var(--fg-1)" : "var(--fg-2)",
    cursor: "pointer", fontFamily: "var(--font-mono)", letterSpacing: "0.02em",
  });

  return (
    <div className="card">
      <div className="card__head">
        <div className="card__title">Billomat · Offen</div>
        <div className="card__meta">
          {loading ? "lade ..." : available ? "Live" : "kein Zugriff"}
        </div>
      </div>

      {/* Filter pills sit at the top so the counts below reflect the chosen
          window. Onclick fan-out goes through the App so the fetch + cache
          can react. */}
      <div style={pillsRowStyle}>
        {PERIOD_OPTIONS.map((opt) => (
          <button
            key={opt.id}
            style={pillStyle(period === opt.id)}
            onClick={() => onPeriodChange && onPeriodChange(opt.id)}
            title={
              opt.id === "all" ? "Alle offenen Positionen"
              : opt.id === "year" ? "Nur Datum >= 1. Januar dieses Jahres"
              : "Nur Datum >= 1. dieses Monats"
            }
          >
            {opt.label}
          </button>
        ))}
      </div>

      {!available ? (
        <div style={{ color: "var(--fg-3)", fontSize: 12, paddingTop: 8 }}>
          {loading ? "Daten werden geladen ..." : "Billomat aktuell nicht erreichbar."}
        </div>
      ) : (
        <>
          {/* Top row: four hard counts with their EUR sum, identical layout
              to the Pipeline card. Overdue is leftmost so a non-zero number
              stays in Bernd's line of sight. */}
          <div className="pipe">
            {blocks.map((b) => {
              const count = (b.data && b.data.count) || 0;
              const sum = (b.data && b.data.total_gross) || 0;
              return (
                <div key={b.id} className="pipe__stat">
                  <div className="pipe__num" style={{ color: b.color }}>{count}</div>
                  <div className="pipe__label">{b.title}</div>
                  <div style={{ fontSize: 10, color: "var(--fg-3)", marginTop: 2 }}>
                    {sum > 0 ? fmtEUR(sum) : "–"}
                  </div>
                </div>
              );
            })}
          </div>

          {/* Top entries grouped by block. Rows are <a> tags so a click opens
              the Billomat web app for the record in a new tab. ``rel`` is
              hardened against tab-nabbing. Empty url falls back to a div. */}
          {blocks.filter((b) => (b.data && b.data.count) > 0).map((b) => {
            const top = (b.data && Array.isArray(b.data.top)) ? b.data.top : [];
            if (top.length === 0) return null;
            return (
              <div key={`${b.id}-top`} style={{ marginTop: 12 }}>
                <div style={{ fontSize: 11, color: "var(--fg-3)", marginBottom: 4 }}>
                  {b.title}
                </div>
                {top.map((it) => {
                  const label = it.address || it.number || `#${it.id}`;
                  const url = it.url || "";
                  const inner = (
                    <>
                      <div style={labelStyle} title={`${label}${it.number ? ` · ${it.number}` : ""}`}>{label}</div>
                      <div style={amountStyle}>{fmtEUR(it.total_gross)}</div>
                    </>
                  );
                  if (url) {
                    return (
                      <a
                        key={`${b.id}-${it.id}`}
                        href={url}
                        target="_blank"
                        rel="noopener noreferrer"
                        style={rowStyle}
                        title={`In Billomat öffnen: ${label}`}
                      >{inner}</a>
                    );
                  }
                  return (
                    <div key={`${b.id}-${it.id}`} style={rowStyle}>{inner}</div>
                  );
                })}
              </div>
            );
          })}
        </>
      )}
    </div>
  );
}

// ------------------------------------------------------------------
// ApprovalsCard (2026-06-03, WB-2) - dashboard tile for the Sidecar
// approval-queue. Backend at /web/approvals filters per caller, so a
// Sabine token receives an empty pending list for tax-pipeline-v1.
// React renders via {textContent} only - no innerHTML.
// ------------------------------------------------------------------
const SOURCE_LABELS = {
  workflow:        "Workflow",
  tax_pipeline_v1: "Steuer-Bundle",
  agent:           "Agent",
};

function ApprovalsCard({ data, onDecide, onPreview }) {
  const loading = data === null || data === undefined;
  const available = !loading && data && data.available !== false;
  const canDecide = !!(data && data.can_decide);
  const [busy, setBusy] = pState({});

  const pending = (available && data.pending) || [];
  const recent = (available && data.recent) || [];

  const sectionHead = {
    fontSize: 11, color: "var(--fg-3)", letterSpacing: "0.05em",
    textTransform: "uppercase", marginTop: 10, marginBottom: 6,
  };
  const rowStyle = {
    borderBottom: "1px solid var(--line)", padding: "8px 0",
  };
  const sourceChip = (s) => ({
    display: "inline-block", padding: "1px 6px", fontSize: 10,
    borderRadius: 4, marginRight: 6,
    background: s === "tax_pipeline_v1" ? "rgba(255,176,32,0.18)" : "rgba(155,200,255,0.18)",
    color: s === "tax_pipeline_v1" ? "#FFB020" : "#9BC8FF",
  });
  const labelStyle = {
    color: "var(--fg-2)", fontSize: 13, overflow: "hidden",
    whiteSpace: "nowrap", textOverflow: "ellipsis",
  };
  const muted = { color: "var(--fg-3)", fontSize: 11 };
  const btnRow = { display: "flex", gap: 6, marginTop: 6 };
  const btn = (variant) => ({
    fontSize: 11, padding: "3px 10px", borderRadius: 6, cursor: "pointer",
    border: "1px solid " + (variant === "approve" ? "var(--accent-1,#05E6A5)" : "#FF6464"),
    color: variant === "approve" ? "var(--fg-1)" : "#FF6464",
    background: variant === "approve" ? "rgba(5,230,165,0.12)" : "transparent",
  });

  async function handleDecide(qid, decision) {
    if (busy[qid]) return;
    setBusy((b) => ({ ...b, [qid]: true }));
    try {
      await onDecide(qid, decision);
    } finally {
      setBusy((b) => ({ ...b, [qid]: false }));
    }
  }

  return (
    <div className="card">
      <div className="card__head">
        <div className="card__title">Freigaben</div>
        <div className="card__meta">
          {loading ? "lade ..." : available
            ? (canDecide ? `${pending.length} offen` : "nur Anzeige")
            : "nicht erreichbar"}
        </div>
      </div>

      {!available ? (
        <div style={{ color: "var(--fg-3)", fontSize: 12, paddingTop: 8 }}>
          {loading ? "Daten werden geladen ..." : "Freigaben-Backend nicht erreichbar."}
        </div>
      ) : (
        <>
          <div style={sectionHead}>Zur Freigabe ({pending.length})</div>
          {pending.length === 0 && (
            <div style={{ color: "var(--fg-3)", fontSize: 12 }}>Nichts offen.</div>
          )}
          {pending.map((entry) => {
            const subject = entry.subject || entry.prompt || `#${entry.queue_id}`;
            const sizeKb = entry.attachment_size_bytes
              ? `(${Math.round(entry.attachment_size_bytes / 1024)} KB)`
              : "";
            return (
              <div key={entry.queue_id} style={rowStyle}>
                <div style={{ display: "flex", justifyContent: "space-between", gap: 6 }}>
                  <div style={{ ...labelStyle, flex: 1 }} title={subject}>
                    <span style={sourceChip(entry.source)}>
                      {SOURCE_LABELS[entry.source] || entry.source}
                    </span>
                    {subject}
                  </div>
                  {onPreview && (
                    <button
                      onClick={() => onPreview(entry.queue_id)}
                      title="Vorschau"
                      style={{ ...muted, background: "transparent", border: "none", cursor: "pointer" }}
                    >Details</button>
                  )}
                </div>
                <div style={{ ...muted, marginTop: 2 }}>
                  An: {entry.to || "?"} {sizeKb}
                </div>
                {canDecide && (
                  <div style={btnRow}>
                    <button
                      style={btn("approve")}
                      disabled={busy[entry.queue_id]}
                      onClick={() => handleDecide(entry.queue_id, "approve")}
                    >Freigeben</button>
                    <button
                      style={btn("reject")}
                      disabled={busy[entry.queue_id]}
                      onClick={() => handleDecide(entry.queue_id, "reject")}
                    >Ablehnen</button>
                  </div>
                )}
              </div>
            );
          })}

          {recent.length > 0 && (
            <>
              <div style={sectionHead}>Zuletzt erledigt</div>
              {recent.slice(0, 6).map((entry) => {
                const subject = entry.subject || entry.prompt || `#${entry.queue_id}`;
                const exec = entry.execution_status;
                const statusColor =
                  entry.status === "approved" && exec === "sent" ? "#05E6A5"
                  : entry.status === "approved" && exec === "failed" ? "#FF6464"
                  : entry.status === "rejected" ? "var(--fg-3)"
                  : "#FFB020";
                return (
                  <div key={entry.queue_id} style={{ ...rowStyle, padding: "5px 0" }}>
                    <div style={{ display: "flex", justifyContent: "space-between" }}>
                      <div style={{ ...labelStyle, flex: 1 }} title={subject}>
                        <span style={sourceChip(entry.source)}>
                          {SOURCE_LABELS[entry.source] || entry.source}
                        </span>
                        {subject}
                      </div>
                      <div style={{ ...muted, color: statusColor }}>
                        {entry.status === "rejected" ? "abgelehnt"
                          : exec === "sent" ? "gesendet"
                          : exec === "failed" ? "fehlgeschlagen"
                          : entry.status}
                      </div>
                    </div>
                  </div>
                );
              })}
            </>
          )}
        </>
      )}
    </div>
  );
}

// ===== ReelCard - Video-Render-UI gegen den connie-app /web/reel/*-Proxy =====
// Mappt die Form-Felder auf rahmendaten (HANDOVER-frontend-kachel.md). Render
// dauert 3-5 Min, async polling alle 5 s.
const REEL_STATUS_LABEL = {
  queued:    "in Warteschlange",
  running:   "rendert",
  completed: "fertig",
  failed:    "fehlgeschlagen",
};
const REEL_STEP_LABEL = {
  keyframes:  "Keyframes",
  clips:      "Veo-Clips",
  voiceover:  "Voiceover",
  assemble:   "Schnitt",
};

function ReelCard() {
  const apiBase = (window.CONNIE_API_BASE ?? "http://127.0.0.1:8081").replace(/\/+$/, "");
  const token = window.CONNIE_WEB_TOKEN || "";
  const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};

  // Form state
  const [anlass, setAnlass] = pState("");
  const [botschaft, setBotschaft] = pState("");
  const [ctaUrl, setCtaUrl] = pState("");
  const [voStyle, setVoStyle] = pState("connie"); // "connie" | "narrator"
  const [voiceId, setVoiceId] = pState("");
  const [format, setFormat] = pState("9:16");
  const [look, setLook] = pState("");

  // Upload state
  const [images, setImages] = pState([]); // [{image_id, name}]
  const [dragOver, setDragOver] = pState(false);
  const [uploading, setUploading] = pState(false);
  const fileInputRef = pRef(null);

  // Job state
  const [jobId, setJobId] = pState(null);
  const [status, setStatus] = pState(null);
  const [step, setStep] = pState(null);
  const [submitting, setSubmitting] = pState(false);
  const [error, setError] = pState(null);
  const [resultUrl, setResultUrl] = pState(null);
  const [backendAvail, setBackendAvail] = pState(null); // null=unknown, true=ok, false=404
  const downloadRef = pRef(null);
  const downloadFiredRef = pRef(null); // job_id we already auto-downloaded

  // V1 Job-Liste in localStorage. Per-Browser/Per-Owner. V2 mit owner-scoped
  // server-side State-File kommt mit Tranche 4 (Plan §3 Hybrid-Bilder + Jobs).
  const JOBS_KEY = "connie.reel.jobs";
  const JOB_TTL_MS = 24 * 3600 * 1000;
  const [jobs, setJobs] = pState(() => {
    try {
      const raw = JSON.parse(localStorage.getItem(JOBS_KEY) || "[]");
      const now = Date.now();
      return raw.filter(j => j && j.job_id && (now - (j.ts || 0)) < JOB_TTL_MS);
    } catch (_) { return []; }
  });
  function saveJobs(list) {
    try { localStorage.setItem(JOBS_KEY, JSON.stringify(list.slice(0, 20))); } catch (_) {}
  }
  function pushJob(j) {
    setJobs(prev => {
      const dedup = prev.filter(x => x.job_id !== j.job_id);
      const next = [j, ...dedup].slice(0, 20);
      saveJobs(next);
      return next;
    });
  }
  function updateJob(job_id, patch) {
    setJobs(prev => {
      const next = prev.map(x => x.job_id === job_id ? { ...x, ...patch } : x);
      saveJobs(next);
      return next;
    });
  }
  function removeJob(job_id) {
    setJobs(prev => {
      const next = prev.filter(x => x.job_id !== job_id);
      saveJobs(next);
      return next;
    });
  }

  // Drag-handlers
  function onDragOver(e) { e.preventDefault(); setDragOver(true); }
  function onDragLeave(e) { e.preventDefault(); setDragOver(false); }
  async function onDrop(e) {
    e.preventDefault();
    setDragOver(false);
    const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith("image/"));
    if (files.length) await uploadFiles(files);
  }
  async function onFilePick(e) {
    const files = Array.from(e.target.files || []).filter(f => f.type.startsWith("image/"));
    if (files.length) await uploadFiles(files);
    e.target.value = "";
  }

  async function uploadFiles(files) {
    setUploading(true);
    setError(null);
    try {
      for (const file of files) {
        const fd = new FormData();
        fd.append("file", file);
        const res = await fetch(`${apiBase}/web/reel/upload`, {
          method: "POST", headers: authHeaders, credentials: "include", body: fd,
        });
        if (res.status === 404) { setBackendAvail(false); return; }
        if (!res.ok) throw new Error(`Upload ${res.status}`);
        const data = await res.json();
        if (data && data.image_id) {
          setImages(prev => [...prev, { image_id: data.image_id, name: file.name }]);
          setBackendAvail(true);
        }
      }
    } catch (e) {
      setError(`Upload-Fehler: ${e.message}`);
    } finally {
      setUploading(false);
    }
  }

  function removeImage(idx) {
    setImages(prev => prev.filter((_, i) => i !== idx));
  }

  async function submit() {
    if (!anlass.trim() || !botschaft.trim()) {
      setError("Anlass und Botschaft sind Pflicht.");
      return;
    }
    setSubmitting(true);
    setError(null); setStatus(null); setStep(null);
    setJobId(null); setResultUrl(null);
    try {
      // Pydantic on the VideoGen side rejects null for optional strings - leave
      // fields out entirely when empty. template defaults to "connie_tippt"
      // (the service has just that one template registered today).
      const rd = {
        anlass: anlass.trim(),
        botschaft: botschaft.trim(),
        vo_style: voStyle,
        template: "connie_tippt",
      };
      if (ctaUrl.trim()) rd.cta_url = ctaUrl.trim();
      if (look.trim()) rd.look = look.trim();
      if (voStyle === "narrator" && voiceId.trim()) rd.voice_id = voiceId.trim();
      if (images.length) rd.source_images = images.map(i => i.image_id);
      const body = { rahmendaten: rd };
      const res = await fetch(`${apiBase}/web/reel`, {
        method: "POST",
        headers: { ...authHeaders, "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify(body),
      });
      if (res.status === 404) { setBackendAvail(false); return; }
      if (!res.ok) {
        const txt = await res.text().catch(() => "");
        throw new Error(`Render ${res.status}${txt ? ": " + txt.slice(0, 120) : ""}`);
      }
      const data = await res.json();
      if (data && data.job_id) {
        setJobId(data.job_id);
        setStatus(data.status || "queued");
        setBackendAvail(true);
        pushJob({
          job_id: data.job_id,
          title: anlass.trim() || "Reel",
          ts: Date.now(),
          status: data.status || "queued",
          step: null,
        });
      } else {
        throw new Error("kein job_id zurueck");
      }
    } catch (e) {
      setError(`Render-Start fehlgeschlagen: ${e.message}`);
    } finally {
      setSubmitting(false);
    }
  }

  // Polling - tolerant gegen den VideoGen-Bug, der den /status-Endpoint
  // waehrend des Renders blockt (FastAPI-Worker haengt am Render-Task,
  // /status timeouted -> connie-app gibt 502). 502s/Timeouts werden als
  // "noch beschaeftigt" gewertet, nicht als harter Fehler. Polling laeuft
  // einfach weiter; erst nach vielen Misses zeigen wir einen Hinweis.
  pEffect(() => {
    if (!jobId || status === "completed" || status === "failed") return;
    let cancelled = false;
    let consecutiveMisses = 0;
    const tick = async () => {
      try {
        const res = await fetch(`${apiBase}/web/reel/status/${encodeURIComponent(jobId)}`, {
          headers: authHeaders, credentials: "include",
        });
        if (cancelled) return;
        if (res.status === 404) {
          // 404 vom Proxy = unknown_job. Job-State ist weg, Hard-Fail.
          setBackendAvail(false);
          return;
        }
        if (res.status === 401 || res.status === 403) {
          setError("Nicht angemeldet - bitte neu einloggen.");
          return;
        }
        if (res.status === 502 || res.status === 503 || res.status === 504) {
          // VideoGen-Service ist gerade busy (blockiert vom Render). Nicht
          // als Fehler werten, weiter pollen. setError nur clear damit alte
          // Fehler nicht haengen bleiben.
          consecutiveMisses += 1;
          if (status === null) setStatus("running");
          if (!step) setStep("clips");
          setError(null);
          return;
        }
        if (!res.ok) throw new Error(`Status ${res.status}`);
        consecutiveMisses = 0;
        const data = await res.json();
        if (cancelled) return;
        if (data.status) setStatus(data.status);
        if (data.step) setStep(data.step);
        if (data.error) setError(data.error);
        if (data.status === "completed") {
          setResultUrl(`${apiBase}/web/reel/result/${encodeURIComponent(jobId)}`);
        }
      } catch (e) {
        // Network/Abort - silent miss, weiter pollen.
        if (!cancelled) {
          consecutiveMisses += 1;
          if (status === null) setStatus("running");
        }
      }
    };
    tick();
    // 8 s Polling-Interval - schont den blockierten VideoGen-Worker.
    const t = setInterval(tick, 8000);
    return () => { cancelled = true; clearInterval(t); };
  }, [jobId, status]);

  function reset() {
    setJobId(null); setStatus(null); setStep(null);
    setError(null); setResultUrl(null);
    downloadFiredRef.current = null;
  }

  // Auto-download bei completion: kurze Verzoegerung damit der versteckte
  // Anchor sicher gerendert ist, dann programmatischer Klick. Browser-
  // Policies blocken das manchmal (Safari + strict-mode); dann faengt der
  // sichtbare 'Im Downloads-Ordner speichern'-Button die User-Geste auf.
  pEffect(() => {
    if (!resultUrl || !jobId) return;
    if (downloadFiredRef.current === jobId) return;
    downloadFiredRef.current = jobId;
    const t = setTimeout(() => {
      if (downloadRef.current) {
        try { downloadRef.current.click(); } catch (_) { /* fallback button */ }
      }
    }, 400);
    return () => clearTimeout(t);
  }, [resultUrl, jobId]);

  // Multi-Job-Polling: jeden aktiven Job (status != completed/failed) alle
  // 8 s durchpollen. 502/503/504/Timeout = still ("rendert"), echte Fehler
  // setzen status=failed im Job-Eintrag.
  pEffect(() => {
    const active = jobs.filter(j => j.status !== "completed" && j.status !== "failed");
    if (active.length === 0) return;
    let cancelled = false;
    const tick = async () => {
      for (const j of active) {
        try {
          const res = await fetch(`${apiBase}/web/reel/status/${encodeURIComponent(j.job_id)}`, {
            headers: authHeaders, credentials: "include",
          });
          if (cancelled) return;
          if (res.status === 502 || res.status === 503 || res.status === 504) continue;
          if (res.status === 401 || res.status === 403) continue;
          if (res.status === 404) {
            updateJob(j.job_id, { status: "failed", error: "unknown_job" });
            continue;
          }
          if (!res.ok) continue;
          const data = await res.json();
          if (cancelled) return;
          const patch = {};
          if (data.status) patch.status = data.status;
          if (data.step) patch.step = data.step;
          if (data.error) patch.error = data.error;
          if (data.status === "completed") {
            patch.result_url = `${apiBase}/web/reel/result/${encodeURIComponent(j.job_id)}`;
          }
          if (Object.keys(patch).length) updateJob(j.job_id, patch);
        } catch (_) { /* silent miss */ }
      }
    };
    tick();
    const t = setInterval(tick, 8000);
    return () => { cancelled = true; clearInterval(t); };
  }, [jobs.map(j => j.job_id + ":" + j.status).join("|")]);

  // Styles - inlined to avoid CSS drift; sticks to the existing card vocabulary
  const labelStyle = { fontSize: 11, color: "var(--fg-3)", letterSpacing: "0.04em", textTransform: "uppercase", marginBottom: 4 };
  const inputStyle = { width: "100%", padding: "6px 8px", background: "transparent", border: "1px solid var(--line)", borderRadius: 6, color: "var(--fg-1)", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box" };
  const sectStyle = { marginBottom: 10 };
  const rowStyle = { display: "flex", gap: 8, alignItems: "center" };
  const dropStyle = {
    border: `1px dashed ${dragOver ? "var(--accent-2,#05E6A5)" : "var(--line)"}`,
    background: dragOver ? "rgba(5,230,165,0.06)" : "transparent",
    borderRadius: 8, padding: "16px 12px", textAlign: "center",
    color: "var(--fg-3)", fontSize: 12, cursor: "pointer",
    transition: "all 120ms ease",
  };
  const thumbStyle = {
    display: "inline-flex", alignItems: "center", gap: 6,
    fontSize: 11, padding: "2px 6px 2px 8px", borderRadius: 4,
    background: "rgba(155,200,255,0.10)", color: "var(--fg-2)",
    marginRight: 4, marginBottom: 4,
  };
  const xBtn = {
    background: "transparent", border: "none", color: "var(--fg-3)",
    cursor: "pointer", padding: 0, marginLeft: 4, fontSize: 14, lineHeight: 1,
  };
  const submitBtn = (disabled) => ({
    width: "100%", padding: "8px 12px", borderRadius: 6,
    border: "1px solid var(--accent-2,#05E6A5)",
    color: disabled ? "var(--fg-3)" : "var(--fg-1)",
    background: disabled ? "transparent" : "rgba(5,230,165,0.12)",
    cursor: disabled ? "not-allowed" : "pointer",
    fontSize: 13, fontFamily: "inherit",
  });
  const stepPill = {
    display: "inline-block", fontSize: 10, padding: "2px 6px",
    borderRadius: 4, marginLeft: 6,
    background: "rgba(225,245,40,0.12)", color: "#E1F528",
  };
  const errStyle = {
    color: "#FF9090", background: "rgba(255,100,100,0.10)",
    border: "1px solid #FF6464", borderRadius: 6, padding: "6px 8px",
    fontSize: 11, marginTop: 8,
  };

  const formatOpts = ["9:16", "16:9", "1:1"];
  const isRendering = jobId && status !== "completed" && status !== "failed";
  const canSubmit = !submitting && !isRendering && anlass.trim() && botschaft.trim();

  const metaLabel = backendAvail === false
    ? "Backend nicht erreichbar"
    : status
      ? `${REEL_STATUS_LABEL[status] || status}${step ? "" : ""}`
      : "bereit";

  // Styles fuer Job-Liste (V1)
  const jobRowStyle = {
    display: "flex", alignItems: "center", justifyContent: "space-between",
    gap: 8, padding: "6px 8px", borderRadius: 6,
    border: "1px solid var(--line)", marginBottom: 4,
    fontSize: 11, color: "var(--fg-2)",
  };
  const jobStatusPill = (st) => ({
    display: "inline-block", padding: "1px 6px", borderRadius: 4, fontSize: 10,
    background: st === "completed" ? "rgba(5,230,165,0.18)"
              : st === "failed" ? "rgba(255,100,100,0.18)"
              : "rgba(225,245,40,0.12)",
    color: st === "completed" ? "var(--accent-2,#05E6A5)"
         : st === "failed" ? "#FF9090"
         : "#E1F528",
  });
  function jobAge(ts) {
    if (!ts) return "";
    const diff = Date.now() - ts;
    if (diff < 60000) return "vor wenigen Sekunden";
    if (diff < 3600000) return `vor ${Math.round(diff/60000)} Min`;
    return `vor ${Math.round(diff/3600000)} h`;
  }

  return (
    <div className="card">
      <div className="card__head">
        <div className="card__title">Reel · Video</div>
        <div className="card__meta">
          {jobs.length > 0 ? `${jobs.length} Job${jobs.length === 1 ? "" : "s"}` : metaLabel}
          {step && status === "running" && <span style={stepPill}>{REEL_STEP_LABEL[step] || step}</span>}
        </div>
      </div>

      {/* V1 Job-Liste (localStorage, per-Browser) */}
      {jobs.length > 0 && (
        <div style={{ marginBottom: 10 }}>
          {jobs.map(j => (
            <div key={j.job_id} style={jobRowStyle}>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
                     title={`${j.title} · job_id=${j.job_id}`}>
                  {j.title || "Reel"}
                </div>
                <div style={{ color: "var(--fg-3)", fontSize: 10 }}>
                  {jobAge(j.ts)}{j.step && j.status === "running" ? ` · ${REEL_STEP_LABEL[j.step] || j.step}` : ""}
                </div>
              </div>
              <span style={jobStatusPill(j.status)}>
                {REEL_STATUS_LABEL[j.status] || j.status}
              </span>
              {j.status === "completed" && j.result_url && (
                <a href={j.result_url} download={`reel-${j.job_id}.mp4`}
                   title="MP4 herunterladen"
                   style={{ fontSize: 11, color: "var(--accent-2,#05E6A5)", textDecoration: "none" }}>
                  <ConnieIcon name="arrow-up" size={12} />
                </a>
              )}
              <button onClick={() => removeJob(j.job_id)} title="Aus Liste entfernen"
                      style={{ background: "transparent", border: "none", color: "var(--fg-3)",
                               cursor: "pointer", padding: 0, fontSize: 14, lineHeight: 1 }}>×</button>
            </div>
          ))}
        </div>
      )}

      {/* Anlass + Botschaft */}
      <div style={sectStyle}>
        <div style={labelStyle}>Anlass / Thema *</div>
        <input style={inputStyle} value={anlass} onChange={e => setAnlass(e.target.value)}
               placeholder="z.B. Connie tippt - WM 2026" disabled={isRendering} />
      </div>
      <div style={sectStyle}>
        <div style={labelStyle}>Botschaft *</div>
        <input style={inputStyle} value={botschaft} onChange={e => setBotschaft(e.target.value)}
               placeholder="z.B. Bist du besser als eine KI?" disabled={isRendering} />
      </div>
      <div style={sectStyle}>
        <div style={labelStyle}>CTA-URL</div>
        <input style={inputStyle} value={ctaUrl} onChange={e => setCtaUrl(e.target.value)}
               placeholder="z.B. tipp.conventic.com" disabled={isRendering} />
      </div>
      <div style={sectStyle}>
        <div style={labelStyle}>Look (optional)</div>
        <input style={inputStyle} value={look} onChange={e => setLook(e.target.value)}
               placeholder="z.B. bright packed football stadium" disabled={isRendering} />
      </div>

      {/* Stimme + Format */}
      <div style={{ ...sectStyle, ...rowStyle }}>
        <div style={{ flex: 1 }}>
          <div style={labelStyle}>Stimme</div>
          <select style={inputStyle} value={voStyle} onChange={e => setVoStyle(e.target.value)} disabled={isRendering}>
            <option value="connie">Connie (du / frech)</option>
            <option value="narrator">Off-Sprecher (Sie / neutral)</option>
          </select>
        </div>
        <div style={{ flex: 1 }}>
          <div style={labelStyle}>Format</div>
          <select style={inputStyle} value={format} onChange={e => setFormat(e.target.value)} disabled={isRendering}>
            {formatOpts.map(o => <option key={o} value={o}>{o}</option>)}
          </select>
        </div>
      </div>
      {voStyle === "narrator" && (
        <div style={sectStyle}>
          <div style={labelStyle}>ElevenLabs Voice-ID (optional)</div>
          <input style={inputStyle} value={voiceId} onChange={e => setVoiceId(e.target.value)}
                 placeholder="leer = Standard-Off-Sprecher" disabled={isRendering} />
        </div>
      )}

      {/* Bild-Upload */}
      <div style={sectStyle}>
        <div style={labelStyle}>Ausgangsbilder (optional)</div>
        <div style={dropStyle}
             onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
             onClick={() => fileInputRef.current && fileInputRef.current.click()}>
          {uploading ? "lade hoch ..." : (
            images.length ? `${images.length} Bild${images.length === 1 ? "" : "er"} bereit - klick/drop fuer mehr` : "Bilder ziehen oder klicken"
          )}
        </div>
        <input ref={fileInputRef} type="file" multiple accept="image/*"
               onChange={onFilePick} style={{ display: "none" }} disabled={isRendering} />
        {images.length > 0 && (
          <div style={{ marginTop: 6 }}>
            {images.map((img, idx) => (
              <span key={img.image_id + idx} style={thumbStyle}>
                {img.name.length > 22 ? img.name.slice(0, 20) + "..." : img.name}
                <button style={xBtn} onClick={() => removeImage(idx)} disabled={isRendering}
                        aria-label={`Bild ${img.name} entfernen`}>×</button>
              </span>
            ))}
          </div>
        )}
      </div>

      {/* Submit */}
      <button style={submitBtn(!canSubmit)} onClick={submit} disabled={!canSubmit}>
        {submitting ? "starte ..." : isRendering ? `rendert ${step ? `(${REEL_STEP_LABEL[step] || step})` : "..."}` : "Reel generieren"}
      </button>

      {/* Error */}
      {error && <div style={errStyle}>{error}</div>}
      {backendAvail === false && (
        <div style={errStyle}>
          Backend nicht erreichbar - das VideoGen-Projekt muss PR #3 noch mergen
          und CONNIE_VIDEOGEN_URL/TOKEN setzen. Siehe HANDOVER-frontend-kachel.md.
        </div>
      )}

      {/* Result */}
      {resultUrl && (
        <div style={{ marginTop: 12 }}>
          {/* Hidden anchor for the auto-download trigger. */}
          <a ref={downloadRef} href={resultUrl} download={`reel-${jobId}.mp4`}
             style={{ display: "none" }} aria-hidden="true">dl</a>

          {/* Prominent download button as the fallback if auto-click is blocked. */}
          <a href={resultUrl} download={`reel-${jobId}.mp4`}
             style={{
               display: "block", padding: "8px 12px", marginBottom: 10,
               borderRadius: 6, textAlign: "center",
               border: "1px solid var(--accent-2,#05E6A5)",
               background: "rgba(5,230,165,0.12)",
               color: "var(--fg-1)", textDecoration: "none",
               fontSize: 13,
             }}>
            In Downloads-Ordner speichern
          </a>

          <div style={labelStyle}>Vorschau (Freigabe vor Veroeffentlichung)</div>
          <video src={resultUrl} controls preload="metadata"
                 style={{ width: "100%", borderRadius: 6, background: "#000" }} />
          <div style={{ ...rowStyle, marginTop: 8, justifyContent: "flex-end" }}>
            <button onClick={reset} style={{ ...xBtn, fontSize: 11, color: "var(--fg-3)" }}>
              neues Reel
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

window.ReelCard = ReelCard;
window.ScheduleCard = ScheduleCard;
window.PipelineCard = PipelineCard;
window.MemoryCard = MemoryCard;
window.WeatherCard = WeatherCard;
window.StockPortfolioCard = StockPortfolioCard;
window.BillomatCard = BillomatCard;
window.ApprovalsCard = ApprovalsCard;
window.WorkingMemoryCard = WorkingMemoryCard;
window.WorkflowCard = WorkflowCard;
window.ActivityCard = ActivityCard;
window.TranscriptCard = TranscriptCard;
window.CardShell = CardShell;
window.SettingsPanel = SettingsPanel;
window.groupKnowledge = groupKnowledge;
window.ConnieIcon = Icon;
