// MDM SORP — Relationship Map screen // ER-like diagram with FOUR semantic view modes: // Business — бизнес-объекты (Company, Contact, Deal, Invoice, Payment, Task, Document, User) // Technical — реальные таблицы из БД // Integration — откуда данные приходят (AmoCRM/Asana/Sheets/Bank/Email → tables) // AI — где работают AI-агенты (extraction/matching/dedup/scoring/...) // // SVG-based, pan/zoom, node selection, side panel with details. (function () { const { useState, useEffect, useMemo, useRef, useCallback } = React; // ───────────────────────────────────────────────────────────── // Modes — node lists + edges + colors per mode // ───────────────────────────────────────────────────────────── // Compact node spec: id, x, y, color, group, label, sub, fields? const M_BUSINESS = { label: "Business view", desc: "Бизнес-объекты — то, чем оперирует бизнес. Без технических деталей.", nodeColor: "#2C5C8C", nodes: [ { id: "company", x: 80, y: 80, label: "Company", sub: "Контрагенты", icon: "parties" }, { id: "contact", x: 80, y: 220, label: "Contact", sub: "Физ. лица", icon: "parties" }, { id: "user", x: 80, y: 360, label: "User", sub: "Наши сотрудники", icon: "parties" }, { id: "deal", x: 360, y: 80, label: "Deal", sub: "Сделки SORP-XXXX", icon: "deals" }, { id: "task", x: 360, y: 220, label: "Task", sub: "Задачи проджекта", icon: "queue" }, { id: "invoice", x: 640, y: 80, label: "Invoice", sub: "Счета", icon: "docs" }, { id: "payment", x: 640, y: 220, label: "Payment", sub: "Платежи", icon: "bar" }, { id: "document", x: 640, y: 360, label: "Document", sub: "Файлы и сканы", icon: "docs" }, ], edges: [ { from: "company", to: "deal", label: "owns" }, { from: "contact", to: "company", label: "works_at" }, { from: "user", to: "deal", label: "manages" }, { from: "user", to: "task", label: "assignee" }, { from: "deal", to: "task", label: "spawns" }, { from: "deal", to: "invoice", label: "produces" }, { from: "invoice", to: "payment", label: "paid_by" }, { from: "deal", to: "document", label: "has" }, ], legend: [ { color: "#2C5C8C", label: "Бизнес-сущности" }, { color: "#7A7468", label: "Связи (semantic)" }, ], }; const M_TECHNICAL = { label: "Technical view", desc: "Реальные таблицы Postgres. Поля, типы, FK — то, что видит разработчик.", nodeColor: "#1E2329", nodes: [ { id: "counterparties", x: 80, y: 60, label: "counterparties", sub: "table", fields: [ { k: "id", t: "uuid", pk: true }, { k: "type", t: "enum" }, { k: "short_name", t: "text" }, { k: "inn", t: "text" }, ] }, { id: "deals", x: 360, y: 60, label: "deals", sub: "table", fields: [ { k: "id", t: "uuid", pk: true }, { k: "sorp_number", t: "text" }, { k: "customer_id", t: "uuid → cp", fk: true }, { k: "supplier_id", t: "uuid → cp", fk: true }, { k: "current_stage", t: "enum" }, { k: "margin_amount", t: "numeric (gen)" }, ] }, { id: "deal_items", x: 660, y: 60, label: "deal_items", sub: "table", fields: [ { k: "id", t: "uuid", pk: true }, { k: "deal_id", t: "uuid → deals", fk: true }, { k: "name", t: "text" }, { k: "quantity", t: "numeric" }, ] }, { id: "contracts", x: 360, y: 280, label: "contracts", sub: "table", fields: [ { k: "id", t: "uuid", pk: true }, { k: "deal_id", t: "uuid → deals", fk: true }, { k: "party_type", t: "enum" }, { k: "amount_rub", t: "numeric" }, ] }, { id: "payments", x: 660, y: 280, label: "payments", sub: "table", fields: [ { k: "id", t: "uuid", pk: true }, { k: "contract_id", t: "uuid → ctr", fk: true }, { k: "amount", t: "numeric" }, { k: "direction", t: "enum" }, ] }, { id: "transitions_log", x: 80, y: 280, label: "transitions_log", sub: "append-only", fields: [ { k: "id", t: "uuid", pk: true }, { k: "deal_id", t: "uuid → deals", fk: true }, { k: "stage_from", t: "enum" }, { k: "stage_to", t: "enum" }, ] }, { id: "employees", x: 80, y: 500, label: "employees", sub: "table", fields: [ { k: "id", t: "uuid", pk: true }, { k: "full_name", t: "text" }, { k: "role", t: "enum" }, ] }, { id: "documents", x: 660, y: 500, label: "documents", sub: "table", fields: [ { k: "id", t: "uuid", pk: true }, { k: "deal_id", t: "uuid → deals", fk: true }, { k: "doc_type", t: "enum" }, { k: "url", t: "text" }, ] }, { id: "shipments", x: 360, y: 500, label: "shipments", sub: "table", fields: [ { k: "id", t: "uuid", pk: true }, { k: "contract_id", t: "uuid → ctr", fk: true }, { k: "status", t: "enum" }, ] }, ], edges: [ { from: "deals", to: "counterparties", label: "customer_id" }, { from: "deals", to: "counterparties", label: "supplier_id", curveOffset: 30 }, { from: "deal_items", to: "deals", label: "FK" }, { from: "contracts", to: "deals", label: "FK" }, { from: "payments", to: "contracts", label: "FK" }, { from: "transitions_log", to: "deals", label: "FK" }, { from: "documents", to: "deals", label: "FK" }, { from: "shipments", to: "contracts", label: "FK" }, { from: "deals", to: "employees", label: "pm_id" }, ], legend: [ { color: "#1E2329", label: "table" }, { color: "#D63D4A", label: "PK column" }, { color: "#2C5C8C", label: "FK column" }, ], }; const M_INTEGRATION = { label: "Integration view", desc: "Откуда данные приходят. Стрелка = поток данных от источника к таблице.", nodeColor: "#1E5E40", nodes: [ // Sources { id: "amocrm", x: 60, y: 80, label: "AmoCRM", sub: "source · OAuth", icon: "plug", kind: "source", srcColor: "#FF8A0B" }, { id: "asana", x: 60, y: 200, label: "Asana", sub: "source · PAT", icon: "plug", kind: "source", srcColor: "#F06A6A" }, { id: "sheets", x: 60, y: 320, label: "Google Sheets", sub: "source · OAuth", icon: "plug", kind: "source", srcColor: "#0F9D58" }, { id: "bank", x: 60, y: 440, label: "Bank statements",sub: "source · CSV", icon: "plug", kind: "source", srcColor: "#2C5C8C" }, { id: "email", x: 60, y: 560, label: "Email", sub: "source · IMAP", icon: "plug", kind: "source", srcColor: "#7A7468" }, // Tables { id: "deals", x: 440, y: 60, label: "deals", sub: "table" }, { id: "tasks", x: 440, y: 200, label: "tasks", sub: "table" }, { id: "fin_staging",x: 440, y: 320, label: "fin_staging", sub: "staging table" }, { id: "bank_txn", x: 440, y: 440, label: "bank_transactions", sub: "table" }, { id: "documents", x: 440, y: 560, label: "documents", sub: "table" }, ], edges: [ { from: "amocrm", to: "deals", label: "leads → deals", kind: "integration" }, { from: "asana", to: "tasks", label: "tasks → tasks", kind: "integration" }, { from: "asana", to: "deals", label: "GID → deal.asana_task_gid", kind: "integration", curveOffset: -40 }, { from: "sheets", to: "fin_staging", label: "sheet → staging", kind: "integration" }, { from: "bank", to: "bank_txn", label: "csv → txn", kind: "integration" }, { from: "email", to: "documents", label: "attachment → doc", kind: "integration" }, ], legend: [ { color: "#FF8A0B", label: "AmoCRM source" }, { color: "#F06A6A", label: "Asana source" }, { color: "#0F9D58", label: "Sheets source" }, { color: "#2C5C8C", label: "Bank source" }, { color: "#7A7468", label: "Email source" }, { color: "#1E5E40", label: "Destination table" }, ], }; const M_AI = { label: "AI view", desc: "Где работают AI-агенты. Стрелка = вход и выход агента.", nodeColor: "#4A2BA8", nodes: [ // Inputs { id: "raw_text", x: 60, y: 80, label: "raw text", sub: "Asana comments, emails", icon: "list", kind: "data" }, { id: "raw_pdf", x: 60, y: 220, label: "PDF / scan", sub: "spec, invoice, КП", icon: "file", kind: "data" }, { id: "csv_data", x: 60, y: 360, label: "CSV / sheet", sub: "bank, finance", icon: "table", kind: "data" }, { id: "duplicates", x: 60, y: 500, label: "duplicate candidates", sub: "fuzzy match input", icon: "merge", kind: "data" }, // Agents { id: "extraction", x: 380, y: 80, label: "Extraction agent", sub: "NER + regex", kind: "agent" }, { id: "matching", x: 380, y: 220, label: "Matching agent", sub: "amo↔asana↔drive", kind: "agent" }, { id: "dedup", x: 380, y: 360, label: "Dedup agent", sub: "fuzzy + INN + name", kind: "agent" }, { id: "scoring", x: 380, y: 500, label: "Scoring agent", sub: "confidence + risk", kind: "agent" }, { id: "reconciliation",x: 380, y: 640, label: "Reconciliation", sub: "amount match cross-source", kind: "agent" }, { id: "summarization", x: 700, y: 80, label: "Summarization", sub: "deal digest, weekly", kind: "agent" }, // Outputs { id: "deals_out", x: 700, y: 220, label: "deals", sub: "table" }, { id: "provenance", x: 700, y: 360, label: "field_provenance", sub: "src + conf" }, { id: "merged_party", x: 700, y: 500, label: "counterparties", sub: "merged record" }, ], edges: [ { from: "raw_text", to: "extraction", label: "input", kind: "ai" }, { from: "raw_pdf", to: "extraction", label: "input", kind: "ai" }, { from: "csv_data", to: "matching", label: "input", kind: "ai" }, { from: "duplicates", to: "dedup", label: "input", kind: "ai" }, { from: "extraction", to: "deals_out", label: "fields", kind: "ai" }, { from: "extraction", to: "provenance", label: "conf+raw", kind: "ai" }, { from: "matching", to: "provenance", label: "match score", kind: "ai" }, { from: "dedup", to: "merged_party", label: "merged uuid", kind: "ai" }, { from: "scoring", to: "provenance", label: "writes conf", kind: "ai" }, { from: "reconciliation", to: "deals_out", label: "amount_rub", kind: "ai", curveOffset: -50 }, { from: "extraction", to: "summarization", label: "digest input", kind: "ai" }, ], legend: [ { color: "#4A2BA8", label: "AI agent" }, { color: "#7A7468", label: "Input data" }, { color: "#1E5E40", label: "Destination" }, { color: "#6E45E2", label: "Data flow", dash: true }, ], }; const MODES = { business: M_BUSINESS, technical: M_TECHNICAL, integration: M_INTEGRATION, ai: M_AI }; // ───────────────────────────────────────────────────────────── // Node sizing — width fixed, height depends on fields count // ───────────────────────────────────────────────────────────── const NODE_W = 220; const HEADER_H = 36; const FIELD_H = 22; function nodeHeight(node) { if (node.fields && node.fields.length) return HEADER_H + node.fields.length * FIELD_H + 8; return HEADER_H + 32; // sub line } // Compute attachment point on the side of a node closest to target center function attachPoint(node, targetCx, targetCy) { const w = NODE_W; const h = nodeHeight(node); const cx = node.x + w / 2; const cy = node.y + h / 2; const dx = targetCx - cx; const dy = targetCy - cy; // Decide if attach is on left/right or top/bottom side if (Math.abs(dx) / w > Math.abs(dy) / h) { // attach on left or right return { x: dx > 0 ? node.x + w : node.x, y: cy }; } else { return { x: cx, y: dy > 0 ? node.y + h : node.y }; } } function curvePath(from, to, offset = 0) { const dx = to.x - from.x; const dy = to.y - from.y; const cx1 = from.x + dx * 0.5; const cy1 = from.y + dy * 0.1 + offset; const cx2 = from.x + dx * 0.5; const cy2 = to.y - dy * 0.1 + offset; return `M ${from.x} ${from.y} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${to.x} ${to.y}`; } // ───────────────────────────────────────────────────────────── // Main screen // ───────────────────────────────────────────────────────────── function RelationshipMapScreen() { const [mode, setMode] = useState("business"); const [selected, setSelected] = useState(null); const [transform, setTransform] = useState({ x: 0, y: 0, k: 1 }); const vpRef = useRef(null); const data = MODES[mode]; const nodes = data.nodes; const edges = data.edges; // Pan: drag background const dragRef = useRef(null); const onMouseDown = (e) => { if (e.target.closest(".rm-node")) return; dragRef.current = { startX: e.clientX, startY: e.clientY, base: { x: transform.x, y: transform.y } }; }; useEffect(() => { const move = (e) => { if (!dragRef.current) return; setTransform(t => ({ ...t, x: dragRef.current.base.x + (e.clientX - dragRef.current.startX), y: dragRef.current.base.y + (e.clientY - dragRef.current.startY) })); }; const up = () => { dragRef.current = null; }; window.addEventListener("mousemove", move); window.addEventListener("mouseup", up); return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; }, []); // Zoom: ctrl/cmd + wheel const onWheel = (e) => { if (!(e.ctrlKey || e.metaKey)) return; e.preventDefault(); const factor = Math.exp(-e.deltaY * 0.002); setTransform(t => ({ ...t, k: Math.max(0.4, Math.min(2.5, t.k * factor)) })); }; const zoomIn = () => setTransform(t => ({ ...t, k: Math.min(2.5, t.k * 1.2) })); const zoomOut = () => setTransform(t => ({ ...t, k: Math.max(0.4, t.k / 1.2) })); const zoomReset = () => setTransform({ x: 0, y: 0, k: 1 }); // Reset selection when mode changes useEffect(() => { setSelected(null); }, [mode]); const selectedNode = selected ? nodes.find(n => n.id === selected) : null; const incoming = selected ? edges.filter(e => e.to === selected) : []; const outgoing = selected ? edges.filter(e => e.from === selected) : []; // ───────────────────────────────────────────────────── // Render // ───────────────────────────────────────────────────── return (
{mode === "business" && businessDesc(selectedNode.id)} {mode === "technical" && technicalDesc(selectedNode.id)} {mode === "integration"&& integrationDesc(selectedNode.id)} {mode === "ai" && aiDesc(selectedNode.id)}
{selectedNode.fields && selectedNode.fields.length > 0 && ( <>{f.k}
{f.t}
{e.from}
{e.label || "→"}
{e.to}
{e.label || "→"}