{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-views",
  "title": "DataViews",
  "description": "Notion/Airtable-class data views: table, board, calendar, gallery, timeline, list — filters/sort/group/color, inline edit, virtualization.",
  "dependencies": [
    "@tanstack/react-table",
    "@tanstack/react-virtual",
    "@dnd-kit/core",
    "@dnd-kit/sortable",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://ui.eburet.tech/r/tokens.json"
  ],
  "files": [
    {
      "path": "registry/eburet/data-views/BoardView.tsx",
      "content": "import { useMemo, useState } from \"react\";\nimport {\n  DndContext, DragOverlay, PointerSensor, closestCorners, useDraggable, useDroppable,\n  useSensor, useSensors, type DragEndEvent,\n} from \"@dnd-kit/core\";\nimport type { Field, Row, ViewConfig } from \"./types\";\nimport { AttachmentChips, PersonTag, RatingStars, Tag, fmtRange, formatNumber } from \"./cells\";\nimport { computeValue, fieldOpt, filterRows, firstFieldOfType, relationLabels } from \"./filter\";\n\nfunction CardBody({ row, fields, stackId }: { row: Row; fields: Field[]; stackId: string }) {\n  const title = fields.find((f) => f.primary) ?? fields.find((f) => f.type === \"text\");\n  const others = fields.filter((f) => f.id !== stackId && f.id !== title?.id && !f.primary);\n  return (\n    <div className=\"rounded-md border border-[var(--color-line)] p-2.5\"\n      style={{ background: \"var(--color-card)\" }}>\n      <div className=\"font-medium text-[13px] mb-1.5\">{title ? String(row[title.id] ?? \"—\") : `#${row.id}`}</div>\n      <div className=\"flex flex-wrap gap-1 items-center\">\n        {others.map((f) => {\n          const computed = f.type === \"formula\" || f.type === \"rollup\";\n          const v = computed ? computeValue(f, row, fields) : row[f.id];\n          if (v == null || v === \"\" || (Array.isArray(v) && !v.length)) return null;\n          if (f.type === \"select\" || f.type === \"status\") return <Tag key={f.id} label={String(v)} color={fieldOpt(f, v)?.color} />;\n          if (f.type === \"person\") return <PersonTag key={f.id} label={String(v)} color={fieldOpt(f, v)?.color} />;\n          if (f.type === \"multiselect\") return (v as string[]).map((x) => <Tag key={f.id + x} label={x} color={fieldOpt(f, x)?.color} />);\n          if (f.type === \"relation\") return <span key={f.id} className=\"text-[11px] text-[var(--color-mut)]\">{f.name}: {relationLabels(f, v).join(\", \")}</span>;\n          if (f.type === \"rating\") return <RatingStars key={f.id} value={v} max={f.max} />;\n          if (f.type === \"attachment\") return <span key={f.id} className=\"flex gap-2\"><AttachmentChips list={Array.isArray(v) ? v : []} /></span>;\n          if (f.type === \"date\") return <span key={f.id} className=\"text-[11px] text-[var(--color-mut2)] tabular-nums\">{String(v).split(\"T\")[0]}</span>;\n          if (f.type === \"daterange\") return <span key={f.id} className=\"text-[11px] text-[var(--color-mut2)] tabular-nums\">{fmtRange(v)}</span>;\n          if (f.type === \"number\" || computed) return <span key={f.id} className=\"text-[11px] text-[var(--color-mut)] tabular-nums\">{f.name}: {typeof v === \"number\" ? formatNumber(v, f) : String(v)}</span>;\n          return <span key={f.id} className=\"text-[11px] text-[var(--color-mut)]\">{f.name}: {String(v)}</span>;\n        })}\n      </div>\n    </div>\n  );\n}\n\nfunction Card({ row, fields, stackId }: { row: Row; fields: Field[]; stackId: string }) {\n  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: String(row.id) });\n  return (\n    <div ref={setNodeRef} {...attributes} {...listeners}\n      className=\"mb-2 cursor-grab active:cursor-grabbing\" style={{ opacity: isDragging ? 0.4 : 1 }}>\n      <CardBody row={row} fields={fields} stackId={stackId} />\n    </div>\n  );\n}\n\nfunction Column({ value, label, color, rows, fields, stackId }: {\n  value: string; label: string; color?: string; rows: Row[]; fields: Field[]; stackId: string;\n}) {\n  const { setNodeRef, isOver } = useDroppable({ id: `col:${value}` });\n  return (\n    <div className=\"w-64 shrink-0 flex flex-col\">\n      <div className=\"flex items-center gap-2 mb-2 px-1\">\n        {color ? <Tag label={label} color={color} /> : <span className=\"text-[var(--color-mut)] text-[13px]\">{label}</span>}\n        <span className=\"text-[var(--color-mut2)] text-xs\">{rows.length}</span>\n      </div>\n      <div ref={setNodeRef} className=\"flex-1 rounded-lg p-2 min-h-24 transition-colors\"\n        style={{ background: isOver ? \"var(--hover)\" : \"var(--hover)\", outline: isOver ? \"1px solid var(--color-focus)\" : \"none\" }}>\n        {rows.map((r) => <Card key={r.id} row={r} fields={fields} stackId={stackId} />)}\n      </div>\n    </div>\n  );\n}\n\nexport default function BoardView({\n  fields, rows, config, onEdit,\n}: { fields: Field[]; rows: Row[]; config: ViewConfig; onEdit: (id: number, fid: string, v: any) => void }) {\n  const stackField = (config.grouping ? fields.find((f) => f.id === config.grouping) : undefined)\n    ?? firstFieldOfType(fields, \"status\", \"select\");\n  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));\n  const data = useMemo(() => filterRows(rows, fields, config), [rows, fields, config]);\n  const [dragId, setDragId] = useState<number | null>(null);\n\n  if (!stackField) return <div className=\"text-[var(--color-mut)] p-4\">Нет поля-статуса для доски.</div>;\n  const cols = [...(stackField.options ?? []), { value: \"\", color: undefined }];\n\n  const onDragEnd = (e: DragEndEvent) => {\n    setDragId(null);\n    const { active, over } = e;\n    if (!over) return;\n    onEdit(Number(active.id), stackField.id, String(over.id).replace(/^col:/, \"\"));\n  };\n  const dragRow = dragId != null ? data.find((r) => r.id === dragId) : null;\n\n  return (\n    <DndContext sensors={sensors} collisionDetection={closestCorners}\n      onDragStart={(e) => setDragId(Number(e.active.id))} onDragEnd={onDragEnd}>\n      <div className=\"dv flex gap-3 overflow-x-auto pb-2\" style={{ minHeight: \"60vh\" }}>\n        {cols.map((o) => (\n          <Column key={o.value || \"__empty\"} value={o.value} label={o.value || \"Без статуса\"} color={(o as any).color}\n            rows={data.filter((r) => String(r[stackField.id] ?? \"\") === o.value)} fields={fields} stackId={stackField.id} />\n        ))}\n      </div>\n      <DragOverlay>\n        {dragRow ? <div className=\"w-64 rotate-1\"><CardBody row={dragRow} fields={fields} stackId={stackField.id} /></div> : null}\n      </DragOverlay>\n    </DndContext>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/dataviews/BoardView.tsx"
    },
    {
      "path": "registry/eburet/data-views/CalendarView.tsx",
      "content": "import { useMemo, useState, type ReactNode } from \"react\";\nimport {\n  DndContext, PointerSensor, useDraggable, useDroppable, useSensor, useSensors, type DragEndEvent,\n} from \"@dnd-kit/core\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport type { Field, Row, ViewConfig } from \"./types\";\nimport { fieldOpt, filterRows, firstFieldOfType } from \"./filter\";\n\nconst WD = [\"Пн\", \"Вт\", \"Ср\", \"Чт\", \"Пт\", \"Сб\", \"Вс\"];\nconst MON = [\"Январь\", \"Февраль\", \"Март\", \"Апрель\", \"Май\", \"Июнь\", \"Июль\", \"Август\", \"Сентябрь\", \"Октябрь\", \"Ноябрь\", \"Декабрь\"];\nconst iso = (v: any) => (v ? String(v).split(\"T\")[0] : \"\");\nconst keyOf = (y: number, m: number, d: number) => `${y}-${String(m + 1).padStart(2, \"0\")}-${String(d).padStart(2, \"0\")}`;\ntype Mode = \"month\" | \"week\" | \"day\";\n\nfunction Chip({ row, label, color, onResizeEnd }: { row: Row; label: string; color?: string; onResizeEnd?: (endKey: string) => void }) {\n  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: String(row.id) });\n  const c = color || \"#9b9b9b\";\n  const startResize = (e: any) => {\n    e.preventDefault(); e.stopPropagation();\n    const up = (ev: PointerEvent) => {\n      document.removeEventListener(\"pointerup\", up);\n      const el = (document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null)?.closest(\"[data-daykey]\") as HTMLElement | null;\n      if (el?.dataset.daykey) onResizeEnd!(el.dataset.daykey);\n    };\n    document.addEventListener(\"pointerup\", up);\n  };\n  return (\n    <div ref={setNodeRef} {...attributes} {...listeners}\n      className=\"relative text-[11px] truncate rounded px-1 py-0.5 cursor-grab active:cursor-grabbing\"\n      style={{ background: `color-mix(in srgb, ${c} 24%, transparent)`, color: c, opacity: isDragging ? 0.4 : 1 }}>\n      {label}\n      {onResizeEnd && <span onPointerDown={startResize} title=\"Тянуть конец периода\" className=\"absolute right-0 top-0 h-full w-1.5 cursor-ew-resize\" style={{ background: c }} />}\n    </div>\n  );\n}\n\nfunction Day({ dayKey, header, minH, children }: { dayKey: string; header: ReactNode; minH: number; children: ReactNode }) {\n  const { setNodeRef, isOver } = useDroppable({ id: dayKey });\n  return (\n    <div ref={setNodeRef} data-daykey={dayKey} className=\"p-1.5 align-top\" style={{ minHeight: minH, background: isOver ? \"var(--hover)\" : \"var(--color-card)\" }}>\n      <div className=\"text-xs text-[var(--color-mut2)] mb-1\">{header}</div>\n      <div className=\"flex flex-col gap-1\">{children}</div>\n    </div>\n  );\n}\n\nexport default function CalendarView({\n  fields, rows, config, onEdit,\n}: { fields: Field[]; rows: Row[]; config: ViewConfig; onEdit: (id: number, fid: string, v: any) => void }) {\n  const dateField = fields.find((f) => f.type === \"daterange\") ?? firstFieldOfType(fields, \"date\");\n  const title = fields.find((f) => f.primary) ?? fields.find((f) => f.type === \"text\");\n  const colorField = firstFieldOfType(fields, \"status\", \"select\");\n  const data = useMemo(() => filterRows(rows, fields, config), [rows, fields, config]);\n  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));\n  const [mode, setMode] = useState<Mode>(\"month\");\n\n  const dkey = (r: Row) => {\n    const raw = dateField && r[dateField.id];\n    if (dateField?.type === \"daterange\") return raw && typeof raw === \"object\" ? iso(raw.start) : \"\";\n    return iso(raw);\n  };\n\n  const init = useMemo(() => {\n    const ds = data.map(dkey).filter(Boolean).sort();\n    const base = ds.length ? new Date(ds[ds.length - 1]) : new Date();\n    return { y: base.getFullYear(), m: base.getMonth(), d: base.getDate() };\n  }, [dateField]); // eslint-disable-line\n  const [cur, setCur] = useState(init);\n\n  const byDay = useMemo(() => {\n    const map = new Map<string, Row[]>();\n    if (dateField) for (const r of data) { const k = dkey(r); if (k) (map.get(k) ?? map.set(k, []).get(k)!).push(r); }\n    return map;\n  }, [data, dateField]); // eslint-disable-line\n\n  if (!dateField) return <div className=\"text-[var(--color-mut)] p-4\">Нет поля-даты для календаря.</div>;\n\n  const shift = (dir: -1 | 1) => setCur((c) => {\n    const d = new Date(c.y, c.m, c.d);\n    if (mode === \"month\") d.setMonth(d.getMonth() + dir);\n    else if (mode === \"week\") d.setDate(d.getDate() + dir * 7);\n    else d.setDate(d.getDate() + dir);\n    return { y: d.getFullYear(), m: d.getMonth(), d: d.getDate() };\n  });\n\n  const onDragEnd = (e: DragEndEvent) => {\n    if (!e.over) return;\n    const k = String(e.over.id);\n    if (dateField.type === \"daterange\") {\n      const curv = rows.find((r) => r.id === Number(e.active.id))?.[dateField.id];\n      onEdit(Number(e.active.id), dateField.id, { start: k, end: curv?.end });\n    } else onEdit(Number(e.active.id), dateField.id, k);\n  };\n\n  const canResize = dateField.type === \"daterange\" && !!onEdit;\n  const resizeEnd = (id: number, endKey: string) => {\n    const cur = rows.find((x) => x.id === id)?.[dateField.id];\n    onEdit?.(id, dateField.id, { start: cur?.start ?? endKey, end: endKey });\n  };\n  const renderChips = (recs: Row[], limit = 4) => (\n    <>\n      {recs.slice(0, limit).map((r) => (\n        <Chip key={r.id} row={r} label={title ? String(r[title.id] ?? \"\") : `#${r.id}`}\n          color={colorField ? fieldOpt(colorField, r[colorField.id])?.color : undefined}\n          onResizeEnd={canResize ? (endKey) => resizeEnd(r.id, endKey) : undefined} />\n      ))}\n      {recs.length > limit && <div className=\"text-[11px] text-[var(--color-mut2)]\">+{recs.length - limit}</div>}\n    </>\n  );\n\n  // сетка для месяца\n  const monthGrid = () => {\n    const first = new Date(cur.y, cur.m, 1);\n    const offset = (first.getDay() + 6) % 7;\n    const days = new Date(cur.y, cur.m + 1, 0).getDate();\n    const cells: (number | null)[] = [...Array(offset).fill(null), ...Array.from({ length: days }, (_, i) => i + 1)];\n    while (cells.length % 7) cells.push(null);\n    return (\n      <div className=\"grid grid-cols-7 gap-px rounded-lg overflow-hidden border border-[var(--color-line)]\" style={{ background: \"var(--color-line)\" }}>\n        {WD.map((d) => <div key={d} className=\"px-2 py-1 text-xs text-[var(--color-mut2)]\" style={{ background: \"var(--color-card)\" }}>{d}</div>)}\n        {cells.map((day, i) => day\n          ? <Day key={i} dayKey={keyOf(cur.y, cur.m, day)} header={day} minH={96}>{renderChips(byDay.get(keyOf(cur.y, cur.m, day)) ?? [])}</Day>\n          : <div key={i} style={{ background: \"var(--color-card)\" }} />)}\n      </div>\n    );\n  };\n\n  // неделя: 7 дней от понедельника\n  const weekGrid = () => {\n    const a = new Date(cur.y, cur.m, cur.d);\n    const monday = new Date(a); monday.setDate(a.getDate() - ((a.getDay() + 6) % 7));\n    const dates = Array.from({ length: 7 }, (_, i) => { const d = new Date(monday); d.setDate(monday.getDate() + i); return d; });\n    return (\n      <div className=\"grid grid-cols-7 gap-px rounded-lg overflow-hidden border border-[var(--color-line)]\" style={{ background: \"var(--color-line)\" }}>\n        {dates.map((d, i) => {\n          const k = keyOf(d.getFullYear(), d.getMonth(), d.getDate());\n          return <Day key={i} dayKey={k} header={`${WD[i]} ${d.getDate()}.${d.getMonth() + 1}`} minH={180}>{renderChips(byDay.get(k) ?? [], 12)}</Day>;\n        })}\n      </div>\n    );\n  };\n\n  // день: одна колонка\n  const dayGrid = () => {\n    const k = keyOf(cur.y, cur.m, cur.d);\n    return (\n      <div className=\"rounded-lg overflow-hidden border border-[var(--color-line)]\">\n        <Day dayKey={k} header={`${cur.d} ${MON[cur.m]} ${cur.y}`} minH={320}>{renderChips(byDay.get(k) ?? [], 100)}</Day>\n      </div>\n    );\n  };\n\n  const label = mode === \"month\" ? `${MON[cur.m]} ${cur.y}`\n    : mode === \"week\" ? (() => { const a = new Date(cur.y, cur.m, cur.d); const mo = new Date(a); mo.setDate(a.getDate() - ((a.getDay() + 6) % 7)); const su = new Date(mo); su.setDate(mo.getDate() + 6); return `${mo.getDate()}.${mo.getMonth() + 1} – ${su.getDate()}.${su.getMonth() + 1} ${cur.y}`; })()\n    : `${cur.d} ${MON[cur.m]} ${cur.y}`;\n\n  return (\n    <div className=\"dv\">\n      <div className=\"flex items-center gap-3 mb-3 flex-wrap\">\n        <button onClick={() => shift(-1)} className=\"inline-flex p-1 rounded border border-[var(--color-line-2)] text-[var(--color-mut)] hover:bg-[var(--hover)] hover:text-[var(--color-ink)]\"><ChevronLeft size={16} /></button>\n        <span className=\"font-medium min-w-40\">{label}</span>\n        <button onClick={() => shift(1)} className=\"inline-flex p-1 rounded border border-[var(--color-line-2)] text-[var(--color-mut)] hover:bg-[var(--hover)] hover:text-[var(--color-ink)]\"><ChevronRight size={16} /></button>\n        <div className=\"inline-flex rounded-md border border-[var(--color-line-2)] overflow-hidden text-[13px]\">\n          {([\"month\", \"week\", \"day\"] as const).map((mm) => (\n            <button key={mm} onClick={() => setMode(mm)}\n              className={`px-2.5 py-1 ${mode === mm ? \"text-[var(--color-acc)] bg-[var(--hover)]\" : \"text-[var(--color-mut)] hover:text-[var(--color-ink)]\"}`}>\n              {mm === \"month\" ? \"Месяц\" : mm === \"week\" ? \"Неделя\" : \"День\"}\n            </button>\n          ))}\n        </div>\n        <span className=\"text-[var(--color-mut2)] text-xs\">перетаскивай события между днями</span>\n      </div>\n      <DndContext sensors={sensors} onDragEnd={onDragEnd}>\n        {mode === \"month\" ? monthGrid() : mode === \"week\" ? weekGrid() : dayGrid()}\n      </DndContext>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/dataviews/CalendarView.tsx"
    },
    {
      "path": "registry/eburet/data-views/GalleryView.tsx",
      "content": "import { useMemo } from \"react\";\nimport type { Field, Row, ViewConfig } from \"./types\";\nimport { AttachmentChips, PersonTag, RatingStars, Tag, fmtRange, formatNumber } from \"./cells\";\nimport { computeValue, fieldOpt, filterRows, relationLabels } from \"./filter\";\n\nexport default function GalleryView({\n  fields, rows, config,\n}: { fields: Field[]; rows: Row[]; config: ViewConfig; onEdit: (id: number, fid: string, v: any) => void }) {\n  const data = useMemo(() => filterRows(rows, fields, config), [rows, fields, config]);\n  const title = fields.find((f) => f.primary) ?? fields.find((f) => f.type === \"text\");\n  const rest = fields.filter((f) => f.id !== title?.id && !config.hidden.includes(f.id));\n\n  return (\n    <div className=\"dv grid gap-3\" style={{ gridTemplateColumns: \"repeat(auto-fill, minmax(230px, 1fr))\" }}>\n      {data.map((row) => (\n        <div key={row.id} className=\"rounded-lg border border-[var(--color-line)] p-3 hover:border-[var(--color-line-2)] transition-colors\"\n          style={{ background: \"var(--hover)\" }}>\n          <div className=\"font-medium mb-2\">{title ? String(row[title.id] ?? \"—\") : `#${row.id}`}</div>\n          <div className=\"flex flex-col gap-1.5\">\n            {rest.map((f) => {\n              const computed = f.type === \"formula\" || f.type === \"rollup\";\n              const v = computed ? computeValue(f, row, fields) : row[f.id];\n              const empty = v == null || v === \"\" || (Array.isArray(v) && !v.length);\n              return (\n                <div key={f.id} className=\"flex items-center justify-between gap-2 text-[13px]\">\n                  <span className=\"text-[var(--color-mut2)] text-xs\">{f.name}</span>\n                  {empty\n                    ? <span className=\"text-[var(--color-mut2)]\">—</span>\n                    : f.type === \"select\" || f.type === \"status\"\n                    ? <Tag label={String(v)} color={fieldOpt(f, v)?.color} />\n                    : f.type === \"person\"\n                    ? <PersonTag label={String(v)} color={fieldOpt(f, v)?.color} />\n                    : f.type === \"multiselect\"\n                    ? <span className=\"flex flex-wrap gap-1 justify-end\">{(v as string[]).map((x) => <Tag key={x} label={x} color={fieldOpt(f, x)?.color} />)}</span>\n                    : f.type === \"relation\"\n                    ? <span className=\"text-right\">{relationLabels(f, v).join(\", \")}</span>\n                    : f.type === \"rating\"\n                    ? <RatingStars value={v} max={f.max} />\n                    : f.type === \"attachment\"\n                    ? <span className=\"flex flex-wrap gap-2 justify-end\"><AttachmentChips list={Array.isArray(v) ? v : []} /></span>\n                    : f.type === \"daterange\"\n                    ? <span className=\"tabular-nums\">{fmtRange(v)}</span>\n                    : f.type === \"number\" || computed\n                    ? <span className=\"tabular-nums\">{typeof v === \"number\" ? formatNumber(v, f) : String(v)}</span>\n                    : <span>{f.type === \"date\" ? String(v).split(\"T\")[0] : String(v)}</span>}\n                </div>\n              );\n            })}\n          </div>\n        </div>\n      ))}\n      {data.length === 0 && <div className=\"text-[var(--color-mut2)]\">Нет записей</div>}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/dataviews/GalleryView.tsx"
    },
    {
      "path": "registry/eburet/data-views/ListView.tsx",
      "content": "import { useMemo } from \"react\";\nimport { Check } from \"lucide-react\";\nimport type { Field, Row, ViewConfig } from \"./types\";\nimport { AttachmentChips, PersonTag, RatingStars, Tag, fmtRange, formatNumber } from \"./cells\";\nimport { computeValue, fieldOpt, filterRows, relationLabels } from \"./filter\";\n\nexport default function ListView({\n  fields, rows, config,\n}: { fields: Field[]; rows: Row[]; config: ViewConfig; onEdit: (id: number, fid: string, v: any) => void }) {\n  const data = useMemo(() => filterRows(rows, fields, config), [rows, fields, config]);\n  const title = fields.find((f) => f.primary) ?? fields.find((f) => f.type === \"text\");\n  const rest = fields.filter((f) => f.id !== title?.id && !config.hidden.includes(f.id)).slice(0, 5);\n\n  return (\n    <div className=\"dv rounded-lg border border-[var(--color-line)] overflow-hidden\" style={{ background: \"var(--color-card)\" }}>\n      {data.map((row) => (\n        <div key={row.id} className=\"flex items-center gap-3 px-3 py-2 border-b border-[var(--color-line)] last:border-0 hover:bg-[var(--hover)]\">\n          <span className=\"font-medium min-w-44\">{title ? String(row[title.id] ?? \"—\") : `#${row.id}`}</span>\n          <div className=\"flex items-center gap-2 flex-wrap text-[13px] text-[var(--color-mut)]\">\n            {rest.map((f) => {\n              const computed = f.type === \"formula\" || f.type === \"rollup\";\n              const v = computed ? computeValue(f, row, fields) : row[f.id];\n              if (v == null || v === \"\" || (Array.isArray(v) && !v.length)) return null;\n              if (f.type === \"select\" || f.type === \"status\") return <Tag key={f.id} label={String(v)} color={fieldOpt(f, v)?.color} />;\n              if (f.type === \"person\") return <PersonTag key={f.id} label={String(v)} color={fieldOpt(f, v)?.color} />;\n              if (f.type === \"multiselect\") return <span key={f.id} className=\"flex gap-1\">{(v as string[]).map((x) => <Tag key={x} label={x} color={fieldOpt(f, x)?.color} />)}</span>;\n              if (f.type === \"relation\") return <span key={f.id}>{relationLabels(f, v).join(\", \")}</span>;\n              if (f.type === \"rating\") return <RatingStars key={f.id} value={v} max={f.max} />;\n              if (f.type === \"attachment\") return <span key={f.id} className=\"flex gap-2\"><AttachmentChips list={Array.isArray(v) ? v : []} /></span>;\n              if (f.type === \"checkbox\") return v ? <span key={f.id} className=\"inline-flex items-center gap-1 text-[var(--color-ok)]\"><Check size={13} /> {f.name}</span> : null;\n              if (f.type === \"date\") return <span key={f.id} className=\"tabular-nums\">{String(v).split(\"T\")[0]}</span>;\n              if (f.type === \"daterange\") return <span key={f.id} className=\"tabular-nums\">{fmtRange(v)}</span>;\n              if (f.type === \"number\" || computed) return <span key={f.id} className=\"tabular-nums\">{typeof v === \"number\" ? formatNumber(v, f) : String(v)}</span>;\n              return <span key={f.id}>{f.name}: {String(v)}</span>;\n            })}\n          </div>\n        </div>\n      ))}\n      {data.length === 0 && <div className=\"px-3 py-4 text-[var(--color-mut2)]\">Нет записей</div>}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/dataviews/ListView.tsx"
    },
    {
      "path": "registry/eburet/data-views/Popover.tsx",
      "content": "import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\n\n/** Попап через портал (не клипается overflow/виртуализацией). Закрытие по клику вне/скроллу. */\nexport function Popover({\n  trigger, children, align = \"left\",\n}: {\n  trigger: (open: boolean, toggle: () => void) => ReactNode;\n  children: (close: () => void) => ReactNode;\n  align?: \"left\" | \"right\";\n}) {\n  const [open, setOpen] = useState(false);\n  const [pos, setPos] = useState<{ top: number; left?: number; right?: number }>({ top: 0 });\n  const triggerRef = useRef<HTMLSpanElement>(null);\n  const dropRef = useRef<HTMLDivElement>(null);\n\n  useLayoutEffect(() => {\n    if (open && triggerRef.current) {\n      const r = triggerRef.current.getBoundingClientRect();\n      setPos(align === \"right\"\n        ? { top: r.bottom + 4, right: window.innerWidth - r.right }\n        : { top: r.bottom + 4, left: r.left });\n    }\n  }, [open, align]);\n\n  useEffect(() => {\n    if (!open) return;\n    const onDown = (e: MouseEvent) => {\n      const t = e.target as Node;\n      if (!triggerRef.current?.contains(t) && !dropRef.current?.contains(t)) setOpen(false);\n    };\n    const onScroll = () => setOpen(false);\n    document.addEventListener(\"mousedown\", onDown);\n    window.addEventListener(\"scroll\", onScroll, true);\n    window.addEventListener(\"resize\", onScroll);\n    return () => {\n      document.removeEventListener(\"mousedown\", onDown);\n      window.removeEventListener(\"scroll\", onScroll, true);\n      window.removeEventListener(\"resize\", onScroll);\n    };\n  }, [open]);\n\n  return (\n    <>\n      <span ref={triggerRef} style={{ display: \"inline-flex\", maxWidth: \"100%\" }}>\n        {trigger(open, () => setOpen((o) => !o))}\n      </span>\n      {open && createPortal(\n        <div ref={dropRef}\n          className=\"dv z-[100] min-w-48 max-h-72 overflow-auto border border-[var(--color-line-2)] p-1\"\n          style={{ position: \"fixed\", top: pos.top, left: pos.left, right: pos.right, borderRadius: \"var(--r-md)\",\n            background: \"var(--color-surface2)\", boxShadow: \"var(--shadow-pop)\" }}>\n          {children(() => setOpen(false))}\n        </div>,\n        document.body\n      )}\n    </>\n  );\n}\n\nexport function MenuItem({ onClick, children }: { onClick: () => void; children: ReactNode }) {\n  return (\n    <button onClick={onClick}\n      className=\"w-full text-left px-2 py-1.5 rounded text-sm hover:bg-[var(--hover)] flex items-center gap-2\">\n      {children}\n    </button>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/dataviews/Popover.tsx"
    },
    {
      "path": "registry/eburet/data-views/RecordPanel.tsx",
      "content": "import { useEffect, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { X } from \"lucide-react\";\nimport type { Field, Row } from \"./types\";\nimport { AttachmentChips, CellEditor, PersonTag, RatingStars, RelationChips, Tag, fmtRange, formatNumber } from \"./cells\";\nimport { computeValue, fieldOpt, relationLabels } from \"./filter\";\n\nconst TYPE_LABEL: Record<string, string> = { text: \"текст\", longtext: \"текст\", number: \"число\", select: \"выбор\", status: \"статус\", date: \"дата\", daterange: \"период\", multiselect: \"мульти\", checkbox: \"флаг\", url: \"ссылка\", person: \"человек\", rating: \"оценка\", attachment: \"файлы\", formula: \"формула\", relation: \"связь\", rollup: \"rollup\" };\n\nfunction readOnly(f: Field, row: Row, fields: Field[]) {\n  const computed = f.type === \"formula\" || f.type === \"rollup\";\n  const v = computed ? computeValue(f, row, fields) : row[f.id];\n  if (v == null || v === \"\" || (Array.isArray(v) && !v.length)) return <span className=\"text-[var(--color-mut2)]\">—</span>;\n  if (f.type === \"select\" || f.type === \"status\") return <Tag label={String(v)} color={fieldOpt(f, v)?.color} />;\n  if (f.type === \"person\") return <PersonTag label={String(v)} color={fieldOpt(f, v)?.color} />;\n  if (f.type === \"multiselect\") return <span className=\"flex flex-wrap gap-1\">{(v as string[]).map((x) => <Tag key={x} label={x} color={fieldOpt(f, x)?.color} />)}</span>;\n  if (f.type === \"relation\") return <span className=\"flex flex-wrap gap-1\"><RelationChips labels={relationLabels(f, v)} /></span>;\n  if (f.type === \"rating\") return <RatingStars value={v} max={f.max} />;\n  if (f.type === \"attachment\") return <span className=\"flex flex-wrap gap-2\"><AttachmentChips list={Array.isArray(v) ? v : []} /></span>;\n  if (f.type === \"daterange\") return <span className=\"tabular-nums\">{fmtRange(v)}</span>;\n  if (f.type === \"number\" || computed) return <span className=\"tabular-nums\">{typeof v === \"number\" ? formatNumber(v, f) : String(v)}</span>;\n  return <span className=\"text-[var(--color-mut)]\">{String(v)}</span>;\n}\n\nexport default function RecordPanel({\n  row, fields, onEdit, onClose,\n}: { row: Row; fields: Field[]; onEdit: (id: number, fid: string, v: any) => void; onClose: () => void }) {\n  const title = fields.find((f) => f.primary) ?? fields.find((f) => f.type === \"text\");\n  const body = fields.filter((f) => f.id !== title?.id);\n  const [shown, setShown] = useState(false);\n  useEffect(() => { const id = requestAnimationFrame(() => setShown(true)); return () => cancelAnimationFrame(id); }, []);\n  const close = () => { setShown(false); window.setTimeout(onClose, 170); };\n\n  return createPortal(\n    <div className=\"dv\">\n      <div className=\"fixed inset-0 bg-black/50 z-[90] transition-opacity duration-200\" style={{ opacity: shown ? 1 : 0 }} onClick={close} />\n      <aside className=\"fixed right-0 top-0 h-full w-full sm:w-[460px] z-[91] overflow-y-auto border-l border-[var(--color-line)]\"\n        style={{ background: \"var(--color-card)\", transform: shown ? \"translateX(0)\" : \"translateX(100%)\", transition: \"transform 180ms ease-out\" }}>\n        <div className=\"sticky top-0 flex items-center gap-3 px-5 py-3 border-b border-[var(--color-line)]\" style={{ background: \"var(--color-card)\" }}>\n          <div className=\"flex-1 text-base font-semibold\">\n            {title ? (title.editable === false ? String(row[title.id] ?? \"—\")\n              : <CellEditor field={title} value={row[title.id]} onChange={(v) => onEdit(row.id, title.id, v)} />) : `#${row.id}`}\n          </div>\n          <button onClick={close} className=\"inline-flex p-1.5 rounded-md border border-[var(--color-line-2)] text-[var(--color-mut)] hover:bg-[var(--hover)] hover:text-[var(--color-ink)]\"><X size={16} /></button>\n        </div>\n        <div className=\"px-5 py-4 flex flex-col gap-3\">\n          {body.map((f) => (\n            <div key={f.id} className=\"grid grid-cols-[140px_1fr] items-center gap-3\">\n              <span className=\"text-[13px] text-[var(--color-mut)]\">{f.name}\n                <span className=\"text-[11px] text-[var(--color-mut2)] ml-1\">{TYPE_LABEL[f.type]}</span>\n              </span>\n              <div>\n                {f.editable === false || f.type === \"formula\" || f.type === \"rollup\" ? readOnly(f, row, fields) : <CellEditor field={f} value={row[f.id]} onChange={(v) => onEdit(row.id, f.id, v)} />}\n              </div>\n            </div>\n          ))}\n        </div>\n      </aside>\n    </div>,\n    document.body\n  );\n}\n",
      "type": "registry:component",
      "target": "components/dataviews/RecordPanel.tsx"
    },
    {
      "path": "registry/eburet/data-views/TableView.tsx",
      "content": "import { useMemo, useRef, useState, type CSSProperties, type ReactNode } from \"react\";\nimport {\n  flexRender, getCoreRowModel, getExpandedRowModel, getFilteredRowModel,\n  getGroupedRowModel, getSortedRowModel, useReactTable,\n  type ColumnDef, type ExpandedState, type RowSelectionState,\n} from \"@tanstack/react-table\";\nimport { useVirtualizer } from \"@tanstack/react-virtual\";\nimport {\n  DndContext, DragOverlay, PointerSensor, closestCenter, useSensor, useSensors,\n  useDraggable, useDroppable, type DragEndEvent,\n} from \"@dnd-kit/core\";\nimport {\n  SortableContext, arrayMove, horizontalListSortingStrategy, useSortable,\n} from \"@dnd-kit/sortable\";\nimport {\n  ArrowUp, ArrowDown, ChevronRight, ChevronDown, GripVertical, Maximize2,\n  MoreHorizontal, Copy, Trash2, Plus, Pin, PinOff, EyeOff, Group, Inbox,\n  AlertTriangle, RotateCw, Check,\n} from \"lucide-react\";\nimport type { Header } from \"@tanstack/react-table\";\nimport type { Field, Row, ViewConfig } from \"./types\";\nimport { AttachmentChips, CellEditor, PersonTag, RatingStars, RelationChips, Tag, TypeIcon, fmtRange, formatNumber } from \"./cells\";\nimport { computeValue, evalFilters, relationLabels, rowColor } from \"./filter\";\nimport { MenuItem, Popover } from \"./Popover\";\nimport RecordPanel from \"./RecordPanel\";\n\ntype CellEdit = { rowId: number; fieldId: string; old: any; new: any };\n\ndeclare module \"@tanstack/react-table\" {\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  interface ColumnMeta<TData extends unknown, TValue> { field?: Field; }\n}\n\nconst ROW_H = { s: 32, m: 40, l: 56 };\nconst AGG_ORDER = [\"sum\", \"avg\", \"min\", \"max\", \"count\", \"empty\"] as const;\ntype AggMode = (typeof AGG_ORDER)[number];\nconst AGG_LABEL: Record<AggMode, string> = { sum: \"Σ\", avg: \"avg\", min: \"min\", max: \"max\", count: \"count\", empty: \"пусто\" };\n\nconst Dash = () => <span className=\"text-[var(--color-mut2)]\">—</span>;\n\nfunction renderCell(f: Field, value: any, onChange: (v: any) => void) {\n  const ro = f.editable === false || f.type === \"formula\" || f.type === \"rollup\";\n  if (ro) {\n    if (f.type === \"select\" || f.type === \"status\") {\n      const o = f.options?.find((x) => x.value === value);\n      return value ? <Tag label={String(value)} color={o?.color} /> : <Dash />;\n    }\n    if (f.type === \"person\") {\n      const o = f.options?.find((x) => x.value === value);\n      return value ? <PersonTag label={String(value)} color={o?.color} /> : <Dash />;\n    }\n    if (f.type === \"relation\") return <span className=\"flex flex-wrap gap-1\"><RelationChips labels={relationLabels(f, value)} /></span>;\n    if (f.type === \"rating\") return <RatingStars value={value} max={f.max} />;\n    if (f.type === \"attachment\") return <span className=\"flex flex-wrap gap-2\"><AttachmentChips list={Array.isArray(value) ? value : []} /></span>;\n    if (f.type === \"daterange\") return value ? <span className=\"tabular-nums whitespace-nowrap\">{fmtRange(value)}</span> : <Dash />;\n    if (f.type === \"number\" || f.type === \"rollup\") return value == null || value === \"\" ? <Dash /> : <span className=\"tabular-nums\">{formatNumber(value, f)}</span>;\n    if (f.type === \"formula\") return value == null || value === \"\" ? <Dash /> : <span className={typeof value === \"number\" ? \"tabular-nums\" : \"\"}>{typeof value === \"number\" ? formatNumber(value, f) : String(value)}</span>;\n    if (f.type === \"checkbox\") return value ? <Check size={15} className=\"text-[var(--color-ok)]\" /> : <Dash />;\n    return <span className=\"text-[var(--color-mut)]\">{value ?? \"—\"}</span>;\n  }\n  return <CellEditor field={f} value={value} onChange={onChange} />;\n}\n\nfunction HeaderCell({\n  header, config, frozenLeft, colIndex, stickyBg, setSort, clearSort, groupBy, hideField, setFrozen,\n}: {\n  header: Header<Row, unknown>;\n  config: ViewConfig;\n  frozenLeft?: number;\n  colIndex: number;\n  stickyBg: string;\n  setSort: (id: string, desc: boolean) => void;\n  clearSort: (id: string) => void;\n  groupBy: (id: string | null) => void;\n  hideField: (id: string) => void;\n  setFrozen: (n: number) => void;\n}) {\n  const f = (header.column.columnDef.meta as any)?.field as Field;\n  const sd = config.sorting.find((s) => s.id === header.column.id);\n  const frozen = frozenLeft !== undefined;\n  const num = f?.type === \"number\";\n  const { setNodeRef, transform, transition, attributes, listeners, isDragging } = useSortable({ id: header.column.id });\n  const style: CSSProperties = {\n    width: header.getSize(), left: frozen ? frozenLeft : undefined, background: frozen ? stickyBg : undefined,\n    transform: transform ? `translate3d(${transform.x}px,0,0)` : undefined,\n    transition, zIndex: isDragging ? 40 : frozen ? 30 : undefined, opacity: isDragging ? 0.85 : 1,\n  };\n  return (\n    <div ref={setNodeRef}\n      className={`group/h relative px-2 h-9 text-[13px] font-medium text-[var(--color-mut)] whitespace-nowrap flex items-center gap-1.5 ${num ? \"justify-end\" : \"\"} ${frozen ? \"sticky\" : \"\"}`}\n      style={style}>\n      <span {...attributes} {...listeners}\n        className=\"text-[var(--color-mut2)] inline-flex items-center cursor-grab select-none shrink-0\">\n        <TypeIcon type={f?.type ?? \"text\"} />\n      </span>\n      <Popover align={num ? \"right\" : \"left\"}\n        trigger={(_o, toggle) => (\n          <button type=\"button\" onClick={toggle} className=\"hover:text-[var(--color-ink)] inline-flex items-center gap-1 min-w-0\">\n            <span className=\"truncate\">{String(header.column.columnDef.header)}</span>\n            {sd && (sd.desc ? <ArrowDown size={13} className=\"text-[var(--color-ink)] shrink-0\" /> : <ArrowUp size={13} className=\"text-[var(--color-ink)] shrink-0\" />)}\n          </button>\n        )}>\n        {(close) => (\n          <>\n            <MenuItem onClick={() => { setSort(header.column.id, false); close(); }}><ArrowUp size={14} /> По возрастанию</MenuItem>\n            <MenuItem onClick={() => { setSort(header.column.id, true); close(); }}><ArrowDown size={14} /> По убыванию</MenuItem>\n            {sd && <MenuItem onClick={() => { clearSort(header.column.id); close(); }}><ArrowUp size={14} className=\"opacity-0\" /> Убрать сортировку</MenuItem>}\n            <MenuItem onClick={() => { groupBy(config.grouping === header.column.id ? null : header.column.id); close(); }}>\n              <Group size={14} /> {config.grouping === header.column.id ? \"Разгруппировать\" : \"Группировать по полю\"}\n            </MenuItem>\n            <MenuItem onClick={() => { setFrozen(colIndex + 1); close(); }}><Pin size={14} /> Закрепить до этой колонки</MenuItem>\n            {(config.frozen ?? 1) > 1 && <MenuItem onClick={() => { setFrozen(1); close(); }}><PinOff size={14} /> Открепить колонки</MenuItem>}\n            <MenuItem onClick={() => { hideField(header.column.id); close(); }}><EyeOff size={14} /> Скрыть поле</MenuItem>\n          </>\n        )}\n      </Popover>\n      {header.column.getCanResize() && (\n        <div onMouseDown={header.getResizeHandler()} onTouchStart={header.getResizeHandler()}\n          onPointerDown={(e) => e.stopPropagation()}\n          className=\"absolute right-0 top-0 h-full w-1 cursor-col-resize opacity-0 group-hover/h:opacity-100 hover:bg-[var(--color-focus)]\"\n          style={{ touchAction: \"none\" }} />\n      )}\n    </div>\n  );\n}\n\n/** Перетаскиваемая строка-обёртка (drag-reorder через ручку-grip). */\nfunction RowDnd({ id, children }: { id: number; children: (drag: { ref: (el: HTMLElement | null) => void; listeners: any; attributes: any }) => ReactNode }) {\n  const drop = useDroppable({ id: `row:${id}` });\n  const drag = useDraggable({ id: `row:${id}` });\n  return (\n    <div ref={drop.setNodeRef} style={drop.isOver ? { boxShadow: \"inset 0 2px 0 var(--color-focus)\" } : undefined}>\n      {children({ ref: drag.setNodeRef, listeners: drag.listeners, attributes: drag.attributes })}\n    </div>\n  );\n}\n\nexport default function TableView({\n  fields, rows, config, onEdit, onConfigChange, onAddRow, onDeleteRows, onDuplicateRow, onReorderRow, loading, error, onRetry,\n}: {\n  fields: Field[];\n  rows: Row[];\n  config: ViewConfig;\n  onEdit: (rowId: number, fieldId: string, value: any) => void;\n  onConfigChange: (c: ViewConfig) => void;\n  onAddRow?: () => void;\n  onDeleteRows?: (ids: number[]) => void;\n  onDuplicateRow?: (id: number) => void;\n  onReorderRow?: (activeId: number, overId: number) => void;\n  loading?: boolean;\n  error?: string | null;\n  onRetry?: () => void;\n}) {\n  const [expanded, setExpanded] = useState<ExpandedState>(true);\n  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});\n  const [openRow, setOpenRow] = useState<number | null>(null);\n  const [dragRow, setDragRow] = useState<number | null>(null);\n  const [agg, setAgg] = useState<Record<string, AggMode>>({});\n  const [active, setActive] = useState<{ r: number; c: string } | null>(null);\n  const [fill, setFill] = useState<{ startR: number; col: string; endR: number } | null>(null);\n  const parentRef = useRef<HTMLDivElement>(null);\n  const rowsRef = useRef(rows); rowsRef.current = rows;\n  const editRef = useRef<(rowId: number, fieldId: string, value: any) => void>(() => {});\n  const undoStack = useRef<CellEdit[][]>([]);\n  const redoStack = useRef<CellEdit[][]>([]);\n\n  const data = useMemo(\n    () => rows.filter((row) => evalFilters(row, config)),\n    [rows, config.filters, config.filterTree, config.conjunction]\n  );\n\n  const columns = useMemo<ColumnDef<Row>[]>(\n    () => fields.map((f) => ({\n      id: f.id, accessorKey: f.id, header: f.name, size: f.width ?? 140,\n      minSize: 60, enableGrouping: true,\n      aggregationFn: f.type === \"number\" ? \"sum\" : undefined,\n      meta: { field: f },\n      cell: (ctx) => renderCell(\n        f,\n        f.type === \"formula\" || f.type === \"rollup\" ? computeValue(f, ctx.row.original, fields) : ctx.row.original[f.id],\n        (v) => editRef.current(ctx.row.original.id, f.id, v),\n      ),\n    })),\n    [fields]\n  );\n\n  const grouping = config.grouping ? [config.grouping] : [];\n  const columnVisibility = Object.fromEntries(config.hidden.map((h) => [h, false]));\n  const columnOrder = config.fieldOrder ?? fields.map((f) => f.id);\n  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));\n\n  const table = useReactTable({\n    data, columns,\n    state: { sorting: config.sorting, grouping, columnVisibility, columnOrder, globalFilter: config.search, expanded, rowSelection },\n    onExpandedChange: setExpanded,\n    onRowSelectionChange: setRowSelection,\n    enableRowSelection: true,\n    getRowId: (r) => String(r.id),\n    globalFilterFn: \"includesString\",\n    columnResizeMode: \"onChange\",\n    getCoreRowModel: getCoreRowModel(),\n    getSortedRowModel: getSortedRowModel(),\n    getFilteredRowModel: getFilteredRowModel(),\n    getGroupedRowModel: getGroupedRowModel(),\n    getExpandedRowModel: getExpandedRowModel(),\n    enableGrouping: true,\n    manualPagination: true,\n  });\n\n  const rowsModel = table.getRowModel().rows;\n  const h = ROW_H[config.rowHeight];\n  const virt = useVirtualizer({\n    count: rowsModel.length, getScrollElement: () => parentRef.current,\n    estimateSize: () => h, overscan: 14,\n  });\n\n  const leafCols = table.getVisibleLeafColumns();\n  const firstId = leafCols[0]?.id;\n  const totalW = table.getTotalSize();\n  const frozenCount = config.frozen ?? 1;\n  const frozenLeft: Record<string, number> = {};\n  { let acc = 0; leafCols.forEach((c, i) => { if (i < frozenCount) { frozenLeft[c.id] = acc; acc += c.getSize(); } }); }\n  const groupField = fields.find((f) => f.id === config.grouping);\n  const leaves = table.getFilteredRowModel().flatRows.filter((r) => !r.getIsGrouped());\n  const selCount = Object.keys(rowSelection).length;\n  const stickyBg = \"var(--color-card)\";\n  const rowTint = (r: Row) => rowColor(config, r, fields);\n  const wrap = !!config.wrap;\n  const numFields = fields.filter((f) => f.type === \"number\" && !config.hidden.includes(f.id));\n  const rowsDraggable = !!onReorderRow && !config.grouping && config.sorting.length === 0;\n\n  /* ── правка / история / буфер / fill-handle ─────────────────────────── */\n  const colById = (id: string) => fields.find((f) => f.id === id);\n  const visibleLeafIds = leafCols.map((c) => c.id);\n  const rowAtIndex = (i: number): Row | null => { const rm = rowsModel[i]; return rm && !rm.getIsGrouped() ? (rm.original as Row) : null; };\n  const cellText = (row: Row, f: Field) => { const v = row[f.id]; return v == null ? \"\" : Array.isArray(v) ? v.join(\",\") : String(v); };\n  const coerce = (f: Field, raw: string): any => {\n    if (raw === \"\") return null;\n    if (f.type === \"number\") { const n = Number(raw); return Number.isNaN(n) ? null : n; }\n    if (f.type === \"checkbox\") return [\"true\", \"1\", \"да\", \"✓\", \"yes\"].includes(raw.toLowerCase());\n    if (f.type === \"multiselect\") return raw.split(\",\").map((s) => s.trim()).filter(Boolean);\n    return raw;\n  };\n  const pushHistory = (batch: CellEdit[]) => { if (!batch.length) return; undoStack.current.push(batch); if (undoStack.current.length > 100) undoStack.current.shift(); redoStack.current = []; };\n  const handleEdit = (rowId: number, fieldId: string, value: any) => {\n    const old = rowsRef.current.find((r) => r.id === rowId)?.[fieldId];\n    pushHistory([{ rowId, fieldId, old, new: value }]);\n    onEdit(rowId, fieldId, value);\n  };\n  editRef.current = handleEdit;\n  const applyBatch = (edits: { rowId: number; fieldId: string; value: any }[]) => {\n    if (!edits.length) return;\n    const batch: CellEdit[] = edits.map((e) => ({ rowId: e.rowId, fieldId: e.fieldId, old: rowsRef.current.find((r) => r.id === e.rowId)?.[e.fieldId], new: e.value }));\n    pushHistory(batch);\n    edits.forEach((e) => onEdit(e.rowId, e.fieldId, e.value));\n  };\n  const undo = () => { const b = undoStack.current.pop(); if (!b) return; [...b].reverse().forEach((e) => onEdit(e.rowId, e.fieldId, e.old)); redoStack.current.push(b); };\n  const redo = () => { const b = redoStack.current.pop(); if (!b) return; b.forEach((e) => onEdit(e.rowId, e.fieldId, e.new)); undoStack.current.push(b); };\n  const copySel = () => {\n    let text = \"\";\n    if (Object.keys(rowSelection).length > 0) {\n      const cols = leafCols.map((c) => colById(c.id)).filter(Boolean) as Field[];\n      const selRows = leaves.filter((r) => r.getIsSelected()).map((r) => r.original as Row);\n      text = selRows.map((row) => cols.map((f) => cellText(row, f)).join(\"\\t\")).join(\"\\n\");\n    } else if (active) {\n      const row = rowAtIndex(active.r); const f = colById(active.c);\n      if (row && f) text = cellText(row, f);\n    }\n    if (text) navigator.clipboard?.writeText(text);\n  };\n  const pasteAt = (text: string) => {\n    if (!active || !text) return;\n    const startCol = visibleLeafIds.indexOf(active.c); if (startCol < 0) return;\n    const matrix = text.replace(/\\r/g, \"\").replace(/\\n$/, \"\").split(\"\\n\").map((l) => l.split(\"\\t\"));\n    const edits: { rowId: number; fieldId: string; value: any }[] = [];\n    matrix.forEach((line, ri) => {\n      const row = rowAtIndex(active.r + ri); if (!row) return;\n      line.forEach((cv, ci) => {\n        const colId = visibleLeafIds[startCol + ci]; if (!colId) return;\n        const f = colById(colId); if (!f || f.editable === false) return;\n        edits.push({ rowId: row.id, fieldId: colId, value: coerce(f, cv) });\n      });\n    });\n    applyBatch(edits);\n  };\n  const onFillStart = (e: any) => {\n    e.preventDefault(); e.stopPropagation();\n    if (!active) return;\n    const col = active.c, startR = active.r;\n    const findR = (cx: number, cy: number) => { const cell = (document.elementFromPoint(cx, cy) as HTMLElement | null)?.closest(\"[data-r]\") as HTMLElement | null; return cell ? Number(cell.dataset.r) : startR; };\n    const move = (ev: PointerEvent) => setFill({ startR, col, endR: Math.max(findR(ev.clientX, ev.clientY), startR) });\n    const upFn = (ev: PointerEvent) => {\n      document.removeEventListener(\"pointermove\", move); document.removeEventListener(\"pointerup\", upFn);\n      const endR = Math.max(findR(ev.clientX, ev.clientY), startR); setFill(null);\n      const src = rowAtIndex(startR), f = colById(col);\n      if (src && f && f.editable !== false && endR > startR) {\n        const val = src[col]; const edits: { rowId: number; fieldId: string; value: any }[] = [];\n        for (let i = startR + 1; i <= endR; i++) { const row = rowAtIndex(i); if (row) edits.push({ rowId: row.id, fieldId: col, value: val }); }\n        applyBatch(edits);\n      }\n    };\n    document.addEventListener(\"pointermove\", move); document.addEventListener(\"pointerup\", upFn);\n  };\n\n  const setSort = (id: string, desc: boolean) =>\n    onConfigChange({ ...config, sorting: [...config.sorting.filter((s) => s.id !== id), { id, desc }] });\n  const clearSort = (id: string) => onConfigChange({ ...config, sorting: config.sorting.filter((s) => s.id !== id) });\n  const hideField = (id: string) => onConfigChange({ ...config, hidden: [...config.hidden, id] });\n  const groupBy = (id: string | null) => onConfigChange({ ...config, grouping: id });\n  const setFrozen = (n: number) => onConfigChange({ ...config, frozen: n });\n  const onColDragEnd = (e: DragEndEvent) => {\n    const { active, over } = e;\n    if (over && active.id !== over.id) {\n      const cur = config.fieldOrder ?? fields.map((f) => f.id);\n      onConfigChange({ ...config, fieldOrder: arrayMove(cur, cur.indexOf(active.id as string), cur.indexOf(over.id as string)) });\n    }\n  };\n  const onRowDragEnd = (e: DragEndEvent) => {\n    setDragRow(null);\n    const { active, over } = e;\n    if (!over || active.id === over.id) return;\n    onReorderRow?.(Number(String(active.id).replace(\"row:\", \"\")), Number(String(over.id).replace(\"row:\", \"\")));\n  };\n\n  const groupAgg = (subRows: typeof rowsModel, f: Field) =>\n    formatNumber(subRows.reduce((s, sr) => s + (Number((sr.original as any)[f.id]) || 0), 0), f);\n\n  return (\n    <div className=\"dv\">\n      {/* toolbar выделения */}\n      {selCount > 0 && (\n        <div className=\"flex items-center gap-3 mb-2 px-3 py-1.5 rounded-md text-sm\" style={{ background: \"var(--selection)\" }}>\n          <span>{selCount} выбрано</span>\n          {onDeleteRows && (\n            <button className=\"inline-flex items-center gap-1 text-[var(--color-danger)] hover:brightness-125\"\n              onClick={() => { onDeleteRows(Object.keys(rowSelection).map(Number)); setRowSelection({}); }}>\n              <Trash2 size={14} /> Удалить\n            </button>\n          )}\n          <button className=\"text-[var(--color-mut)] hover:text-[var(--color-ink)]\" onClick={() => setRowSelection({})}>снять</button>\n        </div>\n      )}\n\n      <div ref={parentRef} className=\"overflow-auto rounded-lg border border-[var(--color-line)]\"\n        style={{ maxHeight: \"70vh\", background: \"var(--color-card)\" }}\n        onFocusCapture={(e) => { const c = (e.target as HTMLElement).closest(\"[data-r]\") as HTMLElement | null; if (c) setActive({ r: Number(c.dataset.r), c: c.dataset.c! }); }}\n        onKeyDown={(e) => {\n          const el = e.target as HTMLElement;\n          const tag = el.tagName;\n          const isText = tag === \"INPUT\" && (el as HTMLInputElement).type === \"text\";\n          const mod = e.metaKey || e.ctrlKey;\n          if (mod && (e.key === \"z\" || e.key === \"Z\")) { e.preventDefault(); e.shiftKey ? redo() : undo(); return; }\n          if (mod && (e.key === \"y\" || e.key === \"Y\")) { e.preventDefault(); redo(); return; }\n          if (mod && (e.key === \"c\" || e.key === \"C\")) { const s = window.getSelection?.(); if (Object.keys(rowSelection).length > 0 || (active && !(isText && s && String(s).length))) { e.preventDefault(); copySel(); } return; }\n          if (mod && (e.key === \"v\" || e.key === \"V\")) { if (active) { e.preventDefault(); navigator.clipboard?.readText().then(pasteAt).catch(() => {}); } return; }\n          const cell = el.closest(\"[data-r]\") as HTMLElement | null;\n          if (!cell) return;\n          const isField = tag === \"INPUT\" || tag === \"SELECT\";\n          if (e.key === \"Escape\") { (el as HTMLInputElement).blur?.(); return; }\n          const move = (dir: -1 | 1) => {\n            const r = Number(cell.dataset.r); const c = cell.dataset.c;\n            const tr = r + dir;\n            if (tr < 0 || tr >= rowsModel.length) return;\n            e.preventDefault();\n            virt.scrollToIndex(tr, { align: \"auto\" });\n            requestAnimationFrame(() => requestAnimationFrame(() => {\n              const t = parentRef.current?.querySelector(`[data-r=\"${tr}\"][data-c=\"${c}\"]`) as HTMLElement | null;\n              (t?.querySelector(\"input,select,button\") as HTMLElement | null)?.focus();\n            }));\n          };\n          if (e.key === \"ArrowDown\") { if (!isText) move(1); }\n          else if (e.key === \"ArrowUp\") { if (!isText) move(-1); }\n          else if (e.key === \"Enter\" && isField) move(1);\n        }}>\n        {error ? (\n          <div className=\"p-10 flex flex-col items-center gap-3 text-center\">\n            <AlertTriangle size={28} strokeWidth={1.5} className=\"text-[var(--color-danger)]\" />\n            <span className=\"text-sm text-[var(--color-danger)]\">{error}</span>\n            {onRetry && (\n              <button onClick={onRetry} className=\"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-[var(--color-line-2)] text-[13px] text-[var(--color-mut)] hover:bg-[var(--hover)] hover:text-[var(--color-ink)]\"><RotateCw size={14} /> Повторить</button>\n            )}\n          </div>\n        ) : loading ? (\n          <div className=\"p-3 flex flex-col gap-2\">\n            {Array.from({ length: 8 }).map((_, i) => (\n              <div key={i} className=\"h-6 rounded animate-pulse\" style={{ background: \"var(--color-surface2)\", width: `${70 + ((i * 7) % 25)}%` }} />\n            ))}\n          </div>\n        ) : (\n        <div style={{ width: totalW, minWidth: \"100%\" }}>\n          {/* header */}\n          <div className=\"sticky top-0 z-20\" style={{ background: stickyBg }}>\n            <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onColDragEnd}>\n              {table.getHeaderGroups().map((hg) => (\n                <SortableContext key={hg.id} items={hg.headers.map((he) => he.column.id)} strategy={horizontalListSortingStrategy}>\n                  <div className=\"flex border-b border-[var(--color-line)]\">\n                    {hg.headers.map((header, ci) => (\n                      <HeaderCell key={header.id} header={header} config={config} frozenLeft={frozenLeft[header.column.id]}\n                        colIndex={ci} stickyBg={stickyBg} setSort={setSort} clearSort={clearSort}\n                        groupBy={groupBy} hideField={hideField} setFrozen={setFrozen} />\n                    ))}\n                  </div>\n                </SortableContext>\n              ))}\n            </DndContext>\n          </div>\n\n          {/* body */}\n          <DndContext sensors={sensors} collisionDetection={closestCenter}\n            onDragStart={(e) => setDragRow(Number(String(e.active.id).replace(\"row:\", \"\")))} onDragEnd={onRowDragEnd}>\n          <div style={{ height: rowsModel.length ? virt.getTotalSize() : undefined, position: \"relative\" }}>\n            {rowsModel.length === 0 && (\n              <div className=\"flex flex-col items-center gap-2 py-16 text-[var(--color-mut2)]\">\n                <Inbox size={28} strokeWidth={1.5} />\n                <span className=\"text-sm\">Нет записей</span>\n              </div>\n            )}\n            {virt.getVirtualItems().map((vi) => {\n              const row = rowsModel[vi.index];\n              const style: CSSProperties = {\n                position: \"absolute\", top: 0, left: 0, width: \"100%\",\n                height: wrap ? undefined : vi.size, transform: `translateY(${vi.start}px)`,\n              };\n              if (row.getIsGrouped()) {\n                const val = String(row.getGroupingValue(config.grouping!) ?? \"\");\n                const opt = groupField?.options?.find((o) => o.value === val);\n                return (\n                  <div key={row.id} style={{ ...style, height: vi.size, background: \"var(--hover)\" }}\n                    className=\"flex items-center gap-2 px-2 border-b border-[var(--color-line)]\">\n                    <button onClick={row.getToggleExpandedHandler()} className=\"text-[var(--color-mut2)] inline-flex\">\n                      {row.getIsExpanded() ? <ChevronDown size={15} /> : <ChevronRight size={15} />}\n                    </button>\n                    {groupField?.type === \"select\" || groupField?.type === \"status\"\n                      ? <Tag label={val} color={opt?.color} />\n                      : <span className=\"font-medium\">{val || \"—\"}</span>}\n                    <span className=\"text-[var(--color-mut2)] text-xs\">{row.subRows.length}</span>\n                    {numFields.map((f) => (\n                      <span key={f.id} className=\"text-[var(--color-mut2)] text-xs tabular-nums ml-1\">Σ {f.name} {groupAgg(row.subRows as any, f)}</span>\n                    ))}\n                  </div>\n                );\n              }\n              const sel = row.getIsSelected();\n              const tint = rowTint(row.original);\n              const rowInner = (drag?: { ref: (el: HTMLElement | null) => void; listeners: any; attributes: any }) => (\n                <div style={{ opacity: dragRow === row.original.id ? 0.4 : 1, minHeight: wrap ? h : vi.size, background: sel ? \"var(--selection)\" : tint ? `color-mix(in srgb, ${tint} 12%, transparent)` : undefined }}\n                  className=\"group/row flex items-stretch border-b border-[var(--color-line)] hover:bg-[var(--hover)]\">\n                  {row.getVisibleCells().map((cell) => {\n                    const f = (cell.column.columnDef.meta as any)?.field as Field;\n                    const isFirst = cell.column.id === firstId;\n                    const left = frozenLeft[cell.column.id];\n                    const frozen = left !== undefined;\n                    const num = f?.type === \"number\";\n                    const isActiveCell = active?.r === vi.index && active.c === cell.column.id;\n                    const inFill = !!fill && fill.col === cell.column.id && vi.index > fill.startR && vi.index <= fill.endR;\n                    return (\n                      <div key={cell.id} data-r={vi.index} data-c={cell.column.id}\n                        className={`relative px-2 flex items-center gap-1 ${num ? \"justify-end tabular-nums\" : wrap ? \"whitespace-normal break-words\" : \"overflow-hidden\"} ${frozen ? \"sticky z-10\" : \"\"}`}\n                        style={{ width: cell.column.getSize(), height: wrap ? undefined : vi.size, minHeight: wrap ? h : undefined, left: frozen ? left : undefined,\n                          background: frozen ? (sel ? \"var(--selection)\" : stickyBg) : undefined,\n                          boxShadow: isActiveCell ? \"inset 0 0 0 1px var(--color-focus)\" : inFill ? \"inset 0 0 0 1px color-mix(in srgb, var(--color-focus) 45%, transparent)\" : undefined }}>\n                        {isFirst && (\n                          <span className=\"flex items-center shrink-0\">\n                            {rowsDraggable && drag && (\n                              <span ref={drag.ref} {...drag.listeners} {...drag.attributes}\n                                className=\"opacity-0 group-hover/row:opacity-100 text-[var(--color-mut2)] cursor-grab -ml-0.5\" title=\"Перетащить\">\n                                <GripVertical size={14} />\n                              </span>\n                            )}\n                            <input type=\"checkbox\" checked={sel} onChange={row.getToggleSelectedHandler()}\n                              className={`shrink-0 ${sel ? \"\" : \"opacity-0 group-hover/row:opacity-100\"}`}\n                              style={{ width: 14, height: 14, accentColor: \"var(--color-focus)\" }} />\n                            <button title=\"Открыть карточку\" onClick={(e) => { e.stopPropagation(); setOpenRow(row.original.id); }}\n                              className=\"shrink-0 opacity-0 group-hover/row:opacity-100 text-[var(--color-mut2)] hover:text-[var(--color-ink)] inline-flex\"><Maximize2 size={13} /></button>\n                            {(onDuplicateRow || onDeleteRows) && (\n                              <span className=\"opacity-0 group-hover/row:opacity-100 shrink-0\">\n                                <Popover align=\"left\"\n                                  trigger={(_o, t) => <button onClick={(e) => { e.stopPropagation(); t(); }} className=\"text-[var(--color-mut2)] hover:text-[var(--color-ink)] inline-flex\"><MoreHorizontal size={14} /></button>}>\n                                  {(close) => (\n                                    <>\n                                      <MenuItem onClick={() => { setOpenRow(row.original.id); close(); }}><Maximize2 size={14} /> Открыть</MenuItem>\n                                      {onDuplicateRow && <MenuItem onClick={() => { onDuplicateRow(row.original.id); close(); }}><Copy size={14} /> Дублировать</MenuItem>}\n                                      {onDeleteRows && <MenuItem onClick={() => { onDeleteRows([row.original.id]); close(); }}><Trash2 size={14} className=\"text-[var(--color-danger)]\" /> Удалить</MenuItem>}\n                                    </>\n                                  )}\n                                </Popover>\n                              </span>\n                            )}\n                          </span>\n                        )}\n                        {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                        {isActiveCell && (\n                          <span onPointerDown={onFillStart} title=\"Протянуть значение\"\n                            className=\"absolute bottom-0 right-0 z-20\" style={{ width: 7, height: 7, background: \"var(--color-focus)\", cursor: \"crosshair\", transform: \"translate(1px,1px)\" }} />\n                        )}\n                      </div>\n                    );\n                  })}\n                </div>\n              );\n              return (\n                <div key={row.id} data-index={vi.index} ref={wrap ? virt.measureElement : undefined} style={style}>\n                  {rowsDraggable ? <RowDnd id={row.original.id}>{(drag) => rowInner(drag)}</RowDnd> : rowInner()}\n                </div>\n              );\n            })}\n          </div>\n          <DragOverlay>\n            {dragRow != null && (() => {\n              const r = rows.find((x) => x.id === dragRow);\n              const t = fields.find((f) => f.primary) ?? fields.find((f) => f.type === \"text\");\n              return r ? <div className=\"px-3 py-1.5 rounded-md text-[13px] font-medium shadow-lg\" style={{ background: \"var(--color-surface2)\", boxShadow: \"var(--shadow-pop)\" }}>{t ? String(r[t.id] ?? \"—\") : `#${r.id}`}</div> : null;\n            })()}\n          </DragOverlay>\n          </DndContext>\n        </div>\n        )}\n      </div>\n\n      {onAddRow && (\n        <button onClick={onAddRow} className=\"mt-2 inline-flex items-center gap-1 text-sm text-[var(--color-mut)] hover:text-[var(--color-ink)]\"><Plus size={14} /> Новая строка</button>\n      )}\n\n      {/* footer — кликабельные агрегаты */}\n      <div className=\"flex flex-wrap items-baseline gap-x-5 gap-y-1 mt-2 text-[13px]\">\n        <span className=\"text-[var(--color-mut2)]\">count {leaves.length}</span>\n        {numFields.map((f) => {\n          const mode = agg[f.id] ?? \"sum\";\n          const vals = leaves.map((r) => (r.original as any)[f.id]);\n          const nums = vals.map((v) => Number(v) || 0);\n          const sum = nums.reduce((a, b) => a + b, 0);\n          const emptyCount = vals.filter((v) => v == null || v === \"\").length;\n          const raw =\n            mode === \"sum\" ? sum\n            : mode === \"avg\" ? (nums.length ? sum / nums.length : 0)\n            : mode === \"min\" ? (nums.length ? Math.min(...nums) : 0)\n            : mode === \"max\" ? (nums.length ? Math.max(...nums) : 0)\n            : mode === \"count\" ? nums.length : 0;\n          const out = mode === \"count\" ? nums.length\n            : mode === \"empty\" ? (vals.length ? `${Math.round((emptyCount / vals.length) * 100)}%` : \"0%\")\n            : formatNumber(mode === \"avg\" ? Number(raw.toFixed(2)) : raw, f);\n          return (\n            <button key={f.id} className=\"text-[var(--color-mut2)] tabular-nums hover:text-[var(--color-ink)]\" title=\"клик — сменить агрегат\"\n              onClick={() => setAgg((a) => { const c = a[f.id] ?? \"sum\"; return { ...a, [f.id]: AGG_ORDER[(AGG_ORDER.indexOf(c) + 1) % AGG_ORDER.length] }; })}>\n              {f.name} {AGG_LABEL[mode]} {out}\n            </button>\n          );\n        })}\n      </div>\n\n      {openRow != null && (() => {\n        const r = rows.find((x) => x.id === openRow);\n        return r ? <RecordPanel row={r} fields={fields} onEdit={onEdit} onClose={() => setOpenRow(null)} /> : null;\n      })()}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/dataviews/TableView.tsx"
    },
    {
      "path": "registry/eburet/data-views/TimelineView.tsx",
      "content": "import { useMemo, useRef, useState } from \"react\";\nimport type { Field, Row, ViewConfig } from \"./types\";\nimport { Tag } from \"./cells\";\nimport { filterRows, firstFieldOfType } from \"./filter\";\n\nconst iso = (v: any) => (v ? String(v).split(\"T\")[0] : \"\");\nconst t = (v: any) => new Date(iso(v)).getTime();\n\nconst LABEL_W = 144, HEADER = 36, ROW = 24, LANE_GAP = 10, BAR_H = 12;\n\nexport default function TimelineView({\n  fields, rows, config, onEdit, deps = [],\n}: {\n  fields: Field[]; rows: Row[]; config: ViewConfig;\n  onEdit?: (id: number, fid: string, v: any) => void;\n  deps?: [number, number][]; // зависимости: [fromId, toId]\n}) {\n  const trackRef = useRef<HTMLDivElement>(null);\n  const [resize, setResize] = useState<{ id: number; edge: \"l\" | \"r\"; pct: number } | null>(null);\n  const rangeF = fields.find((f) => f.type === \"daterange\");\n  const dateFields = fields.filter((f) => f.type === \"date\");\n  const startF = dateFields[0];\n  const endF = dateFields[1];\n  const hasRange = !!rangeF || !!endF;\n  const getStart = (r: Row) => (rangeF ? r[rangeF.id]?.start : startF && r[startF.id]);\n  const getEnd = (r: Row) => (rangeF ? r[rangeF.id]?.end : endF && r[endF.id]);\n  const laneField = (config.grouping ? fields.find((f) => f.id === config.grouping) : undefined)\n    ?? firstFieldOfType(fields, \"status\", \"select\");\n  const title = fields.find((f) => f.primary) ?? fields.find((f) => f.type === \"text\");\n  const data = useMemo(\n    () => filterRows(rows, fields, config).filter((r) => iso(getStart(r))),\n    [rows, fields, config] // eslint-disable-line\n  );\n\n  const { min, span, ticks } = useMemo(() => {\n    const ts = data.flatMap((r) => [t(getStart(r)), hasRange ? t(getEnd(r) || getStart(r)) : NaN]).filter((n) => !isNaN(n));\n    const mn = ts.length ? Math.min(...ts) : Date.now();\n    const mx = ts.length ? Math.max(...ts) : Date.now();\n    const sp = Math.max(mx - mn, 1);\n    const months = (new Date(mx).getFullYear() - new Date(mn).getFullYear()) * 12 + (new Date(mx).getMonth() - new Date(mn).getMonth());\n    const byYear = months > 16;\n    const tk: { x: number; label: string }[] = [];\n    const s = new Date(mn); s.setDate(1); if (byYear) s.setMonth(0);\n    for (const d = new Date(s); d.getTime() <= mx + 1; byYear ? d.setFullYear(d.getFullYear() + 1) : d.setMonth(d.getMonth() + 1))\n      tk.push({ x: ((d.getTime() - mn) / sp) * 100, label: byYear ? String(d.getFullYear()) : `${d.getMonth() + 1}.${String(d.getFullYear()).slice(2)}` });\n    return { min: mn, span: sp, ticks: tk };\n  }, [data, rangeF, startF, endF]); // eslint-disable-line\n\n  // критический путь: самая длинная цепочка по зависимостям (CPM по датам)\n  const critical = useMemo(() => {\n    const nodes = new Set<number>(), edges = new Set<string>();\n    if (!deps.length) return { nodes, edges };\n    const preds = new Map<number, number[]>();\n    deps.forEach(([f, to]) => preds.set(to, [...(preds.get(to) ?? []), f]));\n    const dur = (id: number) => { const r = data.find((x) => x.id === id); if (!r) return 0; const s = t(getStart(r)); const e = hasRange && iso(getEnd(r)) ? t(getEnd(r)) : s; return Math.max(e - s, 86400000); };\n    const memo = new Map<number, { len: number; prev: number | null }>();\n    const calc = (id: number, seen: Set<number>): { len: number; prev: number | null } => {\n      if (memo.has(id)) return memo.get(id)!;\n      if (seen.has(id)) return { len: 0, prev: null };\n      seen.add(id);\n      let best = { len: 0, prev: null as number | null };\n      for (const p of preds.get(id) ?? []) { const c = calc(p, seen); if (c.len > best.len) best = { len: c.len, prev: p }; }\n      const res = { len: best.len + dur(id), prev: best.prev };\n      memo.set(id, res); return res;\n    };\n    let endId: number | null = null, max = -1;\n    for (const r of data) { const c = calc(r.id, new Set()); if (c.len > max) { max = c.len; endId = r.id; } }\n    let cur = endId;\n    while (cur != null) { nodes.add(cur); const prev = memo.get(cur)?.prev ?? null; if (prev != null) edges.add(`${prev}->${cur}`); cur = prev; }\n    return { nodes, edges };\n  }, [deps, data]); // eslint-disable-line\n\n  if (!rangeF && !startF) return <div className=\"text-[var(--color-mut)] p-4\">Нет поля-даты для таймлайна.</div>;\n  const xOf = (v: any) => ((t(v) - min) / span) * 100;\n\n  const pctAt = (clientX: number) => {\n    const r = trackRef.current?.getBoundingClientRect();\n    if (!r || r.width === 0) return 0;\n    return Math.max(0, Math.min(100, ((clientX - r.left) / r.width) * 100));\n  };\n  const dateAtPct = (pct: number) => new Date(min + (pct / 100) * span).toISOString().slice(0, 10);\n  const startResize = (e: any, id: number, edge: \"l\" | \"r\") => {\n    e.preventDefault(); e.stopPropagation();\n    const move = (ev: PointerEvent) => setResize({ id, edge, pct: pctAt(ev.clientX) });\n    const up = (ev: PointerEvent) => {\n      document.removeEventListener(\"pointermove\", move); document.removeEventListener(\"pointerup\", up);\n      const date = dateAtPct(pctAt(ev.clientX)); setResize(null);\n      const row = data.find((x) => x.id === id);\n      if (!row || !onEdit) return;\n      if (rangeF) { const cur = row[rangeF.id] || {}; onEdit(id, rangeF.id, edge === \"r\" ? { start: cur.start, end: date } : { start: date, end: cur.end }); }\n      else if (edge === \"r\" && endF) onEdit(id, endF.id, date);\n      else if (edge === \"l\" && startF) onEdit(id, startF.id, date);\n    };\n    document.addEventListener(\"pointermove\", move); document.addEventListener(\"pointerup\", up);\n  };\n\n  // детерминированная геометрия: лента на лейн, отдельная под-строка на запись\n  const lanes = laneField ? [...(laneField.options ?? []), { value: \"\", color: undefined }] : [{ value: \"\", color: undefined }];\n  const geom = new Map<number, { x1: number; x2: number | null; yc: number }>();\n  const bands: { value: string; color?: string; top: number; h: number; rows: Row[] }[] = [];\n  let y = HEADER;\n  for (const lane of lanes) {\n    const laneRows = data.filter((r) => String(laneField ? r[laneField.id] ?? \"\" : \"\") === lane.value)\n      .sort((a, b) => t(getStart(a)) - t(getStart(b)));\n    if (laneRows.length === 0 && lane.value !== \"\") continue;\n    const top = y;\n    laneRows.forEach((r, idx) => {\n      const yc = top + idx * ROW + ROW / 2;\n      geom.set(r.id, { x1: xOf(getStart(r)), x2: hasRange && iso(getEnd(r)) ? xOf(getEnd(r)) : null, yc });\n    });\n    const h = Math.max(laneRows.length, 1) * ROW;\n    bands.push({ value: lane.value, color: (lane as any).color, top, h, rows: laneRows });\n    y += h + LANE_GAP;\n  }\n  const totalH = y + 8;\n\n  return (\n    <div className=\"dv overflow-x-auto\">\n      <div style={{ minWidth: 1100, position: \"relative\", height: totalH }}>\n        {/* лейблы лейнов слева */}\n        {bands.map((b) => (\n          <div key={b.value || \"__e\"} className=\"absolute\" style={{ left: 0, top: b.top + 4, width: LABEL_W - 8 }}>\n            {laneField && b.value ? <Tag label={b.value} color={b.color} /> : <span className=\"text-[var(--color-mut2)] text-[13px]\">{b.value || \"—\"}</span>}\n          </div>\n        ))}\n\n        {/* трек справа от лейблов */}\n        <div ref={trackRef} className=\"absolute\" style={{ left: LABEL_W, right: 0, top: 0, height: totalH }}>\n          {/* шкала */}\n          <div className=\"absolute left-0 right-0\" style={{ top: 0, height: HEADER }}>\n            {ticks.map((tk, i) => (\n              <div key={i} className=\"absolute top-1 text-[11px] text-[var(--color-mut2)]\" style={{ left: `${tk.x}%` }}>{tk.label}</div>\n            ))}\n          </div>\n          {/* вертикальные линии сетки */}\n          {ticks.map((tk, i) => (\n            <div key={i} className=\"absolute\" style={{ left: `${tk.x}%`, top: HEADER, height: totalH - HEADER, width: 1, background: \"var(--color-line)\" }} />\n          ))}\n          {/* фон лент */}\n          {bands.map((b) => (\n            <div key={b.value || \"__e\"} className=\"absolute left-0 right-0 rounded\" style={{ top: b.top, height: b.h, background: \"var(--hover)\" }} />\n          ))}\n          {/* стрелки зависимостей */}\n          {deps.length > 0 && (\n            <svg className=\"absolute inset-0\" width=\"100%\" height={totalH} style={{ overflow: \"visible\", pointerEvents: \"none\" }}>\n              <defs>\n                <marker id=\"dv-arrow\" markerWidth=\"6\" markerHeight=\"6\" refX=\"5\" refY=\"3\" orient=\"auto\">\n                  <path d=\"M0,0 L6,3 L0,6 Z\" fill=\"var(--color-mut2)\" />\n                </marker>\n                <marker id=\"dv-arrow-c\" markerWidth=\"6\" markerHeight=\"6\" refX=\"5\" refY=\"3\" orient=\"auto\">\n                  <path d=\"M0,0 L6,3 L0,6 Z\" fill=\"var(--color-acc)\" />\n                </marker>\n              </defs>\n              {deps.map(([from, to], i) => {\n                const a = geom.get(from), b = geom.get(to);\n                if (!a || !b) return null;\n                const ax = a.x2 ?? a.x1;\n                const crit = critical.edges.has(`${from}->${to}`);\n                return <line key={i} x1={`${ax}%`} y1={a.yc} x2={`${b.x1}%`} y2={b.yc}\n                  stroke={crit ? \"var(--color-acc)\" : \"var(--color-mut2)\"} strokeWidth={crit ? 2 : 1.5}\n                  markerEnd={crit ? \"url(#dv-arrow-c)\" : \"url(#dv-arrow)\"} opacity={crit ? 1 : 0.6} />;\n              })}\n            </svg>\n          )}\n          {/* бары / точки */}\n          {data.map((r) => {\n            const g = geom.get(r.id); if (!g) return null;\n            const lane = lanes.find((l) => String(laneField ? r[laneField.id] ?? \"\" : \"\") === l.value);\n            const c = (lane as any)?.color || \"#9b9b9b\";\n            const crit = critical.nodes.has(r.id);\n            const tip = `${title ? String(r[title.id] ?? \"\") : r.id} · ${iso(getStart(r))}${g.x2 != null ? \"–\" + iso(getEnd(r)) : \"\"}${crit ? \" · крит. путь\" : \"\"}`;\n            if (g.x2 != null && g.x2 > g.x1) {\n              let x1 = g.x1, x2 = g.x2;\n              if (resize?.id === r.id) { if (resize.edge === \"r\") x2 = Math.max(resize.pct, x1 + 0.3); else x1 = Math.min(resize.pct, x2 - 0.3); }\n              return (\n                <div key={r.id} title={tip} className=\"absolute rounded-full group/bar\"\n                  style={{ left: `${x1}%`, width: `${Math.max(x2 - x1, 0.6)}%`, top: g.yc - BAR_H / 2, height: BAR_H, background: `color-mix(in srgb, ${c} 80%, transparent)`, boxShadow: crit ? `inset 0 0 0 1px ${c}, 0 0 0 1.5px var(--color-acc)` : `inset 0 0 0 1px ${c}` }}>\n                  {onEdit && (\n                    <>\n                      <span onPointerDown={(e) => startResize(e, r.id, \"l\")} title=\"Сдвинуть начало\" className=\"absolute left-0 top-0 h-full w-1.5 rounded-l-full cursor-ew-resize opacity-0 group-hover/bar:opacity-100\" style={{ background: \"var(--color-ink)\" }} />\n                      <span onPointerDown={(e) => startResize(e, r.id, \"r\")} title=\"Сдвинуть конец\" className=\"absolute right-0 top-0 h-full w-1.5 rounded-r-full cursor-ew-resize opacity-0 group-hover/bar:opacity-100\" style={{ background: \"var(--color-ink)\" }} />\n                    </>\n                  )}\n                </div>\n              );\n            }\n            return (\n              <div key={r.id} title={tip} className=\"absolute rounded-full -translate-x-1/2 cursor-default\"\n                style={{ left: `${g.x1}%`, top: g.yc - 4.5, width: 9, height: 9, background: c, boxShadow: crit ? \"0 0 0 2px var(--color-acc)\" : \"0 0 0 2px var(--color-card)\" }} />\n            );\n          })}\n        </div>\n        <p className=\"text-[var(--color-mut2)] text-xs mt-2\" style={{ paddingLeft: LABEL_W, position: \"absolute\", top: totalH - 4 }}>\n          {hasRange ? `Бар = период (${rangeF ? rangeF.name : `${startF?.name}–${endF?.name}`})` : \"Точка = дата\"}. Наведи для названия.\n        </p>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/dataviews/TimelineView.tsx"
    },
    {
      "path": "registry/eburet/data-views/ViewBar.tsx",
      "content": "import {\n  Table, Columns3, Calendar, LayoutGrid, GanttChart, List as ListIcon,\n  Search, Filter, ArrowUpDown, Group, SlidersHorizontal, Palette, Rows3,\n  Plus, X, ChevronDown,\n} from \"lucide-react\";\nimport type { Field, FilterRule, ViewConfig, ViewType } from \"./types\";\nimport { FilterBuilder, FilterGroupEditor, SortBuilder, describeFilter } from \"./controls\";\nimport { countLeaves, flattenLeaves, hasNesting, rootGroup } from \"./filter\";\nimport { Popover } from \"./Popover\";\n\nconst VIEW_ICON: Record<ViewType, typeof Table> = {\n  table: Table, board: Columns3, calendar: Calendar, gallery: LayoutGrid, timeline: GanttChart, list: ListIcon,\n};\nconst VIEW_LABEL: Record<ViewType, string> = {\n  table: \"Таблица\", board: \"Доска\", calendar: \"Календарь\", gallery: \"Галерея\", timeline: \"Таймлайн\", list: \"Список\",\n};\n\nfunction barBtnCls(active?: boolean) {\n  return `inline-flex items-center gap-1.5 px-2 py-1 rounded-[6px] text-[13px] border transition-colors ${\n    active ? \"border-[var(--color-acc)] text-[var(--color-acc)]\" : \"border-[var(--color-line-2)] text-[var(--color-mut)]\"\n  } hover:bg-[var(--hover)] hover:text-[var(--color-ink)]`;\n}\n\nexport default function ViewBar({\n  fields, config, setConfig, views, activeIdx, setActive, addView,\n}: {\n  fields: Field[];\n  config: ViewConfig;\n  setConfig: (c: ViewConfig) => void;\n  views: ViewConfig[];\n  activeIdx: number;\n  setActive: (i: number) => void;\n  addView: () => void;\n}) {\n  const up = (p: Partial<ViewConfig>) => setConfig({ ...config, ...p });\n  const VIcon = VIEW_ICON[config.type];\n\n  return (\n    <div className=\"dv flex flex-col gap-2 mb-3\">\n      {/* табы сохранённых вью */}\n      <div className=\"flex items-center gap-1 border-b border-[var(--color-line)] flex-wrap\">\n        {views.map((v, i) => {\n          const I = VIEW_ICON[v.type];\n          return (\n            <button key={i} onClick={() => setActive(i)}\n              className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-[13px] -mb-px border-b-2 ${i === activeIdx ? \"text-[var(--color-ink)] border-[var(--color-acc)]\" : \"text-[var(--color-mut)] border-transparent hover:text-[var(--color-ink)]\"}`}>\n              <I size={14} strokeWidth={1.75} /> {v.name}\n            </button>\n          );\n        })}\n        <button onClick={addView} className=\"inline-flex items-center gap-1 px-2 py-1.5 text-[13px] text-[var(--color-mut)] hover:text-[var(--color-ink)]\"><Plus size={14} /> вью</button>\n      </div>\n\n      {/* контролы вью */}\n      <div className=\"flex items-center gap-2 flex-wrap\">\n        <Popover trigger={(_o, t) => (\n          <button onClick={t} className={barBtnCls(true)}><VIcon size={14} strokeWidth={1.75} /> {VIEW_LABEL[config.type]} <ChevronDown size={13} /></button>\n        )}>\n          {(close) => (\n            <div className=\"flex flex-col min-w-44\">\n              {(Object.keys(VIEW_LABEL) as ViewType[]).map((tp) => {\n                const I = VIEW_ICON[tp];\n                return (\n                  <button key={tp} onClick={() => { up({ type: tp }); close(); }}\n                    className={`inline-flex items-center gap-2 text-left px-2 py-1.5 rounded text-[13px] hover:bg-[var(--hover)] ${config.type === tp ? \"text-[var(--color-acc)]\" : \"\"}`}>\n                    <I size={15} strokeWidth={1.75} /> {VIEW_LABEL[tp]}\n                  </button>\n                );\n              })}\n            </div>\n          )}\n        </Popover>\n\n        <div className=\"relative inline-flex items-center\">\n          <Search size={14} className=\"absolute left-2 text-[var(--color-mut2)] pointer-events-none\" />\n          <input placeholder=\"Поиск…\" value={config.search} onChange={(e) => up({ search: e.target.value })}\n            className=\"form-input\" style={{ paddingLeft: 28, paddingTop: 5, paddingBottom: 5, minWidth: 180 }} />\n        </div>\n\n        {/* фильтр + чипы активных условий (дерево AND/OR) */}\n        {(() => {\n          const root = rootGroup(config);\n          const n = countLeaves(root);\n          const nested = hasNesting(root);\n          const setRoot = (children: typeof root.children) => up({ filterTree: { ...root, children }, filters: flattenLeaves({ ...root, children }), conjunction: root.conjunction });\n          const chip = \"inline-flex items-center gap-1 px-2 py-1 rounded-[6px] text-[12px] cursor-pointer\";\n          const accent = { background: \"color-mix(in srgb, var(--color-acc) 14%, transparent)\", color: \"var(--color-acc)\" };\n          return (\n            <Popover trigger={(_o, t) => (\n              <span className=\"inline-flex items-center gap-1 flex-wrap\">\n                <button onClick={t} className={barBtnCls(n > 0)}><Filter size={14} /> Фильтр</button>\n                {n > 0 && nested && (\n                  <span onClick={t} className={chip} style={accent}>\n                    {n} {n === 1 ? \"условие\" : \"условий\"} ({root.conjunction === \"and\" ? \"все\" : \"любое\"} + группы)\n                    <button onClick={(e) => { e.stopPropagation(); up({ filterTree: undefined, filters: [], conjunction: \"and\" }); }} className=\"hover:text-[var(--color-ink)]\"><X size={12} /></button>\n                  </span>\n                )}\n                {n > 0 && !nested && root.children.map((c, i) => {\n                  const d = describeFilter(fields, c as FilterRule);\n                  return (\n                    <span key={i} onClick={t} className={chip} style={accent}>\n                      <span className=\"text-[var(--color-ink)]\">{d.name}</span> {d.op} {d.value && <b className=\"font-medium\">{d.value}</b>}\n                      <button onClick={(e) => { e.stopPropagation(); setRoot(root.children.filter((_, j) => j !== i)); }} className=\"hover:text-[var(--color-ink)]\"><X size={12} /></button>\n                    </span>\n                  );\n                })}\n              </span>\n            )}>\n              {() => <FilterBuilder fields={fields} config={config} up={up} />}\n            </Popover>\n          );\n        })()}\n\n        {/* сортировка + чипы */}\n        <Popover trigger={(_o, t) => (\n          <span className=\"inline-flex items-center gap-1 flex-wrap\">\n            <button onClick={t} className={barBtnCls(config.sorting.length > 0)}><ArrowUpDown size={14} /> Сортировка</button>\n            {config.sorting.map((s, i) => {\n              const name = fields.find((f) => f.id === s.id)?.name ?? s.id;\n              return (\n                <span key={i} onClick={t}\n                  className=\"inline-flex items-center gap-1 px-2 py-1 rounded-[6px] text-[12px] cursor-pointer text-[var(--color-mut)]\"\n                  style={{ background: \"var(--color-surface2)\" }}>\n                  {s.desc ? \"↓\" : \"↑\"} <span className=\"text-[var(--color-ink)]\">{name}</span>\n                  <button onClick={(e) => { e.stopPropagation(); up({ sorting: config.sorting.filter((_, j) => j !== i) }); }}\n                    className=\"hover:text-[var(--color-ink)]\"><X size={12} /></button>\n                </span>\n              );\n            })}\n          </span>\n        )}>\n          {() => <SortBuilder fields={fields} config={config} up={up} />}\n        </Popover>\n\n        <Popover trigger={(_o, t) => (\n          <button onClick={t} className={barBtnCls(!!config.grouping)}><Group size={14} /> Группировка</button>\n        )}>\n          {(close) => (\n            <div className=\"flex flex-col min-w-44\">\n              <button onClick={() => { up({ grouping: null }); close(); }} className={`text-left px-2 py-1.5 rounded text-[13px] hover:bg-[var(--hover)] ${!config.grouping ? \"text-[var(--color-acc)]\" : \"\"}`}>без группировки</button>\n              {fields.map((f) => (\n                <button key={f.id} onClick={() => { up({ grouping: f.id }); close(); }}\n                  className={`text-left px-2 py-1.5 rounded text-[13px] hover:bg-[var(--hover)] ${config.grouping === f.id ? \"text-[var(--color-acc)]\" : \"\"}`}>{f.name}</button>\n              ))}\n            </div>\n          )}\n        </Popover>\n\n        <Popover trigger={(_o, t) => (\n          <button onClick={t} className={barBtnCls(config.hidden.length > 0)}><SlidersHorizontal size={14} /> Поля{config.hidden.length ? ` · ${config.hidden.length}` : \"\"}</button>\n        )}>\n          {() => (\n            <div className=\"flex flex-col gap-1 min-w-44\">\n              {fields.map((f) => (\n                <label key={f.id} className=\"flex items-center gap-2 text-[13px] px-1 py-0.5 rounded hover:bg-[var(--hover)] cursor-pointer\">\n                  <input type=\"checkbox\" checked={!config.hidden.includes(f.id)} style={{ accentColor: \"var(--color-focus)\" }}\n                    onChange={(e) => up({ hidden: e.target.checked ? config.hidden.filter((hh) => hh !== f.id) : [...config.hidden, f.id] })} />\n                  {f.name}\n                </label>\n              ))}\n            </div>\n          )}\n        </Popover>\n\n        <Popover trigger={(_o, t) => (\n          <button onClick={t} className={barBtnCls(!!config.colorBy || !!config.colorRules?.length)}><Palette size={14} /> Цвет</button>\n        )}>\n          {() => {\n            const rules = config.colorRules ?? [];\n            const setRules = (r: typeof rules) => up({ colorRules: r });\n            return (\n              <div className=\"flex flex-col gap-3 min-w-64 max-w-[440px]\">\n                <div className=\"flex flex-col\">\n                  <div className=\"text-[11px] uppercase tracking-wide text-[var(--color-mut2)] px-1 mb-1\">По полю</div>\n                  <button onClick={() => up({ colorBy: null })} className={`text-left px-2 py-1.5 rounded text-[13px] hover:bg-[var(--hover)] ${!config.colorBy ? \"text-[var(--color-acc)]\" : \"\"}`}>— без раскраски</button>\n                  {fields.filter((f) => f.type === \"select\" || f.type === \"status\").map((f) => (\n                    <button key={f.id} onClick={() => up({ colorBy: f.id })}\n                      className={`text-left px-2 py-1.5 rounded text-[13px] hover:bg-[var(--hover)] ${config.colorBy === f.id ? \"text-[var(--color-acc)]\" : \"\"}`}>{f.name}</button>\n                  ))}\n                </div>\n                <div className=\"flex flex-col gap-2\">\n                  <div className=\"text-[11px] uppercase tracking-wide text-[var(--color-mut2)] px-1\">По условию</div>\n                  {rules.map((rule, i) => (\n                    <div key={i} className=\"rounded-md border border-[var(--color-line-2)] p-2 flex flex-col gap-2\">\n                      <div className=\"flex items-center gap-2\">\n                        <input type=\"color\" value={rule.color} onChange={(e) => setRules(rules.map((r, j) => (j === i ? { ...r, color: e.target.value } : r)))}\n                          style={{ width: 26, height: 22, padding: 0, border: \"none\", background: \"none\" }} />\n                        <span className=\"inline-block rounded-[3px] px-2 py-0.5 text-[12px]\" style={{ background: `color-mix(in srgb, ${rule.color} 18%, transparent)`, color: rule.color }}>пример строки</span>\n                        <button onClick={() => setRules(rules.filter((_, j) => j !== i))} className=\"ml-auto text-[var(--color-mut2)] hover:text-[var(--color-ink)]\"><X size={14} /></button>\n                      </div>\n                      <FilterGroupEditor fields={fields} group={rule.filter} onChange={(g) => setRules(rules.map((r, j) => (j === i ? { ...r, filter: g } : r)))} />\n                    </div>\n                  ))}\n                  <button onClick={() => setRules([...rules, { filter: { conjunction: \"and\", children: [] }, color: \"#FFD23F\" }])}\n                    className=\"inline-flex items-center gap-1 text-sm text-[var(--color-acc)]\"><Plus size={13} /> Правило</button>\n                </div>\n              </div>\n            );\n          }}\n        </Popover>\n\n        <Popover align=\"right\" trigger={(_o, t) => (\n          <button onClick={t} className={barBtnCls(!!config.wrap)} title=\"Высота строк\"><Rows3 size={14} /></button>\n        )}>\n          {() => (\n            <div className=\"flex flex-col min-w-40\">\n              {([[\"s\", \"компактно\"], [\"m\", \"средне\"], [\"l\", \"просторно\"]] as const).map(([v, l]) => (\n                <button key={v} onClick={() => up({ rowHeight: v })}\n                  className={`text-left px-2 py-1.5 rounded text-[13px] hover:bg-[var(--hover)] ${config.rowHeight === v ? \"text-[var(--color-acc)]\" : \"\"}`}>{l}</button>\n              ))}\n              <label className=\"flex items-center gap-2 text-[13px] px-2 py-1.5 mt-1 border-t border-[var(--color-line)] cursor-pointer\">\n                <input type=\"checkbox\" checked={!!config.wrap} onChange={(e) => up({ wrap: e.target.checked })} style={{ accentColor: \"var(--color-focus)\" }} /> перенос текста\n              </label>\n            </div>\n          )}\n        </Popover>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/dataviews/ViewBar.tsx"
    },
    {
      "path": "registry/eburet/data-views/cells.tsx",
      "content": "import { useState } from \"react\";\nimport {\n  Type, AlignLeft, Hash, List, CircleDot, Calendar, CalendarRange, Tags, CheckSquare, Link, Link2, User,\n  Star, Paperclip, FunctionSquare, Sigma, Check, X,\n} from \"lucide-react\";\nimport type { Attachment, Field, FieldType } from \"./types\";\nimport { relationLabels } from \"./filter\";\nimport { MenuItem, Popover } from \"./Popover\";\n\n/* ── иконки типов полей ───────────────────────────────────────────────── */\nconst TYPE_ICON: Record<FieldType, typeof Type> = {\n  text: Type, longtext: AlignLeft, number: Hash, select: List, status: CircleDot, date: Calendar, daterange: CalendarRange,\n  multiselect: Tags, checkbox: CheckSquare, url: Link, person: User, rating: Star, attachment: Paperclip,\n  formula: FunctionSquare, relation: Link2, rollup: Sigma,\n};\nexport function TypeIcon({ type, className, size = 14 }: { type: FieldType; className?: string; size?: number }) {\n  const I = TYPE_ICON[type] ?? Type;\n  return <I size={size} className={className} strokeWidth={1.75} />;\n}\n\n/* ── форматирование чисел (TZ §4) ─────────────────────────────────────── */\nexport function formatNumber(value: any, field: Field): string {\n  if (value == null || value === \"\") return \"\";\n  const n = Number(value);\n  if (Number.isNaN(n)) return String(value);\n  const grp = (x: number, min = 0, max = min) =>\n    x.toLocaleString(\"ru-RU\", { minimumFractionDigits: min, maximumFractionDigits: max });\n  const p = field.precision;\n  switch (field.format) {\n    case \"int\": return grp(Math.round(n));\n    case \"currency\": { const d = p ?? 0; return `${grp(n, d, d)} ${field.currency ?? \"₽\"}`; }\n    case \"percent\": { const d = p ?? 0; return `${grp(n, d, d)}%`; }\n    case \"duration\": { const m = Math.round(n); return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, \"0\")}`; }\n    case \"float\": { const d = p ?? 1; return grp(n, d, d); }\n    default: return grp(n, 0, p ?? 2);\n  }\n}\n\nconst Dash = () => <span className=\"text-[var(--color-mut2)]\">—</span>;\n\n/* ── цветной тег select/status ────────────────────────────────────────── */\nexport function Tag({ label, color }: { label: string; color?: string }) {\n  if (!label) return <Dash />;\n  const c = color || \"var(--color-mut)\";\n  return (\n    <span className=\"inline-block rounded-[3px] px-2 py-0.5 text-[13px] leading-5 whitespace-nowrap\"\n      style={{ background: `color-mix(in srgb, ${c} 16%, transparent)`, color: c, boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${c} 28%, transparent)` }}>\n      {label}\n    </span>\n  );\n}\n\n/* ── person: аватар-инициалы + имя ────────────────────────────────────── */\nexport function PersonTag({ label, color }: { label: string; color?: string }) {\n  if (!label) return <Dash />;\n  const c = color || \"var(--color-focus)\";\n  const initials = label.split(/\\s+/).map((w) => w[0]).slice(0, 2).join(\"\").toUpperCase();\n  return (\n    <span className=\"inline-flex items-center gap-1.5 text-[13px] whitespace-nowrap\">\n      <span className=\"inline-flex items-center justify-center rounded-full text-[10px] font-medium\"\n        style={{ width: 18, height: 18, background: `color-mix(in srgb, ${c} 24%, transparent)`, color: c }}>\n        {initials}\n      </span>\n      {label}\n    </span>\n  );\n}\n\nfunction fmtDate(v?: string) {\n  if (!v) return \"\";\n  const [y, m, d] = v.split(\"T\")[0].split(\"-\");\n  return d && m && y ? `${d}.${m}.${y}` : v;\n}\n\nexport function fmtRange(v: any): string {\n  if (!v || typeof v !== \"object\") return \"\";\n  const s = v.start ? fmtDate(String(v.start)) : \"\";\n  const e = v.end ? fmtDate(String(v.end)) : \"\";\n  return e ? `${s} – ${e}` : s;\n}\n\nexport function RatingStars({ value, max = 5, onChange }: { value: any; max?: number; onChange?: (n: number) => void }) {\n  const v = Number(value) || 0;\n  return (\n    <span className=\"inline-flex items-center gap-0.5\">\n      {Array.from({ length: max }).map((_, i) => {\n        const n = i + 1, on = n <= v;\n        return <Star key={i} size={14} strokeWidth={1.5}\n          className={on ? \"text-[var(--color-acc)]\" : \"text-[var(--color-mut2)]\"}\n          style={{ fill: on ? \"var(--color-acc)\" : \"none\", cursor: onChange ? \"pointer\" : \"default\" }}\n          onClick={onChange ? (e) => { e.stopPropagation(); onChange(n === v ? n - 1 : n); } : undefined} />;\n      })}\n    </span>\n  );\n}\n\nexport function AttachmentChips({ list }: { list: Attachment[] }) {\n  if (!list?.length) return <Dash />;\n  return <>{list.map((a, i) => (\n    <a key={i} href={a.url} target=\"_blank\" rel=\"noreferrer\" onClick={(e) => e.stopPropagation()}\n      className=\"inline-flex items-center gap-1 text-[13px] text-[var(--color-focus)] hover:underline whitespace-nowrap\"><Paperclip size={12} />{a.name}</a>\n  ))}</>;\n}\n\nexport function RelationChips({ labels }: { labels: string[] }) {\n  if (!labels.length) return <Dash />;\n  return <>{labels.map((l, i) => (\n    <span key={i} className=\"inline-flex items-center rounded-[3px] px-1.5 py-0.5 text-[13px] whitespace-nowrap\"\n      style={{ background: \"var(--hover)\", color: \"var(--color-focus)\", boxShadow: \"inset 0 0 0 1px color-mix(in srgb, var(--color-focus) 28%, transparent)\" }}>{l}</span>\n  ))}</>;\n}\n\nfunction DateCell({ value, onChange }: { value: any; onChange: (v: any) => void }) {\n  const isoVal = value ? String(value).split(\"T\")[0] : \"\";\n  return (\n    <Popover\n      trigger={(_o, toggle) => (\n        <button type=\"button\" onClick={toggle} className=\"cell-input tabular-nums\">\n          {isoVal ? fmtDate(isoVal) : <Dash />}\n        </button>\n      )}\n    >\n      {(close) => (\n        <div className=\"p-1\">\n          <input type=\"date\" value={isoVal} autoFocus className=\"form-input\"\n            onChange={(e) => onChange(e.target.value)} onBlur={close}\n            style={{ colorScheme: \"dark\" } as any} />\n        </div>\n      )}\n    </Popover>\n  );\n}\n\nfunction NumberCell({ field, value, onChange }: { field: Field; value: any; onChange: (v: any) => void }) {\n  const [editing, setEditing] = useState(false);\n  if (editing) {\n    return (\n      <input type=\"number\" step=\"any\" autoFocus defaultValue={value ?? \"\"}\n        onFocus={(e) => e.target.select()}\n        onBlur={(e) => { const v = e.target.value; onChange(v === \"\" ? null : Number(v)); setEditing(false); }}\n        onKeyDown={(e) => { if (e.key === \"Enter\" || e.key === \"Escape\") (e.target as HTMLInputElement).blur(); }}\n        className=\"cell-input cell-input--num\" style={{ minWidth: \"5ch\", maxWidth: \"20ch\" }} />\n    );\n  }\n  const display = value == null || value === \"\" ? null : formatNumber(value, field);\n  return (\n    <button type=\"button\" onClick={() => setEditing(true)}\n      onKeyDown={(e) => { if (e.key === \"Enter\") { e.preventDefault(); setEditing(true); } }}\n      className=\"cell-input cell-input--num w-full\">\n      {display ?? <Dash />}\n    </button>\n  );\n}\n\nfunction TagSelect({ field, value, onChange }: { field: Field; value: any; onChange: (v: any) => void }) {\n  const opt = field.options?.find((o) => o.value === value);\n  const isPerson = field.type === \"person\";\n  return (\n    <Popover\n      trigger={(_open, toggle) => (\n        <button type=\"button\" onClick={toggle} className=\"cell-input flex items-center gap-1 max-w-full text-left\">\n          {value\n            ? (isPerson ? <PersonTag label={String(value)} color={opt?.color} /> : <Tag label={String(value)} color={opt?.color} />)\n            : <Dash />}\n        </button>\n      )}\n    >\n      {(close) => (\n        <>\n          <MenuItem onClick={() => { onChange(\"\"); close(); }}><X size={13} className=\"text-[var(--color-mut2)]\" /><span className=\"text-[var(--color-mut2)]\">очистить</span></MenuItem>\n          {field.options?.map((o) => (\n            <MenuItem key={o.value} onClick={() => { onChange(o.value); close(); }}>\n              {isPerson ? <PersonTag label={o.value} color={o.color} /> : <Tag label={o.value} color={o.color} />}\n            </MenuItem>\n          ))}\n        </>\n      )}\n    </Popover>\n  );\n}\n\nfunction MultiTagSelect({ field, value, onChange }: { field: Field; value: string[]; onChange: (v: any) => void }) {\n  const toggle = (v: string) => onChange(value.includes(v) ? value.filter((x) => x !== v) : [...value, v]);\n  return (\n    <Popover\n      trigger={(_o, t) => (\n        <button type=\"button\" onClick={t} className=\"cell-input flex flex-wrap gap-1 items-center max-w-full text-left\">\n          {value.length ? value.map((v) => <Tag key={v} label={v} color={field.options?.find((o) => o.value === v)?.color} />) : <Dash />}\n        </button>\n      )}>\n      {() => (\n        <>\n          {field.options?.map((o) => (\n            <MenuItem key={o.value} onClick={() => toggle(o.value)}>\n              <span className=\"w-3.5 inline-flex justify-center\">{value.includes(o.value) && <Check size={13} className=\"text-[var(--color-focus)]\" />}</span>\n              <Tag label={o.value} color={o.color} />\n            </MenuItem>\n          ))}\n        </>\n      )}\n    </Popover>\n  );\n}\n\nfunction DateRangeCell({ value, onChange }: { value: any; onChange: (v: any) => void }) {\n  const start = value?.start ? String(value.start).split(\"T\")[0] : \"\";\n  const end = value?.end ? String(value.end).split(\"T\")[0] : \"\";\n  return (\n    <Popover trigger={(_o, t) => (\n      <button type=\"button\" onClick={t} className=\"cell-input tabular-nums whitespace-nowrap\">{fmtRange(value) || <Dash />}</button>\n    )}>\n      {() => (\n        <div className=\"p-2 flex flex-col gap-2\">\n          <label className=\"flex items-center justify-between gap-3 text-[13px] text-[var(--color-mut)]\">начало\n            <input type=\"date\" className=\"form-input\" value={start} style={{ colorScheme: \"dark\" } as any}\n              onChange={(e) => onChange({ start: e.target.value, end: end || undefined })} /></label>\n          <label className=\"flex items-center justify-between gap-3 text-[13px] text-[var(--color-mut)]\">конец\n            <input type=\"date\" className=\"form-input\" value={end} style={{ colorScheme: \"dark\" } as any}\n              onChange={(e) => onChange({ start, end: e.target.value || undefined })} /></label>\n        </div>\n      )}\n    </Popover>\n  );\n}\n\nfunction RelationCell({ field, value, onChange }: { field: Field; value: any; onChange: (v: any) => void }) {\n  const multi = !!field.relation?.multi;\n  const toggle = (id: string) => {\n    if (multi) { const arr: string[] = Array.isArray(value) ? value : []; onChange(arr.map(String).includes(id) ? arr.filter((x) => String(x) !== id) : [...arr, id]); }\n    else onChange(id);\n  };\n  return (\n    <Popover trigger={(_o, t) => (\n      <button type=\"button\" onClick={t} className=\"cell-input flex flex-wrap gap-1 items-center max-w-full text-left\">\n        <RelationChips labels={relationLabels(field, value)} />\n      </button>\n    )}>\n      {(close) => (\n        <>\n          {!multi && <MenuItem onClick={() => { onChange(\"\"); close(); }}><X size={13} className=\"text-[var(--color-mut2)]\" /><span className=\"text-[var(--color-mut2)]\">очистить</span></MenuItem>}\n          {field.relation?.rows.map((r) => {\n            const id = String(r.id), lbl = String(r[field.relation!.labelField] ?? id);\n            const checked = multi ? (Array.isArray(value) && value.map(String).includes(id)) : String(value) === id;\n            return (\n              <MenuItem key={id} onClick={() => { toggle(id); if (!multi) close(); }}>\n                <span className=\"w-3.5 inline-flex justify-center\">{checked && <Check size={13} className=\"text-[var(--color-focus)]\" />}</span>{lbl}\n              </MenuItem>\n            );\n          })}\n        </>\n      )}\n    </Popover>\n  );\n}\n\nfunction LongTextCell({ value, onChange }: { value: any; onChange: (v: any) => void }) {\n  return (\n    <textarea value={value ?? \"\"} onChange={(e) => onChange(e.target.value)} rows={1} placeholder=\"—\"\n      className=\"cell-input resize-none align-top\" style={{ minWidth: \"12ch\", maxWidth: \"40ch\", fieldSizing: \"content\" } as any} />\n  );\n}\n\nfunction AddUrl({ onAdd }: { onAdd: (url: string) => void }) {\n  const [u, setU] = useState(\"\");\n  return (\n    <form onSubmit={(e) => { e.preventDefault(); if (u.trim()) { onAdd(u.trim()); setU(\"\"); } }} className=\"flex gap-1\">\n      <input className=\"form-input flex-1\" placeholder=\"вставь ссылку…\" value={u} onChange={(e) => setU(e.target.value)} />\n      <button className=\"px-2 rounded border border-[var(--color-line-2)] text-[13px] text-[var(--color-mut)]\">+</button>\n    </form>\n  );\n}\n\nfunction AttachmentCell({ value, onChange }: { value: any; onChange: (v: any) => void }) {\n  const list: Attachment[] = Array.isArray(value) ? value : [];\n  return (\n    <Popover trigger={(_o, t) => (\n      <button type=\"button\" onClick={t} className=\"cell-input flex flex-wrap gap-2 items-center max-w-full text-left\">\n        {list.length ? <AttachmentChips list={list} /> : <Dash />}\n      </button>\n    )}>\n      {() => (\n        <div className=\"p-2 flex flex-col gap-2 min-w-56\">\n          {list.map((a, i) => (\n            <div key={i} className=\"flex items-center gap-2 text-[13px]\">\n              <a href={a.url} target=\"_blank\" rel=\"noreferrer\" className=\"text-[var(--color-focus)] truncate flex-1\">{a.name}</a>\n              <button onClick={() => onChange(list.filter((_, j) => j !== i))} className=\"text-[var(--color-mut2)] hover:text-[var(--color-ink)]\"><X size={13} /></button>\n            </div>\n          ))}\n          <AddUrl onAdd={(url) => onChange([...list, { name: url.split(\"/\").pop() || url, url }])} />\n        </div>\n      )}\n    </Popover>\n  );\n}\n\nexport function CellEditor({\n  field, value, onChange,\n}: { field: Field; value: any; onChange: (v: any) => void }) {\n  if (field.type === \"longtext\") return <LongTextCell value={value} onChange={onChange} />;\n  if (field.type === \"rating\") return <RatingStars value={value} max={field.max} onChange={onChange} />;\n  if (field.type === \"attachment\") return <AttachmentCell value={value} onChange={onChange} />;\n  if (field.type === \"daterange\") return <DateRangeCell value={value} onChange={onChange} />;\n  if (field.type === \"relation\") return <RelationCell field={field} value={value} onChange={onChange} />;\n  if (field.type === \"formula\" || field.type === \"rollup\") return <span className=\"text-[var(--color-mut)]\">{value ?? \"—\"}</span>;\n  if (field.type === \"select\" || field.type === \"status\" || field.type === \"person\") {\n    return <TagSelect field={field} value={value} onChange={onChange} />;\n  }\n  if (field.type === \"multiselect\") {\n    return <MultiTagSelect field={field} value={Array.isArray(value) ? value : []} onChange={onChange} />;\n  }\n  if (field.type === \"checkbox\") {\n    return <input type=\"checkbox\" checked={!!value} onChange={(e) => onChange(e.target.checked)} style={{ width: 15, height: 15, accentColor: \"var(--color-focus)\" }} />;\n  }\n  if (field.type === \"url\") {\n    return (\n      <span className=\"flex items-center gap-1\">\n        <input type=\"url\" value={value ?? \"\"} onChange={(e) => onChange(e.target.value)} placeholder=\"—\"\n          className=\"cell-input\" style={{ minWidth: \"8ch\", maxWidth: \"26ch\" }} />\n        {value && <a href={value} target=\"_blank\" rel=\"noreferrer\" className=\"text-[var(--color-acc)] shrink-0\" onClick={(e) => e.stopPropagation()}><Link size={13} /></a>}\n      </span>\n    );\n  }\n  if (field.type === \"date\") {\n    return <DateCell value={value} onChange={onChange} />;\n  }\n  if (field.type === \"number\") {\n    return <NumberCell field={field} value={value} onChange={onChange} />;\n  }\n  return (\n    <input type=\"text\" value={value ?? \"\"} onChange={(e) => onChange(e.target.value)} placeholder=\"—\"\n      className={`cell-input ${field.primary ? \"font-medium\" : \"\"}`}\n      style={{ minWidth: \"8ch\", maxWidth: \"32ch\" }} />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/dataviews/cells.tsx"
    },
    {
      "path": "registry/eburet/data-views/controls.tsx",
      "content": "import { Plus, X } from \"lucide-react\";\nimport type { Field, FieldType, FilterGroup, FilterNode, FilterRule, ViewConfig } from \"./types\";\nimport { flattenLeaves, isGroup } from \"./filter\";\n\nconst OPS: Record<string, [string, string][]> = {\n  text: [[\"contains\", \"содержит\"], [\"not_contains\", \"не содержит\"], [\"is\", \"равно\"], [\"is_not\", \"не равно\"], [\"starts\", \"начинается\"], [\"ends\", \"заканчивается\"], [\"empty\", \"пусто\"], [\"not_empty\", \"не пусто\"]],\n  number: [[\"is\", \"=\"], [\"is_not\", \"≠\"], [\"gt\", \">\"], [\"lt\", \"<\"], [\"gte\", \"≥\"], [\"lte\", \"≤\"], [\"empty\", \"пусто\"], [\"not_empty\", \"не пусто\"]],\n  select: [[\"is\", \"равно\"], [\"is_not\", \"не равно\"], [\"empty\", \"пусто\"], [\"not_empty\", \"не пусто\"]],\n  status: [[\"is\", \"равно\"], [\"is_not\", \"не равно\"], [\"empty\", \"пусто\"], [\"not_empty\", \"не пусто\"]],\n  person: [[\"is\", \"равно\"], [\"is_not\", \"не равно\"], [\"empty\", \"пусто\"], [\"not_empty\", \"не пусто\"]],\n  date: [[\"is\", \"равно\"], [\"before\", \"до\"], [\"after\", \"после\"], [\"on_before\", \"до вкл.\"], [\"on_after\", \"после вкл.\"], [\"empty\", \"пусто\"], [\"not_empty\", \"не пусто\"]],\n  daterange: [[\"is\", \"равно\"], [\"before\", \"до\"], [\"after\", \"после\"], [\"on_before\", \"до вкл.\"], [\"on_after\", \"после вкл.\"], [\"empty\", \"пусто\"], [\"not_empty\", \"не пусто\"]],\n  relation: [[\"is\", \"равно\"], [\"is_not\", \"не равно\"], [\"empty\", \"пусто\"], [\"not_empty\", \"не пусто\"]],\n  checkbox: [[\"checked\", \"отмечено\"], [\"unchecked\", \"не отмечено\"]],\n  multiselect: [[\"contains\", \"содержит\"], [\"not_contains\", \"не содержит\"], [\"empty\", \"пусто\"], [\"not_empty\", \"не пусто\"]],\n  rating: [[\"is\", \"=\"], [\"gte\", \"≥\"], [\"lte\", \"≤\"], [\"empty\", \"пусто\"], [\"not_empty\", \"не пусто\"]],\n  attachment: [[\"not_empty\", \"есть\"], [\"empty\", \"нет\"]],\n};\nconst opsFor = (t: FieldType) => OPS[t] ?? OPS.text;\nconst defaultOp = (f: Field) => opsFor(f.type)[0][0];\nconst noValue = (op: string) => [\"empty\", \"not_empty\", \"checked\", \"unchecked\"].includes(op);\n\nexport function opLabel(type: FieldType | undefined, op: string): string {\n  return (OPS[type ?? \"text\"] ?? OPS.text).find(([v]) => v === op)?.[1] ?? op;\n}\n\n/** Описание фильтра для чипа в ViewBar (Notion-стиль). */\nexport function describeFilter(fields: Field[], f: FilterRule) {\n  const field = fields.find((x) => x.id === f.field);\n  return { name: field?.name ?? f.field, op: opLabel(field?.type, f.op), value: noValue(f.op) ? \"\" : f.value };\n}\n\nconst sel = \"bg-[var(--color-surface2)] border border-[var(--color-line-2)] rounded px-1.5 py-1 text-[13px] outline-none focus:border-[var(--color-focus)] focus:ring-1 focus:ring-[var(--color-focus)]\";\n\nfunction ValueCtl({ field, value, onChange }: { field?: Field; value: string; onChange: (v: string) => void }) {\n  if (!field) return null;\n  if (field.type === \"select\" || field.type === \"status\" || field.type === \"multiselect\" || field.type === \"person\") {\n    return (\n      <select className={`${sel} max-w-40`} value={value} onChange={(e) => onChange(e.target.value)}>\n        <option value=\"\">—</option>\n        {field.options?.map((o) => <option key={o.value} value={o.value}>{o.value}</option>)}\n      </select>\n    );\n  }\n  if (field.type === \"relation\") {\n    return (\n      <select className={`${sel} max-w-40`} value={value} onChange={(e) => onChange(e.target.value)}>\n        <option value=\"\">—</option>\n        {field.relation?.rows.map((r) => <option key={String(r.id)} value={String(r.id)}>{String(r[field.relation!.labelField] ?? r.id)}</option>)}\n      </select>\n    );\n  }\n  if (field.type === \"date\" || field.type === \"daterange\") return <input type=\"date\" className={sel} value={value} onChange={(e) => onChange(e.target.value)} style={{ colorScheme: \"dark\" } as any} />;\n  if (field.type === \"number\" || field.type === \"rating\") return <input type=\"number\" className={`${sel} w-20`} value={value} onChange={(e) => onChange(e.target.value)} />;\n  return <input className={`${sel} w-28`} value={value} placeholder=\"значение\" onChange={(e) => onChange(e.target.value)} />;\n}\n\nconst newRule = (fields: Field[]): FilterRule => ({ field: fields[0].id, op: defaultOp(fields[0]), value: \"\" });\n\nfunction RuleRow({ fields, rule, onChange, onRemove }: { fields: Field[]; rule: FilterRule; onChange: (r: FilterRule) => void; onRemove: () => void }) {\n  const byId = (id: string) => fields.find((f) => f.id === id);\n  const field = byId(rule.field);\n  return (\n    <div className=\"flex gap-1 items-center\">\n      <select className={sel} value={rule.field}\n        onChange={(e) => { const nf = byId(e.target.value)!; onChange({ field: e.target.value, op: defaultOp(nf), value: \"\" }); }}>\n        {fields.map((fl) => <option key={fl.id} value={fl.id}>{fl.name}</option>)}\n      </select>\n      <select className={sel} value={rule.op}\n        onChange={(e) => onChange({ ...rule, op: e.target.value, value: noValue(e.target.value) ? \"\" : rule.value })}>\n        {opsFor(field?.type ?? \"text\").map(([v, l]) => <option key={v} value={v}>{l}</option>)}\n      </select>\n      {!noValue(rule.op) && <ValueCtl field={field} value={rule.value} onChange={(v) => onChange({ ...rule, value: v })} />}\n      <button onClick={onRemove} className=\"text-[var(--color-mut2)] hover:text-[var(--color-ink)] px-1 inline-flex\"><X size={14} /></button>\n    </div>\n  );\n}\n\nfunction ConjToggle({ value, onChange }: { value: \"and\" | \"or\"; onChange: (c: \"and\" | \"or\") => void }) {\n  return (\n    <span className=\"inline-flex items-center gap-1 text-xs text-[var(--color-mut)]\">\n      <span>Где</span>\n      {([\"and\", \"or\"] as const).map((c) => (\n        <button key={c} onClick={() => onChange(c)}\n          className={`px-2 py-0.5 rounded border border-[var(--color-line-2)] ${value === c ? \"text-[var(--color-acc)] border-[var(--color-acc)]\" : \"\"}`}>\n          {c === \"and\" ? \"все\" : \"любое\"}\n        </button>\n      ))}\n      <span>из условий:</span>\n    </span>\n  );\n}\n\nfunction GroupView({ fields, group, onChange, onRemove, depth }: {\n  fields: Field[]; group: FilterGroup; onChange: (g: FilterGroup) => void; onRemove?: () => void; depth: number;\n}) {\n  const setChild = (i: number, child: FilterNode) => onChange({ ...group, children: group.children.map((c, j) => (j === i ? child : c)) });\n  const removeChild = (i: number) => onChange({ ...group, children: group.children.filter((_, j) => j !== i) });\n  return (\n    <div className={depth > 0 ? \"border-l-2 border-[var(--color-line-2)] pl-2.5 py-1\" : \"\"}>\n      <div className=\"flex items-center justify-between gap-2 mb-1.5\">\n        {group.children.length > 1\n          ? <ConjToggle value={group.conjunction} onChange={(c) => onChange({ ...group, conjunction: c })} />\n          : <span className=\"text-xs text-[var(--color-mut2)]\">условие:</span>}\n        {onRemove && <button onClick={onRemove} className=\"text-xs text-[var(--color-mut2)] hover:text-[var(--color-ink)] shrink-0\">удалить группу</button>}\n      </div>\n      <div className=\"flex flex-col gap-1.5\">\n        {group.children.map((c, i) => isGroup(c)\n          ? <GroupView key={i} fields={fields} group={c} depth={depth + 1} onChange={(g) => setChild(i, g)} onRemove={() => removeChild(i)} />\n          : <RuleRow key={i} fields={fields} rule={c} onChange={(r) => setChild(i, r)} onRemove={() => removeChild(i)} />)}\n      </div>\n      <div className=\"flex gap-3 mt-2\">\n        <button onClick={() => onChange({ ...group, children: [...group.children, newRule(fields)] })}\n          className=\"inline-flex items-center gap-1 text-sm text-[var(--color-acc)]\"><Plus size={13} /> Условие</button>\n        <button onClick={() => onChange({ ...group, children: [...group.children, { conjunction: \"and\", children: [newRule(fields)] }] })}\n          className=\"inline-flex items-center gap-1 text-sm text-[var(--color-mut)] hover:text-[var(--color-ink)]\"><Plus size={13} /> Группа</button>\n      </div>\n    </div>\n  );\n}\n\n/** Автономный редактор группы фильтров (для color-rules и т.п.). */\nexport function FilterGroupEditor({ fields, group, onChange }: { fields: Field[]; group: FilterGroup; onChange: (g: FilterGroup) => void }) {\n  return group.children.length === 0\n    ? <button onClick={() => onChange({ conjunction: \"and\", children: [newRule(fields)] })}\n        className=\"inline-flex items-center gap-1 text-sm text-[var(--color-acc)]\"><Plus size={13} /> Добавить условие</button>\n    : <GroupView fields={fields} group={group} onChange={onChange} depth={0} />;\n}\n\nexport function FilterBuilder({ fields, config, up }: { fields: Field[]; config: ViewConfig; up: (p: Partial<ViewConfig>) => void }) {\n  const root: FilterGroup = config.filterTree ?? { conjunction: config.conjunction ?? \"and\", children: config.filters };\n  const set = (r: FilterGroup) => up({ filterTree: r, filters: flattenLeaves(r), conjunction: r.conjunction });\n  return (\n    <div className=\"min-w-72 max-w-[440px]\">\n      {root.children.length === 0\n        ? <button onClick={() => set({ conjunction: \"and\", children: [newRule(fields)] })}\n            className=\"inline-flex items-center gap-1 text-sm text-[var(--color-acc)]\"><Plus size={13} /> Добавить фильтр</button>\n        : <GroupView fields={fields} group={root} onChange={set} depth={0} />}\n    </div>\n  );\n}\n\nexport function SortBuilder({ fields, config, up }: { fields: Field[]; config: ViewConfig; up: (p: Partial<ViewConfig>) => void }) {\n  const ss = config.sorting;\n  const set = (i: number, patch: any) => up({ sorting: ss.map((s, j) => (j === i ? { ...s, ...patch } : s)) });\n  return (\n    <div className=\"flex flex-col gap-2 min-w-64\">\n      {ss.map((s, i) => (\n        <div key={i} className=\"flex gap-1 items-center\">\n          <select className={`${sel} flex-1`} value={s.id} onChange={(e) => set(i, { id: e.target.value })}>\n            {fields.map((f) => <option key={f.id} value={f.id}>{f.name}</option>)}\n          </select>\n          <button className={sel} onClick={() => set(i, { desc: !s.desc })}>{s.desc ? \"↓ убыв.\" : \"↑ возр.\"}</button>\n          <button onClick={() => up({ sorting: ss.filter((_, j) => j !== i) })} className=\"text-[var(--color-mut2)] hover:text-[var(--color-ink)] px-1\">×</button>\n        </div>\n      ))}\n      <button\n        onClick={() => up({ sorting: [...ss, { id: (fields.find((f) => !ss.some((s) => s.id === f.id)) ?? fields[0]).id, desc: false }] })}\n        className=\"text-sm text-[var(--color-acc)] text-left\">+ Добавить сортировку</button>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/dataviews/controls.tsx"
    },
    {
      "path": "registry/eburet/data-views/filter.ts",
      "content": "import type { Field, FilterGroup, FilterNode, FilterRule, Row, ViewConfig } from \"./types\";\n\nconst dpart = (v: any) => String(v ?? \"\").split(\"T\")[0];\n\n/* ── дерево фильтров (вложенные AND/OR) ───────────────────────────────── */\nexport const isGroup = (n: FilterNode): n is FilterGroup => (n as FilterGroup).children !== undefined;\n\n/** Все листовые условия дерева (для зеркала config.filters и счётчика чипов). */\nexport function flattenLeaves(node: FilterNode): FilterRule[] {\n  return isGroup(node) ? node.children.flatMap(flattenLeaves) : [node];\n}\nexport const countLeaves = (g: FilterGroup) => flattenLeaves(g).length;\nexport const hasNesting = (g: FilterGroup) => g.children.some(isGroup);\n\n/** Дерево из конфига: filterTree, иначе плоский filters как одна группа. */\nexport const rootGroup = (config: ViewConfig): FilterGroup =>\n  config.filterTree ?? { conjunction: config.conjunction ?? \"and\", children: config.filters };\n\nfunction matchGroup(row: Row, g: FilterGroup): boolean {\n  const active = g.children.filter((c) => (isGroup(c) ? flattenLeaves(c).length > 0 : true));\n  if (active.length === 0) return true;\n  const test = (n: FilterNode): boolean => (isGroup(n) ? matchGroup(row, n) : matchFilter(row, n));\n  return g.conjunction === \"or\" ? active.some(test) : active.every(test);\n}\n\n/** Применить фильтры конфига к строке (дерево, иначе плоский список). */\nexport function evalFilters(row: Row, config: ViewConfig): boolean {\n  if (config.filterTree) return matchGroup(row, config.filterTree);\n  const conj = config.conjunction ?? \"and\";\n  if (config.filters.length === 0) return true;\n  return conj === \"and\" ? config.filters.every((f) => matchFilter(row, f)) : config.filters.some((f) => matchFilter(row, f));\n}\n\nexport function matchFilter(row: Row, f: FilterRule): boolean {\n  const raw = row[f.field], val = f.value;\n  // date-range фильтруется по дате начала\n  const v = raw && typeof raw === \"object\" && !Array.isArray(raw) && \"start\" in raw ? (raw as any).start : raw;\n  switch (f.op) {\n    case \"contains\": return String(v ?? \"\").toLowerCase().includes(val.toLowerCase());\n    case \"not_contains\": return !String(v ?? \"\").toLowerCase().includes(val.toLowerCase());\n    case \"is\": return String(v ?? \"\") === val;\n    case \"is_not\": return String(v ?? \"\") !== val;\n    case \"starts\": return String(v ?? \"\").toLowerCase().startsWith(val.toLowerCase());\n    case \"ends\": return String(v ?? \"\").toLowerCase().endsWith(val.toLowerCase());\n    case \"empty\": return v == null || v === \"\" || (Array.isArray(raw) && raw.length === 0);\n    case \"not_empty\": return !(v == null || v === \"\" || (Array.isArray(raw) && raw.length === 0));\n    case \"gt\": return Number(v) > Number(val);\n    case \"lt\": return Number(v) < Number(val);\n    case \"gte\": return Number(v) >= Number(val);\n    case \"lte\": return Number(v) <= Number(val);\n    case \"before\": return dpart(v) < val;\n    case \"after\": return dpart(v) > val;\n    case \"on_before\": return dpart(v) <= val;\n    case \"on_after\": return dpart(v) >= val;\n    case \"checked\": return v === true || v === \"true\" || v === 1;\n    case \"unchecked\": return !(v === true || v === \"true\" || v === 1);\n    default: return true;\n  }\n}\n\nexport function filterRows(rows: Row[], fields: Field[], config: ViewConfig): Row[] {\n  let r = rows.filter((row) => evalFilters(row, config));\n  const q = config.search.trim().toLowerCase();\n  if (q) r = r.filter((row) => fields.some((f) => String(row[f.id] ?? \"\").toLowerCase().includes(q)));\n  return r;\n}\n\nexport function fieldOpt(field: Field | undefined, value: any) {\n  return field?.options?.find((o) => o.value === value);\n}\n\n/* ── computed / relation / rollup / раскраска ─────────────────────────── */\n\n/** Значение поля для рендера: formula/rollup вычисляются, остальные — как есть. */\nexport function computeValue(field: Field, row: Row, fields: Field[]): any {\n  if (field.type === \"formula\") return field.compute ? field.compute(row) : null;\n  if (field.type === \"rollup\" && field.rollup) {\n    const rel = fields.find((f) => f.id === field.rollup!.via);\n    if (!rel?.relation) return null;\n    const idv = row[field.rollup.via];\n    const ids = Array.isArray(idv) ? idv.map(String) : idv != null && idv !== \"\" ? [String(idv)] : [];\n    const related = ids.map((id) => rel.relation!.rows.find((r) => String(r.id) === id)).filter(Boolean) as Row[];\n    if (field.rollup.fn === \"count\") return related.length;\n    const vals = related.map((r) => Number(r[field.rollup!.field]) || 0);\n    if (!vals.length) return 0;\n    switch (field.rollup.fn) {\n      case \"sum\": return vals.reduce((a, b) => a + b, 0);\n      case \"avg\": return vals.reduce((a, b) => a + b, 0) / vals.length;\n      case \"min\": return Math.min(...vals);\n      case \"max\": return Math.max(...vals);\n    }\n  }\n  return row[field.id];\n}\n\n/** Метки связанных записей (relation). */\nexport function relationLabels(field: Field, value: any): string[] {\n  if (!field.relation) return [];\n  const ids = Array.isArray(value) ? value : value != null && value !== \"\" ? [value] : [];\n  return ids.map((id) => {\n    const r = field.relation!.rows.find((x) => String(x.id) === String(id));\n    return r ? String(r[field.relation!.labelField] ?? id) : String(id);\n  });\n}\n\n/** Цвет строки: сначала color-rules по условию, затем colorBy по select/status. */\nexport function rowColor(config: ViewConfig, row: Row, fields: Field[]): string | undefined {\n  if (config.colorRules?.length) {\n    for (const r of config.colorRules) if (flattenLeaves(r.filter).length > 0 && matchGroup(row, r.filter)) return r.color;\n  }\n  if (config.colorBy) {\n    const f = fields.find((x) => x.id === config.colorBy);\n    return f?.options?.find((o) => o.value === row[config.colorBy!])?.color;\n  }\n  return undefined;\n}\n\nexport const firstFieldOfType = (fields: Field[], ...types: string[]) =>\n  fields.find((f) => types.includes(f.type));\n",
      "type": "registry:lib",
      "target": "components/dataviews/filter.ts"
    },
    {
      "path": "registry/eburet/data-views/sample.ts",
      "content": "import type { Field, Row } from \"./types\";\n\n/** Связанная таблица «проекты» — источник для relation/rollup. */\nexport const SAMPLE_PROJECTS: Row[] = [\n  { id: 1, name: \"Кухни-2026\", budget: 1_200_000 },\n  { id: 2, name: \"Шкафы\", budget: 540_000 },\n  { id: 3, name: \"Спецзаказ\", budget: 980_000 },\n];\n\nexport const SAMPLE_FIELDS: Field[] = [\n  { id: \"id\", name: \"ID\", type: \"number\", width: 80, editable: false }, // readonly — пример «нечитаемого» поля\n  { id: \"name\", name: \"name\", type: \"text\", primary: true, width: 200 },\n  { id: \"model\", name: \"model\", type: \"select\", width: 150, options: [\n    { value: \"Ебурет\" }, { value: \"Юра\" }, { value: \"Батон\" }, { value: \"Лилия\" },\n    { value: \"Нло\" }, { value: \"Мышонок\" }, { value: \"ЮраЛайт\" },\n  ] },\n  { id: \"color\", name: \"color\", type: \"select\", width: 210, options: [\n    { value: \"Белый (КПС-01)\", color: \"#cdd2db\" },\n    { value: \"Серый (КПС-82)\", color: \"#9aa0ab\" },\n    { value: \"Пшеничный (КПС-72)\", color: \"#e3c98a\" },\n    { value: \"Черешня (КПС-14)\", color: \"#c8794a\" },\n    { value: \"Пудровый\", color: \"#e8b4b8\" },\n    { value: \"Желтый (КПС-32)\", color: \"#ffd23f\" },\n  ] },\n  { id: \"print_date\", name: \"печать_дата\", type: \"date\", width: 130 },\n  { id: \"deadline\", name: \"срок\", type: \"date\", width: 130 },\n  { id: \"type\", name: \"type\", type: \"status\", width: 130, options: [\n    { value: \"в распил\", color: \"#9aa0ab\" },\n    { value: \"распилено\", color: \"#5b8def\" },\n    { value: \"съемочное\", color: \"#b18cf0\" },\n  ] },\n  { id: \"manager\", name: \"менеджер\", type: \"person\", width: 150, options: [\n    { value: \"Серёжа Ибрагимов\", color: \"#447ACB\" },\n    { value: \"Аня Петрова\", color: \"#4F9768\" },\n    { value: \"Игорь Сидоров\", color: \"#CB7B37\" },\n    { value: \"Лиза Кузнецова\", color: \"#BA4A78\" },\n  ] },\n  { id: \"shipped\", name: \"отгружено\", type: \"number\", format: \"int\", width: 110 },\n  { id: \"price\", name: \"цена\", type: \"number\", format: \"currency\", currency: \"₽\", width: 120 },\n  { id: \"progress\", name: \"готовность\", type: \"number\", format: \"percent\", width: 110 },\n  { id: \"project\", name: \"проект\", type: \"relation\", width: 150, relation: { rows: SAMPLE_PROJECTS, labelField: \"name\" } },\n  { id: \"proj_budget\", name: \"бюджет проекта\", type: \"rollup\", width: 150, format: \"currency\", currency: \"₽\", rollup: { via: \"project\", field: \"budget\", fn: \"sum\" } },\n  { id: \"margin\", name: \"маржа\", type: \"formula\", width: 120, format: \"currency\", currency: \"₽\", compute: (r) => Math.round((Number(r.price) || 0) * (Number(r.progress) || 0) / 100) },\n  { id: \"period\", name: \"период\", type: \"daterange\", width: 200 },\n  { id: \"rating\", name: \"оценка\", type: \"rating\", width: 110 },\n  { id: \"note\", name: \"заметка\", type: \"longtext\", width: 240 },\n  { id: \"files\", name: \"файлы\", type: \"attachment\", width: 170 },\n  { id: \"edited\", name: \"редактирован\", type: \"date\", width: 130 },\n  { id: \"done\", name: \"готово\", type: \"checkbox\", width: 80 },\n  { id: \"tags\", name: \"теги\", type: \"multiselect\", width: 200, options: [\n    { value: \"срочно\", color: \"#BE524B\" }, { value: \"новинка\", color: \"#4F9768\" },\n    { value: \"ревью\", color: \"#447ACB\" }, { value: \"хит\", color: \"#C19138\" },\n  ] },\n  { id: \"link\", name: \"ссылка\", type: \"url\", width: 150 },\n];\n\nconst NAMES = [\"ЕБ\", \"$ЮБ\", \"БатПШ\", \"ЛСР\", \"НСР\", \"ЕЧ\", \"$ЛайтПуд\", \"ЮЛЖ\", \"МП\", \"ЮЛПШ\", \"Гриб\", \"НЛО\", \"Мыш\"];\nconst pick = <T,>(a: T[], i: number) => a[i % a.length];\nconst rnd = (seed: number) => { const x = Math.sin(seed) * 10000; return x - Math.floor(x); };\n\nfunction dateStr(seed: number): string {\n  const start = new Date(2021, 7, 1).getTime();\n  const end = new Date(2026, 5, 1).getTime();\n  return new Date(start + rnd(seed) * (end - start)).toISOString().slice(0, 10);\n}\n\nexport function makeSampleRows(n = 220): Row[] {\n  const opt = (id: string) => SAMPLE_FIELDS.find((f) => f.id === id)!.options!;\n  const models = opt(\"model\");\n  const colors = opt(\"color\");\n  const types = opt(\"type\");\n  const managers = opt(\"manager\");\n  const tagOpts = opt(\"tags\");\n  const rows: Row[] = [];\n  for (let i = 0; i < n; i++) {\n    const id = 4259 - i * 7;\n    const tags = tagOpts.filter((_, k) => rnd(i * 10 + k + 1) > 0.7).map((o) => o.value);\n    rows.push({\n      id,\n      name: `${id} ${pick(NAMES, Math.floor(rnd(i + 1) * NAMES.length))}`,\n      model: pick(models, Math.floor(rnd(i + 2) * models.length)).value,\n      color: pick(colors, Math.floor(rnd(i + 3) * colors.length)).value,\n      print_date: dateStr(i + 4),\n      deadline: (() => { const d = new Date(dateStr(i + 4)); d.setDate(d.getDate() + 20 + Math.floor(rnd(i + 40) * 120)); return d.toISOString().slice(0, 10); })(),\n      type: pick(types, Math.floor(rnd(i + 5) * types.length)).value,\n      manager: pick(managers, Math.floor(rnd(i + 9) * managers.length)).value,\n      shipped: Math.floor(rnd(i + 6) * 4),\n      price: 1500 + Math.floor(rnd(i + 11) * 240) * 50,\n      progress: Math.floor(rnd(i + 12) * 101),\n      project: SAMPLE_PROJECTS[Math.floor(rnd(i + 13) * SAMPLE_PROJECTS.length)].id,\n      period: (() => { const s = dateStr(i + 4); const d = new Date(s); d.setDate(d.getDate() + 30 + Math.floor(rnd(i + 14) * 90)); return { start: s, end: d.toISOString().slice(0, 10) }; })(),\n      rating: Math.floor(rnd(i + 15) * 6),\n      note: rnd(i + 16) > 0.5 ? `Комментарий по позиции ${id}: проверить размеры и кромку.` : \"\",\n      files: rnd(i + 17) > 0.6 ? [{ name: `чертёж-${id}.pdf`, url: `https://eburet.com/f/${id}.pdf` }] : [],\n      edited: dateStr(i + 7),\n      done: rnd(i + 8) > 0.5,\n      tags,\n      link: `https://eburet.com/p/${id}`,\n    });\n  }\n  return rows;\n}\n\n/** Демо-зависимости для TimelineView (цепочка + ветка по первым строкам). */\nexport function sampleDeps(rows: Row[]): [number, number][] {\n  const ids = rows.slice(0, 9).map((r) => r.id);\n  const d: [number, number][] = [];\n  for (let i = 0; i < ids.length - 1; i++) d.push([ids[i], ids[i + 1]]);\n  if (ids[2] != null && ids[6] != null) d.push([ids[2], ids[6]]);\n  return d;\n}\n",
      "type": "registry:lib",
      "target": "components/dataviews/sample.ts"
    },
    {
      "path": "registry/eburet/data-views/tokens.ts",
      "content": "/** Палитра тегов Notion (тёмная тема): text / bg. Источник — DESIGN_SPEC.md §1.2 */\nexport const tagColors: Record<string, { t: string; b: string }> = {\n  gray: { t: \"#9B9B9B\", b: \"#252525\" },\n  brown: { t: \"#A27763\", b: \"#2E2724\" },\n  orange: { t: \"#CB7B37\", b: \"#36291F\" },\n  yellow: { t: \"#C19138\", b: \"#372E20\" },\n  green: { t: \"#4F9768\", b: \"#242B26\" },\n  blue: { t: \"#447ACB\", b: \"#1F282D\" },\n  purple: { t: \"#865DBB\", b: \"#2A2430\" },\n  pink: { t: \"#BA4A78\", b: \"#2E2328\" },\n  red: { t: \"#BE524B\", b: \"#332523\" },\n};\n\nexport type TagColor = keyof typeof tagColors;\n",
      "type": "registry:lib",
      "target": "components/dataviews/tokens.ts"
    },
    {
      "path": "registry/eburet/data-views/types.ts",
      "content": "export type FieldType =\n  | \"text\" | \"longtext\" | \"number\" | \"select\" | \"status\" | \"date\" | \"daterange\"\n  | \"multiselect\" | \"checkbox\" | \"url\" | \"person\" | \"rating\" | \"attachment\"\n  | \"formula\" | \"relation\" | \"rollup\";\n\n/** Вложение: имя + ссылка. */\nexport type Attachment = { name: string; url: string };\n\nexport type Option = { value: string; color?: string; label?: string };\n\n/** Формат числового поля (TZ §4). currency/percent/duration — производные от number. */\nexport type NumberFormat = \"int\" | \"float\" | \"currency\" | \"percent\" | \"duration\";\n\nexport type RollupFn = \"sum\" | \"avg\" | \"count\" | \"min\" | \"max\";\n\n/** Значение поля date-range (начало–конец). */\nexport type DateRange = { start: string; end?: string };\n\nexport type Field = {\n  id: string;\n  name: string;\n  type: FieldType;\n  options?: Option[];\n  primary?: boolean;\n  width?: number;\n  editable?: boolean; // default true; false → только чтение (логика поля — наша)\n  /* number-форматирование */\n  format?: NumberFormat; // дефолт для number — \"float\" без группировки\n  precision?: number;    // знаков после запятой (float/currency/percent)\n  currency?: string;     // символ валюты, дефолт \"₽\"\n  max?: number;          // rating: число звёзд (дефолт 5)\n  /* formula/computed (read-only): значение = compute(row) */\n  compute?: (row: Row) => any;\n  /* relation: связь с другой таблицей (value = id или id[]) */\n  relation?: { rows: Row[]; labelField: string; multi?: boolean };\n  /* rollup: агрегат по связанным записям (via — id relation-поля, field — поле в связанных строках) */\n  rollup?: { via: string; field: string; fn: RollupFn };\n};\n\nexport type Row = { id: number; [key: string]: any };\n\nexport type SortRule = { id: string; desc: boolean };\nexport type FilterRule = { field: string; op: string; value: string };\n\n/** Дерево фильтров с вложенными группами AND/OR (TZ §3, §5). */\nexport type FilterNode = FilterRule | FilterGroup;\nexport type FilterGroup = { conjunction: \"and\" | \"or\"; children: FilterNode[] };\n\nexport type ViewType = \"table\" | \"board\" | \"calendar\" | \"gallery\" | \"timeline\" | \"list\";\n\nexport type ViewConfig = {\n  name: string;\n  type: ViewType;\n  search: string;\n  grouping: string | null;\n  sorting: SortRule[];\n  hidden: string[];\n  rowHeight: \"s\" | \"m\" | \"l\";\n  filters: FilterRule[];         // плоский список (зеркало листьев дерева, для обратной совместимости)\n  conjunction: \"and\" | \"or\";     // конъюнкция верхнего уровня\n  filterTree?: FilterGroup;      // каноничное дерево с вложенными AND/OR-группами (если задано — приоритетно)\n  fieldOrder?: string[]; // порядок колонок (reorder)\n  colorBy?: string | null; // раскраска строк по полю (select/status)\n  colorRules?: ColorRule[]; // раскраска строк по условию (приоритетнее colorBy)\n  wrap?: boolean; // перенос текста в ячейках (динамическая высота строк)\n  frozen?: number; // сколько левых колонок закреплено (default 1)\n};\n\n/** Правило раскраски строки по условию (TZ §5). */\nexport type ColorRule = { filter: FilterGroup; color: string };\n\nexport const newView = (name: string, type: ViewType = \"table\"): ViewConfig => ({\n  name, type, search: \"\", grouping: null, sorting: [], hidden: [],\n  rowHeight: \"m\", filters: [], conjunction: \"and\",\n});\n",
      "type": "registry:lib",
      "target": "components/dataviews/types.ts"
    }
  ],
  "type": "registry:block"
}