// MDM SORP — Airtable-style grid (multi-view + toolbar + group + record detail). // // Usage: // // Implements: Grid / Kanban / Gallery / Calendar views, search, filter (multi-cond), // sort (multi), group-by w/ collapse, hide fields, row height, color by field, // expanded sidesheet w/ tabs + comments + workflow stepper, inline edit affordance, // linked-record pills, AI-suggested empty cells, primary-field expand button. const { useState, useMemo, useEffect, useRef, useCallback } = React; /* ============================================================ FIELD TYPE REGISTRY — how a cell of each type renders ============================================================ */ const Field = { text: ({ v, mono }) => v ? {v} : , primary: ({ v, row, onExpand, idStyle }) => (
{v}
), select: ({ v, options }) => { const opt = (options || []).find(o => o.value === v) || (v ? { value: v, label: v, color: "gray" } : null); return opt ? {opt.label} : ; }, status_workflow: ({ v, options }) => { const idx = (options || []).findIndex(o => o.value === v); return (
{(options || []).map((o, i) => ( ))} {options?.[idx]?.label || v || "—"}
); }, multi_select: ({ v, options }) => { if (!v || !v.length) return ; return ( {v.map(x => { const o = (options || []).find(opt => opt.value === x) || { value: x, label: x, color: "gray" }; return {o.label}; })} ); }, linked: ({ v, multiple }) => { if (!v || (Array.isArray(v) && !v.length)) { return ИИ предложит; } const arr = Array.isArray(v) ? v : [v]; return ( {arr.slice(0, 2).map((item, i) => ( e.stopPropagation()}> {item.flag && } {item.id} {item.label} ))} {arr.length > 2 && +{arr.length - 2}} ); }, currency: ({ v, ccy = "USD" }) => v == null ? : ( {fmtMoney(v, ccy).replace(/[^\d., ]/g, "").trim()} {ccy} ), percent: ({ v }) => v == null ? : ( {v}% ), number: ({ v }) => v == null ? : ( {v} ), date: ({ v, refDate }) => { if (!v) return ; const today = new Date(refDate || "2026-05-27"); const d = new Date(v); const days = Math.round((d - today) / 86400000); const cls = days < 0 ? "overdue" : days <= 7 ? "soon" : ""; const rel = days < 0 ? `просрочка ${-days}д` : days === 0 ? "сегодня" : days <= 14 ? `+${days}д` : null; return {v}{rel && {rel}}; }, progress: ({ v }) => { const pct = Math.round((v?.done || 0) / Math.max(1, (v?.total || 1)) * 100); const tone = pct === 100 ? "" : pct > 60 ? "" : pct > 20 ? "warn" : "bad"; return ( {v?.done}/{v?.total} ); }, systems: ({ v }) => ( Am As Dr 1C ), users: ({ v }) => ( {(v || []).slice(0, 3).map((u, i) => ( ))} ), flag: ({ v, sub }) => ( {sub || v?.toUpperCase()} ), sanctions: ({ v }) => , badge: ({ v, raw }) => v ? : , }; /* ============================================================ AIRTABLE GRID — main container with all views & toolbar ============================================================ */ function AirtableGrid({ entity, defaultView = "grid", views, columns, rows, onRowOpen, primaryField, rowColorField, navigate }) { const [view, setView] = useState(defaultView); const [viewIdx, setViewIdx] = useState(0); // Each saved "view" has its own state (filter/sort/group/hidden/height/color) const [viewStates, setViewStates] = useState(() => views.map(v => ({ kind: v.kind || "grid", filter: v.filter || [], sort: v.sort || [], groupBy: v.groupBy || null, hidden: v.hidden || [], height: v.height || "medium", colorBy: v.colorBy || null, search: "", })) ); const vs = viewStates[viewIdx] || {}; const setVS = (patch) => setViewStates(s => s.map((x, i) => i === viewIdx ? { ...x, ...patch } : x)); const activeView = views[viewIdx] || views[0]; useEffect(() => { setView(viewStates[viewIdx]?.kind || "grid"); }, [viewIdx, viewStates]); // Apply filter, sort, search const processed = useMemo(() => { let out = rows; if (vs.search) { const q = vs.search.toLowerCase(); out = out.filter(r => Object.values(r).some(v => typeof v === "string" && v.toLowerCase().includes(q)) ); } for (const f of vs.filter || []) { out = out.filter(r => { const val = r[f.field]; if (f.op === "is") return val === f.value; if (f.op === "not") return val !== f.value; if (f.op === "contains") return String(val || "").toLowerCase().includes(String(f.value).toLowerCase()); if (f.op === "empty") return !val; if (f.op === "notempty") return !!val; if (f.op === "lt") return val < f.value; if (f.op === "gt") return val > f.value; return true; }); } if (vs.sort?.length) { out = [...out].sort((a, b) => { for (const s of vs.sort) { const av = a[s.field], bv = b[s.field]; const cmp = av == null ? 1 : bv == null ? -1 : av < bv ? -1 : av > bv ? 1 : 0; if (cmp !== 0) return s.dir === "desc" ? -cmp : cmp; } return 0; }); } return out; }, [rows, vs.filter, vs.sort, vs.search]); const visibleColumns = columns.filter(c => !vs.hidden.includes(c.id)); // Open record detail const [expanded, setExpanded] = useState(null); const filterCount = (vs.filter || []).length; const sortCount = (vs.sort || []).length; const hideCount = (vs.hidden || []).length; return (
c.type === "select" || c.type === "linked" || c.type === "users")} /> {/* Active filters / sorts shown as chips */} {(filterCount > 0 || sortCount > 0 || vs.groupBy) && (
{vs.filter.map((f, i) => { const col = columns.find(c => c.id === f.field); const opt = (col?.options || []).find(o => o.value === f.value); return ( {i === 0 ? "где" : "и"} {col?.label || f.field} {f.op === "is" ? "=" : f.op === "not" ? "≠" : f.op === "contains" ? "содержит" : f.op === "empty" ? "пусто" : f.op === "notempty" ? "не пусто" : f.op} {f.value != null && f.op !== "empty" && f.op !== "notempty" && {opt?.label || f.value}} setVS({ filter: vs.filter.filter((_, j) => j !== i) })}>× ); })} {vs.sort.map((s, i) => { const col = columns.find(c => c.id === s.field); return ( {i === 0 && filterCount === 0 ? "сорт." : "потом"} {col?.label} {s.dir === "desc" ? "↓" : "↑"} setVS({ sort: vs.sort.filter((_, j) => j !== i) })}>× ); })} {vs.groupBy && ( группа {columns.find(c => c.id === vs.groupBy)?.label} setVS({ groupBy: null })}>× )}
)} {/* Active view */} {view === "grid" && setExpanded(r)} primaryField={primaryField} navigate={navigate}/>} {view === "kanban" && c.id === activeView.stackBy)?.options || []} onExpand={r => setExpanded(r)} primaryField={primaryField}/>} {view === "gallery" && setExpanded(r)} columns={columns} primaryField={primaryField}/>} {view === "calendar" && setExpanded(r)} primaryField={primaryField}/>} {/* Expanded record sheet */} setExpanded(null)} width={760}> {expanded && setExpanded(null)} onOpenFull={onRowOpen} navigate={navigate} entity={entity}/>}
); } /* ============================================================ VIEW TABS ============================================================ */ function AirtableViewTabs({ views, viewIdx, setViewIdx }) { const ICONS = { grid: "table", kanban: "kanban", gallery: "gallery", calendar: "calendar", timeline: "timeline" }; return (
{views.map((v, i) => ( ))}
); } /* ============================================================ TOOLBAR (Hide / Filter / Group / Sort / Color / Height / Search / Share) ============================================================ */ function AirtableToolbar({ vs, setVS, columns, view, filterCount, sortCount, hideCount, entity }) { const [open, setOpen] = useState(null); // 'hide' | 'filter' | 'group' | 'sort' | 'color' | 'height' const close = () => setOpen(null); return (
setOpen(o => o === "hide" ? null : "hide")}> {open === "hide" && setOpen(o => o === "filter" ? null : "filter")}> {open === "filter" && setVS({ filter: f })} close={close}/>} setOpen(o => o === "group" ? null : "group")}> {open === "group" && setVS({ groupBy: g })} close={close}/>} setOpen(o => o === "sort" ? null : "sort")}> {open === "sort" && setVS({ sort: s })} close={close}/>} setOpen(o => o === "color" ? null : "color")}> {open === "color" && setVS({ colorBy: c })} close={close}/>} setOpen(o => o === "height" ? null : "height")}> {open === "height" && setVS({ height: h })} close={close}/>}
setVS({ search: e.target.value })}/>
); } function ToolButton({ label, icon, count, active, onClick, children }) { return (
{children}
); } /* ============================================================ POPOVERS: hide / filter / sort / group / color / height ============================================================ */ function HideFieldsPopover({ columns, hidden, setHidden, close }) { return (

Поля во view ({columns.length - hidden.length}/{columns.length})

{columns.map(c => { const on = !hidden.includes(c.id); return (
setHidden(on ? [...hidden, c.id] : hidden.filter(x => x !== c.id))}> {c.label} {c.type}
); })}
); } function FilterPopover({ columns, filters, setFilters, close }) { const filterable = columns.filter(c => c.filterable !== false); return (

Показывать записи, где

{filters.length === 0 &&
Фильтров нет. Добавьте условие.
} {filters.map((f, i) => { const col = columns.find(c => c.id === f.field) || filterable[0]; return (
{i === 0 ? "где" : "и"} {f.op !== "empty" && f.op !== "notempty" && ( col.options ? : setFilters(filters.map((x, j) => j === i ? { ...x, value: e.target.value } : x))} placeholder="значение"/> )} setFilters(filters.filter((_, j) => j !== i))}>×
); })}
); } function SortPopover({ columns, sort, setSort, close }) { const sortable = columns.filter(c => c.sortable !== false); return (

Сортировать по

{sort.length === 0 &&
Сортировки нет.
} {sort.map((s, i) => (
setSort(sort.filter((_, j) => j !== i))}>×
))}
); } function GroupPopover({ columns, groupBy, setGroupBy, close }) { const groupable = columns.filter(c => ["select", "linked", "users"].includes(c.type)); return (

Группировать по

{ setGroupBy(null); close(); }}> Без группировки
{groupable.map(c => (
{ setGroupBy(c.id); close(); }}> {c.label}
))}
); } function ColorPopover({ columns, colorBy, setColorBy, close }) { const colorable = columns.filter(c => c.type === "select" || c.type === "sanctions"); return (

Цвет строк по

{ setColorBy(null); close(); }}>Без цвета
{colorable.map(c => (
{ setColorBy(c.id); close(); }}> {c.label}
))}
); } function HeightPopover({ height, setHeight, close }) { const opts = [ { id: "short", l: "Низкая", lines: [2] }, { id: "medium", l: "Средняя", lines: [2, 2] }, { id: "tall", l: "Высокая", lines: [2, 2, 2] }, { id: "xtall", l: "Очень высокая", lines: [2, 2, 2, 2] }, ]; return (

Высота строк

{opts.map(o => (
{ setHeight(o.id); close(); }}> {o.lines.map((_, i) => )} {o.l}
))}
); } /* ============================================================ GRID VIEW ============================================================ */ function GridView({ columns, rows, groupBy, height = "medium", colorBy, onExpand, primaryField, navigate }) { const [collapsed, setCollapsed] = useState({}); const groups = useMemo(() => { if (!groupBy) return [{ key: "_all", rows }]; const map = new Map(); rows.forEach(r => { const k = (Array.isArray(r[groupBy]) ? r[groupBy][0]?.id || r[groupBy][0]?.label : r[groupBy]) || "—"; if (!map.has(k)) map.set(k, []); map.get(k).push(r); }); return [...map.entries()].map(([k, rs]) => ({ key: k, rows: rs })); }, [rows, groupBy]); const groupCol = columns.find(c => c.id === groupBy); const colorCol = columns.find(c => c.id === colorBy); return (
{columns.map(c => ( ))} {groups.map(g => { const isCollapsed = collapsed[g.key]; const opt = groupCol?.options?.find(o => o.value === g.key); const sumValue = g.rows.reduce((s, r) => s + (r.value_usd || r.value || 0), 0); return ( {groupBy && ( setCollapsed(c => ({ ...c, [g.key]: !c[g.key] }))}> )} {!isCollapsed && g.rows.map((r, ri) => { const rowColor = colorCol && colorCol.options ? colorOf(colorCol.options.find(o => o.value === r[colorBy])?.color) : "transparent"; return ( onExpand(r)} style={{ "--row-color": rowColor }}> {columns.map(c => { const Cell = Field[c.type] || Field.text; const v = c.accessor ? c.accessor(r) : r[c.id]; return ( ); })} ); })} ); })}
{c.label}
{opt && } {opt?.label || g.key} · {g.rows.length} {pluralize(g.rows.length)} {sumValue > 0 && сумма: {fmtMoney(sumValue, "USD")}}
{ri + 1} {c.id === primaryField ? : }
); } function pluralize(n) { if (n % 10 === 1 && n % 100 !== 11) return "запись"; if ([2,3,4].includes(n % 10) && ![12,13,14].includes(n % 100)) return "записи"; return "записей"; } function colorOf(c) { return ({ gray: "#7A7468", red: "#D63D4A", orange: "#C77A1F", yellow: "#D6B900", green: "#2F7D5B", teal: "#1F6862", blue: "#2C5C8C", purple: "#6E45E2", pink: "#C84571", ink: "#14171C" })[c] || "#7A7468"; } /* ============================================================ KANBAN VIEW ============================================================ */ function KanbanView({ rows, stackBy, stackOptions, onExpand, primaryField }) { const stacks = useMemo(() => { const map = new Map(); rows.forEach(r => { const k = r[stackBy] || "_none"; if (!map.has(k)) map.set(k, []); map.get(k).push(r); }); return stackOptions.map(o => ({ ...o, rows: map.get(o.value) || [] })).concat( map.has("_none") ? [{ value: "_none", label: "—", color: "gray", rows: map.get("_none") }] : [] ); }, [rows, stackBy, stackOptions]); return (
{stacks.map(s => (
{s.label} {s.rows.length} {s.rows.reduce((sum, r) => sum + (r.value_usd || 0), 0) ? "$" + Math.round(s.rows.reduce((sum, r) => sum + (r.value_usd || 0), 0) / 1000) + "k" : ""}
{s.rows.map(r => (
onExpand(r)}> {r.id}
{r[primaryField]}
{r.value_usd && ${(r.value_usd / 1000).toFixed(0)}k} {r.margin_pct && · {r.margin_pct}%}
{r.customer && {r._customer?.short}} {r.sanctions && }
{r.items_done != null && ( <>
{r.items_done}/{r.items_count} позиций {r.deadline}
)}
))}
))}
); } /* ============================================================ GALLERY VIEW ============================================================ */ function GalleryView({ rows, columns, onExpand, primaryField }) { const stageCol = columns.find(c => c.id === "stage"); return (
{rows.map(r => { const opt = stageCol?.options?.find(o => o.value === r.stage); return (
onExpand(r)} style={{ "--cover": `linear-gradient(135deg, ${colorOf(opt?.color || "gray")}22, ${colorOf(opt?.color || "gray")}10)` }}>
{r.id}
{r[primaryField]}
{opt && {opt.label}} {r.sanctions && } {r._customer && {r._customer.short}}
${(r.value_usd / 1000).toFixed(0)}kсумма
{r.margin_pct}%маржа
{r.items_done}/{r.items_count}позиций
{r.deadline?.slice(5)}дедлайн
); })}
); } /* ============================================================ CALENDAR VIEW (month) ============================================================ */ function CalendarView({ rows, dateField, columns, onExpand }) { // Start at month with most events const [refMonth, setRefMonth] = useState(() => { const counts = new Map(); rows.forEach(r => { const dt = r[dateField]; if (dt) { const k = dt.slice(0, 7); counts.set(k, (counts.get(k) || 0) + 1); } }); const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]; return top ? top[0] : "2026-06"; }); const [yearStr, monthStr] = refMonth.split("-"); const year = +yearStr, month = +monthStr - 1; const firstDay = new Date(year, month, 1).getDay(); const startOffset = (firstDay + 6) % 7; const daysInMonth = new Date(year, month + 1, 0).getDate(); const cells = []; for (let i = 0; i < startOffset; i++) cells.push(null); for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(year, month, d)); while (cells.length % 7) cells.push(null); const eventsByDate = useMemo(() => { const m = new Map(); rows.forEach(r => { const dt = r[dateField]; if (!dt) return; if (!m.has(dt)) m.set(dt, []); m.get(dt).push(r); }); return m; }, [rows, dateField]); const stageCol = columns.find(c => c.id === "stage"); const today = new Date("2026-05-27"); today.setHours(0,0,0,0); const monthEvents = rows.filter(r => r[dateField]?.startsWith(refMonth)); const monthName = new Date(year, month).toLocaleString("ru-RU", { month: "long", year: "numeric" }); const shift = (n) => { const d = new Date(year, month + n); setRefMonth(d.toISOString().slice(0, 7)); }; return (

{monthName}

· {monthEvents.length} дедлайнов
{["Пн","Вт","Ср","Чт","Пт","Сб","Вс"].map(d =>
{d}
)} {cells.map((d, i) => { if (!d) return
; const k = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; const evts = eventsByDate.get(k) || []; const isToday = d.getTime() === today.getTime(); return (
{d.getDate()}
{evts.map(e => { const opt = stageCol?.options?.find(o => o.value === e.stage); const c = colorOf(opt?.color); return ( onExpand(e)}> {e.id} · {(e.title || "").slice(0, 24)} ); })}
); })}
); } /* ============================================================ RECORD SHEET (expanded detail with tabs + comments + stepper) ============================================================ */ function RecordSheet({ row, columns, primaryField, onClose, onOpenFull, navigate, entity }) { const [tab, setTab] = useState("fields"); const stageCol = columns.find(c => c.id === "stage"); const stageOpt = stageCol?.options?.find(o => o.value === row.stage); const groups = [ { id: "ident", title: "Идентификация", fields: columns.filter(c => ["primary", "name", "title", "id"].some(k => c.id.includes(k))) }, { id: "rel", title: "Связи", fields: columns.filter(c => c.type === "linked") }, { id: "money", title: "Финансы", fields: columns.filter(c => ["currency", "percent"].includes(c.type)) }, { id: "sched", title: "Сроки и состав", fields: columns.filter(c => ["date", "progress", "status_workflow"].includes(c.type)) }, { id: "meta", title: "Метаданные", fields: columns.filter(c => ["sanctions", "systems", "users", "select", "multi_select"].includes(c.type)) }, ].map(g => ({ ...g, fields: g.fields.filter(f => f) })).filter(g => g.fields.length); return (
{row.id} · обновлено {row.updated || "2 мин назад"} ·
{row[primaryField]}
{stageOpt && {stageOpt.label}} {row.sanctions && } {row._customer && {row._customer.short}} {row.value_usd && ${(row.value_usd / 1000).toFixed(0)}k · маржа {row.margin_pct}%} {row.deadline && дедлайн {row.deadline}}
{/* Workflow stepper */} {stageCol && stageCol.options && (
{stageCol.options.map((o, i) => { const curIdx = stageCol.options.findIndex(opt => opt.value === row.stage); return (
{i < curIdx && } {o.label}
); })}
)}
{[ { id: "fields", l: "Поля", n: columns.length }, { id: "comments", l: "Комментарии", n: 3 }, { id: "activity", l: "Активность", n: 42 }, { id: "linked", l: "Связи", n: 12 }, ].map(t => ( ))}
{tab === "fields" && (
{groups.map(g => (
{g.title}
{g.fields.map(c => { const Cell = Field[c.type] || Field.text; const v = c.accessor ? c.accessor(row) : row[c.id]; return (
{c.label}
); })}
))}
)} {tab === "comments" && (
{[ { who: "Сергей К.", when: "вчера 16:42", text: "Заказчик попросил перенести дедлайн на +2 недели — записал в Asana." }, { who: "Анна Л.", when: "2 дня назад", text: "@Денис П. посмотри маржу по поз. 03 — Schaffler по новой цене 14.6%, низковато." }, { who: "ИИ Claude", when: "3 дня назад", text: "Найден возможный дубль: p_2003 «Schaffler M.E.» совпадает по TRN с p_2002. Открыть ревью." }, ].map((c, i) => (
{c.who}{c.when}
{c.text.replace(/@(\S+)/g, (m, n) => `__M__${n}__M__`).split(/__M__/).map((p, i) => i % 2 === 1 ? @{p} : p)}
))}
)} {tab === "activity" && (
{[ { who: "Е. Соколова", what: "stage", from: "Поиск поставщиков", to: "Спецификация", when: "14:08", src: "manual" }, { who: "ИИ Claude", what: "items[03].supplier", from: "—", to: "Schaeffler Middle East FZE", when: "12:18", src: "ai", conf: 0.94 }, { who: "Service", what: "amo_id", from: "—", to: "47821", when: "03:14", src: "amo" }, { who: "Д. Петров", what: "deadline", from: "2026-07-04", to: "2026-07-18", when: "вчера 16:42", src: "manual" }, ].map((e, i) => (
{e.who}
{e.what} {e.from} {e.to} {e.when}
))}
)} {tab === "linked" && (
Связанные записи · 12
{[ { type: "Документы", n: 8, hint: "8 файлов в Drive" }, { type: "Контактные лица", n: 4, hint: "Mohammed Al-Fardan, Priya Ramesh, ещё 2" }, { type: "Платежи", n: 3, hint: "104k USD на Caspian, 2 ожидают" }, { type: "Задачи Asana", n: 8, hint: "5 готовы, 3 в работе" }, { type: "Поставщики", n: 3, hint: "SKF · Schaeffler · Wuxi" }, ].map((l, i) => (
{l.type}
{l.n} — {l.hint}
))}
)}
); } /* ============================================================ Add missing icons used here ============================================================ */ const _additionalIcons = {}; window.AirtableGrid = AirtableGrid; window.AirtableField = Field;