/* ========================================================= 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: <>, mic: <>, square: , search: <>, mail: <>, sun: <>, presentation: <>, "arrow-up": <>, check: , "loader-2": <>, bot: <>, // dashboard-v3 controls settings: <>, "eye-off": <>, "chevron-up": , "chevron-down": , "chevron-left": , "chevron-right": , "minimize-2": <>, "maximize-2": <>, x: <>, "volume-2": <>, "volume-x": <>, film: <>, upload: <>, image: <>, eye: <>, // dashboard-v3.1: drag handle (6-dot grip) + media controls for voice preview grip: <>, play: , pause: <>, // per-user-visibility UI: calendar section title icon calendar: <>, // Standort (S2a) section title icon "map-pin": <>, // Aktien-Portfolio: card title + per-line trend + add/remove controls "trending-up": <>, "trending-down": <>, "line-chart": <>, plus: <>, trash: <>, // Wissen (Knowledge-Input, S4) section title icon "book-open": <>, // Auth-Login slice (WB-Auth, 2026-06-04): logout button in topbar. "log-out": <>, }; // 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 ( {ICON_PATHS[name] || null} ); } // ============================================================ // 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 (
{text}
); } 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 (
Heute · {weekday}
{meta}
{loading && } {!loading && list.length === 0 && } {!loading && list.map((a, i) => (
{a.time}
{a.title}
{a.sub &&
{a.sub}
}
))}
); } // ============================================================ // 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 ( ); } // 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 = ( <> {label} {stage && {stage}} ); if (deal.url) { return ( {inner} ); } return
{inner}
; } 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 (
Pipeline · HubSpot
{loading ? "lade ..." : "letzte 30 Tage"}
{openN}
Offene Deals
{/* Decorative spark - no historical series is exposed by the API. */}
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" }} >
{followN}
Wiedervorlage{canOpen ? " ›" : ""}
{topDeals.length > 0 && (
{topDeals.map((d) => )}
)}
{headline}
{modal && ReactDOM.createPortal(
setModal(false)} role="dialog" aria-modal="true">
e.stopPropagation()}>
Wiedervorlage
Deals mit einem Folgetermin in der Zukunft - bis dahin geparkt, daher nicht in der Live-Liste.
{followups.map((f) => (
{f.url ? {f.name} : {f.name}} {f.stage && f.stage !== "-" && · {f.stage}}
{f.date || ""}
))}
, document.body )}
); } // ============================================================ // 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 (
); } const focusNode = nodes.find((n) => n.id === focusId) || nodes[0]; const neighbours = nodes.filter((n) => n.id !== focusNode.id); // Build an opaque "