// MDM SORP — Платежи / Smart Table // Default state shows ONLY data. Advanced features (column visibility, // source badges, group, color, row height) live behind toolbar dropdowns. // Click row → slide-over drawer. Double-click cell → inline edit. (function () { const { useState, useEffect, useMemo, useRef, useCallback } = React; // ───────────────────────────────────────────────────────────── // Source meta — 7 sources, but indicator is OFF by default. // Toggle via toolbar. // ───────────────────────────────────────────────────────────── const ST_SOURCES = { amocrm: { short: "Amo", label: "AmoCRM", color: "#FF8A0B" }, asana: { short: "Asana", label: "Asana", color: "#F06A6A" }, sheets: { short: "Sheets", label: "Google Sheets", color: "#0F9D58" }, bank: { short: "Bank", label: "Банк", color: "#2C5C8C" }, manual: { short: "Manual", label: "Вручную", color: "#7A7468" }, ai: { short: "AI", label: "AI-generated", color: "#6E45E2" }, verified: { short: "✓", label: "Verified", color: "#2F7D5B" }, }; // ───────────────────────────────────────────────────────────── // Reference data // ───────────────────────────────────────────────────────────── const VENDORS = [ { id: "v_chem", short: "Chemify", country: "ae" }, { id: "v_ineq", short: "Ineq", country: "ae" }, { id: "v_axis", short: "Axis", country: "ae" }, { id: "v_andr", short: "Andritz CN", country: "cn" }, { id: "v_meta", short: "Метафракс", country: "ru" }, { id: "v_tmk", short: "ТМК-Волжский", country: "ru" }, { id: "v_sib", short: "СИБУР", country: "ru" }, { id: "v_keng", short: "Keshang Eng.", country: "cn" }, ]; const PEOPLE = [ { id: "u_aa", name: "Aleksey A.", role: "PM" }, { id: "u_az", name: "Azat D.", role: "Buyer" }, { id: "u_es", name: "Елена С.", role: "PM" }, { id: "u_vk", name: "Валентин К.", role: "Buyer" }, { id: "u_ai", name: "ИИ-агент", role: "AI" }, ]; const STATUS = { "new": { label: "Новая", color: "#7A7468" }, "matched": { label: "Согласовано",color: "#2F7D5B" }, "conflict": { label: "Конфликт", color: "#A1242E" }, "approved": { label: "Одобрено", color: "#1A4173" }, "synced": { label: "Закрыто", color: "#4A2BA8" }, }; const c = (v, src = "manual", extra = {}) => ({ v, src, ...extra }); // Realistic data — 14 rows const INITIAL_ROWS = [ { id: "p1", sorp: c("SORP-3606", "asana"), vendor: c("v_meta", "amocrm"), status: c("matched", "ai", { conf: 0.88 }), amount: c(10000, "bank"), currency: c("AED"), due: c("2026-06-04", "amocrm"), assignee: c("u_aa"), vat: c(20), docs: c(["spec.pdf","kp_v3.xlsx"], "sheets"), sanctions: c(false), comments: 2, history: 4 }, { id: "p2", sorp: c("SORP-3560", "asana"), vendor: c("v_andr", "ai", { conf: 0.71, edited: true }), status: c("conflict", "manual", { error: "Asana=В работе, Amo=Lost" }), amount: c(48750, "manual", { edited: true }), currency: c("USD"), due: c("2026-06-12", "amocrm"), assignee: c("u_az"), vat: c(0), docs: c(["invoice.pdf"], "sheets"), sanctions: c(false), comments: 5, history: 9 }, { id: "p3", sorp: c("SORP-3534", "asana"), vendor: c("v_andr", "amocrm"), status: c("approved", "manual"), amount: c(2890, "bank"), currency: c("USD"), due: c("2026-05-30", "amocrm", { error: "Просрочено на 3 дня" }), assignee: c("u_az"), vat: c(20), docs: c([]), sanctions: c(false), comments: 0, history: 2 }, { id: "p4", sorp: c("SORP-3533", "asana"), vendor: c("v_chem"), status: c("synced", "ai", { conf: 0.95 }), amount: c(127500, "bank"), currency: c("AED"), due: c("2026-07-15", "amocrm"), assignee: c("u_es"), vat: c(5), docs: c(["contract_v2.pdf","spec.docx","photo_1.jpg"], "sheets"), sanctions: c(false), comments: 1, history: 6 }, { id: "p5", sorp: c("SORP-3366", "asana"), vendor: c("v_meta", "amocrm"), status: c("matched"), amount: c(null, "ai", { conf: 0.42, suggested: 3500 }), currency: c("EUR", "ai", { conf: 0.42 }), due: c("2026-06-20"), assignee: c("u_ai", "ai"), vat: c(20), docs: c(["zayavka.pdf"], "sheets"), sanctions: c(false), comments: 3, history: 1 }, { id: "p6", sorp: c("SORP-3201", "asana"), vendor: c("v_tmk", "amocrm"), status: c("new"), amount: c(8400000, "sheets"), currency: c("RUB", "sheets"), due: c("2026-06-28"), assignee: c("u_aa"), vat: c(20), docs: c(["оферта.docx"], "sheets"), sanctions: c(true, "verified", { error: "Возможный hit OFAC" }), comments: 0, history: 1 }, { id: "p7", sorp: c("SORP-3198", "asana"), vendor: c("v_sib", "amocrm"), status: c("matched", "ai", { conf: 0.93 }), amount: c(2150000, "bank"), currency: c("RUB", "bank"), due: c("2026-06-15"), assignee: c("u_es"), vat: c(20), docs: c(["спец.xlsx","договор.pdf"], "sheets"), sanctions: c(false), comments: 1, history: 5 }, { id: "p8", sorp: c("SORP-3174", "asana"), vendor: c("v_andr", "amocrm"), status: c("approved"), amount: c(75200, "bank"), currency: c("USD"), due: c("2026-07-02"), assignee: c("u_vk"), vat: c(0), docs: c(["invoice_andr_174.pdf","cert.pdf"], "sheets"), sanctions: c(false), comments: 0, history: 3 }, { id: "p9", sorp: c("SORP-3155", "asana"), vendor: c("v_keng"), status: c("matched"), amount: c(34800, "manual", { edited: true }), currency: c("USD"), due: c("2026-06-22"), assignee: c("u_az"), vat: c(0), docs: c([]), sanctions: c(false), comments: 0, history: 1 }, { id: "p10", sorp: c("SORP-3142", "asana"), vendor: c("v_ineq"), status: c("synced"), amount: c(18900, "bank"), currency: c("AED"), due: c("2026-05-28", "amocrm"), assignee: c("u_es"), vat: c(5), docs: c(["act.pdf"], "sheets"), sanctions: c(false), comments: 2, history: 8 }, { id: "p11", sorp: c("SORP-3098", "asana"), vendor: c("v_meta", "amocrm"), status: c("new"), amount: c(null, "ai", { conf: 0.35, suggested: 1200 }), currency: c("USD", "ai"), due: c("2026-07-08"), assignee: c("u_ai", "ai"), vat: c(20), docs: c([]), sanctions: c(false), comments: 0, history: 1 }, { id: "p12", sorp: c("SORP-3076", "asana"), vendor: c("v_axis"), status: c("approved"), amount: c(265000, "bank"), currency: c("AED"), due: c("2026-06-30"), assignee: c("u_aa"), vat: c(5), docs: c(["spec.pdf","contract.pdf"], "sheets"), sanctions: c(false), comments: 1, history: 4 }, { id: "p13", sorp: c("SORP-3045", "asana"), vendor: c("v_andr"), status: c("conflict", "manual", { error: "Bank ≠ Контракт на 250 USD" }), amount: c(48500, "bank"), currency: c("USD"), due: c("2026-06-18"), assignee: c("u_vk"), vat: c(0), docs: c(["statement.csv"], "bank"), sanctions: c(false), comments: 3, history: 7 }, { id: "p14", sorp: c("SORP-2998", "asana"), vendor: c("v_tmk", "amocrm"), status: c("matched"), amount: c(5600000, "sheets"), currency: c("RUB", "sheets"), due: c("2026-07-12"), assignee: c("u_aa"), vat: c(20), docs: c(["оферта.pdf"], "sheets"), sanctions: c(false), comments: 0, history: 2 }, ]; const INITIAL_COLS = [ { key: "sorp", label: "№", type: "text", w: 110, frozen: true }, { key: "vendor", label: "Контрагент", type: "linked", w: 200 }, { key: "status", label: "Статус", type: "select", w: 150 }, { key: "amount", label: "Сумма", type: "currency", w: 160 }, { key: "due", label: "Срок", type: "date", w: 130 }, { key: "assignee", label: "Исполнитель",type: "person", w: 170 }, // Hidden by default — surface in drawer { key: "vat", label: "НДС %", type: "number", w: 80, hidden: true }, { key: "docs", label: "Документы", type: "attachment", w: 200, hidden: true }, { key: "sanctions", label: "Скрин.", type: "bool", w: 80, hidden: true }, ]; const VIEWS = [ { id: "v_all", name: "Все", filters: [] }, { id: "v_overdue", name: "Просроченные", filters: [{ col: "due", op: "before", val: "today" }] }, { id: "v_conflict", name: "Конфликты", filters: [{ col: "status", op: "is", val: "conflict" }] }, { id: "v_my", name: "Мои (Елена)", filters: [{ col: "assignee", op: "is", val: "u_es" }] }, { id: "v_audit", name: "Аудит > 50k", filters: [{ col: "amount", op: "gt", val: 50000 }], locked: true }, ]; // ───────────────────────────────────────────────────────────── // Tiny components // ───────────────────────────────────────────────────────────── function SrcDot({ src, conf, edited }) { if (!src || src === "manual") return null; const m = ST_SOURCES[src]; if (!m) return null; const title = m.label + (conf != null && conf < 1 ? ` · ${Math.round(conf * 100)}%` : ""); return ; } function StatusDot({ v }) { const m = STATUS[v]; if (!m) return ; return ( {m.label} ); } function Linked({ id }) { const v = VENDORS.find(x => x.id === id); if (!v) return ; return ( {v.short} ); } function DateCell({ v }) { if (!v) return ; const d = new Date(v); const today = new Date(); today.setHours(0,0,0,0); const days = Math.round((d - today) / 86400000); const overdue = days < 0; const soon = days >= 0 && days <= 3; return ( {d.toLocaleDateString("ru-RU", { day: "2-digit", month: "short" })} {(overdue || soon) && ( {overdue ? `${days} дн` : `+${days} дн`} )} ); } function Person({ id }) { const p = PEOPLE.find(x => x.id === id); if (!p) return ; return ( {p.name} ); } function Money({ v, ccy }) { if (v == null) return ; return {fmtMoney(v, ccy)}; } function AiSuggest({ value, ccy, onApply, onDismiss }) { return ( ≈{fmtMoney(value, ccy)} ); } // Generic cell renderer function Cell({ cell, col, row, showSrc }) { if (col.type === "select") return ; if (col.type === "linked") return ; if (col.type === "date") return ; if (col.type === "person") return ; if (col.type === "currency") { if (cell.v == null && cell.suggested != null) { return ; } return ; } if (col.type === "number") return {cell.v}; if (col.type === "bool") { return cell.v ? : ; } if (col.type === "attachment") { const arr = cell.v || []; if (!arr.length) return ; return ( {arr.slice(0, 2).map((f, i) => ( {f.length > 16 ? f.slice(0, 14) + "…" : f} ))} {arr.length > 2 && +{arr.length - 2}} ); } return {cell.v ?? "—"}; } // ───────────────────────────────────────────────────────────── // Main screen // ───────────────────────────────────────────────────────────── function SmartTableScreen() { const [rows, setRows] = useState(INITIAL_ROWS); const [cols, setCols] = useState(INITIAL_COLS); const [viewId, setViewId] = useState("v_all"); const [selected, setSelected] = useState(new Set()); const [search, setSearch] = useState(""); const [showSrc, setShowSrc] = useState(false); // source dots toggle (OFF default) const [activeRowId, setActiveRowId] = useState(null); const [activePanel, setActivePanel] = useState("info"); const [openMenu, setOpenMenu] = useState(null); // 'columns' | 'sort' | 'group' | 'more' | 'view' const [editCell, setEditCell] = useState(null); const toast = useToast(); const view = VIEWS.find(v => v.id === viewId); const visibleCols = cols.filter(c => !c.hidden); // Filter + search const filtered = useMemo(() => { let res = rows; if (search.trim()) { const q = search.toLowerCase(); res = res.filter(r => { for (const k of Object.keys(r)) { const cell = r[k]; if (cell && typeof cell === "object" && cell.v != null && String(cell.v).toLowerCase().includes(q)) return true; } return false; }); } const today = new Date(); today.setHours(0,0,0,0); for (const f of (view?.filters || [])) { res = res.filter(r => { const cell = r[f.col]; if (!cell) return false; if (f.op === "is") return cell.v === f.val; if (f.op === "gt") return Number(cell.v) > Number(f.val); if (f.op === "before") { if (f.val === "today") return new Date(cell.v) < today; return false; } return true; }); } return res; }, [rows, search, view]); const toggleSelect = (id) => { const next = new Set(selected); if (next.has(id)) next.delete(id); else next.add(id); setSelected(next); }; const toggleAll = () => { if (selected.size === filtered.length) setSelected(new Set()); else setSelected(new Set(filtered.map(r => r.id))); }; const toggleCol = (key) => setCols(prev => prev.map(c => c.key === key ? { ...c, hidden: !c.hidden } : c)); const startEdit = (rowId, colKey) => setEditCell({ rowId, colKey }); const commitEdit = (rowId, colKey, newVal) => { setRows(prev => prev.map(r => r.id !== rowId ? r : { ...r, [colKey]: { ...r[colKey], v: (typeof r[colKey].v === "number") ? Number(newVal) : newVal, src: "manual", edited: true } })); setEditCell(null); }; const bulkApprove = () => { setRows(prev => prev.map(r => selected.has(r.id) ? { ...r, status: { v: "approved", src: "manual", edited: true } } : r)); toast(`Одобрено: ${selected.size}`, { icon: "check", onUndo: () => setRows(INITIAL_ROWS) }); setSelected(new Set()); }; const dirty = rows.reduce((s, r) => s + Object.values(r).filter(v => v && typeof v === "object" && v.edited).length, 0); const errors = rows.reduce((s, r) => s + Object.values(r).filter(v => v && typeof v === "object" && v.error).length, 0); const activeRow = activeRowId ? rows.find(r => r.id === activeRowId) : null; // Close menus on outside click useEffect(() => { if (!openMenu) return; const h = (e) => { if (!e.target.closest(".st2-menu") && !e.target.closest(".st2-tool")) setOpenMenu(null); }; const t = setTimeout(() => document.addEventListener("click", h), 0); return () => { clearTimeout(t); document.removeEventListener("click", h); }; }, [openMenu]); return (
{/* HEADER */}

Платежи

{filtered.length} записей {dirty > 0 && {dirty} несохранённых} {errors > 0 && {errors} конфликтов}
{dirty > 0 && ( )}
{/* TOOLBAR — one line, minimal */}
{/* View dropdown */}
{openMenu === "view" && (
Представления
{VIEWS.map(v => ( ))}
)}
{/* Filter pill (shows count if active) */} {/* Sort */} {/* Group */} {/* Source toggle — the key user-control */} {/* More menu — columns, height, color, AI-fill, export */}
{openMenu === "more" && (
Колонки
{cols.map(col => ( ))}
)}
{/* Search */}
setSearch(e.target.value)}/>
{/* TABLE */}
{visibleCols.map(c => )} {visibleCols.map(col => ( ))} {filtered.map(row => { const isActive = activeRowId === row.id; const isSelected = selected.has(row.id); const hasError = Object.values(row).some(c => c && typeof c === "object" && c.error); return ( { if (e.target.closest("input,button,.st2-linked-open,.st2-ai-yes,.st2-ai-no")) return; setActiveRowId(row.id); }}> {visibleCols.map(col => { const cell = row[col.key]; const isEditing = editCell && editCell.rowId === row.id && editCell.colKey === col.key; const cellHasError = cell?.error; const cellEdited = cell?.edited; return ( ); })} ); })}
0 && selected.size === filtered.length} onChange={toggleAll}/> {col.label}
e.stopPropagation()}> toggleSelect(row.id)}/> { e.stopPropagation(); if (col.type === "linked" || col.type === "person" || col.type === "select" || col.type === "bool") return; startEdit(row.id, col.key); }} title={cellHasError || undefined}> {isEditing ? ( commitEdit(row.id, col.key, e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); if (e.key === "Escape") setEditCell(null); }}/> ) : ( {showSrc && } )} {row.comments > 0 && ( { e.stopPropagation(); setActiveRowId(row.id); setActivePanel("comments"); }}> {row.comments} )}
{/* Add row */}
{/* Bulk floating bar (Linear-style) */} {selected.size > 0 && (
{selected.size} выбрано
)} {/* Row drawer */} setActiveRowId(null)} width={520}> {activeRow && (
{activeRow.sorp.v}

{(VENDORS.find(v => v.id === activeRow.vendor.v) || {}).short || "—"}

·
{activePanel === "info" && (
v.id === activeRow.vendor.v) || {}).short} src={activeRow.vendor.src} conf={activeRow.vendor.conf}/> p.id === activeRow.assignee.v) || {}).name} src={activeRow.assignee.src}/>
)} {activePanel === "comments" && (
{activeRow.comments > 0 ? ( <>
Aleksey A. 2 ч
Сумма расходится с банком на 250.
ИИ-агент 3 ч
Предлагаю статус matched (88%).
) : (
Пока нет комментариев
)}