
  /* TT Commons (локальные woff2 рядом в /fonts) */
  /* ── ГЛОБАЛЬНЫЙ СЛОЙ СТИЛЯ (2026-08-15). Дисплей — Prata (один вес, как у ориентира,
     родная кириллица), интерфейс — Golos Text. Оба ВШИТЫ ЛОКАЛЬНО: демо открывается
     через file://, и с Google Fonts сериф там просто не появился бы. Подмножества
     только cyrillic+latin — остальные весят и не нужны. */
  @font-face { font-family:'Prata'; src:url('../assets/prata-cyrillic.woff2') format('woff2'); font-weight:400; font-style:normal; font-display:swap; unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; }
  @font-face { font-family:'Prata'; src:url('../assets/prata-latin.woff2') format('woff2'); font-weight:400; font-style:normal; font-display:swap; unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
  @font-face { font-family:'Golos Text'; src:url('../assets/golos-cyrillic.woff2') format('woff2'); font-weight:400 600; font-style:normal; font-display:swap; unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; }
  @font-face { font-family:'Golos Text'; src:url('../assets/golos-latin.woff2') format('woff2'); font-weight:400 600; font-style:normal; font-display:swap; unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
  @font-face { font-family:'TT Commons'; src:url('../assets/TTCommons-Light.woff2')     format('woff2'); font-weight:300; font-style:normal; font-display:swap; }
  @font-face { font-family:'TT Commons'; src:url('../assets/TTCommons-Regular.woff2')   format('woff2'); font-weight:400; font-style:normal; font-display:swap; }
  @font-face { font-family:'TT Commons'; src:url('../assets/TTCommons-Medium.woff2')    format('woff2'); font-weight:500; font-style:normal; font-display:swap; }
  @font-face { font-family:'TT Commons'; src:url('../assets/TTCommons-DemiBold.woff2')  format('woff2'); font-weight:600; font-style:normal; font-display:swap; }
  @font-face { font-family:'TT Commons'; src:url('../assets/TTCommons-Bold.woff2')      format('woff2'); font-weight:700; font-style:normal; font-display:swap; }
  @font-face { font-family:'TT Commons'; src:url('../assets/TTCommons-ExtraBold.woff2') format('woff2'); font-weight:800; font-style:normal; font-display:swap; }
  /* ══════════════════════════════════════════════════════════════
     ДИЗАЙН-СИСТЕМА «ДВА СЧЁТА» — контракт токенов.
     Полные правила и «почему» — design/design-rules.md (их читает агент).
     Три закона:
       1) Акцент один: кобальт var(--color-accent). Пара к нему: песок var(--color-accent-soft).
          Активное состояние везде = песок-заливка + кобальт-текст.
       2) Радиусы: кнопки 9 · контролы 11 · карточки 16 · панели 22. Других нет.
       3) Поверхности плоские: теней нет, глубина — хайрлайн var(--color-line-1).
     Цвет/размер в разметке — только var(--...), не хардкодом hex.
     ══════════════════════════════════════════════════════════════ */
  :root {
/* TOKENS:START — сгенерировано tools/tokens-sync.js из design/tokens.json, руками не править */

    /* Цвет / Текст */
    --color-text-primary:          #1C1A15;
    --color-text-secondary:        #534E42;
    --color-text-tertiary:         #7E765D;  /* третья ступень. Сюда же слился --c-expense (#9E978A): «расход» — не цвет, а серый, ΔE 4.3 */
    --color-text-4:                #9A9179;  /* четвёртая ступень: подписи под подписями, неактивные пункты, оси графиков. Найдена аудитом 2026-07-31: 217 мест жили литералами в девяти вариантах */
    --color-text-5:                #B8AE98;  /* пятая ступень, самая тихая: плейсхолдеры, выключенные контролы, сетка графика */

    /* Цвет / Поверхности */
    --color-background-page:       #F9F8F3;
    --color-background-primary:    #FCFCFC;
    --color-background-secondary:  #F1EFE8;
    --color-background-rail:       #F5F2ED;  /* фон сайдбара — замер макета: ступень между page и secondary */
    --color-background-tertiary:   #EAE7DE;
    --color-background-hover:      #F2F0E9;

    /* Цвет / Линии */
    --color-border-secondary:      transparent;  /* НАМЕРЕННО прозрачный: держит место под рамку, не рисуя её. Для видимой линии брать --color-line-* */
    --color-border-tertiary:       transparent;  /* НАМЕРЕННО прозрачный, см. --color-border-secondary */
    --color-line-strong:           #D6CBB6;  /* граница при наведении: ступень ниже line-1 */
    --color-background-selected:   #E4E1D1;  /* выбранная строка ленты — песочная, не серая */
    --color-ink-soft:              #33312C;  /* ховер чернильной кнопки и граница НА чернилах */
    --color-accent-dark:           #003C90;  /* ховер кобальта */
    --color-positive-line:         #CFE0BE;  /* граница «травяной/мятной» пары */
    --color-line-1:                #D9D3C9;  /* хайрлайн: первая линия лестницы. Раньше звался --glass-border, хотя стекла в плоском дизайне нет */
    /* ⚠ ЗАТЕМНЕНИЕ ФОНА — ЧЕРНИЛА, А НЕ СИНЕВА (макет `.scrim`, 18.08.2026). Жило
       холодным синеватым литералом в пятнадцати местах, чужим песочной странице под
       ним: страница уходила в серо-синие сумерки, хотя синего в палитре нет вовсе.
       У макета затемнение замешано на тех же чернилах `--ink` (#1C1A15), что и текст.
       Литерал пропустила сводка палитры 31.07 — она собирала `#rrggbb`, а не `rgba()`. */
    --color-scrim:                 rgba(28,26,21,.42);
    --color-line-2:                #EFEDE6;  /* разделитель под шапкой таблицы. Был литералом: CLAUDE.md прямо велел хардкодить, потому что border-secondary прозрачный */
    --color-line-3:                #F3F1EA;  /* между строками таблиц; берётся через tok() в SVG */

    /* Цвет / Акцент */
    --color-accent:                #0047AB;
    --color-accent-soft:           #F5E6CA;
    --color-blue:                  #B7CCEC;

    /* Цвет / Семантика */
    --color-positive:              #3B6D11;
    --color-positive-soft:         #EAF3DE;
    --color-negative:              #A32D2D;
    --color-negative-soft:         #FCEBEB;
    --color-warn:                  #854F0B;
    --color-warn-soft:             #FAEEDA;
    --color-positive-mid:          #97C459;  /* средняя ступень «дохода»: заливки и точки «поступления», «закрыто». Не цвет графика — семантика */
    --color-warn-mid:              #D6A24A;  /* средняя ступень «внимания»: заливки «списания», «не оплачено» */
    --color-sand-deep:             #E4D3AE;  /* линейка и граница НА песке. Жил литералом в трёх местах (CARD_LOUD и две доски) */

    /* Цвет / Лесенка диаграмм — замер макета 17.08.2026.
       ⚠ ЭТО РАЗВОРОТ ПРЕЖНЕГО РЕШЕНИЯ (31.07): тогда ранжированные полосы красили
       ОДНИМ тоном, потому что порядок уже показан длиной. Макет от 17.08 красит их
       лесенкой, и ранг №1 у него почти чёрный — так столбик читается как «вот этот»,
       а не как «вот эти три». Прежняя лесенка (text-tertiary → text-5 →
       background-tertiary) шла в другую сторону, от среднего к светлому, и на песочном
       фоне все три ступени сливались в одно пятно.
       Направление: chart-3 — самый крупный, chart-1 — самый мелкий. */
    --chart-1:                     #A39A85;
    --chart-2:                     #6B6455;
    --chart-3:                     #2B2924;
    --chart-rest:                  #CBC6BB;  /* «Остальное»: у макета это chart-1 под opacity .55; здесь та же смесь, посчитанная по фону карточки — прозрачность в атрибутах SVG живёт отдельным свойством и разъезжается с DOM-полосами */

    /* Форма / Радиусы */
    --radius-chip:                 6px;   /* пилюля дельты («↑ +18%») — замер макета. Третья ступень заведена ОСОЗНАННО: на коробке высотой 21px радиус 10 даёт стадион, и пилюля перестаёт быть плашкой при числе, становясь кнопкой */
    --radius:                      10px;  /* контролы — замер макета */
    --radius-card:                 16px;  /* карточки: радиусов ДВА, у контролов и карточек разные */  /* единственный радиус в системе. Решение 2026-07-31: было четыре ступени (7/11/16/22) и 22 литеральных значения — стало одно */
    --radius-full:                 999px;  /* круглое: аватары, точки, кружки. Это не ступень шкалы, а отдельное состояние «круг» */

    /* Форма / Глубина */
    --shadow-sm:                   none;
    --shadow-md:                   none;
    --shadow-lg:                   none;
    --shadow-card:                 none;
    --shadow-accent:               none;
    --focus-ring:                  0 0 0 3px rgba(0,71,171,.14);
    --steep-shadow:                0 0 0 0.5px #E6E4DC;
    --steep-hairline:              1px solid rgba(23,25,28,.07);

    /* Ритм / Отступы
       ── ПОЛУСТУПЕНИ (2026-08-18) ────────────────────────────────────────────
       Шкала была шестиступенчатой (4/8/12/16/24/32) и держалась ровно до тех
       пор, пока перенос макета не пошёл экран за экраном. Замер макета показал
       другое: в мелком диапазоне он живёт на СЕТКЕ ПО 2, и четыре его значения
       встречаются десятками раз — 6 (35 мест), 10 (50), 14 (19), 20 (32). Наши
       шесть ступеней их не выражают: ближайшая мимо на 2-4px, а это не «почти
       то же». На 20 против 16 гутер таблицы уезжает на 8px по ширине; на 14
       против 16 шапка секции вырастает на 4px. Три переноса подряд обходились
       литералами с пометками — то есть шкала де-факто уже росла, просто молча.
       Полуступень пишется с `h` (half) и живёт МЕЖДУ соседями: 1h между 1 и 2.
       ⚠ Полуступени — для ПЕРЕНОСА, а не для новой вёрстки. Своё рисуем целыми
       ступенями; полуступень ставим, когда её велит замер макета.
       ⚠ 9 и 11 в шкалу НЕ вошли намеренно, хотя у макета встречаются (17 и 12
       раз): это вертикальный ритм строк таблиц, подобранный под конкретную
       высоту ряда (шапка 40, ряд 60). Они остаются литералами на месте, с
       пометкой — округление до 10 и 12 двигает ряды на 2px каждый. */
    --space-1:                     4px;  /* самый плотный: внутри чипа, между иконкой и текстом */
    --space-1h:                    6px;  /* полуступень: шаг ряда фасетов, отбивка под заголовком группы */
    --space-2:                     8px;  /* рабочий шаг: между строками списка, внутри кнопки по вертикали */
    --space-2h:                    10px;  /* полуступень: отбивка точки-разделителя, паддинг тихой кнопки */
    --space-3:                     12px;  /* между блоками внутри карточки */
    --space-3h:                    14px;  /* полуступень: шапка секции по вертикали, отбивка ряда фасетов */
    --space-4:                     16px;  /* паддинг карточки, между карточками */
    --space-4h:                    20px;  /* полуступень: гутер таблицы-ленты по горизонтали */
    --space-5:                     24px;  /* между секциями экрана */
    --space-5h:                    28px;  /* полуступень: гутер окна операции (макет `.dw-body`) */
    --space-6:                     32px;  /* крупный разрыв: между смысловыми зонами. Заведён, чтобы 30 и 32 не уезжали на 24 (сдвиг 6-8px виден) */

    /* Шрифт / Кегли */
    --fs-micro:                    12px;
    --fs-note:                     13.5px;
    --fs-base:                     15px;

    /* Размер / Контролы */
    --h-control:                   42px;
    --card-ratio:                  5 / 3;   /* Пропорция плитки «Контроля» (.ctrl-card) — единственное
                                               место, где отношение ещё стоит: там ряд одинаковых плиток
                                               с фиксированными строками, форма и есть содержание.
                                               ⚠ Общего класса `.fm-tile` больше нет (удалён 16.08.2026,
                                               последним носителем была карточка проекта). Карточке с
                                               текстом и числами пропорцию не задавать — правило и замеры
                                               в design/design-rules.md, раздел «Высота». */

    /* Движение / Кривые */
    --ease-out-quart:              cubic-bezier(.165,.84,.44,1);  /* Токены движения (motion-design): ease-out — вход/выход, ease-in-out — морфинг на месте */
    --ease-out-quint:              cubic-bezier(.23,1,.32,1);
    --ease-in-out-cubic:           cubic-bezier(.645,.045,.355,1);
    --ease-in-out-quart:           cubic-bezier(.77,0,.175,1);
    --ease-out-quad:               cubic-bezier(.25,.46,.45,.94);
    --ease-in-cubic:               cubic-bezier(.55,.055,.675,.19);  /* ease-in — только для того, что уходит с экрана насовсем (панель-вылет) */

    /* Движение / Длительности */
    --t-fast:                      120ms cubic-bezier(.4,0,.2,1);
    --dur-1:                       120ms;
    --dur-2:                       180ms;
    --dur-3:                       240ms;
    --dur-4:                       300ms;
    --t-med:                       200ms cubic-bezier(.4,0,.2,1);

    /* TOKENS:END */
  }
  * { box-sizing: border-box; }
  html, body { margin: 0; padding: 0; height: 100%; }
  body {
    font-family: 'Golos Text', 'TT Commons', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background: var(--color-background-page);
    color: var(--color-text-primary);
    -webkit-font-smoothing: antialiased;
    font-feature-settings: "cv11","ss01";
  }
  #root {
    width: 100%;
    min-height: 100vh;
    background: var(--color-background-page);
    border-radius: 0;
    border: none;
    box-shadow: none;
    overflow-x: clip;   /* клипуем горизонталь, но НЕ создаём контекст прокрутки по вертикали — иначе ломается sticky-сайдбар */
  }
  /* Единое начертание ВСЕХ цифр в проекте — как в карточках-метрик Аналитики: лёгкий вес + разрядка. !important перебивает любые инлайновые жирные fontWeight/тесные letterSpacing. */
  .num,[data-num]{ font-family:'Golos Text','TT Commons',system-ui,sans-serif; font-variant-numeric:tabular-nums; font-feature-settings:"tnum"; font-weight:400 !important; }
  /* ── ПРАВИЛО (2026-07-23): НИКАКОГО БОЛДА. Единый вес по всему приложению. !important
     перебивает любые инлайновые fontWeight:500/600/700/800 и шорткат font:"700 …".
     Иерархия — размером и цветом, не жирностью. Хочешь акцент — не bold’ом. */
  /* ⚠ `.fm-flyout` ПЕРЕЧИСЛЕН ОТДЕЛЬНО, И ЭТО НЕ ИЗБЫТОЧНОСТЬ. Вылет сайдбара рисуется
     ПОРТАЛОМ в `document.body` (иначе его перехватывала липкая колонка отчёта), то есть
     живёт ВНЕ `#root` — и все три сети ниже до него не доставали. Ровно поэтому в меню
     отчётов оказался полужирный, которого в приложении нет нигде. Появится второй портал —
     добавлять сюда же, все три строки. */
  #root *, #root, .fm-flyout, .fm-flyout * { font-weight: 400 !important; }
  /* ── ПРАВИЛО (2026-08-10): НИКАКОЙ РАЗРЯДКИ. Межбуквенное расстояние везде одинаковое —
     то, которое задумал шрифт. Разогнанный трекинг на мелких подписях («С т а т ь я») —
     первый признак интерфейса, собранного нейросетью: живой дизайнер так не делает, а
     читаемость от этого только падает. Прямая просьба: «никогда не делай такие большие
     расстояния между буквами, это сразу нейронный флёр; везде одинаковое расстояние».
     Вычищено 195 инлайновых letterSpacing и 27 объявлений в CSS (в том числе .03em у
     всех чисел и −.02em у крупных заголовков: «одинаковое» значит и без сжатия тоже).
     !important нужен, потому что инлайн-стиль React иначе сильнее. Ставить letterSpacing
     в новом коде бессмысленно — правило перебьёт. Снять — только правкой этой строки. */
  #root *, #root, .fm-flyout, .fm-flyout * { letter-spacing: normal !important; }
  /* ── ПРАВИЛО (2026-08-14): НИКАКИХ РУЧЕК РАСТЯГИВАНИЯ. Уголок в правом нижнем углу
     текстового поля (CSS `resize`, штриховка-грабber) отдаёт размер поля пользователю —
     и он немедленно расходится с соседними полями, ради которых заведены и шкала
     высот (37/64), и сетка. Прямая просьба Игоря: «убери, они должны быть нами
     зафиксированы». Высоту задаём мы; там, где текста бывает много, поле само растёт
     по содержимому (`AutoTextarea`), а не просит тянуть себя мышью.
     !important нужен, потому что инлайновый `resize:"none"` иначе сильнее. Ставить
     `resize` в новом коде бессмысленно — правило перебьёт. */
  #root textarea { resize: none !important; }
  /* ── ПРАВИЛО (2026-08-10): НИКАКИХ ЗАСЕЧЕК. Инлайновый шорткат font:"600 15px SpaceGrotesk"
     задаёт семейство БЕЗ запасных — а у Space Grotesk нет кириллицы, поэтому «расходы 200 000 ₽»
     в панели «Завести как подрядчика» рисовалось Times: цифры своим шрифтом, слово — браузерным
     по умолчанию, то есть засечным. Сеть ниже делает это невозможным: !important перебивает
     инлайн-стиль, а [style*=…]/[font-family*=…] сохраняют Space Grotesk там, где он выбран
     намеренно (в т.ч. в SVG, где семейство приходит атрибутом). Засечное в приложении = баг. */
  #root, #root *, .fm-flyout, .fm-flyout * { font-family:'Golos Text','TT Commons',system-ui,sans-serif !important; }
  /* ⚠ ДЫРКА В СЕТИ ДЛЯ ДИСПЛЕЯ. Сеть выше стоит с !important, поэтому серифный заголовок
     инлайном поставить НЕЛЬЗЯ — его надо разрешить здесь. Экраны переводим по одному:
     вешаем class="disp" (или data-disp) на заголовок экрана, заголовок карточки и
     сумму-героя. Prata существует в ОДНОМ начертании и разваливается мельче 24px —
     ниже 23px сериф не ставить, там работает Golos с табличными цифрами. */
  /* ⚠ ДВОЙНОЙ АТРИБУТ ОБЯЗАТЕЛЕН. Правило цифр (#root [data-num]) объявлено НИЖЕ этого
     и при равной специфичности (0,1,0) побеждает — серифная сумма-герой откатывалась
     в Golos, хотя data-disp на ней стоял. [data-disp][data-num] даёт (0,2,0) и выигрывает
     независимо от порядка. Ставишь сериф на число — оба атрибута. */
  #root [data-disp][data-num], #root [data-disp][data-num] *,
  #root .disp, #root [data-disp], #root .disp *, #root [data-disp] * { font-family:'Prata',Georgia,'Times New Roman',serif !important; }
  #root .num, #root [data-num], #root [style*="Space Grotesk"], #root [font-family*="Space Grotesk"]
    { font-family:'Golos Text','TT Commons',system-ui,sans-serif !important; }
  /* ⚠ СЛОВО ВНУТРИ СЕРИФНОГО ЧИСЛА. У макета единица измерения при герое набрана
     интерфейсным шрифтом: `.rw-v em`, `.bs-v em`, `.rn-hero em` — все с
     font-family:var(--ui). Сеть выше накрывает ПОТОМКОВ [data-disp], и выйти
     из-под неё инлайновым стилем нельзя (там !important), поэтому нужен класс.
     Почему не `data-num`: он тоже вернул бы Golos, но обещал бы, что внутри
     число, — а внутри слово «месяца».
     ⚠ Класс вешать на слово, а НЕ снимать data-disp с обёртки: обёртка задаёт
     строку (strut), и если её шрифт перестанет быть серифным, высота строки
     меняется на 3px, хотя видимый текст тот же. Проверено замером.
     ⚠ ТРИ СЕЛЕКТОРА, А НЕ ОДИН. Серифная сумма-герой несёт ОБА атрибута
     (`data-disp data-num`), и её потомки ловятся правилом `#root
     [data-disp][data-num] *` весом (1,2,0) — одинокий `#root .fm-ui` (1,1,0)
     ему проигрывает. Проверено вживую: слово оставалось серифным. */
  #root .fm-ui, #root [data-disp] .fm-ui, #root [data-disp][data-num] .fm-ui
    { font-family:'Golos Text','TT Commons',system-ui,sans-serif !important; }
  /* ── БУМАГА МАКЕТА (2026-08-17). Корень «всё приходится подгонять».
     Замер: у макета `body{font:15px/1.5}`, у приложения — 16px и line-height:normal
     (≈1.2 у Golos). То есть КАЖДАЯ строка перенесённого блока садилась на 3–6px
     плотнее макетной, и это лечили поштучно, ставя lineHeight в каждом втором
     инлайн-стиле. Класс задаёт ту же бумагу, что у макета, один раз на страницу.
     Переносим страницу — вешаем ей `.fm-paper`; когда так оденутся все, правило
     поднимется в #root, а класс исчезнет.
     ⚠ Своего font-family здесь НЕТ: семейство держат сети выше (с !important),
     и вторая декларация тут ничего бы не решала, зато выглядела бы источником. */
  .fm-paper{ font-size:15px; line-height:1.5; }

  /* Редизайн карточки подрядчика (двухколоночный view) — scoped через [data-contractor-page] */
  [data-contractor-page] .fm-proj-inner{ height:100%; }
  [data-contractor-page] .fm-proj-inner.is-edit{ zoom:1; width:100%; }
  /* Полосы .con-scroll убраны 16.08.2026 — см. общее правило прокрутки ниже. */
  [data-client-page] .fm-proj-inner{ height:100%; }
  /* Поля формы документа (дизайн «Форма документа»): мягкий акцентный фокус */
  .doc-field{ transition:background-color .15s, border-color .15s, box-shadow .15s; }
  .doc-field:focus{ background:var(--color-background-primary); border-color:var(--color-blue) !important; box-shadow:var(--focus-ring); }
  select.doc-field:hover{ border-color:var(--color-text-5); }
  /* ── КАРКАС ФОРМЫ-СУЩНОСТИ (Э0): один вид у всех форм создания и правки ──
     Операция открывается ПАНЕЛЬЮ-ВЫЛЕТОМ от правого края (как карточка счёта в
     Аспро), а не окном по центру: список слева остаётся на месте, и видно, из
     какой строки пришёл. Тот же приём, что у разбора выписки (fm-drawer-in).
     Ширину задаёт --ef-w (её ставит EntityForm), а не инлайновый width: иначе
     медиа-запрос не смог бы растянуть панель на всю ширину узкого экрана.
     Сетка полей задаётся переменной --ef-cols. Поддерживаются span 1 и 2:
     span 3+ на узком экране вылезет из сетки (медиа-запросы жмут 4→2→1). */
  .ef-scrim{ position:fixed; inset:0; background:var(--color-scrim); z-index:400; display:flex; align-items:stretch; justify-content:flex-end; animation:fm-fade-in var(--dur-3) ease both; }
  .ef-card{ width:var(--ef-w,620px); max-width:100%; height:100%; background:var(--color-background-primary); border-radius: var(--radius) 0 0 var(--radius); box-shadow:-30px 0 70px rgba(17,19,32,.24); display:flex; flex-direction:column; overflow:hidden; animation:fm-drawer-in var(--dur-4) var(--ease-out-quart) both; }
  /* Уход быстрее входа и на ease-in: панель уезжает совсем, мягко приземляться ей негде. */
  .ef-scrim[data-closing="true"]{ animation:fm-fade-out var(--dur-2) ease both; }
  .ef-scrim[data-closing="true"] .ef-card{ animation:fm-drawer-out var(--dur-2) var(--ease-in-cubic) both; }
  @media (max-width:700px){ .ef-card{ width:100%; border-radius:0; } }
  @media (prefers-reduced-motion: reduce){ .ef-scrim, .ef-scrim .ef-card{ animation:none !important; } }
  .ef-head{ display:flex; align-items:center; gap: var(--space-2); padding: var(--space-4) var(--space-5) var(--space-4); border-bottom:1px solid var(--color-background-secondary); flex-shrink:0; }
  .ef-title{ font-size:17px; font-weight:800; color:var(--color-text-primary); }
  .ef-icon{ width:28px; height:28px; display:flex; align-items:center; justify-content:center; border:none; background:transparent; border-radius: var(--radius); color:var(--color-text-tertiary); font-size:15px; line-height:1; cursor:pointer; font-family:inherit; transition:background 120ms, color 120ms; }
  .ef-icon:hover{ background:var(--color-background-hover); color:var(--color-text-secondary); }
  .ef-body{ padding: var(--space-4) var(--space-5) var(--space-5); overflow-y:auto; flex:1 1 auto; min-height:0; }
  .ef-foot{ display:flex; align-items:center; gap: var(--space-2); padding: var(--space-3) var(--space-5) var(--space-4); border-top:1px solid var(--color-background-secondary); flex-shrink:0; }
  .ef-grid{ display:grid; grid-template-columns:repeat(var(--ef-cols,4),minmax(0,1fr)); gap: var(--space-3) var(--space-4); }
  .ef-grid > *{ min-width:0; }
  .ef-lbl{ display:block; font-size:11.5px; font-weight:700; text-transform:none; color:var(--color-text-tertiary); margin-bottom: var(--space-1); }
  .ef-req{ color:var(--color-negative); margin-left:3px; }
  /* Тихая ссылка-действие внутри формы: раскрывает необязательный блок (график траншей,
     начисление). Форма по умолчанию короткая, сложное появляется по требованию. */
  .ef-link{ display:inline-block; font:500 13px 'Inter'; color:var(--color-accent); cursor:pointer;
            background:none; border:none; padding:0; font-family:inherit; }
  .ef-link:hover{ text-decoration:underline; }
  .ef-link.mute{ color:var(--color-text-tertiary); }
  /* Строка графика траншей: дата · сумма · доля · крестик */
  .ef-split{ display:grid; grid-template-columns:1fr 130px 58px 26px; gap: var(--space-2); align-items:center;
             padding: var(--space-1) 0; border-bottom:1px solid var(--color-background-secondary); }
  .ef-split.h{ font:700 10.5px 'Inter'; text-transform:none; color:var(--color-text-4);
               padding-bottom: var(--space-1); }
  .ef-split.f{ border-bottom:none; font:600 13px 'Inter'; color:var(--color-text-primary); padding-top: var(--space-2); }

  /* ── Таблица разбивки суммы (общая для всех окон операции) ────────────────
     Претензия Игоря на первую версию: «выглядит по-убански» — ряд отдельных
     полей-коробок с рамками читается как форма, а не как таблица: границы полей
     спорят с границами строк, и глазу не за что вести взгляд по строке.
     Настоящая таблица: рамка снаружи, тонкие линии внутри, шапка на подложке,
     а поля ВНУТРИ ячеек лишены своих рамок и фона — ячейка и есть их граница. */
  /* ⚠ РАМКА ТАБЛИЦЫ НЕ КЛИПАЕТ (13.08.2026). Было `overflow:hidden` ради скруглённых
     углов — и оно же срезало выпадашки «Кому» и «Статья» внутри ячеек: список имён
     вылезал за строку голым текстом без панели (скриншот Игоря «всплывающее окно
     сломано»). Углы теперь скругляет сама шапка (единственная строка с заливкой),
     а панели выпадашек выходят за рамку, как им и положено. */
  .spl-wrap{ border:1px solid var(--color-line-1); border-radius: var(--radius);
             background:var(--color-background-primary); }
  .spl-row{ display:grid; align-items:stretch; border-bottom:1px solid var(--color-line-3); }
  .spl-row:last-child{ border-bottom:none; }
  .spl-row.h{ background:var(--color-background-secondary); border-bottom:1px solid var(--color-line-1);
              border-radius: var(--radius) var(--radius) 0 0; }
  /* ⚠ `nowrap` у подписей: колонки подрезаны под содержимое, и подпись в две строки
     («Дата / начисления») растит шапку и делает таблицу неряшливой. Не влезает подпись —
     это повод пересчитать ширину колонки, а не переносить слово. */
  .spl-row.h > *{ font-family:'Inter', sans-serif; font-size:11.5px; color:var(--color-text-tertiary);
                  padding: var(--space-2) var(--space-3); display:flex; align-items:center; white-space:nowrap; }
  /* `min-height:40` у КАЖДОЙ ячейки: сетка строки выровнена по верху, и ячейка ростом
     в галочку (колонка «Начислено») вставала выше соседних полей высотой 40. */
  .spl-row > .c{ border-right:1px solid var(--color-line-3); min-width:0; display:flex; align-items:center; min-height:40px; }
  .spl-row > .c:last-child{ border-right:none; }
  .spl-row > .c.num{ justify-content:flex-end; }
  /* Поле внутри ячейки: рамка ячейки заменяет рамку поля. Высота строки одна — 40. */
  /* ⚠ Триггер ArtSelect/DateField — БЕЗКЛАССОВЫЙ div внутри .fp-wrap с ИНЛАЙНОВОЙ
     рамкой (замер 12.08). Поэтому селектор идёт по структуре, а не по классу, и с
     !important — иначе инлайн-стиль компонента побеждает и в ячейке снова коробка. */
  /* ⚠ `:not(.fp-pop)` — НЕ КОСМЕТИКА, А ПОЧИНКА ВЫПАДАШКИ (13.08.2026). Панель
     `.fp-pop` — такой же прямой ребёнок `.fp-wrap`, как и триггер, поэтому правило
     «сними рамку, фон и держи 40px» раздевало ЕЁ ТОЖЕ: список имён рисовался без
     фона, без тени и обрезанный по 40px — «поиск по имени кривой, всплывающее окно
     сломано». Селектор по структуре обязан исключать панель. */
  .spl-row .c .fp-wrap > div:not(.fp-pop),
  .spl-row .c .ef-in, .spl-row .c .fp-trig{
    border:0 !important; background:transparent !important; border-radius:0 !important;
    height:40px !important; min-height:40px !important; box-shadow:none !important;
    padding-left: var(--space-3); padding-right: var(--space-3); width:100%; }
  .spl-row .c .fp-wrap{ width:100%; }
  .spl-row .c > *:not(.fp-pop){ width:100%; }
  .spl-row .c.pad{ padding: 0 var(--space-3); }
  /* Галочка «Начислено»: у input[type=checkbox] есть СВОИ 3–4px внешнего поля от
     браузера — из-за них квадрат уезжал вправо. Стоит по ЦЕНТРУ колонки (просьба
     13.08.2026): одинокий значок без подписи центрируется, как значок удаления
     в последней колонке, — по левому краю он читался бы как обрубок текста.
     Шапка «Начислено» отцентрована вместе с ним, иначе колонка косит. */
  .spl-row .c.pad input[type=checkbox]{ margin:0; }
  /* Календарь в ячейке рисовать нечем: поле и так называется «Дата начисления», а
     иконка держала под себя 38px в колонке шириной 112. */
  .spl-row .c .fp-trig .fp-car{ display:none; }
  /* Подвал таблицы — вне рамки: «Добавить строку» слева, итог справа. */
  .spl-foot{ display:grid; align-items:center; margin-top: var(--space-2); }
  .spl-foot > *{ padding: 0 var(--space-3); }

  /* ⚠ СЕТКА ПОЛЕЙ ОКНА ОПЕРАЦИИ — ОДНА КОЛОНКА, И ЭТО НЕ ПЕРЕБИВАЕТСЯ (13.08.2026).
     Инлайновый grid-template-columns у React-разметки может быть перекрыт чем угодно
     с !important: дизайн-режимом, пользовательским стилем, расширением браузера. Полдня
     ушло на спор, где у меня рисовалась одна колонка, а у Игоря те же поля вставали
     парами — по скриншоту такое неотличимо от «код не тот». Раскладка формы слишком
     важна, чтобы зависеть от того, что кто-то когда-то навесил поверх. */
  /* ⚠ СЕЛЕКТОР НАМЕРЕННО ПЕРЕУТЯЖЕЛЁН (13.08.2026). Замер у Игоря в шапке окна показал
     «сетки: 546px | 311.633px 218.367px»: к первому блоку правило применилось, ко
     второму — нет, то есть его перебивает что-то более специфичное с !important
     (пользовательский стиль, расширение браузера, остаток дизайн-режима). Обычный
     `.fm-op-form` (0,1,0) такому проигрывает, поэтому здесь `#root` + дубль класса
     (1,2,0) — выиграть у этого может только inline-стиль с !important, которого в
     React-разметке не бывает. Не «упрощать» обратно: за этим селектором полдня
     разбирательства, где на одном файле у двух людей разная раскладка. */
  /* ⚠ Пробовали 13.08 увести форму в блочный поток (колонок бы не было в принципе,
     перебивать нечего) — и сразу потеряли ПОРЯДОК полей: он задан `order`, а тот
     работает только во flex/grid. Замер показал раскладку в две оси и порядок как в
     разметке, а не как просил Игорь. Поэтому grid остаётся, но селектор утроен —
     перебить его может только inline с !important. */
  /* ⚠ ФОРМА — ДВЕ КОЛОНКИ, ПАРЫ ЗАДАНЫ ПОШТУЧНО (13.08.2026, порядок Игоря:
     «в паре сумма и дата начисления · ниже счёт и контрагент · ниже проект и статья ·
     снизу одиноко назначение шириной в два блока»). Пары здесь не «по две подряд»,
     а осмысленные: сколько и когда начислено, откуда и кому, куда записать. */
  /* ⚠ ОДНА КОЛОНКА 556 (18.08.2026, перенос макета `.form`; Игорь: «надо как в
     макете»). Было два столбца с потолком 640 — правка 16.08 под «назначению платежа
     побольше места, пошире». Замер макета говорит другое: колонка ОДНА, ширина 556, а
     «пошире» достигается не вторым столбцом, а тем, что КАЖДОЕ поле занимает всю
     ширину формы. Двойной столбец ставил «Счёт и юрлицо» и «Контрагент» в один ряд —
     два ответа на разные вопросы бок о бок, — и каждому доставалось 314 вместо 556.
     Половинные поля (дата, сумма, дата начисления) остаются половинными: рядом с ними
     стоит отметка, и вместе они занимают ту же строку 556.
     ⚠ Число связано ещё с четырьмя: `--op-col` = (556 − 12) / 2 = 272, потолок
     `.fm-triage-body > *`, ширина трёх окон операции (макетные 620) и база
     `splitWide`. Правишь одно — правь все. */
  div.fm-op-form.fm-op-form.fm-op-form,
  .fm-op-form{ display:grid !important; grid-template-columns:minmax(0,1fr) !important;
               max-width:556px !important; align-items:start !important; }
  /* ⚠ В ОДНОЙ КОЛОНКЕ КАЖДАЯ ЯЧЕЙКА ЗАНИМАЕТ ВСЮ СТРОКУ — ВКЛЮЧАЯ `span 2`
     (18.08.2026, регрессия одноколоночной формы; Игорь: «у нас уже было разбиение,
     ты его спрятал»). Пока форма была двухколоночной, восемь полей стояли с
     `EfField span={2}` — «График выплат», «Документы», «Кому платим», «Счёт»,
     блоки налогового правила. `EfField` кладёт им инлайновый `grid-column:span 2`.
     В сетке ИЗ ОДНОЙ колонки `span 2` не «растягивает на всю ширину», а ЗАВОДИТ
     НЕЯВНУЮ ВТОРУЮ колонку: график траншей уезжал за правый край окна, а окно с
     `overflow:hidden` его срезало. Разбиение было на месте — его не было ВИДНО.
     Правило снимает инлайновый span у всех детей разом, а не у перечисленных:
     в одноколоночной сетке других вариантов не бывает, и следующее поле со
     `span` не повторит эту историю. */
  .fm-op-form > *{ grid-column:1 / -1 !important; }
  /* ⚠ ПОДПИСЬ В ОКНЕ ОПЕРАЦИИ — ВТОРАЯ СТУПЕНЬ (макет `.f-l`, 18.08.2026). Общий
     `.ef-lbl` третьей ступенью верен для панели проекта — там у макета ровно она
     (`.pe-lbl` на `--ink-3`), — но в окне операции макет ставит вторую, и это не
     случайность: в панели подпись сопровождает значение, которое и так читается,
     а в окне разбора она единственное, что называет поле-факт без рамки. Правило
     «одна подпись на всё приложение» писалось против того, чтобы ТРИ ОКНА ОБ
     ОДНОЙ операции расходились между собой; здесь разница между окном и панелью
     осознанная, объявлена одним селектором и работает во всех трёх окнах разом. */
  .fm-op-form .ef-lbl, .fm-op-lbl{ color:var(--color-text-secondary); }
  /* ⚠ ШИРИНА ПОЛЯ В ШИРОКОМ РЯДУ = ШИРИНЕ КОЛОНКИ (14.08.2026). Ряды «дата + отметка»
     и «сумма» занимают обе колонки, а поле внутри них держало 266px при колонке 272 —
     замер: правый край «Даты» 958, правый край «Счёта» под ней 964, отметка начиналась
     на 970 вместо 976. Шесть пикселей, но они ломают ровно то, ради чего сетка и
     заведена: вертикаль колонок. Считается из потолка формы: (556 − 12) / 2. */
  :root{ --op-col: 272px; }
  /* ⚠ ЭТОТ КЛАСС — ЕДИНСТВЕННАЯ СЕТКА ВО ВСЕХ ТРЁХ ОКНАХ ОПЕРАЦИИ (14.08.2026):
     разбор, «Новая операция», правка строки. Раньше у каждого была своя (`EfGrid`
     с шагом 16/24, инлайновый `1fr 1fr`), и одна операция выглядела как три разные
     программы. Правишь шаг или число колонок — правишь сразу все три окна, других
     сеток у операции нет. Порядок полей — в `OP_ORD` рядом с `EfField`.
     ⚠ Прежний комментарий обещал «страховку» `.fm-op-form > * { grid-column:1/-1 }`
     — такого правила ЗДЕСЬ НЕТ и не должно быть: оно схлопывало форму в один
     столбец, а пары («счёт | контрагент», «проект | статья») заданы осознанно.
     ⚠ Класс `.fm-op-pair` удалён 14.08.2026 — он держал пару «дата + состояние»,
     которой больше нет: отметка стоит внутри ряда даты. */

  /* ⚠ Подпись СЛЕВА от поля пробовали 12.08 и откатили по прямому «нет-нет»:
     Игорю нужны просто параметры друг под другом, с именем НАД полем. Не заводить
     заново — вопрос закрыт. */


  /* Поп-ап дня в календаре «Ближайшие списания» — перенесён из макета как есть.
     Операции показаны ПО ОТДЕЛЬНОСТИ: одна сумма за день не говорит, кому и за что.
     Появляется без задержки; радиусы концентричны (14 снаружи, отступ 12).
     width:max-content — чтобы ширина не зависела от того, у какого края экрана стоит
     поп-ап: иначе у правого края текст переносится сильнее и отступ до дня гуляет. */
  .dpop { position:fixed; z-index:60; left:0; top:0; width:max-content; min-width:236px; max-width:308px;
          box-sizing:border-box; background:var(--color-background-primary); border:0.5px solid var(--color-background-tertiary); border-radius: var(--radius);
          padding: var(--space-3) var(--space-3) var(--space-3);
          box-shadow:0 2px 6px rgba(16,24,40,.07), 0 16px 38px rgba(16,24,40,.09);
          font-family:'Inter','TT Commons',system-ui,sans-serif; -webkit-font-smoothing:antialiased;
          pointer-events:none; opacity:0; transform:translateY(4px);
          transition-property:opacity, transform; transition-duration:90ms;
          transition-timing-function:cubic-bezier(.2,0,0,1); will-change:transform, opacity; }
  .dpop, .dpop * { font-weight:400; }
  .dpop.is-on { opacity:1; transform:none; }
  .dpop-arw { position:absolute; width:9px; height:9px; background:var(--color-background-primary); transform:rotate(45deg); }
  .dpop-h { display:flex; align-items:baseline; justify-content:space-between; gap: var(--space-3);
            font-size:12px; color:var(--color-text-4); white-space:nowrap; }
  .dpop-h b { font-size:13.5px; }
  .dpop-l { margin-top: var(--space-2); display:flex; flex-direction:column; gap: var(--space-2); }
  .dpop-r { display:flex; align-items:flex-start; gap: var(--space-2); font-size:13px; }
  .dpop-r i { width:7px; height:7px; border-radius: var(--radius); flex-shrink:0; margin-top: var(--space-1); }
  .dpop-r i.out { background:var(--color-warn-mid); }
  .dpop-r i.in  { background:var(--color-positive-mid); }
  .dpop-n { flex:1; min-width:0; color:var(--color-text-primary); line-height:1.3; text-wrap:pretty; }
  .dpop-n em { display:block; font-style:normal; font-size:11.5px; color:var(--color-text-4); margin-top:2px; }
  .dpop-s { flex-shrink:0; color:var(--color-text-primary); }
  .dpop-more { font-size:12px; color:var(--color-text-4); padding-left: var(--space-4); }
  .dpop-f { margin-top: var(--space-3); padding-top: var(--space-2); border-top:1px solid var(--color-background-hover);
            font-size:11.5px; color:var(--color-text-4); line-height:1.45; text-wrap:pretty; white-space:normal; }
  @media (prefers-reduced-motion:reduce){ .dpop{ transition-duration:1ms; transform:none } }
  /* ⚠ 37px — ОДНА ВЫСОТА У ВСЕХ ПОЛЕЙ ФОРМЫ (2026-08-12). Комментарий ниже уже год
     обещал «37px = .ef-in и .fp-trig», но самому `.ef-in` правило так и не поставили:
     обычный ввод выходил 34.5px (кегль + паддинги), денежный — 35.5 (кегль 15), а
     выпадашка и поле-факт — ровно 37. В ряду «На какой счёт | От кого» стояли коробки
     34.5 и 37, и форма выглядела собранной наспех. Высоту задаёт СЛОТ, а не содержимое:
     min-height + border-box. `textarea.ef-in` (min-height:64) специфичнее — не задет. */
  .ef-in{ width:100%; box-sizing:border-box; min-height:37px; padding: var(--space-2) var(--space-3); font-size:13.5px; border:1px solid var(--color-line-1); border-radius: var(--radius); background:var(--color-background-primary); color:var(--color-text-primary); font-family:inherit; outline:none; transition:border-color 150ms, box-shadow 150ms; }
  .ef-in:hover{ border-color:var(--color-text-5); }
  .ef-in:focus{ border-color:var(--color-blue); box-shadow:var(--focus-ring); }
  select.ef-in{ cursor:pointer; }
  textarea.ef-in{ resize:none; min-height:64px; line-height:1.45; }
  /* ⚠ ФАКТ — ТАКОЕ ЖЕ ПОЛЕ, ЧТО И ВВОД (2026-08-11). Данные из выписки жили
     отдельной песочной колонкой слева, и одно и то же окно читалось как два
     разных экрана. Теперь факт стоит в общей сетке: та же коробка, что у .ef-in,
     но без рамки-приглашения и на песке — «это факт, а не поле для ввода». */
  /* ⚠ ТА ЖЕ ВЫСОТА, ЧТО У ПОЛЯ ВВОДА (37px = .ef-in и .fp-trig). Иначе в одной
     строке сетки стоят коробки разной высоты и ряд «пляшет» — ровно это было видно
     на скриншоте 11.08.2026 («размеры у блоков у всех разные»). Уточнение (ИНН и
     т.п.) НЕ живёт внутри коробки — оно уезжает в .ef-hint под ней, иначе поле
     становится двухэтажным и снова выше соседа. */
  /* ⚠ ПОДЛОЖКА ФАКТА — ТЁПЛАЯ СТУПЕНЬ, А НЕ ЦВЕТ СТРАНИЦЫ (макет `.f-ro` на
     `--warm-1`, 18.08.2026). Поле-факт заливалось цветом страницы: на песочной
     странице разница с белым полем ввода рядом — процент тона, её физически не
     видно, и «не правится» приходилось узнавать по замку. Ступень выше делает
     разницу читаемой сразу, а замок остаётся подтверждением, а не единственным
     признаком. */
  .ef-ro{ width:100%; box-sizing:border-box; padding: var(--space-2) var(--space-3); font-size:13.5px;
          border:1px solid transparent; border-radius: var(--radius); background:var(--color-background-rail);
          color:var(--color-text-primary); min-height:37px; display:flex; align-items:center; gap: var(--space-2); }
  .ef-ro-v{ flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; line-height:19px; }
  .ef-ro[data-wrap="true"]{ align-items:flex-start; }
  .ef-ro[data-wrap="true"] .ef-ro-v{ white-space:pre-wrap; overflow:visible; text-overflow:clip; line-height:1.5; }
  .ef-ro-lock{ flex:none; color:var(--color-text-5); display:inline-flex; }
  .ef-ro[data-wrap="true"] .ef-ro-lock{ margin-top:2px; }
  .ef-hint{ font-size:11.5px; line-height:1.35; color:var(--color-text-tertiary); margin-top: var(--space-1); }
  .ef-err{ font-size:11.5px; line-height:1.4; color:var(--color-negative); margin-top: var(--space-1); }
  .ef-sec + .ef-sec{ margin-top: var(--space-4); padding-top: var(--space-4); border-top:1px solid var(--color-background-hover); }
  /* Заголовок секции темнее подписей полей — иначе два уровня читаются как один. */
  .ef-sec-t{ font-size:12px; font-weight:700; text-transform:none; color:var(--color-text-secondary); margin-bottom: var(--space-3); }
  .ef-btn{ font-size:13px; font-weight:600; padding: var(--space-2) var(--space-4); border-radius: var(--radius); border:1px solid var(--color-line-1); background:var(--color-background-primary); color:var(--color-text-secondary); cursor:pointer; font-family:inherit; white-space:nowrap; transition:background 120ms, border-color 120ms; }
  .ef-btn:hover{ background:var(--color-background-page); border-color:var(--color-text-5); }
  .ef-btn-go{ border:none; background:var(--color-accent); color:var(--color-accent-soft); padding: var(--space-2) var(--space-4); }
  .ef-btn-go:hover{ background:var(--color-accent); border-color:transparent; }
  .ef-btn-go:disabled{ background:var(--color-line-1); color:var(--color-text-tertiary); cursor:default; }
  .ef-btn-go:disabled:hover{ background:var(--color-line-1); }
  /* ── ПОЛЯ-ВЫПАДАШКИ ФОРМЫ: статья и дата (2026-07-31) ──────────────────────
     Нативные select и input[type=date] рисует операционная система: чужой
     шрифт в списке, чужой календарь, серая стрелка — посреди формы это
     читается дырой в интерфейсе. Оба поля собраны на ОДНОМ каркасе: кнопка
     ростом с .ef-in плюс всплывающая панель под ней.
     Движение: приход 180мс ease-out-quart (быстрый старт = ощущение отклика,
     спокойное приземление), уход 140мс — закрытое хотят видеть закрытым
     сразу. Масштаб от .96, а не от нуля: из нуля панель читается синтетикой. */
  .fp-wrap{ position:relative; }
  .fp-trig{ display:flex; align-items:center; gap: var(--space-2); width:100%; box-sizing:border-box; text-align:left;
            padding: var(--space-2) var(--space-3); min-height:37px; font-size:13.5px; font-family:inherit; color:var(--color-text-primary);
            background:var(--color-background-primary); border:1px solid var(--color-line-1); border-radius: var(--radius); cursor:pointer;
            transition:border-color 150ms ease, box-shadow 150ms ease; }
  .fp-trig:hover{ border-color:var(--color-text-5); }
  .fp-trig[data-open="true"]{ border-color:var(--color-blue); box-shadow:var(--focus-ring); }
  .fp-trig[data-empty="true"] .fp-val{ color:var(--color-text-4); }
  .fp-val{ flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .fp-sub{ flex:none; font-size:12px; color:var(--color-text-4); }
  .fp-car{ flex:none; color:var(--color-text-4); display:inline-flex; transition:transform var(--dur-2) var(--ease-out-quart); }
  .fp-trig[data-open="true"] .fp-car{ transform:rotate(180deg); }
  .fp-pop{ position:absolute; left:0; z-index:90; background:var(--color-background-primary); border:1px solid var(--color-line-1); border-radius: var(--radius);
           box-shadow:0 14px 32px -18px rgba(44,46,49,.34), 0 2px 8px -4px rgba(44,46,49,.12);
           transform-origin:top left; animation:fp-in var(--dur-2) var(--ease-out-quart) both; }
  .fp-pop[data-side="up"]{ transform-origin:bottom left; animation-name:fp-in-up; }
  .fp-pop[data-closing="true"]{ animation:fp-out 140ms var(--ease-in-cubic) both; }
  @keyframes fp-in    { from{ opacity:0; transform:scale(.96) translateY(-6px) } to{ opacity:1; transform:none } }
  @keyframes fp-in-up { from{ opacity:0; transform:scale(.96) translateY(6px)  } to{ opacity:1; transform:none } }
  @keyframes fp-out   { from{ opacity:1; transform:none } to{ opacity:0; transform:scale(.985) translateY(-3px) } }
  .fp-search{ display:flex; align-items:center; gap: var(--space-2); padding: var(--space-2) var(--space-3); border-bottom:1px solid var(--color-background-secondary); }
  .fp-search input{ flex:1; min-width:0; border:0; outline:0; background:none; font-family:inherit; font-size:13.5px; color:var(--color-text-primary); }
  .fp-list{ max-height:252px; overflow-y:auto; padding: var(--space-1); }
  .fp-sec{ font-size:11px; color:var(--color-text-4); padding: var(--space-2) var(--space-2) var(--space-1); }
  .fp-item{ display:flex; align-items:center; gap: var(--space-2); padding: var(--space-2) var(--space-2); border-radius: var(--radius); cursor:pointer;
            font-size:13.5px; color:var(--color-text-primary); transition:background 120ms ease; }
  .fp-item[data-cur="true"]{ background:var(--color-background-hover); }
  .fp-item[data-on="true"]{ background:var(--color-accent-soft); color:var(--color-accent); }
  .fp-item.mute{ color:var(--color-text-tertiary); }
  /* Появление пунктов — только на открытии (атрибут снимается по таймеру): при
     наборе в поиске список обязан меняться мгновенно, иначе фильтр «залипает». */
  .fp-pop[data-enter="true"] .fp-item{ animation:fp-item 240ms var(--ease-out-quart) both; animation-delay:calc(var(--i,0) * 16ms); }
  @keyframes fp-item{ from{ opacity:0; transform:translateY(-5px) } to{ opacity:1; transform:none } }
  /* Убрать пункт из СПРАВОЧНИКА (не из проекта) — тихий крестик у строки списка:
     громким он читался бы главным действием строки, а главное здесь — выбрать. */
  .fp-del{ flex:none; display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px;
           border-radius: var(--radius); color:var(--color-text-4); opacity:.35; cursor:pointer;
           transition:opacity var(--t-fast), background var(--t-fast), color var(--t-fast); }
  .fp-item:hover .fp-del{ opacity:1; }
  .fp-del:hover{ background:var(--color-negative-soft); color:var(--color-negative); }
  .fp-none{ padding: var(--space-3) var(--space-3); font-size:13px; color:var(--color-text-tertiary); }
  .fp-new{ display:flex; align-items:center; gap: var(--space-2); margin: 0 var(--space-1) var(--space-1); padding: var(--space-2) var(--space-2); border-radius: var(--radius);
           border-top:1px solid var(--color-background-hover); cursor:pointer; font-size:13.5px; color:var(--color-text-secondary); transition:background 120ms ease, color 120ms ease; }
  .fp-new:hover{ background:var(--color-background-hover); color:var(--color-text-primary); }
  /* ── КАЛЕНДАРЬ ПОЛЯ ДАТЫ ─────────────────────────────────────────────────── */
  .fcal{ padding: var(--space-3); width:258px; }
  .fcal-h{ display:flex; align-items:center; justify-content:space-between; gap: var(--space-1); margin-bottom: var(--space-1); }
  .fcal-m{ font-size:13.5px; color:var(--color-text-primary); }
  .fcal-nav{ width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; flex:none;
             border:0; background:transparent; border-radius: var(--radius); color:var(--color-text-tertiary); cursor:pointer;
             transition:background 120ms ease, color 120ms ease; }
  .fcal-nav:hover{ background:var(--color-background-secondary); color:var(--color-text-primary); }
  .fcal-wd{ display:grid; grid-template-columns:repeat(7,1fr); }
  .fcal-wd span{ text-align:center; font-size:10.5px; color:var(--color-text-4); padding: var(--space-1) 0; }
  /* Смена месяца — свап по тик-счётчику: сетка приезжает с той стороны, куда нажали. */
  .fcal-g{ display:grid; grid-template-columns:repeat(7,1fr); animation:fcal-in 200ms var(--ease-out-quart) both; }
  .fcal-g[data-dir="-1"]{ animation-name:fcal-in-b; }
  @keyframes fcal-in  { from{ opacity:0; transform:translateX(8px)  } to{ opacity:1; transform:none } }
  @keyframes fcal-in-b{ from{ opacity:0; transform:translateX(-8px) } to{ opacity:1; transform:none } }
  .fcal-d{ height:32px; border:0; background:transparent; border-radius: var(--radius); cursor:pointer; font-family:inherit;
           font-size:13px; color:var(--color-text-primary); transition:background 120ms ease, color 120ms ease; }
  .fcal-d:hover, .fcal-d[data-cur="true"]{ background:var(--color-background-secondary); }
  .fcal-d[data-out="true"]{ color:var(--color-text-5); }
  .fcal-d[data-wknd="true"]{ color:var(--color-text-4); }
  .fcal-d[data-today="true"]{ box-shadow:inset 0 0 0 1px var(--color-line-1); }
  .fcal-d[data-on="true"]{ background:var(--color-accent-soft); color:var(--color-accent); }
  .fcal-f{ display:flex; gap: var(--space-1); margin-top: var(--space-2); padding-top: var(--space-2); border-top:1px solid var(--color-background-hover); }
  .fcal-f button{ flex:1; padding: var(--space-1) 0; font-family:inherit; font-size:12px; color:var(--color-text-secondary); background:var(--color-background-page);
                  border:0; border-radius: var(--radius); cursor:pointer; white-space:nowrap;
                  transition:background 120ms ease, color 120ms ease; }
  .fcal-f button:hover{ background:var(--color-background-secondary); color:var(--color-text-primary); }
  @media (prefers-reduced-motion:reduce){
    .fp-pop, .fp-pop[data-enter="true"] .fp-item, .fcal-g{ animation:none !important; }
    .fp-car{ transition:none; }
  }
  @media (max-width:1000px){ .ef-grid[data-cols="4"], .ef-grid[data-cols="3"]{ --ef-cols:2; } }
  @media (max-width:620px){ .ef-grid{ --ef-cols:1; } .ef-grid > *{ grid-column:auto !important; } }
  /* ⚠ ТА ЖЕ СТРАХОВКА, ЧТО У `.fm-op-form`, И ПО ТОЙ ЖЕ ПРИЧИНЕ (18.08.2026). В сетке
     ИЗ ОДНОЙ колонки `span 2` не растягивает на всю ширину, а заводит неявную вторую:
     поле уезжает за край и пропадает под `overflow:hidden` — ровно так спрятался
     график траншей. Сейчас ни у одной `EfGrid cols={1}` детей со `span` нет (проверено
     обходом всех восьми сеток), но правило снимает саму возможность на будущее. */
  .ef-grid[data-cols="1"] > *{ grid-column:auto !important; }
  /* ── РЕЖИМ ПРАВКИ ПАНЕЛИ: аккордеон секций (вариант Б, выбран 2026-07-19) ──
     Секции свёрнуты в строки со сводкой справа, открыта одна. Раскрытие — трюк
     grid-template-rows 0fr→1fr (как у OpCombo): высота анимируется без замеров. */
  /* ⚠ Без overflow:hidden намеренно: он резал выпадашки «＋ Добавить» границей секции.
     Скругление держим на самой шапке и на нижней кромке тела. */
  /* ⚠ ГЕОМЕТРИЯ СЕКЦИИ — ЗАМЕР МАКЕТА `.pe-acc` (18.08.2026). Свёрнутая секция без
     рамки не читалась коробкой: семь строк подряд на общем фоне выглядели списком, а
     не семью закрытыми ящиками. Радиус — КАРТОЧНЫЙ (16), а не контрольный (10): это
     карточка, и рядом с ней стоят карточки панели того же радиуса.
     ⚠ 14px по вертикали шапки — литерал: в шкале отступов между 12 и 16 ступени нет,
     а замер макета даёт ровно 14 (шапка 49 = 14+21+14). Ставить 16 «ради токена»
     значит поднять шапку на 4px и разойтись с макетом. */
  .pe-acc{ background:var(--color-background-primary); border:1px solid var(--color-line-1); border-radius: var(--radius-card); margin-bottom: var(--space-2); }
  .pe-head{ display:flex; align-items:center; gap: var(--space-3); padding: var(--space-3h) var(--space-4); cursor:pointer;
            user-select:none; border-radius: var(--radius-card); transition:background 140ms; }
  .pe-acc.on .pe-head{ border-radius: var(--radius-card) var(--radius-card) 0 0; }
  /* Открытая шапка — тёплая ступень сайдбара (--warm-1 макета), а не цвет страницы:
     на песочной странице заливка страницей физически неразличима. */
  .pe-head:hover{ background:var(--color-background-rail); }
  .pe-acc.on .pe-head{ background:var(--color-background-rail); }
  /* ⚠ Межстрочный ОБЯЗАН стоять в шорткате `font:`: без него шоткат сбрасывает
     унаследованные от `.fm-paper` 1.5 в normal, и строка шапки садится на 17 вместо 21. */
  .pe-name{ font:700 14px/1.5 'Inter'; color:var(--color-text-primary); }
  .pe-sum{ font:400 13px/1.5 'Inter'; color:var(--color-text-tertiary); text-align:right; }
  .pe-sum.warn{ color:var(--color-negative); }
  .pe-car{ color:var(--color-text-tertiary); font-size:11px; flex:none; transition:transform 240ms var(--ease-out-quart); }
  .pe-acc.on .pe-car{ transform:rotate(180deg); }
  .pe-slot{ display:grid; grid-template-rows:0fr; transition:grid-template-rows 260ms var(--ease-out-quart); }
  .pe-acc.on .pe-slot{ grid-template-rows:1fr; }
  .pe-slot > div{ overflow:hidden; min-height:0; }
  .pe-body{ padding: var(--space-4) var(--space-4) var(--space-4); border-top:1px solid var(--color-line-1); }
  /* ⚠ ШАГ СЕТКИ ПОЛЕЙ В СЕКЦИИ — 12/12 (макет `.pe-grid`). Общий `.ef-grid` разводит
     колонки на 16: в двухколоночной секции это делало вертикальный шов шире
     горизонтального, и пара «Клиент | Статус» переставала читаться парой. */
  .pe-body .ef-grid{ column-gap: var(--space-3); }
  /* Под-шапка группы внутри секции: название + счётчик + СВОЯ кнопка добавления */
  .pe-bar{ display:flex; align-items:center; gap: var(--space-2); margin-bottom: var(--space-3); }
  .pe-bar-t{ font:700 12px/1.5 'Inter'; text-transform:none; color:var(--color-text-secondary); }
  .pe-bar-c{ font:400 12px/1.5 'Inter'; color:var(--color-text-tertiary); font-variant-numeric:tabular-nums; }
  /* ⚠ ПУНКТИР — ЭТО СМЫСЛ, А НЕ УКРАШЕНИЕ (макет `.pe-add`). Сплошная белая кнопка
     весила столько же, сколько «Готово» в шапке панели, — то есть обещала
     завершающее действие. Пунктир говорит другое: «здесь будет ещё одна строка».
     Высота 30, а не 36: это не поле формы, и вставать вровень с полями ей незачем. */
  .pe-add{ display:inline-flex; align-items:center; gap: var(--space-1); font:400 13px 'Inter'; padding: 0 var(--space-2h);
           border-radius: var(--radius); border:1px dashed var(--color-line-1); background:transparent; color:var(--color-text-tertiary); cursor:pointer;
           font-family:inherit; white-space:nowrap; height:30px;
           transition:border-color 140ms, background 140ms, color 140ms, scale 120ms; }
  .pe-add:hover{ border-color:var(--color-line-strong); color:var(--color-text-secondary); }
  .pe-add:active{ scale:.96; }
  .pe-add .car{ font-size:9px; color:var(--color-text-tertiary); }
  .pe-menuwrap{ position:relative; }
  /* Выпадашка должна ЧИТАТЬСЯ как слой над таблицей. Плоская рамка сливалась
     со строками ниже — отсюда ощущение «месса». Глубина — слоями теней. */
  .pe-menu{ position:absolute; top:calc(100% + 6px); right:0; min-width:212px; background:var(--color-background-primary);
            border-radius: var(--radius); padding: var(--space-1); z-index:30;
            box-shadow:0 0 0 1px rgba(44,46,49,.06), 0 2px 6px rgba(44,46,49,.05),
                       0 12px 28px rgba(44,46,49,.13); }
  .pe-menu .mh{ font:700 10.5px 'Inter'; text-transform:none;
                color:var(--color-text-tertiary); padding: var(--space-2) var(--space-2) var(--space-1); }
  .pe-mi{ display:block; width:100%; text-align:left; font:500 13.5px 'Inter'; padding: var(--space-2) var(--space-2);
          border-radius: var(--radius); border:none; background:none; color:var(--color-text-primary); cursor:pointer; font-family:inherit; }
  .pe-mi:hover{ background:var(--color-background-hover); }
  .pe-mi small{ display:block; font:400 11.5px 'Inter'; color:var(--color-text-tertiary); margin-top:1px; }
  .pe-msep{ height:1px; background:var(--color-background-tertiary); margin: var(--space-1) var(--space-2); }
  /* Строки-сущности внутри секции */
  .pe-rows{ border-top:1px solid var(--color-background-tertiary); }
  /* ── ТАБЛИЦА ОПЕРАЦИЙ — КАРТОЧКА (макет `.mn`, 18.08.2026) ──────────────────
     Отдельным классом, а не правкой `.pe-rows`: в карточку у макета убрана ТОЛЬКО
     таблица денег. Строки подрядчиков, документов и сметы остаются простыми рядами
     с хайрлайном — они живут внутри своих группировок, и вторая рамка вокруг них
     дала бы карточку в карточке.
     ⚠ 11 по вертикали — литерал СОЗНАТЕЛЬНО, в шкалу он не вошёл: это ритм строки
     таблицы, подобранный под высоту ряда 60 (11+38+11). Округление до ближайшей
     ступени (10 или 12) двигает каждый ряд на 2px и всю таблицу на 10. */
  .pe-rows.mn{ border:1px solid var(--color-line-1); border-radius: var(--radius-card); overflow:hidden; }
  .pe-rows.mn .pe-row{ padding: 11px var(--space-4); border-bottom:0;
                       border-top:1px solid var(--color-line-1); font:400 14px/1.5 'Inter'; }
  .pe-rows.mn .pe-row.h{ background:var(--color-background-rail); border-top:0;
                         border-bottom:1px solid var(--color-line-1);
                         font:400 11.5px/1.5 'Inter'; color:var(--color-text-tertiary); }
  .pe-rows.mn .pe-row.h + .pe-row{ border-top:0; }
  .pe-rows.mn .pe-row.f{ background:var(--color-background-rail); padding-top:11px;
                         border-top:1px solid var(--color-line-1); color:var(--color-text-primary); }
  /* Подзаголовок «Без даты» — не строка таблицы: линия над ним уже нарисована. */
  .pe-rows.mn .pe-row.g{ padding: var(--space-3) var(--space-4) var(--space-1); }
  .pe-rows.mn .pe-row.g + .pe-row{ border-top:0; }
  .pe-row{ display:grid; align-items:center; gap: var(--space-3); padding: var(--space-3) 2px;
           border-bottom:1px solid var(--color-background-tertiary); font:400 13.5px 'Inter'; }
  .pe-row.h{ font:700 11px 'Inter'; text-transform:none;
             color:var(--color-text-tertiary); padding: var(--space-2) 2px; }
  .pe-row .r{ text-align:right; }
  /* Зона нажатия крестика — 40×40 через псевдоэлемент: видимый глиф 14px,
     но попадать в него мышью должно быть легко. */
  .pe-x{ position:relative; color:var(--color-text-5); cursor:pointer; text-align:center; font-size:14px;
         line-height:1; transition:color 140ms, scale 120ms; }
  .pe-x::after{ content:""; position:absolute; left:50%; top:50%; width:40px; height:40px;
                transform:translate(-50%,-50%); }
  .pe-x:hover{ color:var(--color-negative); }
  .pe-x:active{ scale:.96; }
  /* РАСЧЁТНОЕ значение — НЕ поле. Раньше «Получено» и «Долг» были серыми боксами
     ровно той же геометрии, что инпуты, и читались как сломанные поля. */
  .pe-ro{ font:700 15px 'Space Grotesk','Inter',sans-serif; font-variant-numeric:tabular-nums; color:var(--color-text-primary); padding: var(--space-2) 0; min-height:37px; display:flex; align-items:center; }
  .pe-ro.accent{ color:var(--color-accent); }
  .pe-ro.muted{ color:var(--color-text-tertiary); }
  /* Долг > 0 — единственное место в блоке «Деньги», где цвет значит «посмотри сюда».
     Раньше цветным было «Получено» (синий = ссылка), а долг шёл нейтральным. */
  .pe-ro.warn{ color:var(--color-warn); }
  /* Денежное поле: те же цифры, что у расчётных значений рядом. Без этого бюджет
     читался как «число из формы» (499996) рядом с соседним «470 000». */
  .ef-in.money{ font:700 15px 'Space Grotesk','Inter',sans-serif; font-variant-numeric:tabular-nums; }
  /* Оболочка поля для чужеродных контролов (статус, теги): снаружи это такое же
     поле, как инпут рядом, внутри — родной пикер. */
  .pe-shell{ border:1px solid var(--color-line-1); border-radius: var(--radius); background:var(--color-background-primary); padding: var(--space-1) var(--space-2);
             min-height:37px; display:flex; align-items:center; gap: var(--space-2); flex-wrap:wrap;
             transition:border-color 140ms; }
  .pe-shell:hover{ border-color:var(--color-text-5); }
  /* `.txt` — оболочка, внутри которой не пикер с чипами, а СТРОКА (договор): у неё
     паддинг и кегль поля ввода, иначе текст в ней крупнее соседей по сетке. */
  /* ⚠ 7px по вертикали — замер макета `.pe-in`, и это не придирка: строка Golos 13.5
     с обычным межстрочным даёт 22, и при паддинге 8 коробка вырастает до 38 — на
     пиксель выше соседних полей той же сетки (поймано замером 18.08.2026). */
  .pe-shell.txt{ padding: var(--space-1h) var(--space-3); font-size:13.5px; line-height:normal; }
  /* Раскрывающаяся строка подрядчика */
  /* Подсветка была var(--color-background-page) — процент тона на белом, её физически не видно, и строка
     читалась как текст, а не как цель. Полоска слева говорит «сюда можно нажать». */
  .pe-conrow{ cursor:pointer; }
  .pe-conrow:hover{ background:var(--color-background-hover); box-shadow:inset 2px 0 0 var(--color-line-1); }
  /* Итог под списком строк: те же колонки, без нижней границы, но плотнее строк. */
  .pe-row.f{ border-bottom:none; padding-top: var(--space-3); color:var(--color-text-primary); font-weight:600; }
  /* Подзаголовок группы внутри таблицы («Без даты»): объясняет пустую колонку датой
     один раз, вместо прочерка в каждой строке. */
  .pe-row.g{ border-bottom:none; padding: var(--space-3) 2px var(--space-1); font:700 10.5px 'Inter';
             text-transform:none; color:var(--color-text-4); }
  /* Статус операции — ДВА слова на всё приложение: план и факт. Просрочка не отдельный
     статус, а красная дата у плана; «расчётный» не статус, а пунктир под суммой.
     Ширина фиксированная, иначе правый край колонки рвётся. */
  .pe-chip{ display:inline-block; min-width:56px; text-align:center; font:600 11.5px 'Inter';
            padding: 3px var(--space-2); border-radius: var(--radius); border:1px solid transparent; }
  /* Оба чипа были почти одной светлоты — боковым зрением «факт» и «план» не различались. */
  .pe-chip.ok{ background:var(--color-positive-soft); color:var(--color-positive); }
  .pe-chip.plan{ background:var(--color-background-tertiary); color:var(--color-text-secondary); }
  .pe-conedit{ padding: var(--space-3) 2px var(--space-1); border-bottom:1px solid var(--color-background-tertiary); }
  /* Подсказки — без «сирот» на последней строке */
  .ef-hint, .pe-empty{ text-wrap:pretty; }
  /* Композитный бокс суммы транша: комиссия банка — вторая строка ВНУТРИ бокса.
     ⚠ Не выносить её отдельным полем: этот вариант уже забраковали — рвёт сетку. */
  .pe-amt{ border:1px solid var(--color-line-1); border-radius: var(--radius); overflow:hidden; background:var(--color-background-primary);
           transition:border-color 150ms; }
  .pe-amt:hover{ border-color:var(--color-text-5); }
  .pe-amt:focus-within{ border-color:var(--color-blue); box-shadow:var(--focus-ring); }
  .pe-amt input{ width:100%; border:none; background:transparent; outline:none; font-family:inherit;
                 font-size:13.5px; padding: var(--space-2) var(--space-3); color:var(--color-text-primary); text-align:right; }
  .pe-amt .comm{ display:flex; align-items:center; border-top:1px dashed var(--color-line-1); background:var(--color-background-page); }
  .pe-amt .comm span.lbl{ font:700 9.5px 'Inter'; text-transform:none;
                          color:var(--color-text-4); padding-left: var(--space-2); white-space:nowrap; }
  .pe-amt .comm input{ font-size:11.5px; padding: var(--space-1) var(--space-1); }
  /* Тихая ссылка «+ комиссия» — опция, а не поле: не должна весить как контрол */
  .pe-quiet{ display:inline-block; margin-top: var(--space-1); font:400 11.5px 'Inter'; color:var(--color-text-4);
             cursor:pointer; transition:color 140ms; }
  .pe-quiet:hover{ color:var(--color-accent); }
  /* Стрелка «в карточку подрядчика» — тихая, но всегда видимая: по hover её бы не нашли */
  .pe-go{ flex:none; display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px;
          border-radius: var(--radius); color:var(--color-text-4); cursor:pointer; transition:background var(--t-fast), color var(--t-fast); }
  .pe-go:hover{ background:var(--color-accent-soft); color:var(--color-accent); }
  .pe-warnrow{ display:flex; justify-content:space-between; align-items:center; gap: var(--space-2);
               flex-wrap:wrap; margin-top: var(--space-3); font:400 13px 'Inter'; color:var(--color-warn); }
  .pe-empty{ font:400 13px 'Inter'; color:var(--color-text-tertiary); padding: var(--space-4) 2px; text-align:center;
             border:1px dashed var(--color-line-1); border-radius: var(--radius); }
  /* статус-ссылка (выплачено/долг/получено/ожидается): подчёркивание выезжает слева направо при наведении на строку или слово */
  .fm-statlink{ position:relative; display:inline-block; }
  .fm-statlink::after{ content:""; position:absolute; left:0; right:0; bottom:-2px; height:1.5px; background:currentColor; border-radius: var(--radius); transform:scaleX(0); transform-origin:left center; transition:transform .3s cubic-bezier(.4,0,.2,1); }
  .fm-payrow:hover .fm-statlink::after, .fm-statlink:hover::after{ transform:scaleX(1); }
  /* Смета-модалка: чистые строки, редактирование по клику, грип/крестик на ховере */
  .sm-irow{ border-bottom:1px solid var(--color-background-secondary); transition:background .1s; }
  .sm-irow:hover{ background:var(--color-background-page); }
  .sm-val{ transition:border-color .12s; }
  .sm-irow:hover .sm-val, .sm-taxrow:hover .sm-val{ border-bottom-color:var(--color-line-1) !important; }
  .sm-del{ opacity:0; transition:opacity .12s; }
  .sm-irow:hover .sm-del, .sm-sechd:hover .sm-del, .sm-taxrow:hover .sm-del{ opacity:1; }
  .sm-grip{ opacity:0; transition:opacity .12s; cursor:grab; }
  .sm-irow:hover .sm-grip, .sm-sechd:hover .sm-grip{ opacity:.5; }
  .sm-grip:hover{ opacity:.9 !important; }
  .sm-addlink{ border:0; background:none; padding:0; font:500 16px 'Inter'; color:var(--color-text-tertiary); cursor:pointer; transition:color .12s; }
  .sm-addlink:hover{ color:var(--color-text-primary); }
  /* Пульсация «в работе» удалена вместе с точками-маркерами (2026-08-11): пульсировать
     было нечему — сам пульс жил только на кружке 6px рядом со словом «в работе». */
  button {
    font-family: inherit;
    transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast),
                transform var(--t-fast), box-shadow var(--t-fast);
  }
  /* Тактильный отклик на нажатие (принцип scale-on-press). transform уже в transition выше.
     :not(:disabled) — не «дёргать» неактивные; элементы с инлайн-transform (драг и т.п.) не затрагиваются. */
  button:active:not(:disabled) { transform: scale(0.96); }
  /* Единый «календарный» округлый язык: все кнопки — пилюли/круги (как «Сохранить»/дни в date-picker).
     !important перебивает инлайновые radius-sm/md/lg. Инпуты/карточки (div) не затрагиваются. */
  button { border-radius: var(--radius) !important; }
  input[type=number] { -moz-appearance: textfield; }
  input[type=number]::-webkit-outer-spin-button,
  input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
  /* Плейсхолдер обязан отличаться от значения. Правила не было вообще — цвет брал браузер,
     и на песочном поле подсказка «0» читалась как введённый ноль, а пример «Точка / частный
     займ» — как заполненное поле (см. скриншот экрана займов 2026-08-05). */
  ::placeholder { color: var(--color-text-4); opacity: 1; }
  /* Системный outline не используем нигде — только общее кольцо (см. --focus-ring).
     Раньше здесь стоял `outline: 2px solid var(--color-accent)`: у select и полей
     форм он давал сплошную кобальтовую рамку, которая в песочном интерфейсе
     читалась как ошибка ввода, а не как «сюда попадут нажатия клавиш». */
  button:focus, input:focus, select:focus, textarea:focus { outline: none; }
  button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
    outline: none; box-shadow: var(--focus-ring);
  }
  /* Форма реквизитов контрагента (ClientFormFields). Вся форма — ОДНА сетка: интервал
     между полями задаёт gap, своих вертикальных отступов у полей нет. Число колонок
     задано прямо, а не через auto-fill: форма живёт и в широкой модалке, и в узкой
     панели справа, а auto-fill считает колонки от ширины и давал бы в них разное
     число столбцов при одинаковом наборе полей. */
  .cff-grid { display:grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--space-3); align-items:start; }
  @media (max-width: 560px) { .cff-grid { grid-template-columns: minmax(0,1fr); } }
  .btn-primary { background: var(--color-accent); color:var(--color-accent-soft); box-shadow:var(--shadow-accent); }
  .btn-primary:hover { filter:brightness(1.12); } /* анти-нейро: без подлёта и свечения — только тон */
  .btn-primary:active { transform:translateY(0); filter:brightness(.98); }
  .fm-card { background:var(--color-background-primary); border:none; box-shadow:none; transition:transform var(--t-med); }
  .fm-card:hover { box-shadow:none; }
  .fm-card .fm-card { background: var(--color-background-secondary); }
  .btn-ghost { background:transparent; color:var(--color-text-secondary); border:0.5px solid var(--color-border-secondary); }
  .btn-ghost:hover { background:var(--color-background-secondary); color:var(--color-text-primary); }
  /* ⚠ !important обязателен: глобальное `button{border-radius:var(--radius) !important}`
     перебивает и класс, и инлайн. У пункта сайдбара радиус 8 (замер макета), у
     остальных контролов 10 — это разные роли, а не разнобой. */
  /* Песочная панель: две колонки только пока цепочка помещается в левую одной
     строкой. Ниже 1300 складывается, и цепочка получает всю ширину. */
  .fm-loud-grid{ grid-template-columns:minmax(0,2fr) minmax(0,1fr); }
  @media(max-width:1300px){ .fm-loud-grid{ grid-template-columns:1fr; gap:20px !important; } }
  /* ══ БЛОК «ХВАТИТ ЛИ ДЕНЕГ» — СВОИ КЛАССЫ, НИ С ЧЕМ НЕ ПЕРЕПЛЕТЁННЫЕ ══
     Заведены после того, как попытка выразить связь «абзац по границе отдать»
     через общую сетку растянула колонки цепочки на всю ширину: текст участвовал
     в расчёте max-content. Правило: у этого блока собственные имена, и ничего
     общего с .card/.lrow/.step остальных экранов. */
  .fm-chain{ display:flex; align-items:flex-end; gap:8px; flex-wrap:nowrap; }
  /* Интерлиньяж 1.5 — замер макета (.loud p: 14/21). Было 1.55: те же 14px,
     но строка на 0,7px выше, и абзац из двух строк вставал на 1,4px ниже. */
  .fm-horizon-note{ font-size:14px; line-height:1.5; color:var(--color-text-secondary);
    margin-top:16px; max-width:58ch; }
  .nav-item { background: transparent; border-radius:8px !important; transition: background var(--t-fast), color var(--t-fast); }
  .nav-item:hover { background:var(--color-background-secondary) !important; color:var(--color-text-primary) !important; }
  .nav-item.is-active { background:var(--color-accent-soft) !important; color:var(--color-accent) !important; font-weight:600; }
  .nav-item:focus, .nav-item:focus-visible { outline: none !important; }
  .card { transition: border-color var(--t-fast), box-shadow var(--t-fast), transform var(--t-fast); }
  .card:hover { box-shadow:var(--shadow-sm); border-color:var(--color-border-secondary); }
  .card.is-selected { border-color:var(--color-accent); box-shadow:var(--shadow-sm); }
  /* ── КАРТОЧКА ПРОЕКТА НА КАНБАНЕ — перенос из макета (2026-08-16) ──
     Паспорт снят замером с `~/Desktop/dva-scheta-paddle.html`, а не подобран на глаз:
     рамка 1px --color-line-1, радиус карточки, паддинг 16, ховер — --color-line-strong,
     выбранная — кобальтовая рамка + кольцо 1px. Движение 800мс на цвет, как у .fm-cta;
     изинг НАШ (--ease-out-quart), кривую Paddle второй системой не заводим.
     ⚠ Классы `.card` и общий класс пропорции у карточки СНЯТЫ, и это не уборка, а два исправления:
     ① `.card:hover` красил рамку в --color-border-secondary, а он ПРОЗРАЧНЫЙ — рамка при
     наведении исчезала вместо того, чтобы набирать контраст; ② класс плитки держал пропорцию
     5:3, из-за которой карточка «Запроса» (у неё нет ни полосы, ни пары «получено / бюджет»)
     добирала пустотой высоту насыщенной. Высоту задаёт контент — правило «высота от
     контента, а не от пропорции». Полоса под главным числом может отсутствовать: кегли в
     ряду она не меняет. После этого класс пропорции остался без носителей и удалён совсем. */
  .fm-pcard { background:var(--color-background-primary); border:1px solid var(--color-line-1);
              border-radius:var(--radius-card); padding:var(--space-4); cursor:pointer;
              transition:border-color 800ms var(--ease-out-quart), box-shadow 800ms var(--ease-out-quart), opacity 800ms var(--ease-out-quart); }
  .fm-pcard:hover { border-color:var(--color-line-strong); }
  .fm-pcard.is-selected { border-color:var(--color-accent); box-shadow:0 0 0 1px var(--color-accent); }
  .fm-pcard.is-off { opacity:.62; }
  /* ── ПЕСОЧНАЯ ПАНЕЛЬ НАД ДОСКОЙ («Проекты») — перенос из макета 16.08.2026 ──
     Единственный громкий блок экрана: доска под ней тихая. Две колонки — деньги
     доски слева, кому отдать справа; та же композиция, что у «Хватит ли денег».
     ⚠ Две колонки только пока цепочка «бюджет − получено = ждём» помещается в левую
     ОДНОЙ строкой (замер тот же, что на дашборде: ~1300px). Перенос строки делает
     «ждём» отдельным числом вместо итога ряда — уменьшаем кегль, а не рвём строку. */
  .fm-board-loud { display:grid; grid-template-columns:minmax(0,2fr) minmax(0,1fr); gap: var(--space-6); }
  @media (max-width:1300px) { .fm-board-loud { grid-template-columns:1fr; gap: var(--space-5); } }
  /* ⚠ ОТБИВКА ЦЕПОЧКИ ОТ ПОДПИСИ — 18, А НЕ 12 (правка 17.08.2026 по замечанию
     Игоря «расстояние в макете больше, чем у нас»). У макета это не одно
     значение, а СХЛОПНУВШАЯСЯ ПАРА: `.card-s{margin-bottom:18}` и
     `.chain{margin-top:14}` стоят в блочном потоке и дают max(18,14)=18. У нас
     подпись margin-bottom не несёт, поэтому 18 надо назвать здесь прямо —
     иначе цепочка, абзац под ней и кнопки поднимаются на 6px каждое.
     Копируя из макета отступ между соседями, всегда проверяй, не схлопываются
     ли там два: одно из значений в исходнике окажется меньше видимого. */
  .fm-chain { display:flex; align-items:flex-end; gap: var(--space-2); flex-wrap:nowrap; margin-top:18px; }
  .fm-chain-s { min-width:0; }
  .fm-chain-k { font-size:13px; line-height:1.5; color:var(--color-text-secondary); white-space:nowrap; }
  .fm-chain-v { font-size:clamp(19px,1.85vw,26px); line-height:1.15; margin-top:2px; white-space:nowrap; }
  .fm-chain-s.is-fin .fm-chain-v { font-size:clamp(23px,2.2vw,31px); }
  /* Межстрочный у знака — 1.5, как у макета (`.chain .op` его не задаёт и берёт
     от body). Коробка знака от этого выше коробки числа, а ряд выровнен по низу,
     поэтому «−» и «=» встают ВЫШЕ середины цифр — на уровне их верхней трети.
     На 1.15 знак съезжал к середине и читался как часть числа, а не как
     арифметический шов между двумя величинами. */
  .fm-chain-op { font-size:clamp(16px,1.55vw,22px); line-height:1.5; color:var(--color-text-tertiary); padding-bottom:6px; flex:none; }
  /* На узком экране знаки арифметики в столбик превращаются в россыпь чёрточек —
     порядок несут строки, а итог отбивается линейкой на песке. */
  @media (max-width:760px) {
    .fm-chain { display:grid; grid-template-columns:1fr 1fr; gap: var(--space-3) var(--space-4); align-items:start; }
    .fm-chain-op { display:none; }
    .fm-chain-s.is-fin { grid-column:1 / -1; border-top:1px solid #E4D3AE; padding-top: var(--space-3); }
  }
  .pill { transition: background var(--t-fast), color var(--t-fast); }
  /* ── Экран «Кредиты и займы» ──
     Карточка займа была <div onClick>: без наведения, без отклика на нажатие и недостижима
     с клавиатуры. Стала <button> — и тем самым получила глобальные :active/:focus-visible.
     scale(.96) от общего правила для карточки такого размера слишком крупный — тише. */
  /* Фон живёт в классе, а не инлайном: инлайновый стиль перебил бы :hover и выбранное состояние.
     Выбранная карточка — тот же токен активного состояния, что у сайдбара и фильтров. */
  .ln-card { background: var(--color-background-primary); border: 1px solid transparent;
             transition: border-color var(--t-fast), background var(--t-fast); }
  .ln-card:hover { background: var(--color-background-secondary); }
  .ln-card[data-sel="true"], .ln-card[data-sel="true"]:hover { background: var(--color-accent-soft); }
  .ln-card:active:not(:disabled) { transform: scale(.995); }
  .ln-card[data-pending="true"] { border-color: var(--color-line-1); border-style: dashed; background: transparent; }
  /* Поле формы займа отрисовано инлайновым объектом, поэтому :focus достаётся только классом;
     !important — чтобы перебить инлайновый border (тот же приём, что у .doc-field:focus).
     Переход держим здесь, а не в инлайновом объекте: тот же объект спредится в кнопки формы,
     и инлайновый transition перебил бы глобальный button-переход вместе с scale-на-нажатие. */
  .ln-in { transition: border-color 150ms var(--ease-out-quad), box-shadow 150ms var(--ease-out-quad); }
  .ln-in:focus { border-color: var(--color-blue) !important; }
  /* ── Отчёт «Прибыли и убытки» ──
     Сумма в отчёте — это вход в операции, из которых она сложилась, но об этом не говорило
     ничего: нажимаемое число выглядело ровно так же, как итоговая строка, которая не
     нажимается («Выручка», «Валовая прибыль»). Пунктир виден В ПОКОЕ — обнаружить можно,
     не водя мышью по таблице; на наведении число становится песочной пилюлей, тем же
     активным состоянием, что у сайдбара и фильтров, а пунктир уходит (он своё отработал). */
  .pnl-row { transition: background var(--t-fast); }
  .pnl-row:hover { background: var(--color-background-secondary); }
  .pnl-cell { border-radius: var(--radius); transition: background var(--t-fast); }
  .pnl-cell > span { border-bottom: 1px dashed var(--color-line-1); padding-bottom: 1px; transition: border-color var(--t-fast); }
  .pnl-cell:hover { background: var(--color-accent-soft); }
  .pnl-cell:hover > span { border-bottom-color: transparent; }
  /* Столбец «Статья» не уезжает при прокрутке вправо: без имени строки число ничего не
     значит, а месяцев в году двенадцать — правый край таблицы всегда за экраном.
     Липнет ЯЧЕЙКА каждой строки, а не колонка: колонок как элементов в гриде нет.
     Отсюда обязательный непрозрачный фон — иначе числа поедут под текст статьи. */
  .pnl-stick { position: sticky; left: 0; z-index: 1; background: var(--color-background-primary); }
  .pnl-row:hover .pnl-stick { background: var(--color-background-secondary); }
  /* Пояснение раздела («привязаны к проектам») — только под курсором: в покое таблица
     про цифры, а термин нужен ровно в тот момент, когда смотришь на конкретный расход.
     Ведём мышью по любой строке раздела — подпись проявляется у его заголовка. */
  .pnl-hint { opacity: 0; transition: opacity var(--t-fast); }
  .pnl-sec:hover .pnl-hint { opacity: 1; }
  /* Разворот суммы: окно по центру, вход — тот же, что у остальных центрированных окон. */
  .pnl-scrim { animation: fm-fade-in var(--dur-2) ease both; }
  .pnl-modal { animation: fm-onb-in var(--dur-3) var(--ease-out-quart) both; }
  /* Выводы. Отдельный механизм от подсказок, и НАМЕРЕННО непохожий: подсказка — про
     устройство приложения, вывод — про деньги пользователя. Прятать вывод за значком «?»
     значит записать его в справку.
     Полоса лежит слоем по низу карточки (position:absolute), а не в потоке: иначе
     появление толкало бы содержимое и ряд прыгал бы на каждом проходе мышью.
     Цель наведения — вся карточка: попасть в неё легко, специально целиться не нужно.
     focus-within — чтобы вывод открывался и с клавиатуры, а не только мышью. */
  .fm-take-host { position:relative; }
  .fm-take {
    position:absolute; left:0; right:0; bottom:0; z-index:5;
    padding:var(--space-3) var(--space-5) var(--space-4);
    background:var(--color-background-primary);
    border-top:1px solid var(--color-line-1);
    /* Радиус КАРТОЧНЫЙ: полоса ложится по низу карточки, и на контрольном радиусе
       её углы не совпадали с углами карточки под ней — на 6px мимо. */
    border-radius:0 0 var(--radius-card) var(--radius-card);
    font-size:13.5px; line-height:1.5; color:var(--color-text-secondary); text-wrap:pretty;
    opacity:0; transform:translateY(6px); pointer-events:none;
    transition:opacity 180ms var(--ease-out-quart), transform 180ms var(--ease-out-quart);
  }
  .fm-take-host:hover .fm-take, .fm-take-host:focus-within .fm-take { opacity:1; transform:none; }
  /* Уход быстрее прихода — see .fm-hint-p. */
  .fm-take-host:not(:hover):not(:focus-within) .fm-take { transition-duration:140ms; }
  /* Без наведения (тач) вывод недостижим, поэтому там он просто стоит в потоке. */
  @media (hover:none) {
    .fm-take { position:static; opacity:1; transform:none; padding:var(--space-3) 0 0; margin-top:var(--space-3); }
  }

  /* ⚠ КНОПКА-ДЕЙСТВИЕ КАРТОЧКИ — ОДИН КЛАСС .fm-cta (16.08.2026, замер макета
     dva-scheta-paddle.html). Паспорт: паддинг 11/16, радиус var(--radius) = 10,
     кегль 14.5, интерлиньяж 1.5, зазор до иконки 8, высота 43.75 — и она РАСТЁТ
     ИЗ ПАДДИНГА, а не задаётся числом.
     ⚠ lineHeight ОБЯЗАТЕЛЕН. Без него браузер берёт метрику шрифта (normal), и
     при тех же паддингах кнопка выходит 39px вместо 43.75 — на пять пикселей
     ниже образца. Именно так разъезжались три кнопки дашборда: GBTN держал
     height:40 числом, LOUD_BTN не держал ничего.
     ⚠ Рамка есть у ВСЕХ вариантов, у залитых — цветом заливки. Иначе залитая и
     обведённая кнопки в одном ряду отличаются на 2px, и ряд выравнивается
     растяжением — то есть высота кнопки начинает зависеть от соседа.
     ⚠ Цвет медленный, геометрия быстрая (закон макета): 800ms на смену фона —
     это НЕ описка. Изинг берём свой, --ease-out-quart: второй системы кривых
     в приложении быть не должно, а на восьмистах миллисекундах разница между
     ним и кривой макета неразличима.
     Высоту контролов ШАПКИ (--h-control 42px) и пилюль окна операции (37px)
     этот класс не трогает: у них свои правила и свои причины. */
  .fm-cta {
    display:inline-flex; align-items:center; gap:var(--space-2);
    padding:11px 16px; border-radius:var(--radius); border:1px solid transparent;
    font-family:inherit; font-size:14.5px; line-height:1.5; white-space:nowrap;
    background:transparent; color:var(--color-text-primary); cursor:pointer;
    transition:background-color 800ms var(--ease-out-quart),
               color 800ms var(--ease-out-quart),
               border-color 800ms var(--ease-out-quart);
  }
  .fm-cta-ink { background:var(--color-text-primary); border-color:var(--color-text-primary); color:var(--color-accent-soft); }
  .fm-cta-ink:hover { background:var(--color-ink-soft); border-color:var(--color-ink-soft); }
  .fm-cta-a   { background:var(--color-accent); border-color:var(--color-accent); color:var(--color-accent-soft); }
  .fm-cta-a:hover { background:var(--color-accent-dark); border-color:var(--color-accent-dark); }
  .fm-cta-g   { border-color:var(--color-line-1); color:var(--color-text-primary); }
  .fm-cta-g:hover { background:var(--color-background-hover); border-color:var(--color-line-strong); }
  /* На песочной панели линия своя — та же, что у разделителей внутри неё.
     ⚠ ЛИТЕРАЛ, И ЭТО ИЗВЕСТНАЯ ДЫРА, А НЕ НЕБРЕЖНОСТЬ. #E4D3AE — цвет без имени:
     он уже стоит рамкой CARD_LOUD и линейкой строк «С кого ждём», то есть роль
     у него есть («линия НА песке», в макете это --sand-deep), а токена нет.
     Завести его через design/tokens.json СЕЙЧАС НЕЛЬЗЯ: файл отстал от :root
     (в нём ещё дописочные #2C2E31 и #E4E1D6), и tokens-sync --to-css откатит
     потепление палитры от 15.08. Сначала синхронизировать источник, потом
     токенизировать все четыре места разом. */
  .fm-cta-g.on-sand { border-color:#E4D3AE; }
  .fm-cta-g.on-sand:hover { background:transparent; border-color:var(--color-line-strong); }

  /* Подсказки. Значок должен быть заметен, только когда его ищут, поэтому он тихий
     по умолчанию и набирает контраст на наведении. Размер 15px и вертикальное
     выравнивание по середине строки: подпись рядом не должна подпрыгивать. */
  .fm-hint { position:relative; display:inline-flex; vertical-align:middle; margin-left:var(--space-2); }
  .fm-hint-b {
    width:15px; height:15px; flex:none; padding:0; cursor:pointer;
    border:none; border-radius:var(--radius-full);
    background:var(--color-background-secondary); color:var(--color-text-4);
    font:inherit; font-size:10px; line-height:15px; text-align:center;
    transition:background var(--t-med), color var(--t-med);
  }
  .fm-hint-b:hover, .fm-hint-b[aria-expanded="true"] { background:var(--color-accent-soft); color:var(--color-accent); }
  .fm-hint-p {
    position:absolute; left:0; z-index:70; display:block;
    padding:var(--space-3) var(--space-4);
    background:var(--color-background-primary); border:1px solid var(--color-line-1);
    border-radius:var(--radius); box-sizing:border-box;
    font-size:13px; line-height:1.45; color:var(--color-text-secondary); text-align:left;
    white-space:normal; font-weight:400;
    /* Ключевые кадры, а не transition: элемент появляется в DOM уже открытым,
       и переходу не от чего стартовать — он сработал бы только со второго раза. */
    animation:fm-hint-in 180ms var(--ease-out-quart) both;
  }
  /* Уход быстрее прихода — иначе закрытие читается как задержка интерфейса. */
  .fm-hint-p[data-off] { animation:fm-hint-out 140ms var(--ease-out-quart) both; }
  .fm-hint-p[data-side="up"] { animation-name:fm-hint-in-up; }
  .fm-hint-p[data-side="up"][data-off] { animation-name:fm-hint-out-up; }
  @keyframes fm-hint-in     { from { opacity:0; transform:translateY(-4px) } to { opacity:1; transform:none } }
  @keyframes fm-hint-out    { from { opacity:1 } to { opacity:0; transform:translateY(-3px) } }
  @keyframes fm-hint-in-up  { from { opacity:0; transform:translateY(4px) }  to { opacity:1; transform:none } }
  @keyframes fm-hint-out-up { from { opacity:1 } to { opacity:0; transform:translateY(3px) } }
  /* Панель у правого края экрана не должна уезжать за сгиб. */
  @media (max-width:900px) { .fm-hint-p { left:auto; right:0; } }

  /* Подрядчики: три карточки в ряд. Число колонок задано прямо, а не выведено из минимальной
     ширины трека через auto-fill — иначе одна вёрстка даёт два столбца на одном экране и
     четыре на другом. minmax(0,1fr) вместо 1fr: у грид-колонки минимум по умолчанию auto,
     и длинное имя подрядчика распирало бы столбец шире доли. Ступени — там, где три колонки
     перестают вмещать пару «имя + ставка». */
  /* grid-auto-rows:1fr — ВСЕ ряды одной высоты, по самой высокой карточке набора (fr-ряды
     делят место равными долями и не могут стать ниже своего содержимого). Внутри ряда высоту
     равнял stretch и раньше, а вот ряд с двумя строками компетенций стоял выше ряда без них —
     сетка распадалась на полосы. Размер задаёт слот, а не данные конкретного подрядчика. */
  .fm-con-grid { display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:var(--space-3); grid-auto-rows:1fr; }
  @media (max-width:1180px) { .fm-con-grid { grid-template-columns:repeat(2, minmax(0,1fr)); } }
  @media (max-width:780px)  { .fm-con-grid { grid-template-columns:minmax(0,1fr); } }

  /* Клиенты — та же лесенка, шире только шаг сетки. ⚠ Ключевое здесь minmax(0,1fr) вместо
     1fr: у грид-колонки минимум по умолчанию auto, то есть НЕ УЖЕ содержимого. Строка
     «Расходы 265 099 ₽ · Прибыль 1 184 901 ₽» и имя «ИП Зуев Игорь Владимирович» не
     переносятся — три такие колонки требовали больше, чем ширина экрана, и третья карточка
     уезжала за правый край (у пользователя обрезалась 06.08). */
  /* Водопад «Хватит ли денег» кликабелен целиком (уводит в «Поток»), а кликабельная
     область без отклика на курсор ничем себя не выдаёт. Тонировать фон нельзя —
     подложка столбцов светлая и тинт съел бы их края; поэтому откликаются подписи
     колонок: уходят из служебного серого в текстовый. Живёт классом, а не инлайном,
     потому что :hover инлайновым стилем не выражается, а состояние на React‑хуке
     перерисовывало бы весь дашборд на каждое движение мыши. */
  .fm-wf:hover .fm-wf-name { color: var(--color-text-secondary); }
  .fm-wf-name { transition: color 160ms var(--ease-out-quart); }
  .fm-cl-grid { display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:var(--space-5); }
  @media (max-width:1180px) { .fm-cl-grid { grid-template-columns:repeat(2, minmax(0,1fr)); } }
  @media (max-width:780px)  { .fm-cl-grid { grid-template-columns:minmax(0,1fr); } }

  /* Пояснения в «Правилах учёта» скрываются одним переключателем на экран.
     Скрываем display'ем, а не высотой: свёрнутый абзац не должен занимать место
     и не должен читаться скринридером как пустой блок. */
  /* !important обязателен: у части блоков display задан ИНЛАЙНОМ (display:"grid"),
     а инлайновый стиль перебивает правило из таблицы стилей. Без него сетка
     примеров оставалась видимой при свёрнутых пояснениях. */
  [data-notes="off"] .ac-note { display:none !important; }
  /* Строка-суть — то, что остаётся ВСЕГДА: одно предложение вместо абзаца.
     Когда пояснения раскрыты, она бы дублировала первый абзац — прячем её. */
  [data-notes="on"] .ac-lead { display:none; }
  .ac-lead { font-size:13.5px; color:var(--color-text-tertiary); line-height:1.45; margin:-2px 0 var(--space-3); }
  /* Карточка-справка: в ней нечего крутить. Отличается от настройки заливкой
     вместо белого — чтобы взгляд не искал в ней ручку. */
  .ac-info { background:var(--color-background-secondary) !important; }
  .pnl-drill-row { transition: background var(--t-fast); }
  .pnl-drill-row:hover { background: var(--color-background-secondary); }
  @media (prefers-reduced-motion: reduce) { .pnl-scrim, .pnl-modal { animation: none !important; } }
  /* ── Сегментный тумблер (SegSwitch) ──
     Одно решение = один контрол: дорожка с рамкой (правило «рамка var(--color-line-1) = интерактивное»)
     и песочная шайба на выбранном. Заливка/цвет — тот же токен активного состояния,
     что у сайдбара и фильтров: var(--color-accent-soft) + var(--color-accent), новых цветов не заводим.
     Шайба едет и одновременно тянется по ширине сегмента; ширина идёт чуть дольше
     сдвига — на излёте получается лёгкий резиновый довод вместо механического прыжка. */
  /* ── Чип-фасет ленты «Операции» ─────────────────────────────────────────
     Один вид на все отборы: и на выбор одного из трёх (тип операции), и на
     мультивыбор (счета, статьи). Раньше первые собирались тумблером SegSwitch —
     из-за этого строка фильтров читалась как два разных механизма подряд.
     Подпись чипа всегда называет ТЕКУЩЕЕ состояние словами («Любой тип» →
     «Приходы»), поэтому отбор виден, не открывая список.
     Радиус 9px, не капсула: капсулы у нас убраны глобально (анти-нейро). */
  /* ⚠ Чип — КОНТЕЙНЕР, а не кнопка: внутри два самостоятельных действия
     (раскрыть список и снять отбор). Кнопка внутри кнопки — невалидная
     разметка, и с клавиатуры до крестика было бы не добраться. */
  .ops-chip { display:inline-flex; align-items:center; height:34px; max-width:250px;
              padding: 0 var(--space-1) 0 0; border-radius: var(--radius); border:1px solid var(--color-line-1); background:var(--color-background-primary);
              color:var(--color-text-secondary); font-size:14px; white-space:nowrap; box-sizing:border-box;
              transition: background-color 160ms var(--ease-out-quad), color 160ms var(--ease-out-quad), border-color 160ms var(--ease-out-quad); }
  .ops-chip:hover { border-color:var(--color-line-1); }
  .ops-chip.is-on { background:var(--color-accent-tint, var(--color-accent-soft)); border-color:transparent; color:var(--color-accent); }
  .ops-chip-main { display:inline-flex; align-items:center; gap: var(--space-1); min-width:0; height:100%;
                   padding: 0 var(--space-1) 0 var(--space-3); border:none; background:transparent; color:inherit;
                   font-family:inherit; font-size:inherit; white-space:nowrap; cursor:pointer;
                   border-radius: var(--radius); -webkit-tap-highlight-color:transparent; }
  /* Фокус виден, но чёрной рамки в интерфейсе нет — кольцо песочное. */
  .ops-chip-main:focus-visible, .ops-chip-x:focus-visible { outline:2px solid var(--color-warn-mid); outline-offset:2px; }
  .ops-chip-lbl { overflow:hidden; text-overflow:ellipsis; }
  /* ✕ — со своим именем и зоной нажатия 22px: иначе в неё не попасть ни
     пальцем, ни курсором, а скринридер назвал бы её «крестик». */
  .ops-chip-x { display:inline-flex; align-items:center; justify-content:center; width:22px; height:22px;
                flex-shrink:0; border:none; border-radius: var(--radius); background:transparent; color:inherit;
                font-family:inherit; font-size:12px; line-height:1; cursor:pointer; opacity:.6;
                transition: background-color 140ms var(--ease-out-quad), opacity 140ms var(--ease-out-quad); }
  .ops-chip-x:hover { opacity:1; background:rgba(0,0,0,.06); }
  .ops-chip-car { flex-shrink:0; width:18px; padding-right: var(--space-1); text-align:center; font-size:10px; opacity:.55; }

  .seg-sw { position:relative; display:inline-flex; align-items:stretch; padding: var(--space-1); box-sizing:border-box;
            height:var(--h-control); background:var(--color-background-primary); border:1px solid var(--color-line-1); border-radius: var(--radius); flex-shrink:0; }
  .seg-sw-thumb { position:absolute; top:4px; bottom:4px; left:0; border-radius: var(--radius);
                  background:var(--color-accent-soft); pointer-events:none; }
  .seg-sw-thumb[data-ready="true"] { transition: transform 300ms var(--ease-out-quint), width 380ms var(--ease-out-quart); }
  .seg-sw-btn { position:relative; z-index:1; display:flex; align-items:center; justify-content:center; gap: var(--space-1);
                padding: 0 var(--space-4); border:none; background:transparent; border-radius: var(--radius); cursor:pointer;
                font-family:inherit; font-size:14px; white-space:nowrap; color:var(--color-text-secondary);
                -webkit-tap-highlight-color:transparent;
                transition: color 200ms var(--ease-out-quad), transform 120ms var(--ease-out-quad); }
  /* Счётчик внутри сегмента — тем же цветом, что и лейбл, но тише: он уточняет
     подпись, а не спорит с ней. Отдельный серый сделал бы из одного слова два. */
  .seg-sw-n { opacity:.5; }
  /* Компактный вариант — для фильтра внутри колонки, где 42px спорят с карточками */
  .seg-sw.is-sm { height:34px; padding:3px; border-radius: var(--radius); max-width:100%;
                  overflow-x:auto; scrollbar-width:none; }
  .seg-sw.is-sm::-webkit-scrollbar { display:none; }
  .seg-sw.is-sm .seg-sw-thumb { top:3px; bottom:3px; border-radius: var(--radius); }
  .seg-sw.is-sm .seg-sw-btn { padding: 0 var(--space-3); font-size:13px; border-radius: var(--radius); gap: var(--space-1); }
  .seg-sw-btn:hover { color:var(--color-text-primary); }
  .seg-sw-btn:active { transform: scale(.95); }
  .seg-sw-btn.is-on { color:var(--color-accent); transition-delay:90ms; }
  .seg-sw-btn:focus { outline:none; }
  .seg-sw-btn:focus-visible { outline:none; box-shadow: inset 0 0 0 1.5px var(--color-accent); }
  @media (prefers-reduced-motion: reduce) {
    .seg-sw-thumb[data-ready="true"] { transition:none; }
    .seg-sw-btn:active { transform:none; }
  }
  .prop-row-edit { transition: background var(--t-fast); }
  .prop-row-edit:hover { background: var(--color-background-secondary); }
  .prop-row-edit:hover input,
  .prop-row-edit:hover textarea { background: transparent; }
  .prop-row-edit input:focus,
  .prop-row-edit button:focus-visible { outline: none; }
  .prop-row-edit:focus-within { background: var(--color-accent-soft); }
  .chip:hover { border-color:var(--color-text-primary) !important; }
  .nb-block:hover .block-ctrls { opacity: 1 !important; }
  .empty { display:flex; flex-direction:column; align-items:center; gap: var(--space-2); padding: 56px var(--space-4); color:var(--color-text-secondary); font-size:13px; }
  .empty .glyph { width:40px; height:40px; border-radius:var(--radius); background:var(--color-background-secondary); display:flex; align-items:center; justify-content:center; font-size:18px; color:var(--color-text-tertiary); }
  /* ⚠ ГЛОБАЛЬНОЕ ПРАВИЛО ПОЛОСЫ ПРОКРУТКИ УДАЛЕНО 16.08.2026 — ПЕРЕНОС ИЗ МАКЕТА.
     Было: ::-webkit-scrollbar{width:10px} + свой ползунок на ВЕСЬ документ.
     Любая ширина в ::-webkit-scrollbar отменяет НАПЛЫВАЮЩУЮ полосу macOS и
     включает классическую: она отъедает 10px ширины окна НАВСЕГДА — не только
     когда прокручиваешь, а всегда, на каждом экране. Отсюда и расхождение с
     макетом: поля страницы там и тут одинаковые (32px), но в приложении справа
     стояла ещё и постоянная колонка полосы, и блок не доходил до края на 42.
     В макете правил прокрутки НЕТ ВООБЩЕ — полоса наплывает поверх поля 32px и
     пропадает, когда её не трогают. Перенос = убрать правило, а не подобрать
     число: система сама решает, чем рисовать полосу.
     ⚠ Следствие для Windows/Linux: там наплывающих полос нет, и вместо тихого
     ползунка вернётся широкая серая полоса Chrome. Захотим её усмирить —
     ставить `scrollbar-width:thin` + `scrollbar-color` (стандартные свойства,
     macOS их игнорирует и оставляет наплывающую), но НЕ ::-webkit-scrollbar
     с шириной: он снова заберёт 10px у всех.
     ⚠ ТО ЖЕ ПРАВИЛО РАСПРОСТРАНЕНО НА ВСЁ ПРИЛОЖЕНИЕ (16.08.2026, второй заход).
     Такие же ::-webkit-scrollbar с шириной сидели на внутренних областях и
     отъедали место ВНУТРИ себя ровно так же: .con-scroll (6px, карточки
     подрядчика и клиента), .clx-panel/.clx-det (8px, панель проекта),
     .cbd-scroll (6px, секции карточки). Убраны все.
     Замер, на котором это стоит (Chrome, macOS): без правил резервируется 0,
     ::-webkit-scrollbar{width:6px} — ровно 6, width:10px — ровно 10,
     а scrollbar-width:thin — СНОВА 0. Отсюда и лечение ниже: стандартные
     свойства дают тонкую полосу там, где полоса обязана занимать место
     (Windows, Linux), и не трогают наплывающую macOS. Заводя новую область
     прокрутки, брать ЭТО правило, а не ::-webkit-scrollbar с шириной.
     Исключения оставлены осознанно: .fm-hbar — не полоса прокрутки, а видимый
     контрол канбана (тонкая полоса СВЕРХУ вместо нативной снизу), ширина там и
     есть смысл; .fm-noscroll и .seg-sw.is-sm полосу ПРЯЧУТ, а не рисуют. */
  .con-scroll, .clx-panel, .clx-det, .cbd-scroll, .ef-body, .fm-triage-body {
    scrollbar-width: thin; scrollbar-color: var(--color-line-1) transparent;
  }
  /* Канбан: тонкий горизонтальный ползунок сверху (верхняя полоса) + скрытый нативный снизу */
  .fm-hbar { overflow-x:auto; overflow-y:hidden; }
  .fm-hbar::-webkit-scrollbar { height:5px; }
  .fm-hbar::-webkit-scrollbar-track { background:transparent; margin:0 2px; }
  .fm-hbar::-webkit-scrollbar-thumb { background:var(--color-line-1); border:none; border-radius: var(--radius); }
  .fm-hbar::-webkit-scrollbar-thumb:hover { background:var(--color-text-5); }
  .fm-noscroll { scrollbar-width:none; -ms-overflow-style:none; }
  .fm-noscroll::-webkit-scrollbar { height:0; width:0; display:none; }
  /* Канбан drag&drop (SortableJS) */
  .fm-drag-ghost { opacity:.4; }
  .fm-drag-chosen { cursor:grabbing; }
  /* Летящая карточка (fallback-режим — тач-устройства). transition:none обязателен:
     клон копирует инлайновые стили карточки, и любой унаследованный переход
     заставляет его тащиться ЗА курсором вместо того, чтобы быть под ним. */
  .fm-drag-fly { box-shadow:0 16px 38px rgba(20,30,60,.22) !important; opacity:.97 !important;
                 cursor:grabbing !important; transition:none !important; will-change:transform; }
  .fm-col-handle:active { cursor:grabbing; }
  /* Анимация контента при переключении вкладок в сайдбаре */
  @keyframes fm-tab-in { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } }
  .fm-tab-anim { animation: fm-tab-in .26s cubic-bezier(.2,.8,.2,1); }
  .boot { min-height:100vh; display:flex; flex-direction:column; align-items:center; justify-content:center; gap: var(--space-5); }
  .boot-stage { position:relative; display:flex; align-items:flex-end; height:120px; width:292px; }
  .boot-bar { width:12px; height:16px; margin-right: var(--space-2); border-radius: var(--radius-full); transform-origin:bottom center;
              background:var(--color-background-tertiary); }
  .boot-bar:last-child { margin-right:0; }
  .boot-ball { position:absolute; left:0; bottom:0; width:12px; height:12px; border-radius: var(--radius-full);
               background:var(--color-text-primary); transform-origin:bottom center; z-index:2; }
  .boot-cap { color:var(--color-text-tertiary); font-size:13px; }

  /* Layout */
  .app-layout {
    display: grid;
    grid-template-columns: 240px 1fr;
    min-height: 100vh;
    background: var(--color-background-page);
  }
  .app-sidebar {
    position: sticky;
    top: 0;
    height: 100vh;
    overflow-y: auto;
    /* ⚠ Числа из макета (dva-scheta-paddle.html, .rail): фон на полтона теплее
       холста, справа линейка, паддинг 24/16, зазор между пунктами 1px. Белый фон
       и отсутствие границы делали сайдбар продолжением содержимого. */
    background: var(--color-background-rail);
    border-right: 1px solid var(--color-line-1);
    padding: 24px 16px;
    display: flex;
    flex-direction: column;
    gap: 1px;
    flex-shrink: 0;
  }
  /* Вторая панель — разделы кабинета. Появляется только в настройках, поэтому
     сетка оболочки в этот момент становится трёхколоночной (см. .app-layout.has-subrail).
     Фон на полтона теплее сайдбара: панель читается как его продолжение, а не как
     начало содержимого. */
  .app-subrail {
    position: sticky;
    top: 0;
    height: 100vh;
    overflow-y: auto;
    padding: calc(var(--space-6) + 34px) var(--space-3) var(--space-5);
    background: var(--color-background-secondary);
    flex-shrink: 0;
    /* Слой 2: сама панель. Вход из-за сайдбара — ease-out, с задержкой 60 мс:
       сначала расступается колонка, и только потом в освободившееся место
       приезжает панель. Одновременно они читались бы как «дёрнулось всё». */
    animation: fm-subrail-in var(--dur-3) var(--ease-out-quart) 60ms both;
    will-change: transform, opacity;
  }
  @keyframes fm-subrail-in { from { opacity: 0; transform: translateX(-16px); } to { opacity: 1; transform: none; } }

  /* Слой 3: пункты — лесенкой. Индекс приходит инлайном (--i), потому что он
     сквозной по всем группам: заголовок группы и её пункты идут одной очередью,
     иначе вторая группа начинала бы заново и лесенка ломалась. */
  .app-subrail .sr-in { animation: fm-sr-item var(--dur-2) var(--ease-out-quart) both; animation-delay: calc(150ms + var(--i) * 22ms); }
  @keyframes fm-sr-item { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: none; } }

  /* Слой 1: колонка. Элементы УЖЕ на экране и переезжают — значит ease-in-out,
     а не ease-out. Анимируем саму сетку: иначе контент справа прыгнул бы на
     новое место мгновенно, пока панель ещё едет. */
  .app-layout { transition: grid-template-columns var(--dur-3) var(--ease-in-out-cubic); }
  .app-layout.has-subrail { grid-template-columns: 240px 216px 1fr; }

  /* Уважаем системную настройку: движение выключено — показываем сразу. */
  @media (prefers-reduced-motion: reduce) {
    .app-layout { transition: none; }
    .app-subrail, .app-subrail .sr-in { animation: none; }
  }

  /* ── ВЫЛЕТ ИЗ САЙДБАРА (меню второго уровня) ───────────────────────────────
     Отчётов четыре, и держать их все тумблером в шапке экрана значило показывать
     список там, где выбор уже сделан: человек приходит на отчёт читать цифры, а
     не выбирать отчёт. Меню открывается от пункта сайдбара и гаснет, как только
     отчёт выбран, — выбор здесь событие, а не постоянный контрол.
     Отличие от .app-subrail (разделы настроек): та панель РАЗДВИГАЕТ сетку и
     остаётся стоять, потому что по настройкам ходят вперёд-назад. Здесь уходят
     в один конец, поэтому слой, а не колонка.
     ⚠ position:fixed обязателен: у .app-sidebar overflow-y:auto, и слой,
     спозиционированный внутри него, обрезался бы по краю колонки. Координаты
     считаются от кнопки (getBoundingClientRect) — см. Flyout. */
  /* z-index выше всех слоёв приложения (потолок наших модалок — 1200): сайдбар не
     закрывается ни панелью проекта, ни карточками, значит вылет из него не имеет права
     оказаться под ними. Открыть его при открытой модалке нельзя — она перехватывает
     клик, — так что «перекрыть модалку» этот номер не может. */
  .fm-flyout {
    position: fixed; z-index: 1300; min-width: 200px; padding: var(--space-2);
    background: var(--color-background-primary);
    border: 1px solid var(--color-line-1);
    border-radius: var(--radius);
    animation: fm-flyout-in var(--dur-2) var(--ease-out-quart) both;
  }
  @keyframes fm-flyout-in { from { opacity:0; transform: translateX(-10px); } to { opacity:1; transform:none; } }
  /* ⚠ ПУНКТЫ ВЫЛЕТА — ЭТО `.nav-item`, А НЕ ЕГО КОПИЯ (14.08.2026). Своя пара классов
     разъезжалась с сайдбаром при первой же правке кегля: меню выезжает из него и обязано
     читаться продолжением, а не отдельным списком. Класс даёт наведение и переходы,
     метрики приходят инлайном — теми же числами, что в NavItem.
     ⚠ Открытый отчёт в меню НЕ подсвечен (просьба Игоря 14.08): песочная заливка и
     полужирный на активном пункте повторяли акцент самого пункта «Отчёты» и делали из
     тихого списка вторую панель. Заливка теперь только под курсором. */
  /* Шеврон у пункта с вылетом: единственный признак, что пункт не уводит на экран,
     а раскрывает список. На открытом меню он отъезжает вправо — туда же, куда ушёл слой. */
  .nav-chev { margin-left:auto; display:inline-flex; opacity:.5; transition: transform var(--t-fast), opacity var(--t-fast); }
  .nav-item:hover .nav-chev { opacity:.9; }
  .nav-chev[data-open="true"] { opacity:1; transform: translateX(3px); }
  @media (prefers-reduced-motion: reduce) { .fm-flyout { animation: none; } }

  /* ⚠ ПОЛЯ СТРАНИЦЫ — ЗАМЕР МАКЕТА dva-scheta-paddle.html (16.08.2026).
     Боковые 36 → 32: тридцати шести НЕТ на шкале отступов (4/8/12/16/24/32), и
     в этом же объявлении верх и низ стояли токенами — голое число по бокам было
     единственным литералом в строке, то есть значение выпало из системы молча.
     var(--space-6) чинит шкалу и попадает в макет одной правкой.
     Низ 24 → 64: последний ряд карточек упирался в край окна, и прокрутив до
     конца, нельзя было понять — страница кончилась или что-то не догрузилось.
     64 шкале не подчиняются законно: правило шкалы прямо выводит значения
     больше 32 из-под неё («это размеры, а не отступы»).
     Верх оставлен 32, хотя в макете 28: 28 на шкалу не ложится, а 32 — это
     var(--space-6). Единственное осознанное расхождение с макетом в этой строке. */
  .app-main {
    min-width: 0;
    padding: var(--space-6) var(--space-6) 64px;
    background: transparent;
    transition: background 420ms var(--ease-in-out-cubic) 60ms;
  }
  /* Открыта карточка проекта/клиента: слой закрывает всё ниже шапки, поэтому виден
     только верхний строй — красим его в белый, чтобы он сомкнулся с сайдбаром. */
  .app-main[data-panel="true"] { background: var(--color-background-primary); }
  .hdr-item { display:flex; align-items:center; gap: var(--space-2); padding: var(--space-3) var(--space-3); border-radius: var(--radius); cursor:pointer; font-size:15px; font-weight:600; color:var(--color-text-primary); white-space:nowrap; }
  .hdr-item:hover { background:var(--color-background-hover); }
  .hdr-plus { border-radius: var(--radius-full) !important; }
  .icon-btn-12 { border-radius: var(--radius) !important; }
  /* ── Составной переход «канбан → карточка» ────────────────────────────── */
  /* Возврат на доску: панель непрозрачная, поэтому под ней ничего не видно —
     анимируем не улёт панели, а ПРИХОД канбана. Колонки поднимаются лесенкой
     сразу после снятия слоя. Только transform: текст не гасим, чтобы не рябил. */
  @keyframes fmColIn { from { transform: translateY(8px); } to { transform: none; } }
  [data-col-list][data-return="true"] > * { animation: fmColIn 220ms var(--ease-out-quint) both; }
  [data-col-list][data-return="true"] > *:nth-child(2) { animation-delay: 25ms; }
  [data-col-list][data-return="true"] > *:nth-child(3) { animation-delay: 50ms; }
  [data-col-list][data-return="true"] > *:nth-child(4) { animation-delay: 75ms; }
  [data-col-list][data-return="true"] > *:nth-child(n+5) { animation-delay: 95ms; }
  @media (prefers-reduced-motion: reduce){ [data-col-list][data-return="true"] > *{animation:none;} }

  [data-pid] { transition: transform var(--dur-1) var(--ease-out-quad); }
  [data-pid]:active { transform: scale(.988); }
  /* Вернулись из карточки — обводим ту, где были: после полноэкранного экрана
     глаз ищет точку, из которой ушёл. */
  @keyframes fmReturned {
    0%   { box-shadow: 0 0 0 0 rgba(0,71,171,.34); }
    35%  { box-shadow: 0 0 0 4px rgba(0,71,171,.22); }
    100% { box-shadow: 0 0 0 10px rgba(0,71,171,0); }
  }
  [data-pid][data-returned="true"] > * { border-radius: var(--radius); animation: fmReturned 620ms var(--ease-out-quart) both; }
  @media (prefers-reduced-motion: reduce) {
    [data-pid] { transition: none; }
    [data-pid][data-returned="true"] > * { animation: none; }
  }
  .mobile-toggle { display: none; }
  .sidebar-overlay { display: none; }

  /* ── Onboarding ─────────────────────────────────────────────────────────── */
  @keyframes fm-fade-in { from { opacity:0; } to { opacity:1; } }
  @keyframes fm-onb-in  { from { opacity:0; transform:translateY(12px) scale(.985); } to { opacity:1; transform:none; } }
  @keyframes fm-pop-in  { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } }
  @keyframes fm-qs-pop  { from { opacity:0; transform:translateY(14px) scale(.98); } to { opacity:1; transform:none; } }
  @keyframes fm-drawer-in { from { transform:translateX(100%); } to { transform:translateX(0); } }
  /* Модалка по центру: от .97 и вверх на 6px. Не от нуля — из нуля окно читается
     синтетикой; и не «выезд справа» — оно больше не панель у края экрана. */
  @keyframes fm-modal-in { from { opacity:0; transform:scale(.97) translateY(6px); } to { opacity:1; transform:none; } }
  @keyframes fm-drawer-out { from { transform:translateX(0); } to { transform:translateX(100%); } }
  /* Копия: поверх карточки сдаётся второй лист и растворяется, открывая уже
     новую операцию. Копирование меняет в карточке только дату и пометку
     «вручную» — без этого движения нажатие неотличимо от «ничего не произошло».
     Лист держится в кадре 40% времени: меньше — мигает, больше — тормозит. */
  @keyframes fm-copy-deal {
    0%   { transform:translateX(44px); opacity:0; }
    26%  { transform:translateX(0);    opacity:1; }
    66%  { transform:translateX(0);    opacity:1; }
    100% { transform:translateX(0);    opacity:0; }
  }
  @keyframes fm-fade-out { from { opacity:1; } to { opacity:0; } }

  /* ── Строка-кандидат в разборе операции ─────────────────────────────────────
     Карточка незакрытой строки проекта — это КНОПКА: клик сопоставляет с ней
     операцию. Раньше единственным признаком нажимаемости была серая стрелка
     справа, и карточка читалась как справка (кастдев 09.08.2026). Даём то же,
     что даёт кнопка: подсветку под курсором и просадку под нажатием. Тень не
     берём — поверхности в приложении плоские; отвечает рамка и заливка. */
  /* ⚠ Колонка разбора операции. Порядок блоков задаётся `order`, поэтому колонка —
     flex. Но flex-элемент с `overflow:hidden` получает автоминимум высоты РАВНЫЙ НУЛЮ
     (правило `min-height:auto` не действует при не-visible overflow), и когда контент
     перерастает колонку, такой элемент сплющивается в ноль. Ровно это случилось с
     выпадашкой статьи: список раскрывался в высоту 0, и статьи «переставали
     предлагаться» (регрессия 09.08.2026, поймана вживую). Детям запрещаем сжиматься —
     колонка и так прокручивается. */
  .fm-triage-body { display: flex; flex-direction: column; }
  .fm-triage-body > * { flex: 0 0 auto; }
  /* ⚠ ОКНО РАСТЁТ ВЛЕВО, А ФОРМА ОСТАЁТСЯ СПРАВА (13.08.2026, просьба Игоря: «пусть
     вся основная часть остаётся справа, а влево увеличивается только сетка»). Окно
     прижато к правому краю экрана, и раздача под таблицу уводила ВСЮ форму влево:
     заголовок, поля, кнопки «Копировать»/«Правило для похожих», теги и загрузчик
     файлов растягивались вслед за окном, хотя к таблице отношения не имеют. Теперь
     тело — колонка шириной 556 у правого края (ровно та, что при обычных 620), а
     наружу, влево, выходит единственный блок, которому это нужно, — таблица
     разбивки (`.fm-split-full`). Подвал прижат тем же способом (704 = 640 + два
     паддинга по 32), чтобы «Удалить операцию» не уезжало под пустое место. */
  /* ⚠ ПО ЛЕВОМУ КРАЮ, А НЕ ПО ПРАВОМУ (18.08.2026). Стояло `flex-end`: содержимое
     тела 564 (620 − 28 − 28), а форма 556 — и восемь пикселей слака оказывались
     СЛЕВА. Заголовок «Разбор операции» стоял на 28, поля под ним на 36: два левых
     края в одном окне, и это видно. У макета всё на 28, слак уходит вправо.
     `flex-end` заводили, чтобы при раздаче окна под таблицу разбивки форма не
     уезжала влево, — но за это отвечает `maxWidth` (форма и так не растягивается),
     а подвал держится своим `marginLeft:auto`. */
  .fm-triage-body > * { align-self: flex-start; width: 100%; max-width: 556px; }
  .fm-triage-body > .fm-split-full { max-width: none; }

  .fm-pickrow { transition: border-color 140ms ease, background 140ms ease, transform 160ms cubic-bezier(.23,1,.32,1); }
  .fm-pickrow:hover { border-color: var(--color-accent); background: var(--color-background-secondary); }
  .fm-pickrow:active { transform: scale(.99); }
  .fm-pickrow-go { transition: transform 160ms cubic-bezier(.23,1,.32,1); }
  .fm-pickrow:hover .fm-pickrow-go { transform: translateX(3px); }
  @media (prefers-reduced-motion: reduce) {
    .fm-pickrow, .fm-pickrow-go { transition: none; }
    .fm-pickrow:active { transform: none; }
  }

  /* ── СЕГМЕНТНЫЙ ТУМБЛЕР (разрезы карточек дашборда) ─────────────────────────
     Раньше активная вкладка просто перекрашивалась — состояние менялось скачком,
     и связь «нажал сюда → поехало туда» приходилось достраивать самому.
     Здесь заливка — ОДНА пилюля под кнопками, которая переезжает к активной:
     переключатель показывает не только где ты, но и откуда пришёл.
     Колонки строго равной ширины (grid 1fr), поэтому позиция пилюли считается
     как translateX(--i * 100%) — без измерений DOM и без ref. Измерять ширины
     было бы хрупко: карточка перерисовывается на каждое изменение данных.
     ⚠ Геометрия живёт в КЛАССЕ, а через inline-style приходит только --i.
     Иначе частый ре-рендер пересобирал бы правило и сбрасывал переход.
     Пилюля едет ease-out-quart: движение запускается кликом, и ранний разгон
     читается как отклик. Цвет текста — built-in ease, это подчинённое движение
     и своя кривая ему не нужна.                                              */
  /* ⚠ ДОРОЖКА ТЁПЛАЯ, ВЫБРАННЫЙ СЕГМЕНТ — БЕЛЫЙ (макет `.seg2`, 18.08.2026).
     Дорожка стояла цветом страницы: на песочной странице её края не видно, и
     тумблер читался тремя словами в воздухе. Пилюля была песочной с синей
     подписью — то есть выбранное направление кричало акцентом, хотя это не
     действие, а состояние. У макета ровно наоборот: тёплая дорожка держит
     границы, белая пилюля выносит выбранное ВПЕРЁД, подпись — чернила. */
  /* ⚠ РАДИУСЫ ТУМБЛЕРА — 8 СНАРУЖИ, 6 ВНУТРИ (18.08.2026, Игорь дважды: «направление
     выглядит не так»; «сделай выделение состояния как в падл»). Контрольные 10/8 при
     высоте дорожки 37 и пилюли 33 давали заметно круглее макетного: у `.seg2` 8 и 6.
     Это НЕ третья ступень радиуса в системе — это ОДИН контрол, у которого скругление
     считается от его собственной высоты: пилюля 33 с радиусом 10 уже читается капсулой,
     а капсулы в приложении убраны. Значения живут здесь и никуда больше не расходятся. */
  .fm-seg      { position:relative; display:grid; gap:0; background:var(--color-background-tertiary); border-radius: 8px; padding: 2px; flex:0 0 auto; }
  /* Пилюля — два слоя. Внешний ЕДЕТ (translateX), внутренний ТЯНЕТСЯ (scaleX).
     Разделены потому, что это два разных движения с разными кривыми: если
     навесить squash на тот же элемент, что и переезд, transform-строка
     перезапишется и одно съест другое. */
  .fm-seg-pill { position:absolute; top:2px; bottom:2px; left:2px; width:calc((100% - 4px) / var(--n));
                 transform:translateX(calc(var(--i) * 100%)); transition:transform 230ms var(--ease-out-quart); will-change:transform; }
  /* ⚠ РАДИУС ПИЛЮЛИ = РАДИУС ДОРОЖКИ МИНУС ПАДДИНГ (18.08.2026, Игорь: «направление
     выглядит не так, как в макете»). Оба стояли по 10 при паддинге 2 — и углы пилюли
     выпирали из углов дорожки: вложенные скругления обязаны считаться, а не
     назначаться. У макета 8 снаружи и 6 внутри — та же разница в 2. */
  .fm-seg-pill > i { display:block; width:100%; height:100%; border-radius: 6px; background:var(--color-background-primary); }
  .fm-seg-btn  { position:relative; z-index:1; height:33px; padding: 0 var(--space-3); border:none; background:transparent; border-radius: var(--radius); cursor:pointer;
                 display:inline-flex; align-items:center; justify-content:center; gap: var(--space-2);
                 font-family:inherit; font-size:var(--fs-base); white-space:nowrap; color:var(--color-text-secondary); transition:color 150ms ease; }
  .fm-seg-btn.on { color:var(--color-text-primary); }
  /* Блочный вариант — в формах, где тумблер занимает колонку сетки целиком.
     Ширина по содержимому там вредна: соседние тумблеры разъезжаются и «План/Факт»
     оказывается каждый раз в новом месте. */
  .fm-seg.is-block { width:100%; }
  /* Знак задолженности при сумме: буква + слово. Слово обязательно — правило
     «состояние называют словом»; буква даёт короткую метку, как в ПланФакте. */
  /* Подпись при знаке — 12.5 второй ступенью (макет `.debt`): это уточнение к сумме,
     а не самостоятельная строка, и оно не должно весить как значение поля. */
  .fm-debt{ display:inline-flex; align-items:center; gap: 7px; height:37px; box-sizing:border-box;
            font-family:inherit; font-size:12.5px; white-space:nowrap; color:var(--color-text-secondary); }
  .fm-debt > .fm-debt-b{ display:inline-flex; align-items:center; justify-content:center; width:21px; height:21px; font-size:12px; flex:none;
            border-radius: var(--radius); background:var(--color-warn-soft); color:var(--color-warn); }
  /* Отметка-подтверждение (см. ConfirmMark). Высота 37 — как у поля рядом. */
  .fm-mark{ display:inline-flex; align-items:center; gap: var(--space-2); height:37px; box-sizing:border-box;
            padding: 0 var(--space-3); border:1px solid var(--color-line-1); border-radius: var(--radius);
            background:var(--color-background-primary); color:var(--color-text-tertiary);
            font-family:inherit; font-size:13.5px; white-space:nowrap; cursor:pointer;
            transition: color 150ms ease, border-color 150ms ease, background-color 150ms ease, transform 160ms var(--ease-out-quart); }
  .fm-mark:hover{ border-color:var(--color-text-5); color:var(--color-text-secondary); }
  .fm-mark:active{ transform:scale(.97); }
  .fm-mark:focus-visible{ outline:none; box-shadow:var(--focus-ring); }
  .fm-mark .fm-mark-i{ display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px; flex:none;
            border-radius:var(--radius-full); border:1px solid var(--color-line-1); color:transparent;
            transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease; }
  /* ⚠ ВКЛЮЧЁННАЯ ОТМЕТКА — МЯТНАЯ, А НЕ ПЕСОЧНАЯ (макет `.mark.on`, 18.08.2026).
     Песок в этом приложении означает «выбрано/активно» — им залиты включённые
     фасеты, активный пункт рельса, выбранный сегмент. Отметка «Получено» говорит
     другое: не «я это выбрал», а «деньги правда пришли». На песке два разных
     смысла делили один цвет, и в одном окне «Начислено» рядом с включённым
     фасетом читались как одна порода. Мята — цвет свершившегося факта, тот же,
     что у приходов в ленте. */
  .fm-mark.on{ color:var(--color-positive); border-color:var(--color-positive-line); background:var(--color-positive-soft); }
  .fm-mark.on .fm-mark-i{ background:var(--color-positive); border-color:var(--color-positive); color:var(--color-background-primary); }
  .fm-mark.ro{ cursor:default; }
  @media (prefers-reduced-motion: reduce){ .fm-mark{ transition:none } .fm-mark:active{ transform:none } }
  /* ⚠ Тумблер В ФОРМЕ говорит кеглем полей — 13.5 (13.08.2026, просьба «сделай
     одноразмерными шрифты»). База 15 осталась у самостоятельных переключателей вида
     (шапка «Потока»), где тумблер стоит в ряду контролов шапки, а не среди полей. */
  .fm-seg.is-block .fm-seg-btn { font-size:13.5px; }
  /* Чёрной системной обводки в этом интерфейсе быть не должно; клавиатуре при
     этом нужен видимый фокус — отсюда песочное кольцо вместо outline. */
  .fm-seg-btn:focus { outline:none; }
  .fm-seg-btn:focus-visible { box-shadow:var(--focus-ring); }
  /* ⚠ ТУМБЛЕР-ФАКТ (2026-08-12). Карточка операции из выписки обязана читаться так же,
     как форма «Новая операция» (прямая просьба Игоря: «должно выглядеть так же, как если
     бы мы заводили операцию — что это выплата, это факт и из выписки»). Но выбора там
     нет: деньги уже прошли по счёту, направление и факт менять нечем. Поэтому та же
     форма и то же место, а сегменты — не кнопки, а `span`: в фокус не попадают, курсор
     обычный, нажать нечего. Отличие от живого тумблера — тоном: пилюля нейтральная
     вместо песочной, активная подпись чернилами вместо синего. Так строка сразу
     говорит «это состояние, а не переключатель», не ломая узнаваемость контрола. */
  /* ⚠ 210px — МИНИМУМ КОЛОНКИ ПОД ТУМБЛЕР (замер, не на глаз): «Поступление» просит
     97px содержимого, «Выплата» столько же по слоту, плюс паддинги дорожки и кнопок.
     На 190 текст подрезался на 1–6px в узкой полосе ширин. Ставя новый тумблер,
     мерь `scrollWidth` сегмента, а не прикидывай. */
  .fm-seg.is-ro .fm-seg-pill > i { background:var(--color-background-tertiary); }
  .fm-seg.is-ro .fm-seg-btn { cursor:default; color:var(--color-text-5); }
  .fm-seg.is-ro .fm-seg-btn.on { color:var(--color-text-primary); }
  /* ── КАРТОЧКА ДЕЛА (.ctrl-*) ─────────────────────────────────────────────
     Раньше эти правила жили в <style> ВНУТРИ экрана «Контроль», поэтому на
     дашборде их не существовало и сводка дел рисовалась своей вёрсткой —
     два разных языка для одной сущности. Теперь стили глобальные: карточка
     дела выглядит одинаково, где бы ни стояла. ── */
  .ctrl-cols{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap: var(--space-4);align-items:start}
  .ctrl-colh{display:flex;align-items:center;gap: var(--space-2);font:700 13px 'Inter';color:var(--color-text-secondary);margin-bottom: var(--space-3);padding-left:2px}
  .ctrl-cc{margin-left:2px;font:700 12px 'Inter';color:var(--color-text-tertiary);background:var(--color-background-tertiary);padding: 2px var(--space-2);border-radius: var(--radius)}
  .ctrl-card{background:var(--color-background-primary);border-radius: var(--radius);padding: var(--space-4);margin-bottom: var(--space-3);display:flex;flex-direction:column;aspect-ratio:var(--card-ratio)}
  /* «Требует внимания» берёт у .ctrl-card только поверхность: это лента строк во всю
     ширину, а не плитка. При 1113px пропорция дала бы 668px высоты — поле пустоты. */
  .ctrl-band{aspect-ratio:auto}
  /* ⚠ ВСЕ карточки Контроля одной высоты (203px). Держится на двух правилах:
  (1) каждая строка ниже имеет ФИКСИРОВАННУЮ высоту и не переносится (nowrap+ellipsis);
  (2) в JSX строки рендерятся ВСЕГДА, даже пустыми — условный рендер съедал бы строку
  и карточка становилась ниже. Раньше «Пробелы» были на 35px ниже именно потому,
  что у GapCard не было .ctrl-top. Добавляешь строку — задай ей высоту здесь
  и рендери безусловно, иначе колонки снова разъедутся. */
  .ctrl-top{display:flex;align-items:center;justify-content:space-between;gap: var(--space-2);height:24px;margin-bottom: var(--space-3)}
  .ctrl-kind{font:13px 'Inter';color:var(--color-text-tertiary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .ctrl-hero{font:26px 'Space Grotesk','Inter',sans-serif;color:var(--color-text-primary);white-space:nowrap;font-variant-numeric:tabular-nums;height:34px;line-height:34px;overflow:hidden;text-overflow:ellipsis}
  .ctrl-hero-t{font:20px 'Inter';color:var(--color-text-primary);height:34px;line-height:34px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .ctrl-sub{font:13.5px 'Inter';color:var(--color-text-secondary);margin-top: var(--space-1);height:18px;line-height:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .ctrl-when{font:12.5px 'Inter';color:var(--color-text-tertiary);margin-top: var(--space-1);height:18px;line-height:18px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
  .ctrl-chip{display:inline-flex;align-items:center;font:12.5px 'Inter';padding: var(--space-1) var(--space-2);border-radius: var(--radius);white-space:nowrap;flex:0 0 auto;font-variant-numeric:tabular-nums}
  .ctrl-chip-late{background:var(--color-negative-soft);color:var(--color-negative)}
  .ctrl-chip-soon{background:var(--color-warn-soft);color:var(--color-warn)}
  .ctrl-chip-cold{background:var(--color-background-secondary);color:var(--color-text-tertiary)}
  /* ⚠ Без flex-wrap: перенос кнопок на вторую строку ломал бы единую высоту.
  Высота кнопок ОДНА (34px) у .ctrl-btn и .ctrl-pill — раньше их было три
  (34 / 29 инлайном у «Напомнить» / 25), и подвалы карточек не совпадали. */
  .ctrl-foot{display:flex;align-items:center;justify-content:flex-end;gap: var(--space-2);height:34px;margin-top: var(--space-4)}
  .ctrl-remind{font:12.5px 'Inter';color:var(--color-positive);margin-right:auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .ctrl-pill{display:inline-flex;align-items:center;gap: var(--space-1);font:600 12px 'Inter';height:34px;padding: 0 var(--space-3);border-radius: var(--radius);white-space:nowrap;border:none;cursor:pointer;font-family:'Inter'}
  .ctrl-pill-wait{background:var(--color-warn-soft);color:var(--color-warn)}
  .ctrl-pill-due{background:var(--color-negative-soft);color:var(--color-negative)}
  .ctrl-btn{border:0;cursor:pointer;display:inline-flex;align-items:center;gap: var(--space-1);font:600 13px 'Inter';height:34px;padding: 0 var(--space-4);border-radius: var(--radius);white-space:nowrap}
  .ctrl-btn-blue{background:var(--color-accent-soft);color:var(--color-accent)}
  .ctrl-btn-ghost{background:var(--color-background-secondary);color:var(--color-text-secondary)}
  .ctrl-empty{font:500 13px 'Inter';color:var(--color-text-5);padding:8px 2px}

  /* ⚠ Размер ОДИН. Раньше было два (28px в карточках, 32px в панелях) — на одном
     экране это читалось как два разных контрола, хотя работа у них одинаковая.
     Тумблер сегментов приложения имеет ровно одну геометрию: высота 32, кегль
     базовый, радиус 11/7. Нужен другой размер — сначала докажи, что это другой
     по смыслу контрол, а не тот же в тесном месте. */

  /* SQUASH & STRETCH. Пилюля в полёте тянется по ходу движения и подбирается
     обратно на посадке — так масса читается как упругая, а не как картинка,
     которую передвинули. Растяжение маленькое (7%): на 14% это уже мультик,
     а тут переключатель в финансовом приложении.
     Ключевой момент — origin: тянуться надо ОТ хвоста, то есть от той стороны,
     откуда пилюля стартовала. Origin по центру даёт «дышащую таблетку» вместо
     разгона. Отсюда две зеркальные анимации вместо одной.
     Перезапуск при быстром повторном клике обеспечивает key на <i> (тик), а не
     хак с reflow: React просто монтирует новый узел, анимация стартует с нуля. */
  @keyframes fm-seg-squash-f { 0%{transform:scaleX(1)} 40%{transform:scaleX(1.07)} 100%{transform:scaleX(1)} }
  @keyframes fm-seg-squash-b { 0%{transform:scaleX(1)} 40%{transform:scaleX(1.07)} 100%{transform:scaleX(1)} }
  .fm-seg[data-dir="1"]  .fm-seg-pill > i { transform-origin:left  center; animation:fm-seg-squash-f 230ms var(--ease-out-quart) both; }
  .fm-seg[data-dir="-1"] .fm-seg-pill > i { transform-origin:right center; animation:fm-seg-squash-b 230ms var(--ease-out-quart) both; }
  .fm-seg[data-dir="0"]  .fm-seg-pill > i { animation:none; }

  /* ТЕЛО КАРТОЧКИ. Направленный свап: контент приезжает с той стороны, куда
     поехала пилюля. Ненаправленный fade заставляет заново искать, что
     изменилось; направление переносит намерение с контрола на данные.
     Сдвиг 14px — заметно, но не «слайдер»: карточка не уезжает целиком. */
  @keyframes fm-seg-in-f { from { opacity:0; transform:translateX(14px); } to { opacity:1; transform:none; } }
  @keyframes fm-seg-in-b { from { opacity:0; transform:translateX(-14px); } to { opacity:1; transform:none; } }
  .fm-seg-body[data-dir="1"]  { animation:fm-seg-in-f 210ms var(--ease-out-quart) both; }
  .fm-seg-body[data-dir="-1"] { animation:fm-seg-in-b 210ms var(--ease-out-quart) both; }

  /* СТРОКИ РЕЙТИНГА — каскад. Не украшение: три строки, влетающие разом, читаются
     как одно пятно, а с задержкой 34мс глаз успевает считать порядок — то есть
     сам рейтинг. Хвост каскада 3×34 = 102мс, внутри свапа тела, ощущения
     ожидания не создаёт. Больше трёх строк карточка и не показывает. */
  @keyframes fm-rank-in { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } }
  .fm-rank { animation:fm-rank-in 220ms var(--ease-out-quart) both; animation-delay:calc(var(--d) * 34ms); }
  /* Полоса-доля выезжает из своего края следом за строкой. scaleX, а не width:
     width дёргает layout на каждом кадре, scaleX живёт на композиторе. */
  @keyframes fm-rank-bar { from { transform:scaleX(0); } to { transform:scaleX(1); } }
  .fm-rank-bar { transform-origin:left center; animation:fm-rank-bar 380ms var(--ease-out-quart) both;
                 animation-delay:calc(var(--d) * 34ms + 40ms); }

  /* Карточка «Контроля»: она кликается, поэтому обязана отвечать на наведение.
     Ответ тихий — граница чуть темнеет, стрелка подаётся вправо и набирает цвет.
     Ни тени, ни подъёма: поверхность остаётся плоской по дизайн-контракту. */
  .fm-ctrl-card    { transition:border-color 180ms var(--ease-out-quart), background-color 180ms var(--ease-out-quart); }
  .fm-ctrl-card:hover { border-color:var(--color-line-1); background-color:var(--color-background-page); }
  /* Стрелка живёт в подвале плитки и набрана его же цветом (третья ступень) —
     на пятой она читалась выключенной, хотя вся плитка кликается. */
  .fm-ctrl-go      { color:var(--color-text-tertiary); transition:transform 180ms var(--ease-out-quart), color 180ms var(--ease-out-quart); }
  .fm-ctrl-card:hover .fm-ctrl-go { transform:translateX(3px); color:var(--color-text-secondary); }

  @media (prefers-reduced-motion: reduce) {
    .fm-seg-pill, .fm-seg-btn, .fm-ctrl-card, .fm-ctrl-go { transition:none; }
    .fm-seg-pill > i, .fm-seg-body, .fm-rank, .fm-rank-bar { animation:none; }
    .fm-ctrl-card:hover .fm-ctrl-go { transform:none; }
  }

  .fm-onb-backdrop { position:fixed; inset:0; z-index:1000; display:flex; align-items:center; justify-content:center; padding: var(--space-4); background:rgba(30,32,60,.42); -webkit-backdrop-filter:blur(4px); backdrop-filter:blur(4px); animation:fm-fade-in .22s ease; }
  .fm-onb-card { position:relative; width:440px; max-width:100%; background:var(--color-background-primary); border-radius:var(--radius); box-shadow:var(--shadow-lg); overflow:hidden; display:flex; flex-direction:column; animation:fm-onb-in .3s cubic-bezier(.2,.8,.2,1); }
  .fm-onb-x { position:absolute; top:14px; right:14px; width:30px; height:30px; border:none; background:transparent; color:var(--color-text-tertiary); font-size:18px; line-height:1; border-radius:var(--radius); cursor:pointer; z-index:2; }
  .fm-onb-x:hover { background:var(--color-background-hover); color:var(--color-text-primary); }
  .fm-onb-hero { padding: 40px 36px var(--space-2); text-align:center; }
  .fm-onb-glyph { width:62px; height:62px; border-radius:var(--radius); margin:0 auto 18px; display:flex; align-items:center; justify-content:center; font-size:28px; background:var(--color-accent-soft); color:var(--color-accent); box-shadow:var(--shadow-sm); }
  .fm-onb-title { font-size:20px; font-weight:800; line-height:1.25; }
  .fm-onb-body { font-size:13.5px; line-height:1.55; color:var(--color-text-secondary); margin-top: var(--space-2); }
  .fm-onb-dots { display:flex; gap: var(--space-1); justify-content:center; padding: var(--space-5) 0 2px; }
  .fm-onb-dot { width:7px; height:7px; border-radius: var(--radius); background:var(--color-border-secondary); transition:background-color var(--t-med), width var(--t-med); }
  .fm-onb-dot.is-active { background:var(--color-accent); width:22px; }
  .fm-onb-foot { display:flex; align-items:center; justify-content:space-between; gap: var(--space-2); padding: var(--space-3) var(--space-5) var(--space-5); }
  .fm-onb-choice { display:flex; gap: var(--space-3); align-items:flex-start; width:100%; text-align:left; font-family:inherit; cursor:pointer; padding: var(--space-3) var(--space-4); border:1px solid var(--color-border-secondary); border-radius:var(--radius); background:var(--color-background-primary); transition:background-color var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast); }
  .fm-onb-choice:hover { border-color:var(--color-accent); }
  .fm-onb-choice.is-sel { border-color:var(--color-accent); background:var(--color-accent-soft); box-shadow:var(--shadow-sm); }
  .fm-onb-radio { width:18px; height:18px; flex-shrink:0; margin-top:1px; border-radius: var(--radius); border:1px solid var(--color-border-secondary); display:flex; align-items:center; justify-content:center; transition:border-color var(--t-fast); }
  .fm-onb-choice.is-sel .fm-onb-radio { border-color:var(--color-accent); }
  .fm-onb-choice.is-sel .fm-onb-radio::after { content:""; width:9px; height:9px; border-radius: var(--radius); background:var(--color-accent); }

  /* Spotlight tour */
  .fm-tour-catch { position:fixed; inset:0; z-index:1000; animation:fm-fade-in .2s ease; }
  .fm-tour-hole  { position:fixed; z-index:1001; pointer-events:none; border-radius:var(--radius); border:2px solid var(--color-accent); box-shadow:0 0 0 9999px rgba(28,30,58,.55); transition:top .26s cubic-bezier(.2,.8,.2,1), left .26s cubic-bezier(.2,.8,.2,1), width .26s cubic-bezier(.2,.8,.2,1), height .26s cubic-bezier(.2,.8,.2,1); }
  .fm-tour-tip   { position:fixed; z-index:1002; width:282px; background:var(--color-background-primary); border-radius:var(--radius); box-shadow:var(--shadow-lg); padding: var(--space-4) var(--space-4); animation:fm-pop-in .2s ease; }
  .fm-tour-eyebrow { font-size:10.5px; font-weight:700; color:var(--color-accent); }
  .fm-link-btn { border:none; background:transparent; color:var(--color-text-tertiary); font-family:inherit; font-size:12px; cursor:pointer; padding: var(--space-1) 2px; }
  .fm-link-btn:hover { color:var(--color-text-secondary); }

  /* Quick-start checklist */
  .fm-qs { position:fixed; right:22px; bottom:22px; z-index:150; width:306px; max-width:calc(100vw - 32px); background:var(--color-background-primary); border:1px solid var(--color-border-secondary); border-radius:var(--radius); box-shadow:var(--shadow-lg); overflow:hidden; animation:fm-qs-pop .26s cubic-bezier(.2,.8,.2,1); }
  .fm-qs-head { padding: var(--space-4) var(--space-4) var(--space-3); cursor:pointer; }
  .fm-qs-bar { height:5px; border-radius: var(--radius); background:var(--color-background-tertiary); overflow:hidden; margin-top: var(--space-3); }
  .fm-qs-bar > div { height:100%; background:var(--color-positive); border-radius: var(--radius); transition:width .35s cubic-bezier(.2,.8,.2,1); }
  .fm-qs-row { display:flex; align-items:flex-start; gap: var(--space-3); padding: var(--space-2) var(--space-4); cursor:pointer; transition:background var(--t-fast); }
  .fm-qs-row:hover { background:var(--color-background-hover); }
  .fm-qs-row.is-done { cursor:default; }
  .fm-qs-row.is-done:hover { background:transparent; }
  .fm-qs-check { width:20px; height:20px; flex-shrink:0; margin-top:1px; border-radius: var(--radius); border:1px solid var(--color-border-secondary); display:flex; align-items:center; justify-content:center; font-size:11px; color:transparent; transition:background-color var(--t-fast), border-color var(--t-fast), color var(--t-fast); }
  .fm-qs-check.is-done { background:var(--color-positive); border-color:var(--color-positive); color:var(--color-background-primary); }
  .fm-qs-iconbtn { border:none; background:transparent; color:var(--color-text-tertiary); cursor:pointer; font-size:15px; line-height:1; padding: 2px var(--space-1); border-radius:var(--radius); }
  .fm-qs-iconbtn:hover { background:var(--color-background-hover); color:var(--color-text-primary); }

  /* Help popover (sidebar) */
  .fm-help-pop { position:absolute; left:0; right:0; bottom:calc(100% + 6px); background:var(--color-background-primary); border:1px solid var(--color-border-secondary); border-radius:var(--radius); box-shadow:var(--shadow-lg); padding: var(--space-1); z-index:60; animation:fm-pop-in .16s ease; }
  .fm-help-pop button { display:flex; align-items:center; gap: var(--space-2); width:100%; text-align:left; border:none; background:transparent; padding: var(--space-2) var(--space-2); font-size:12.5px; font-family:inherit; color:var(--color-text-secondary); border-radius:var(--radius); cursor:pointer; }
  .fm-help-pop button:hover { background:var(--color-background-hover); color:var(--color-text-primary); }

  /* Редактор шаблона документа: форма слева + живое превью справа */
  .fm-tpl-wrap { display:flex; gap: var(--space-5); align-items:flex-start; }
  .fm-tpl-left { flex:1 1 520px; min-width:0; max-width:780px; }
  .fm-tpl-preview { flex:0 0 44%; max-width:600px; min-width:360px; position:sticky; top:14px; }
  @media (max-width:1120px) { .fm-tpl-preview { display:none; } }

).
// Шкала — как в форме операции (.ef-in / .ef-lbl) и в мастере документа: 13.5px поле,
// 11.5px метка, рамка var(--color-line-1), радиус 9. Раньше форма жила на своей крупной шкале
// (15px поля, 12px паддинги) и читалась как кусок другого приложения. Паддинги ужаты
// вместе с кеглем — оставить 12px при 13.5px значило бы получить пустые «подушки»
// вокруг мелкого текста. Заголовок остался крупным: это имя документа, не поле.
const DOC_FORM = {
  /* ⚠ ЗАГОЛОВОК ОКНА — СЕРИФ 22 (2026-08-18, язык макета). Был Golos 19 «жирным»
     (вес всё равно срезает глобальное правило), то есть окно начиналось строкой,
     неотличимой от подписи поля. Во всём приложении заголовок карточки и окна —
     Prata; поле ввода не исключение, `data-disp` работает и на input.
     ⚠ ВЫСОТА ПОЛЯ 37 — общий размер приложения (.ef-in). Поля здесь росли из
     паддинга и выходили 34.5: в одном окне с выпадашками на 37 они читались
     двумя разными наборами. */
  title:    { fontSize:22, lineHeight:1.15, color:"var(--color-text-primary)", background:"transparent", border:"none", outline:"none", fontFamily:"inherit", padding:0, width:"100%", boxSizing:"border-box" },
  input:    { width:"100%", boxSizing:"border-box", minHeight:37, background:"var(--color-background-primary)", border:"1px solid var(--color-line-1)", borderRadius: "var(--radius)", padding: "var(--space-2) var(--space-3)", fontSize:13.5, color:"var(--color-text-primary)", fontFamily:"inherit", outline:"none" },
  select:   { width:"100%", boxSizing:"border-box", minHeight:37, background:"var(--color-background-primary)", border:"1px solid var(--color-line-1)", borderRadius: "var(--radius)", padding: "var(--space-2) var(--space-3)", fontSize:13.5, color:"var(--color-text-primary)", fontFamily:"inherit", outline:"none", cursor:"pointer" },
  subLabel: { fontSize:11.5,  color:"var(--color-text-tertiary)", margin: "var(--space-3) 0 var(--space-1)" },
};

// Кандидаты-родители для привязки документа: договор/приложение ЛЮБОГО источника —
// генерённый (по type) или загруженный/ссылка/блок (по docKind). Свой проект — вперёд.
// opts: { projectId, excludeId }. Используется в реестре (формы) и в блоке «Документ».
function docParentCandidates(documents, opts) {
  const isKind = d => d.type === "contract" || d.type === "appendix" || d.docKind === "Договор" || d.docKind === "Приложение";
  const pid = (opts && opts.projectId != null && String(opts.projectId) !== "") ? String(opts.projectId) : "";
  const ex  = opts && opts.excludeId;
  // Проект выбран → показываем ТОЛЬКО документы этого проекта; без проекта → все.
  return (documents || []).filter(d => isKind(d) && d.id !== ex && (!pid || String(d.projectId) === pid));
}
function docParentLabelOf(d) {
  const kind = d.type ? (DOC_TYPE_LABEL[d.type] || "Документ") : (d.docKind || "Документ");
  const name = d.number || d.title || "";
  return kind + (name ? " " + name : "") + (d.projectName ? " · " + d.projectName : "");
}

// Блок прикрепления документа: сначала выбор способа (файл / ссылка), потом нужное поле.
// onChange(patch) мержит в content блока. Состояния: загруженный файл (ya), ссылка (url) или выбор.
function DocUpload({ content, onChange, lockFile }){
  const [busy,setBusy] = React.useState(false);
  const [err,setErr]   = React.useState("");
  const [over,setOver] = React.useState(false);
  const [mode,setMode] = React.useState(lockFile ? "file" : (content.url ? "link" : null)); // null=выбор | "file" | "link" (lockFile → только файл)
  const inputRef = React.useRef(null);
  const ya = content.ya;
  const inputStyle = { width:"100%", boxSizing:"border-box", background:"var(--color-background-secondary)", border:"1px solid transparent", borderRadius: "var(--radius)", padding: "var(--space-3) var(--space-4)", fontSize:15, fontWeight:500, color:"var(--color-text-primary)", fontFamily:"inherit", outline:"none" };
  const openStyle  = { fontSize:12, fontWeight:500, color:"var(--color-text-primary)", textDecoration:"none", padding: "3px var(--space-2)", borderRadius:"var(--radius)", border:"0.5px solid transparent", background:"var(--color-accent-soft)", flexShrink:0, fontFamily:"inherit", cursor:"pointer" };
  const Back = ({ onClick }) => (
    <button onClick={onClick} style={{ marginTop: "var(--space-1)", fontSize:12, fontFamily:"inherit", cursor:"pointer", border:"none", background:"none", color:"var(--color-text-tertiary)", padding:0, display:"block" }}>← другой способ</button>
  );
  const send = async (file) => {
    if(!file) return;
    setErr(""); setBusy(true);
    try{
      const fd = new FormData();
      fd.append("file", file);
      fd.append("name", file.name);
      // Публикация запрашивается ЯВНО: превью-миниатюра доступна только у опубликованного
      // файла. Функция yadisk без этого флага файл не публикует (см. index.ts) — так
      // сгенерённые документы, уходящие кнопкой ☁, остаются приватными.
      fd.append("publish", "1");
      const { data, error } = await yadiskInvoke(fd, true);
      if(error || !data || !data.ok) throw new Error((error&&error.message) || (data&&data.error) || "Ошибка загрузки");
      // Заменили файл — прежний убираем с Диска, иначе он остаётся сиротой навсегда.
      const prevPath = content.ya && content.ya.path;
      if(prevPath && prevPath !== data.path) deleteYaFile(prevPath);
      onChange({ ya:{ path:data.path, name:data.name, size:data.size, public_url:data.public_url||null, preview:data.preview||null }, label: content.label || data.name });
    }catch(e){ setErr(String(e.message||e)); }
    finally{ setBusy(false); }
  };

  // 1) Файл уже загружен — карточка с миниатюрой (publish добивается в ProjectBlocksZone)
  if(ya && ya.path) return (
    <div style={{ marginTop: "var(--space-1)", display:"flex", alignItems:"center", gap: "var(--space-3)", padding: "var(--space-2) var(--space-2)", border:"0.5px solid var(--color-border-secondary)", borderRadius:"var(--radius)", background:"var(--color-background-secondary)" }}>
      <div style={{ width:64, height:48, flexShrink:0, borderRadius:"var(--radius)", overflow:"hidden", border:"0.5px solid var(--color-border-secondary)", background:"var(--color-background-primary)", display:"flex", alignItems:"center", justifyContent:"center" }}>
        {ya.preview ? <img src={ya.preview} alt="" style={{ width:"100%", height:"100%", objectFit:"cover", objectPosition:"top" }} /> : <span style={{ fontSize:21 }}>📄</span>}
      </div>
      <div style={{ minWidth:0, flex:1 }}>
        <div style={{ fontSize:12, color:"var(--color-text-tertiary)" }}>{fmtFileSize(ya.size)}{ya.public_url?" · опубликован":""}</div>
      </div>
      <button onClick={()=> ya.public_url ? window.open(ya.public_url,"_blank","noopener") : openYaFile(ya.path)} style={openStyle}>Открыть ↗</button>
      <button title="Убрать файл" onClick={()=>{ onChange({ ya:null }); setMode(lockFile ? "file" : null); }} style={{ fontSize:16, lineHeight:1, fontFamily:"inherit", cursor:"pointer", border:"none", background:"none", color:"var(--color-text-tertiary)" }}>×</button>
    </div>
  );

  // 2) Выбор способа
  if(!mode) return (
    <div style={{ marginTop: "var(--space-1)", display:"flex", gap: "var(--space-2)" }}>
      {[["file","📎 Загрузить файл"],["link","🔗 Вставить ссылку"]].map(([m,label])=>(
        <button key={m} onClick={()=>{ setErr(""); setMode(m); }}
          style={{ flex:1, padding: "var(--space-3) var(--space-2)", fontSize:13, fontFamily:"inherit", cursor:"pointer", borderRadius:"var(--radius)", border:"0.5px solid var(--color-border-secondary)", background:"var(--color-background-secondary)", color:"var(--color-text-primary)", transition:"background-color var(--t-fast), border-color var(--t-fast), color var(--t-fast), box-shadow var(--t-fast), opacity var(--t-fast), transform var(--t-fast)" }}
          onMouseEnter={e=>{ e.currentTarget.style.borderColor="var(--color-accent)"; e.currentTarget.style.color="var(--color-accent)"; }}
          onMouseLeave={e=>{ e.currentTarget.style.borderColor="var(--color-border-secondary)"; e.currentTarget.style.color="var(--color-text-primary)"; }}>
          {label}
        </button>
      ))}
    </div>
  );

  // 3) Режим «ссылка»
  if(mode==="link") return (
    <div style={{ marginTop: "var(--space-1)" }}>
      <div style={{ display:"flex", gap: "var(--space-2)", alignItems:"center" }}>
        <input className="doc-field" autoFocus value={content.url||""} onChange={e=>onChange({url:e.target.value})} placeholder="https://…" style={inputStyle} />
        {content.url && <a href={content.url} target="_blank" rel="noreferrer" style={openStyle}>Открыть ↗</a>}
      </div>
      <Back onClick={()=>{ onChange({ url:"" }); setMode(null); }} />
    </div>
  );

  // 4) Режим «файл» — дропзона
  return (
    <div style={{ marginTop: "var(--space-1)" }}>
      <div
        onClick={()=>{ if(!busy && inputRef.current) inputRef.current.click(); }}
        onDragOver={e=>{ e.preventDefault(); setOver(true); }}
        onDragLeave={()=>setOver(false)}
        onDrop={e=>{ e.preventDefault(); setOver(false); send(e.dataTransfer.files && e.dataTransfer.files[0]); }}
        /* ⚠ Рамка `--color-line-1`, а НЕ `--color-border-secondary`: второй прозрачен
           намеренно (держит место под рамку, см. CLAUDE.md), поэтому дропзона рисовалась
           вообще без пунктира — на экране оставалась строчка серого текста со скрепкой,
           висящая в пустоте. Мишень, у которой не видно границ, мишенью не читается.
           Скрепка — обычная иконка проекта, а не эмодзи: 📎 рисуется системным цветным
           шрифтом, не подчиняется ни палитре, ни `currentColor`, и на подсветке при
           перетаскивании оставалась жёлтой посреди синего. */
        /* ⚠ ВЫСОТА МИШЕНИ (2026-08-12). Было `space-3` по вертикали — полоска в 42px,
           которая читалась как ещё одна строка текста, а не как область, куда что-то
           бросают («блок куда перетаскивать файлы обычно больше» — Игорь). Дропзона
           обязана выглядеть площадью: 88px и центрирование содержимого. Заодно это
           единственная мишень для drag&drop в приложении — по правилу минимального
           размера цели её нельзя держать на высоте строки. */
        style={{ cursor: busy?"default":"pointer", minHeight:64, display:"flex", alignItems:"center", justifyContent:"center",
          padding: "var(--space-4) var(--space-3)", textAlign:"center", fontSize:13,
          color: over?"var(--color-accent)":"var(--color-text-tertiary)",
          border:"1px dashed "+(over?"var(--color-accent)":"var(--color-line-1)"),
          borderRadius:"var(--radius)", background: over?"var(--color-accent-soft)":"transparent", transition:"background-color var(--t-fast), border-color var(--t-fast), color var(--t-fast), box-shadow var(--t-fast), opacity var(--t-fast), transform var(--t-fast)" }}>
        {busy ? "Загрузка на Яндекс.Диск…" : (
          <span style={{ display:"inline-flex", alignItems:"center", gap:"var(--space-2)" }}>
            <svg width="14" height="14" style={{ flex:"none" }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21.4 11.05 12.25 20.2a5.5 5.5 0 0 1-7.78-7.78l9.2-9.19a3.67 3.67 0 1 1 5.18 5.18l-9.2 9.2a1.83 1.83 0 1 1-2.59-2.6l8.5-8.49"></path></svg>
            Перетащите файл сюда или нажмите, чтобы выбрать
          </span>
        )}
      </div>
      <input ref={inputRef} type="file" style={{ display:"none" }} onChange={e=>{ send(e.target.files && e.target.files[0]); e.target.value=""; }} />
      {err && <div style={{ marginTop: "var(--space-1)", fontSize:12, color:"var(--color-negative)" }}>{err}</div>}
      {!lockFile && <Back onClick={()=>{ setMode(null); setErr(""); }} />}
    </div>
  );
}

// Тег типа документа — светлый селект в правом верхнем углу карточки файла.
// Хранит ОДИН тип (DOC_KINDS) или пусто → пунктирное «Указать тип». Клик открывает
// светлое меню, выбор пункта заменяет значение и закрывает. Родитель = position:relative.
// openUp — раскрывать меню вверх (для нижних карточек, чтобы не обрезалось).
function DocTypeTag({ value, onChange, openUp=false }){
  const [open,setOpen] = React.useState(false);
  const [dropUp,setDropUp] = React.useState(false);
  const ref = React.useRef(null);
  const btnRef = React.useRef(null);
  React.useEffect(()=>{
    if(!open) return;
    const onDoc = e => { if(ref.current && !ref.current.contains(e.target)) setOpen(false); };
    const onKey = e => { if(e.key==="Escape") setOpen(false); };
    document.addEventListener("mousedown", onDoc);
    document.addEventListener("keydown", onKey);
    return ()=>{ document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
  },[open]);
  const toggle = () => setOpen(o=>{
    const next = !o;
    if(next && btnRef.current){ const r = btnRef.current.getBoundingClientRect(); setDropUp((window.innerHeight - r.bottom) < 320); } // мало места снизу → вверх
    return next;
  });
  const up = openUp || dropUp;
  const has = !!value;
  const trigBase = { display:"inline-flex", alignItems:"center", gap: "var(--space-2)", fontSize:12.5, fontWeight:600, fontFamily:"inherit", borderRadius: "var(--radius)", padding: "var(--space-1) var(--space-3)", cursor:"pointer", whiteSpace:"nowrap", lineHeight:1, transition:"background-color .14s, border-color .14s, color .14s" };
  const trig = has
    ? { ...trigBase, color:"var(--color-text-secondary)", background: open ? "var(--color-background-tertiary)" : "var(--color-background-secondary)", border:0 }
    : { ...trigBase, color:"var(--color-text-tertiary)", background: open ? "var(--color-background-secondary)" : "transparent", border:"1px dashed var(--color-line-1)", padding: "var(--space-1) var(--space-2)" };
  return (
    <div ref={ref} style={{ position:"absolute", top:13, right:15, zIndex:7 }}>
      <button type="button" ref={btnRef} onClick={toggle} style={trig}
        onMouseEnter={e=>{ if(!open) e.currentTarget.style.background = has ? "var(--color-background-tertiary)" : "var(--color-background-secondary)"; }}
        onMouseLeave={e=>{ if(!open) e.currentTarget.style.background = has ? "var(--color-background-secondary)" : "transparent"; }}>
        {!has && <svg width="12" height="12" viewBox="0 0 24 24" fill="none" style={{ flexShrink:0 }}><path d="M12 5v14M5 12h14" stroke={tok("--color-text-tertiary")} strokeWidth="2.2" strokeLinecap="round"/></svg>}
        {has ? value : "Указать тип"}
        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" style={{ flexShrink:0, transition:"transform .16s", transform: open ? "rotate(180deg)" : "none" }}><path d="M6 9l6 6 6-6" stroke={tok("--color-text-4")} strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/></svg>
      </button>
      {open && (
        <div style={{ position:"absolute", right:0, width:220, background:"var(--color-background-primary)", border:"1px solid var(--color-background-tertiary)", borderRadius: "var(--radius)", boxShadow:"0 14px 44px rgba(20,22,40,.18)", padding: "var(--space-1)", zIndex:20,
          ...(up ? { bottom:"calc(100% + 6px)" } : { top:"calc(100% + 6px)" }) }}>
          <div style={{ fontSize:11, fontWeight:600,  color:"var(--color-text-tertiary)", padding: "var(--space-2) var(--space-3) var(--space-1)", fontFamily:"inherit" }}>Тип документа</div>
          {DOC_KINDS.map(k=>{
            const sel = k===value;
            return (
              <button key={k} type="button" onClick={()=>{ onChange(k); setOpen(false); }}
                style={{ display:"flex", width:"100%", alignItems:"center", gap: "var(--space-2)", padding: "var(--space-2) var(--space-3)", borderRadius: "var(--radius)", fontSize:14, fontWeight: sel?600:500, fontFamily:"inherit", color: sel?"var(--color-text-primary)":"var(--color-text-primary)", background: sel?"var(--color-accent-soft)":"transparent", border:0, cursor:"pointer", textAlign:"left" }}
                onMouseEnter={e=>{ if(!sel) e.currentTarget.style.background="var(--color-background-secondary)"; }}
                onMouseLeave={e=>{ if(!sel) e.currentTarget.style.background="transparent"; }}>
                {k}
                {sel && <svg width="15" height="15" viewBox="0 0 24 24" fill="none" style={{ marginLeft:"auto", flexShrink:0 }}><path d="M5 12.5l4.5 4.5L19 6" stroke={tok("--color-accent")} strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"/></svg>}
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
}

// Строка «Дата документа · Сумма · Валюта» (дизайн формы документа). Общая для форм
// реестра (ссылка/файл) и блока «Документ» в проекте. Валюта — ₽/$ тумблер.
/* Дата и сумма — необязательные поля, и по умолчанию их не видно: у ссылки на
   рабочую таблицу или папку суммы нет вообще, а две пустые графы посреди формы
   читаются как незаполненные обязательные. Открываются по «+», и если значение
   уже есть (правка документа) — поле показано сразу, иначе правку было бы не
   видно. Закрытие поля стирает значение: иначе останется невидимая сумма. */
function DocMoneyDateRow({ date, total, currency, onDate, onTotal, onCurrency }) {
  const cur = currency || "RUB";
  const hasDate  = !!date;
  const hasTotal = total != null && total !== "";
  const [openDate,  setOpenDate]  = React.useState(hasDate);
  const [openTotal, setOpenTotal] = React.useState(hasTotal);
  const totalRef = React.useRef(null);   // onDigitsInput возвращает каретку по числу цифр слева
  React.useEffect(() => { if (hasDate)  setOpenDate(true);  }, [hasDate]);
  React.useEffect(() => { if (hasTotal) setOpenTotal(true); }, [hasTotal]);
  const lbl = { fontSize:13.5, fontWeight:600, color:"var(--color-text-primary)", marginBottom: "var(--space-2)", display:"flex",
                alignItems:"baseline", justifyContent:"space-between", gap: "var(--space-2)" };
  const curBtn = (val, label) => (
    <button type="button" onClick={()=>onCurrency(val)} style={{ width:34, border:"none", borderRadius: "var(--radius)", cursor:"pointer", fontFamily:"inherit", fontSize:15, fontWeight:600, background: cur===val ? "var(--color-accent)" : "transparent", color: cur===val ? "var(--color-background-primary)" : "var(--color-text-tertiary)" }}>{label}</button>
  );
  const addBtn = (label, onClick) => (
    <button type="button" onClick={onClick}
      style={{ border:"1px solid var(--color-line-1)", background:"var(--color-background-primary)", borderRadius: "var(--radius)", padding: "var(--space-2) var(--space-3)", fontSize:13,
               fontFamily:"inherit", color:"var(--color-text-secondary)", cursor:"pointer" }}>+ {label}</button>
  );
  const hide = (onClick) => (
    <span onClick={onClick} title="Убрать поле"
      style={{ fontSize:12, fontWeight:400, color:"var(--color-text-4)", cursor:"pointer" }}>×</span>
  );
  if (!openDate && !openTotal) return (
    <div style={{ display:"flex", gap: "var(--space-2)", marginTop: "var(--space-4)" }}>
      {addBtn("дата", ()=>setOpenDate(true))}
      {addBtn("сумма", ()=>setOpenTotal(true))}
    </div>
  );
  return (
    <div style={{ display:"grid", gridTemplateColumns: openDate && openTotal ? "1fr 1fr" : "1fr", gap: "var(--space-3)", marginTop: "var(--space-4)" }}>
      {openDate && (
        <div>
          <div style={lbl}>Дата документа {hide(()=>{ setOpenDate(false); onDate(""); })}</div>
          <DateField value={date || ""} onChange={onDate} style={DOC_FORM.input} />
        </div>
      )}
      {openTotal && (
        <div>
          <div style={lbl}>Сумма {hide(()=>{ setOpenTotal(false); onTotal(""); })}</div>
          <div style={{ display:"flex", gap: "var(--space-2)", alignItems:"stretch" }}>
            {/* Разряды и в поле ввода: «600 000», а не «600000» — общее правило приложения,
                до этой формы просто не дошли. Наружу отдаём чистые цифры: получатель
                кладёт их в total числом, и пробел превратил бы сумму в NaN. */}
            <input ref={totalRef} className="doc-field" value={total ? fmtN(String(total).replace(/\D/g, "")) : ""}
              onChange={e=>onDigitsInput(e, totalRef, v => onTotal(v.replace(/\D/g, "")))}
              placeholder="0" inputMode="numeric" style={{ ...DOC_FORM.input, flex:1, minWidth:0 }} />
            <div style={{ display:"flex", gap:3, padding:3, borderRadius: "var(--radius)", background:"var(--color-background-secondary)", flex:"0 0 auto" }}>{curBtn("RUB","₽")}{curBtn("USD","$")}</div>
          </div>
        </div>
      )}
      {(!openDate || !openTotal) && (
        <div style={{ display:"flex", alignItems:"flex-end", paddingBottom:2 }}>
          {!openDate  && addBtn("дата",  ()=>setOpenDate(true))}
          {!openTotal && addBtn("сумма", ()=>setOpenTotal(true))}
        </div>
      )}
    </div>
  );
}

/* ── Форма документа-ссылки и документа-файла ────────────────────────────────
   Жила внутри DocumentsScreen, поэтому открыть её можно было только на вкладке
   «Документы»: карточка проекта звала goTab("documents"), а тот сбрасывает selPid
   — панель проекта закрывалась под пользователем ради попапа на три поля.
   Логика вынесена сюда целиком и не знает, кто её показывает: prepare проверяет
   и собирает документ, commit пишет в список. Экран документов и панель проекта
   зовут одно и то же — расходиться нечему. ── */

// Сторона документа (контрагент + проект). Подрядчик → counterpartyType:"contractor",
// иначе клиент проекта. clientName держит имя стороны для колонки реестра.
// ⚠ Сравнения через String(): в форме projectId — строка из <select>, в документе — сырой id.
function docPartyOf(f, { projects, clients, people }) {
  const proj = (projects || []).find(p => String(p.id) === String(f.projectId));
  const projFields = { projectId: proj ? proj.id : null, projectName: proj ? proj.name : "" };
  if (f.contractorCid != null) {
    const con = (people || []).find(pp => String(pp.id) === String(f.contractorCid));
    const nm = con ? (con.name || "") : "";
    return { ...projFields, counterpartyType:"contractor", contractorCid: con ? con.id : f.contractorCid, counterpartyName: nm, clientId:null, clientName: nm };
  }
  const cl = proj ? (clients || []).find(c => String(c.id) === String(proj.clientId)) : null;
  return { ...projFields, counterpartyType:"client", contractorCid:null, clientId: cl ? cl.id : (proj && proj.clientId) || null, clientName: cl ? cl.name : (proj && proj.client) || "" };
}

// Проверка и сборка документа. Возвращает {error} либо {base}; сам ничего не пишет
// и ничего не показывает — alert остаётся на стороне вызывающего.
function docFormPrepare(f, ctx) {
  if (!f) return { error:"Форма пуста" };
  if (f.mode === "upload") {
    if (!(f.ya && f.ya.path)) return { error:"Сначала загрузите файл" };
    return { base: {
      source:"upload", type:"upload", ya:f.ya,
      docKind: f.kind || "Иное",
      title: (f.title || "").trim() || (f.ya.name || f.kind || "Файл"),
      parentId: f.parentId || null,
      date: f.date || "",
      total: (f.total != null && f.total !== "") ? (+f.total || null) : null,
      currency: f.currency || "RUB",
      updatedAt: Date.now(),
      ...docPartyOf(f, ctx),
    } };
  }
  // Только веб-ссылки: javascript:/data:-схемы исполнялись бы при «Открыть ↗»
  // (stored-XSS при будущем шаринге). Без схемы — дописываем https://.
  let linkUrl = (f.url || "").trim();
  if (!linkUrl) return { error:"Вставьте ссылку" };
  if (!/^https?:\/\//i.test(linkUrl)) {
    if (/^[a-z][a-z0-9+.-]*:/i.test(linkUrl)) return { error:"Ссылка должна начинаться с http:// или https://" };
    linkUrl = "https://" + linkUrl;
  }
  return { base: {
    source:"link", type:"link", url:linkUrl,
    provider: gsheetPreviewUrl(f.url) ? "gsheet" : "other",
    docKind: f.kind || "Иное",
    title: (f.title || "").trim() || (f.kind || "Ссылка"),
    parentId: f.parentId || null,
    date: f.date || "",
    total: (f.total != null && f.total !== "") ? (+f.total || null) : null,
    currency: f.currency || "RUB",
    updatedAt: Date.now(),
    ...docPartyOf(f, ctx),
  } };
}

// Запись в список документов. Замену файла чистим на Диске ДО setDocuments:
// побочный эффект внутри updater'а выполнился бы дважды в StrictMode.
function docFormCommit(f, base, documents, setDocuments) {
  if (f.id) {
    if (f.mode === "upload") {
      const prev = (documents || []).find(d => d.id === f.id);
      if (prev && prev.ya && prev.ya.path && base.ya && prev.ya.path !== base.ya.path) deleteYaFile(prev.ya.path);
    }
    setDocuments(ds => ds.map(d => d.id === f.id ? { ...d, ...base } : d));
  } else {
    setDocuments(ds => [{ id:"doc-"+Date.now(), status:"draft", createdAt:Date.now(), ...base }, ...ds]);
  }
}

// Сама модалка. form.mode: "link" | "upload". onChange получает патч поля.
function DocFormModal({ form, onChange, onSave, onClose, projects, people, documents, onCreateProject }) {
  /* Escape закрывает форму сам, а не через каскад экрана: модалка живёт в двух
     местах (реестр и панель проекта), и в реестре каскада нет вовсе — там Esc
     раньше просто не работал. Хук объявлен ДО раннего возврата: порядок хуков
     в React обязан быть одинаковым на каждом рендере. */
  const open = !!form;
  React.useEffect(() => {
    if (!open) return;
    /* ⚠ ФАЗА ПЕРЕХВАТА обязательна. Каскады экранов слушают Escape на `document`, а
       document-слушатели в фазе всплытия срабатывают РАНЬШЕ window-слушателей: без
       capture один Esc закрывал и форму, и разбор операции под ней, хотя человек
       хотел отменить только форму. Перехват на window идёт до всех, а
       stopPropagation не даёт клавише дойти до каскада. */
    const onKey = e => { if (e.key === "Escape") { e.stopPropagation(); onClose(); } };
    window.addEventListener("keydown", onKey, true);
    return () => window.removeEventListener("keydown", onKey, true);
  }, [open, onClose]);
  useScrollLock(open);   // тоже ДО раннего возврата — по той же причине
  if (!form) return null;
  const f = form;
  const isUpload = f.mode === "upload";
  const set = (k, v) => onChange({ ...f, [k]: v });
  const ready = isUpload ? !!(f.ya && f.ya.path) : !!(f.url || "").trim();
  const parentOpts = docParentCandidates(documents || [], { projectId: f.projectId, excludeId: f.id });
  const isSheet = !isUpload && !!gsheetPreviewUrl(f.url);
  return (
    <React.Fragment>
      {/* ⚠ 620, а не 62 (12.08.2026): форма документа открывается ИЗ разбора операции
          (мост «файл → документ»), а разбор лежит на 400 — на прежнем слое форма
          открывалась ПОД ним, ровно как мастер документов из окна правки строки.
          Она везде последняя в стопке (это же утверждает Esc-каскад экрана), и
          слой обязан это повторять. */}
      <div onClick={onClose} style={{ position:"fixed", inset:0, background:"var(--color-scrim)", zIndex:620 }} />
      <div style={{ position:"fixed", top:"50%", left:"50%", transform:"translate(-50%,-50%)", width:"min(480px, calc(100vw - 40px))", maxHeight:"86vh", overflowY:"auto", background:"var(--color-background-primary)", border:"1px solid var(--color-line-1)", borderRadius: "var(--radius-card)", boxShadow:"none", zIndex:621, padding:"24px 24px 24px" }}>
        <div style={{ position:"relative" }}>
          <DocTypeTag value={f.kind} onChange={k=>set("kind", k)} />
          <div style={{ paddingRight:150, marginBottom: f.contractorCid != null ? "var(--space-2)" : "var(--space-4)"}}>
            <input value={f.title || ""} onChange={e=>set("title", e.target.value)}
              placeholder={isUpload ? ((f.ya && f.ya.name) || "Название документа") : "Название документа"}
              data-disp style={DOC_FORM.title} autoFocus={!isUpload} />
          </div>
          {f.contractorCid != null && (() => {
            const con = (people || []).find(pp => String(pp.id) === String(f.contractorCid));
            return <div style={{ fontSize:13, color:"var(--color-text-secondary)", marginBottom: "var(--space-4)" }}>Подрядчик: <span>{con ? con.name : "—"}</span></div>;
          })()}
          {/* «Файл или ссылка» — один и тот же вопрос «где лежит документ», поэтому
              переключатель, а не две разные формы в меню. Показывается только у входа
              «+ → Файл» (anyKind): формы из карточек знают свой род заранее. */}
          {f.anyKind && (
            <div style={{ marginBottom: "var(--space-3)" }}>
              <SegSwitch size="sm" label="Где лежит документ" value={isUpload ? "upload" : "link"}
                onChange={m=>set("mode", m)}
                items={[["upload","Файл"],["link","Ссылка"]]} />
            </div>
          )}
          {isUpload ? (
            <React.Fragment>
              <DocUpload lockFile content={{ ya:f.ya }}
                onChange={patch => onChange((() => { const next = { ...f, ...patch }; if (patch.label && !(f.title || "").trim()) next.title = patch.label; delete next.label; return next; })())} />
              {!ready && <div style={{ fontSize:12.5, color:"var(--color-text-tertiary)", lineHeight:1.5, marginTop: "var(--space-2)" }}>Перетащите файл или выберите — он зальётся на Яндекс.Диск. После загрузки нажмите «Сохранить».</div>}
            </React.Fragment>
          ) : (
            <React.Fragment>
              <input className="doc-field" value={f.url || ""} onChange={e=>set("url", e.target.value)} placeholder="https://…" style={DOC_FORM.input} />
              {isSheet && <div style={{ fontSize:12.5, color:"var(--color-text-tertiary)", lineHeight:1.5, marginTop: "var(--space-2)" }}>Похоже на Google Таблицу — покажем превью прямо в реестре. Чтобы оно отображалось, у таблицы должен быть доступ «по ссылке».</div>}
            </React.Fragment>
          )}
          <DocMoneyDateRow date={f.date} total={f.total} currency={f.currency}
            onDate={v=>set("date", v)} onTotal={v=>set("total", v)} onCurrency={v=>set("currency", v)} />
          {/* Свои выпадашки, а не нативные <select>: их рисует ОС, стили к ним не пристают,
              и форма документов оставалась единственным местом в приложении, где поля
              выглядели чужими. Проект отдаётся с подписью-клиентом строкой ниже — как в ленте. */}
          <div style={DOC_FORM.subLabel}>Проект</div>
          <ArtSelect value={f.projectId ? String(f.projectId) : ""} emptyLabel="Без проекта" placeholder="Без проекта"
            options={[{ v:"", t:"Без проекта" }, ...(projects || []).map(p => ({ v:String(p.id), t:p.name || "Без названия", sub:p.client || "" }))]}
            searchPlaceholder="Найти проект" createLabel="Создать проект"
            onCreate={onCreateProject ? (nm => onChange({ ...f, projectId:String(onCreateProject(nm, null)), parentId:null })) : undefined}
            onChange={v=>onChange({ ...f, projectId:v, parentId:null })} />
          {parentOpts.length > 0 && (
            <React.Fragment>
              <div style={DOC_FORM.subLabel}>Относится к документу</div>
              <ArtSelect value={f.parentId ? String(f.parentId) : ""} emptyLabel="Без привязки" placeholder="Без привязки"
                options={[{ v:"", t:"Без привязки" }, ...parentOpts.map(d => ({ v:String(d.id), t:docParentLabelOf(d) }))]}
                onChange={v=>set("parentId", v || null)} />
            </React.Fragment>
          )}
          {/* Подвал прижат ВПРАВО, как в окне документа макета: слева читают форму,
              справа её закрывают. Растянутые на пол-окна кнопки читались двумя
              главными действиями, между которыми надо выбирать. */}
          <div style={{ display:"flex", justifyContent:"flex-end", gap: "var(--space-2)", marginTop: "var(--space-5)" }}>
            {/* ⚠ ОБЩИЕ КНОПКИ .fm-cta, А НЕ СВОИ (2026-08-18). Здесь жили две
                самодельные: высота 42 против 37 у полей рядом, кегль 14 против
                14.5 у всех кнопок приложения, а главная была ЧЕРНИЛЬНОЙ — тем же
                цветом, что «Открыть Движение средств» на песке, хотя тут это
                обычное сохранение. Главное действие окна — акцентное (.fm-cta-a),
                отмена — призрак. Растяжку на всю ширину сняли: кнопка шириной в
                пол-окна читается вторым главным действием. */}
            <button className="fm-cta fm-cta-g" onClick={onClose}>Отмена</button>
            <button className="fm-cta fm-cta-a" onClick={onSave} disabled={!ready}
              style={{ cursor: ready ? "pointer" : "default", opacity: ready ? 1 : .45 }}>Сохранить</button>
          </div>
        </div>
      </div>
    </React.Fragment>
  );
}

// Попап-превью загруженного файла (одинаковый для реестра и блока «Документ»).
// doc: { ya:{preview,public_url,path,name,size}, title, docKind, projectName }.
// onEdit(doc) — опц. кнопка «Изменить»; «Открыть ↗» ведёт на Яндекс.Диск.
function DocPreviewModal({ doc, onClose, onEdit }) {
  useScrollLock(!!doc);   // до раннего возврата: порядок хуков обязан совпадать
  if (!doc) return null;
  const ya = doc.ya || {};
  const openFile = () => ya.public_url ? window.open(ya.public_url, "_blank", "noopener") : (ya.path ? openYaFile(ya.path) : null);
  const sizeText = [ya.size ? fmtFileSize(ya.size) : null, ya.public_url ? "опубликован" : null].filter(Boolean).join(" · ");
  return (
    <React.Fragment>
      <div onClick={onClose} style={{ position:"fixed", inset:0, background:"var(--color-scrim)", zIndex:400 }} />
      <div style={{ position:"fixed", top:"50%", left:"50%", transform:"translate(-50%,-50%)", width:"min(680px, calc(100vw - 40px))", maxHeight:"86vh", overflowY:"auto", background:"var(--color-background-primary)", border:"1px solid var(--color-background-tertiary)", borderRadius: "var(--radius)", boxShadow:"0 14px 44px rgba(20,22,40,.16)", zIndex:401, padding: "var(--space-5) var(--space-5) var(--space-5)" }}>
        <div style={{ position:"relative" }}>
          <button onClick={onClose} title="Закрыть" style={{ position:"absolute", top:-6, right:-8, border:"none", background:"transparent", color:"var(--color-text-tertiary)", fontSize:22, lineHeight:1, cursor:"pointer", fontFamily:"inherit", padding: "var(--space-1)" }}>×</button>
          <div style={{ display:"flex", alignItems:"center", gap: "var(--space-3)", paddingRight:36, marginBottom: "var(--space-4)" }}>
            <div style={{ flex:1, minWidth:0, fontSize:22, fontWeight:700,  color:"var(--color-text-primary)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{doc.title || doc.docKind || "Документ"}</div>
            {doc.docKind && <div style={{ flexShrink:0, fontSize:12.5, fontWeight:600, color:"var(--color-text-secondary)", background:"var(--color-background-secondary)", borderRadius: "var(--radius)", padding: "var(--space-2) var(--space-3)" }}>{doc.docKind}</div>}
          </div>
          <div style={{ display:"flex", alignItems:"center", gap: "var(--space-3)", padding: "var(--space-3) var(--space-3)", background:"var(--color-background-secondary)", borderRadius: "var(--radius)" }}>
            <div style={{ width:64, height:48, flexShrink:0, borderRadius: "var(--radius)", overflow:"hidden", border:"0.5px solid var(--color-background-tertiary)", background:"var(--color-background-primary)", display:"flex", alignItems:"center", justifyContent:"center" }}>
              {ya.preview ? <img src={ya.preview} alt="" style={{ width:"100%", height:"100%", objectFit:"cover", objectPosition:"top" }} /> : <span style={{ fontSize:22 }}>📄</span>}
            </div>
            <div style={{ flex:1, minWidth:0, fontSize:13.5, color:"var(--color-text-tertiary)" }}>{sizeText}</div>
            <button onClick={openFile} style={{ flexShrink:0, border:"none", background:"var(--color-accent-soft)", color:"var(--color-accent)", borderRadius: "var(--radius)", height:40, padding: "0 var(--space-4)", fontSize:14, fontWeight:600, fontFamily:"inherit", cursor:"pointer" }}>Открыть ↗</button>
          </div>
          {onEdit && <div style={{ marginTop: "var(--space-3)", textAlign:"right" }}><button onClick={()=>{ onClose(); onEdit(doc); }} style={{ border:"1px solid var(--color-line-1)", background:"var(--color-background-primary)", borderRadius: "var(--radius)", height:36, padding: "0 var(--space-4)", fontSize:13, fontWeight:600, fontFamily:"inherit", cursor:"pointer", color:"var(--color-text-secondary)" }}>Изменить</button></div>}
        </div>
      </div>
    </React.Fragment>
  );
}

// ─── CONSTANTS ───────────────────────────────────────────────────────────────

/* ── Пункт порядка расчётов ───────────────────────────────────────────────────
   Транш был свободной строкой «Условие» + сумма + дата: человеку понятно, машине
   нет. В договор это приходилось перепечатывать руками, а «в течение 5 дней после
   подписания акта» вообще нигде не жило — только в голове.
   Вид, доля и срок — три поля, из которых генератор договора собирает готовый
   «Порядок расчётов». Всё опционально: пустые поля дают прежний вид строки. */
const PAY_KINDS = [
  { id:"advance", label:"Аванс",      word:"Аванс" },
  { id:"stage",   label:"Этап",       word:"Оплата этапа" },
  { id:"final",   label:"Постоплата", word:"Окончательный расчёт" },
];
const DUE_EVENTS = [
  { id:"sign",   label:"подписания договора", doc:"подписания Договора" },
  { id:"start",  label:"начала работ",        doc:"начала работ" },
  { id:"accept", label:"сдачи работ",         doc:"приёмки работ Заказчиком" },
  { id:"act",    label:"подписания акта",     doc:"подписания Акта сдачи-приёмки" },
];
const payKindOf  = id => PAY_KINDS.find(k => k.id === id) || null;
const dueEventOf = id => DUE_EVENTS.find(e => e.id === id) || null;
// Срок пункта человеческим языком: «в течение 5 дней после подписания акта».
// Пустой день или событие → срока нет, и придумывать его за пользователя нельзя.
function dueTextOf(t, forDoc) {
  const n = +String(t && t.dueDays || "").trim();
  const ev = dueEventOf(t && t.dueEvent);
  if (!(n > 0) || !ev) return "";
  return "в течение " + n + " " + plur(n, "дня", "дней", "дней") + " после " + (forDoc ? ev.doc : ev.label);
}

/* tok(name) — значение токена для мест, где var() не работает: презентационные
   атрибуты SVG (fill=, stroke=, stopColor=) браузер не разбирает как CSS-свойство.
   Кэш сбрасывается tokReset() — понадобится при вводе тёмной темы. */
const _tokCache = new Map();
function tok(name) {
  if (!_tokCache.has(name)) {
    const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
    _tokCache.set(name, v || "var(--color-text-primary)");
  }
  return _tokCache.get(name);
}
function tokReset() { _tokCache.clear(); }

/* bakeTokens(html) — подставляет значения токенов вместо var(--…) в разметку, которая
   уедет ИЗ приложения: печатный лист открывается отдельным окном, Word-файл вообще
   отдельным документом — :root с определениями токенов там отсутствует, поэтому
   объявление молча отбрасывается (пропадали фон листа и заливка шапки таблицы сметы).
   Word к тому же не понимает CSS-переменные в принципе. Внутри приложения так делать
   НЕ надо — там работает обычный var(). */
function bakeTokens(html) {
  return String(html).replace(/var\((--[a-z0-9-]+)\s*(?:,[^()]*)?\)/gi, (_, name) => tok(name));
}

const ROLES = [
  { id:"ad",  label:"Арт директор",    desc:"Визия, концепция, контроль качества",    bg:"var(--color-background-secondary)", tc:"var(--color-text-secondary)" },
  { id:"cgg", label:"CG Generalist",   desc:"Полный цикл 3D от модели до рендера",    bg:"var(--color-background-secondary)", tc:"var(--color-text-secondary)" },
  { id:"sup", label:"Супервайзер",     desc:"Технический надзор, пайплайн, дедлайны", bg:"var(--color-background-secondary)", tc:"var(--color-text-secondary)" },
  { id:"md",  label:"Motion Designer", desc:"Движение, тайминг, анимация",            bg:"var(--color-background-secondary)", tc:"var(--color-text-secondary)" },
  { id:"d2",  label:"2D дизайнер",     desc:"Графика, композиция, статика",           bg:"var(--color-background-secondary)", tc:"var(--color-text-secondary)" },
];

const S3 = [
  { id:"viz3d", l:"3D визуализация" },
  { id:"mot3d", l:"3D motion design" },
  { id:"sim",   l:"Симуляции" },
  { id:"mod",   l:"Моделлинг" },
];
const S2 = [
  { id:"mot2d", l:"2D motion design" },
  { id:"ill",   l:"Иллюстрация" },
  { id:"gd",    l:"Графический дизайн" },
];

/* ⚠ «ЗавершЁн», через ё (2026-08-17). Ярлык статуса печатается в рельсе, в чипах
   и в канбане — то есть на трёх экранах сразу, и везде стоял без ё, тогда как
   весь остальной текст приложения её держит («получено», «объём», «счёт»). */
const STATUS_LABEL = { request:"Запрос", active:"В работе", done:"Завершён", rejected:"Отказ", pause:"Пауза" };
const STATUS_COLOR = {
  request:  { bg:"var(--color-background-tertiary)", tc:"var(--color-text-secondary)" },
  active:   { bg:"var(--color-accent-soft)",   tc:"var(--color-accent)" },
  done:     { bg:"var(--color-positive-soft)",  tc:"var(--color-positive)" },
  rejected: { bg:"var(--color-negative-soft)",  tc:"var(--color-negative)" },
  pause:    { bg:"var(--color-warn-soft)",      tc:"var(--color-warn)" }, // legacy
};

// ── Свои этапы канбана (CRM-стадии) ──────────────────────────────────────────
// CUSTOM_STAGES (id → label) заполняется приложением из состояния customStages.
// Свои этапы — промежуточные звенья пайплайна между «В работе» и «Завершён»:
// считаются как активные (план/долги), но не как факт-доход (факт = только «Завершён»).
let CUSTOM_STAGES = {};
let COLUMN_ORDER = null; // сохранённый пользователем порядок колонок (fm_col_order), заполняется приложением
const STAGE_COLOR = { bg:"var(--color-background-tertiary)", tc:"var(--color-text-secondary)" }; // нейтральный цвет для своих этапов
function statusLabel(s) { return STATUS_LABEL[s] || CUSTOM_STAGES[s] || s; }
function statusSC(s)    { return STATUS_COLOR[s] || (CUSTOM_STAGES[s] ? STAGE_COLOR : STATUS_COLOR.done); }
function isCustomStage(s){ return !!CUSTOM_STAGES[s]; }
// Базовый порядок: Запрос → В работе → свои этапы → Завершён → Отказ
function baseStatuses() { return ["request", "active", ...Object.keys(CUSTOM_STAGES), "done", "rejected"]; }
// Итоговый порядок колонок с учётом ручной перестановки (новые статусы/этапы дописываются в конец)
function orderedStatuses() {
  const base = baseStatuses();
  if (!Array.isArray(COLUMN_ORDER) || !COLUMN_ORDER.length) return base;
  const known = new Set(base);
  const head = COLUMN_ORDER.filter(s => known.has(s));
  const headSet = new Set(head);
  const tail = base.filter(s => !headSet.has(s));
  return [...head, ...tail];
}

const AVC = [
  ["var(--color-background-hover)","var(--color-text-primary)"],["var(--color-background-secondary)","var(--color-text-primary)"],["var(--color-background-tertiary)","var(--color-text-primary)"],
  ["var(--color-background-page)","var(--color-text-secondary)"],["var(--color-background-secondary)","var(--color-text-primary)"],["var(--color-background-tertiary)","var(--color-text-primary)"],
];

const MONTHS    = ["Январь","Февраль","Март","Апрель","Май","Июнь","Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"];
const MONTHS_SH = ["янв","фев","мар","апр","май","июн","июл","авг","сен","окт","ноя","дек"];

// ─── ОБЩИЙ ПЕРИОД ПРИЛОЖЕНИЯ ─────────────────────────────────────────────────
// Один выбор на всё: Дашборды, Доходы, Расходы и Банк смотрят в одно окно дат,
// период переживает переключение вкладок и перезагрузку (ключ fm_period).
// «Поток» намеренно НЕ подчиняется общему периоду: это прогноз на ближайшие дни
// (кассовый разрыв), а не отчёт за выбранный отрезок — он всегда «от сегодня».
// Форма: { mode:"month"|"quarter"|"year"|"custom", y, m (1-12), q (1-4), from, to }.
const PERIOD_MODES = [["month","Месяц"],["quarter","Квартал"],["year","Год"],["custom","Свой период"],["all","Всё время"]];
/* Готовые отрезки в поповере периода. Пресет — не новая сущность, а ярлык:
   он просто выставляет уже существующую модель. Те, что ложатся на месяц/
   квартал/год, ставят соответствующий режим и остаются ШАГАЕМЫМИ стрелками;
   «сегодня», недели и «±N дней» ложатся только на custom — у них шага нет и
   быть не может. `need` — какой режим требуется: экран, ограничивший `modes`
   (например «Поток» — только месяц), увидит лишь совместимые пресеты. */
function periodPresets() {
  const t   = isoToday();
  // полдень — чтобы перевод часов и часовой пояс не сдвигали дату на сутки
  const shift = (base, n) => { const d = new Date(base + "T12:00:00"); d.setDate(d.getDate() + n); return d.toISOString().slice(0,10); };
  const monday = (base) => { const d = new Date(base + "T12:00:00"); d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); return d.toISOString().slice(0,10); };
  const now = new Date(), y = now.getFullYear(), m = now.getMonth() + 1, q = Math.floor(now.getMonth() / 3) + 1;
  const cust = (from, to) => ({ mode:"custom", from, to });
  const w0 = monday(t), pw0 = shift(w0, -7);
  return [
    { k:"today",  lbl:"Сегодня",           need:"custom",  v:cust(t, t) },
    { k:"yest",   lbl:"Вчера",             need:"custom",  v:cust(shift(t,-1), shift(t,-1)) },
    { k:"d7",     lbl:"Сегодня ± 7 дней",  need:"custom",  v:cust(shift(t,-7), shift(t,7)) },
    { k:"d30",    lbl:"Сегодня ± 30 дней", need:"custom",  v:cust(shift(t,-30), shift(t,30)) },
    { k:"week",   lbl:"Текущая неделя",    need:"custom",  v:cust(w0, shift(w0,6)) },
    { k:"month",  lbl:"Текущий месяц",     need:"month",   v:{ mode:"month", y, m } },
    { k:"quart",  lbl:"Текущий квартал",   need:"quarter", v:{ mode:"quarter", y, q } },
    { k:"year",   lbl:"Текущий год",       need:"year",    v:{ mode:"year", y } },
    { k:"pweek",  lbl:"Прошлая неделя",    need:"custom",  v:cust(pw0, shift(pw0,6)) },
    { k:"pmonth", lbl:"Прошлый месяц",     need:"month",   v: m > 1 ? { mode:"month", y, m:m-1 } : { mode:"month", y:y-1, m:12 } },
    { k:"pquart", lbl:"Прошлый квартал",   need:"quarter", v: q > 1 ? { mode:"quarter", y, q:q-1 } : { mode:"quarter", y:y-1, q:4 } },
    { k:"pyear",  lbl:"Прошлый год",       need:"year",    v:{ mode:"year", y:y-1 } },
    { k:"all",    lbl:"Всё время",         need:"all",     v:{ mode:"all", y } },
  ];
}
/* Умолчание — ТЕКУЩИЙ МЕСЯЦ. Был год: на нём лента и метрики открывались сразу
   всей историей, и первое, что видел человек, — простыня за двенадцать месяцев.
   Месяц отвечает на вопрос «что происходит сейчас», а год добирается пресетом.
   ⚠ Меняет вид только для тех, у кого ещё нет `fm_period`; сохранённый период
   не трогаем — это выбор человека. */
function periodDefault() {
  const d = new Date();
  return { mode:"month", y:d.getFullYear(), m:d.getMonth()+1, q:Math.floor(d.getMonth()/3)+1, from:"", to:"" };
}
function loadPeriod() {
  try {
    const raw = localStorage.getItem("fm_period");
    if (!raw) return periodDefault();
    const v = JSON.parse(raw);
    return (v && typeof v === "object") ? { ...periodDefault(), ...v } : periodDefault();
  } catch { return periodDefault(); }
}
const lastDayOf = (y, m) => new Date(y, m, 0).getDate();       // m = 1..12
const pad2 = n => String(n).padStart(2, "0");
// Сегодняшняя дата в ISO — для отметок факта. Локальные todayISO в экранах это
// СТРОКИ в своих областях видимости, наружу они не видны; общей функции не было.
const isoToday = () => { const d = new Date(); return `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())}`; };
// Диапазон дат периода + человеческая подпись. Всегда возвращает валидное окно:
// незаполненный «свой период» откатывается на год, перевёрнутый — разворачивается.
function periodRange(p) {
  const pr = p || periodDefault();
  const y = +pr.y || new Date().getFullYear();
  if (pr.mode === "month") {
    const m = Math.min(12, Math.max(1, +pr.m || 1));
    return { from:`${y}-${pad2(m)}-01`, to:`${y}-${pad2(m)}-${pad2(lastDayOf(y,m))}`, label:`${MONTHS[m-1]} ${y}` };
  }
  if (pr.mode === "quarter") {
    const q = Math.min(4, Math.max(1, +pr.q || 1));
    const m1 = (q - 1) * 3 + 1, m2 = m1 + 2;
    return { from:`${y}-${pad2(m1)}-01`, to:`${y}-${pad2(m2)}-${pad2(lastDayOf(y,m2))}`,
             label:`${["I","II","III","IV"][q-1]} квартал ${y}` };
  }
  /* ⚠ «Всё время» НАМЕРЕННО ограничено десятью годами, а не 1970–2999:
     помесячный график на дашбордах строит по ведру на месяц окна, и на тысяче
     лет он превратился бы в двенадцать тысяч точек. Десять лет накрывают всю
     историю студии с запасом. */
  if (pr.mode === "all") {
    const y0 = y - 9;
    return { from:`${y0}-01-01`, to:`${y}-12-31`, label:"Всё время" };
  }
  if (pr.mode === "custom" && pr.from && pr.to) {
    let a = pr.from, b = pr.to;
    if (b < a) { const t = a; a = b; b = t; }
    const d = iso => { const [yy,mm,dd] = iso.split("-").map(Number); return `${dd} ${MONTHS_SH[mm-1]}${yy!==+a.slice(0,4)||yy!==+b.slice(0,4)?" "+yy:""}`; };
    return { from:a, to:b, label:`${d(a)} — ${d(b)} ${b.slice(0,4)}` };
  }
  return { from:`${y}-01-01`, to:`${y}-12-31`, label:`${y} год` };
}
// Шаг ← → по текущему режиму (для «своего периода» шага нет — сдвигать нечего).
function periodStep(p, dir) {
  const pr = { ...(p || periodDefault()) };
  if (pr.mode === "month")   { let m = pr.m + dir; let y = pr.y; if (m > 12) { m = 1; y++; } if (m < 1) { m = 12; y--; } return { ...pr, m, y }; }
  if (pr.mode === "quarter") { let q = pr.q + dir; let y = pr.y; if (q > 4) { q = 1; y++; } if (q < 1) { q = 4; y--; } return { ...pr, q, y }; }
  if (pr.mode === "year")    return { ...pr, y: pr.y + dir };
  return pr;   // «свой период» и «всё время» шагать нечем
}
const inPeriod = (iso, r) => { const d = String(iso || "").slice(0,10); return !!d && d >= r.from && d <= r.to; };

// Переключатель общего периода: ← [подпись ▾] →. Один и тот же контрол на всех
// экранах, где период имеет смысл. Стиль — как у прежнего селектора года.
// modes — какие режимы доступны в выпадашке. По умолчанию все; «Поток» просит
// только месяц (он живёт месяцем-календарём), но контрол при этом тот же самый.
// ── Сегментный тумблер ───────────────────────────────────────────────────────
// Три отдельные пилюли читаются как три кнопки — «а что будет, если нажать вторую?».
// Одна дорожка с едущей шайбой говорит «это один выбор из трёх» ещё до чтения текста.
// Шайбу приходится мерить по DOM (`offsetLeft`/`offsetWidth`), а не считать по числу
// сегментов: лейблы разной длины, равные доли растянули бы контрол до ширины «План и факт»×3.
// Вёрстка/переходы — в CSS (.seg-sw*), здесь только замер.
function SegSwitch({ items, value, onChange, label, size }) {
  const btns = useRef({});
  const [thumb, setThumb] = useState(null);
  const [ready, setReady] = useState(false);   // первый кадр — без перехода, иначе шайба выезжает из левого угла

  const keys = items.map(it => it[1] + ":" + (it[2] == null ? "" : it[2])).join("|");
  React.useLayoutEffect(() => {
    const measure = () => {
      const el = btns.current[value];
      if (!el) return;
      const x = el.offsetLeft, w = el.offsetWidth;   // offsetLeft — от padding-края дорожки, как и left:0 у шайбы
      setThumb(t => (t && t.x === x && t.w === w) ? t : { x, w });   // без сравнения — бесконечный ре-рендер
    };
    measure();
    const raf = requestAnimationFrame(() => setReady(true));
    window.addEventListener("resize", measure);
    // веб-шрифт приезжает после первой отрисовки и меняет ширину лейблов — перемерить
    if (document.fonts && document.fonts.ready) document.fonts.ready.then(measure).catch(()=>{});
    return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", measure); };
  }, [value, keys]);

  return (
    <div className={"seg-sw" + (size === "sm" ? " is-sm" : "")} role="tablist" aria-label={label}>
      {thumb && <span className="seg-sw-thumb" data-ready={ready ? "true" : "false"}
        style={{ width: thumb.w, transform: "translateX(" + thumb.x + "px)" }} />}
      {items.map(([id, text, n]) => (
        <button key={id} type="button" role="tab" aria-selected={value === id}
          ref={el => { btns.current[id] = el; }}
          className={"seg-sw-btn" + (value === id ? " is-on" : "")}
          onClick={() => onChange(id)}>
          {text}{n != null && <span className="seg-sw-n" data-num>{n}</span>}
        </button>
      ))}
    </div>
  );
}

/* `size="sm"` и `onClose` — для ряда фасетов «Операций»: там период стоит таким
   же чипом, как остальные отборы, и закрывается так же. Свой навигатор дат
   вместо этого контрола не рисуем никогда (правило репозитория): у экрана
   меняется только оправа, механика остаётся одна на всё приложение. */
function PeriodPicker({ value, onChange, modes, size, onClose }) {
  const sm = size === "sm";
  const [open, setOpen] = useState(false);
  /* Панель 352px шире своей кнопки, а кнопка стоит в ПРАВОМ углу шапки — с left:0
     панель уезжала за правый край экрана и половина пресетов обрезалась
     («Текущий кварта…», «31.07.20…»). Прижимаем правым краем, когда справа не
     хватает места; popSide делает то же самое по вертикали. Меряем на открытии:
     до него у панели нет геометрии, а после ширина окна уже не меняется. */
  const wrapRef = useRef(null);
  const [xRight, setXRight] = useState(false);
  useEffect(() => {
    if (!open || !wrapRef.current) return;
    const r = wrapRef.current.getBoundingClientRect();
    setXRight(window.innerWidth - r.left < 352 + 16);
  }, [open]);
  const allowed = (modes && modes.length) ? PERIOD_MODES.filter(([id]) => modes.indexOf(id) >= 0) : PERIOD_MODES;
  const p = value || periodDefault();
  const r = periodRange(p);
  const curY = new Date().getFullYear();
  const years = Array.from({ length:6 }, (_, k) => curY + 1 - k);
  const box  = { display:"flex", alignItems:"center", gap: sm ? "var(--space-1)" : "var(--space-2)", background:"var(--color-background-primary)", borderRadius: "var(--radius)",
    padding: sm ? "0 var(--space-1) 0 var(--space-3)" : "0 var(--space-2)", border:"1px solid var(--color-line-1)",
    height: sm ? 34 : "var(--h-control)", boxSizing:"border-box" };
  const arrow = (dis) => ({ width: sm ? 22 : 28, height: sm ? 22 : 28, borderRadius: "var(--radius)", border:"none", background:"transparent", display:"flex", alignItems:"center", justifyContent:"center",
    color: dis ? "var(--color-line-1)" : "var(--color-text-tertiary)", cursor: dis ? "default" : "pointer", fontFamily:"inherit", fontSize: sm ? 13 : 15 });
  const item = (on) => ({ display:"flex", alignItems:"center", justifyContent:"space-between", gap: "var(--space-2)", width:"100%", textAlign:"left",
    padding: "var(--space-2) var(--space-3)", fontSize:14, fontFamily:"inherit", border:"none", cursor:"pointer", borderRadius:"var(--radius)", whiteSpace:"nowrap",
    background: on ? "var(--color-background-secondary)" : "transparent", color:"var(--color-text-primary)" });
  const cap  = { fontSize:11, color:"var(--color-text-4)", padding: "var(--space-2) var(--space-3) var(--space-1)" };
  const dateIn = { border:"0.5px solid var(--color-border-secondary)", borderRadius:"var(--radius)", padding: "var(--space-1) var(--space-2)", fontSize:13, fontFamily:"inherit", background:"var(--color-background-primary)", color:"var(--color-text-primary)", outline:"none", width:"100%", boxSizing:"border-box" };
  const set  = (patch) => onChange({ ...p, ...patch });
  const noStep = p.mode === "custom" || p.mode === "all";
  /* Подпись контрола — НАЗВАНИЕ отрезка, если он совпал с готовым («Текущий
     месяц»), и сам отрезок, если нет («Май 2026», «12 мар — 4 апр»). Так подпись
     отвечает на вопрос «что показано» тем же словом, которым это выбирали.
     Точная дата не теряется: она в подсказке и в подзаголовке экрана — там
     `periodRange().label` остался прежним, и «за июль» в тексте не превращается
     в «за текущий месяц». */
  const presets = periodPresets();
  const hitPreset = presets.find(pr => { const rr = periodRange(pr.v); return rr.from === r.from && rr.to === r.to; });
  const title = hitPreset ? hitPreset.lbl : r.label;
  return (
    <div ref={wrapRef} style={{ position:"relative" }}>
      <div style={box}>
        {/* Стрелки — только в полном размере. В ряду фасетов период такой же чип,
            как остальные: одна кнопка, всё остальное внутри списка. Пять
            контролов в одной пилюле (← подпись ▾ → ✕) читались как панель. */}
        {!sm && (
          <button onClick={()=>{ if(!noStep) onChange(periodStep(p,-1)); }} disabled={noStep} title="Назад" style={arrow(noStep)}
            onMouseEnter={e=>{ if(!noStep) e.currentTarget.style.background="var(--color-background-secondary)"; }} onMouseLeave={e=>e.currentTarget.style.background="transparent"}>←</button>
        )}
        <button onClick={()=>setOpen(o=>!o)} title={hitPreset ? r.label : undefined}
          style={{ display:"flex", alignItems:"center", gap: "var(--space-2)", border:"none", background:"transparent", cursor:"pointer", fontFamily:"inherit", fontWeight:600, fontSize: sm ? 13.5 : 14, color:"var(--color-text-primary)", whiteSpace:"nowrap", padding:"0 2px" }}>
          {title} <span style={{ color:"var(--color-text-5)", fontSize:10, transform: open?"rotate(180deg)":"none", transition:"transform var(--t-fast)" }}>▾</span>
        </button>
        {!sm && (
          <button onClick={()=>{ if(!noStep) onChange(periodStep(p,1)); }} disabled={noStep} title="Вперёд" style={arrow(noStep)}
            onMouseEnter={e=>{ if(!noStep) e.currentTarget.style.background="var(--color-background-secondary)"; }} onMouseLeave={e=>e.currentTarget.style.background="transparent"}>→</button>
        )}
        {onClose && (
          <button type="button" className="ops-chip-x" aria-label="Убрать фильтр «Период»" onClick={onClose}>✕</button>
        )}
      </div>
      {open && (
        <React.Fragment>
          <div onClick={()=>setOpen(false)} style={{ position:"fixed", inset:0, zIndex:60 }} />
          <div style={{ position:"absolute", top:"calc(100% + 6px)", ...(xRight ? { right:0 } : { left:0 }), zIndex:61, width:352, maxWidth:"92vw", background:"var(--color-background-primary)", border:"0.5px solid var(--color-border-secondary)", borderRadius: "var(--radius)", boxShadow:"var(--shadow-lg)", padding: "var(--space-2)" }}>
            {/* Сверху — сам отрезок датами: он же и «свой период». Правка любого
                из двух полей переводит период в custom, поэтому отдельной
                вкладки «Свой период» больше нет. */}
            <div style={{ display:"flex", gap: "var(--space-2)", alignItems:"center", padding: "2px 3px var(--space-2)" }}>
              <input type="date" value={r.from} aria-label="Начало периода"
                onChange={e=>e.target.value && onChange({ ...p, mode:"custom", from:e.target.value, to:r.to })} style={dateIn} />
              <span style={{ color:"var(--color-text-4)", fontSize:13 }}>—</span>
              <input type="date" value={r.to} aria-label="Конец периода"
                onChange={e=>e.target.value && onChange({ ...p, mode:"custom", from:r.from, to:e.target.value })} style={dateIn} />
            </div>
            {/* Готовые отрезки. Активный подсвечен тем же песочным токеном, что и
                везде: «какой сейчас» видно, не сверяя даты глазами. */}
            <div style={{ display:"flex", flexWrap:"wrap", gap: "var(--space-1)", padding: "0 3px var(--space-1)" }}>
              {presets.filter(pr => allowed.some(([id]) => id === pr.need)).map(pr => {
                const rr = periodRange(pr.v);
                const on = rr.from === r.from && rr.to === r.to;
                return (
                  <button key={pr.k} onClick={()=>{ onChange({ ...p, ...pr.v }); setOpen(false); }}
                    style={{ height:30, padding: "0 var(--space-3)", borderRadius: "var(--radius)", border:"1px solid " + (on ? "transparent" : "var(--color-background-secondary)"),
                      background: on ? "var(--color-accent-soft)" : "var(--color-background-page)", color: on ? "var(--color-accent)" : "var(--color-text-secondary)",
                      fontFamily:"inherit", fontSize:13, cursor:"pointer", whiteSpace:"nowrap" }}>{pr.lbl}</button>
                );
              })}
            </div>
            {/* Месяц и квартал остаются сеткой: пресеты дают «этот» и «прошлый»,
                а «март позапрошлого года» иначе набирался бы стрелками или руками. */}
            {(p.mode === "month" || p.mode === "quarter") && (
              <React.Fragment>
                <div style={{ borderTop:"1px solid var(--color-background-secondary)", margin: "var(--space-1) 3px 2px" }} />
                {p.mode === "month" && (
                  <div style={{ display:"grid", gridTemplateColumns:"repeat(6,1fr)", gap:2 }}>
                    {MONTHS.map((mn, i) => (
                      <button key={mn} onClick={()=>{ set({ m:i+1 }); setOpen(false); }}
                        style={{ ...item(p.m===i+1), justifyContent:"center", fontSize:12.5, padding: "var(--space-2) 2px" }}>{MONTHS_SH[i]}</button>
                    ))}
                  </div>
                )}
                {p.mode === "quarter" && (
                  <div style={{ display:"grid", gridTemplateColumns:"repeat(4,1fr)", gap:2 }}>
                    {["I","II","III","IV"].map((q, i) => (
                      <button key={q} onClick={()=>{ set({ q:i+1 }); setOpen(false); }}
                        style={{ ...item(p.q===i+1), justifyContent:"center", fontSize:13 }}>{q}</button>
                    ))}
                  </div>
                )}
                <div data-disp style={cap}>Год</div>
                <div style={{ display:"grid", gridTemplateColumns:"repeat(6,1fr)", gap:2 }}>
                  {years.map(y => (
                    <button key={y} onClick={()=>set({ y })}
                      style={{ ...item(p.y===y), justifyContent:"center", fontSize:12.5, padding: "var(--space-2) 2px" }}>{String(y).slice(2)}</button>
                  ))}
                </div>
              </React.Fragment>
            )}
          </div>
        </React.Fragment>
      )}
    </div>
  );
}

const SEED_TAGS = [
  { id:"brand",  label:"Брендинг"   },
  { id:"motion", label:"Моушн"      },
  { id:"web",    label:"Веб"        },
  { id:"3d",     label:"3D"         },
  { id:"video",  label:"Видео"      },
  { id:"ux",     label:"UX/UI"      },
];

/* ─── ТРИ ОСИ ПРОЕКТА ────────────────────────────────────────────────────────
   Один плоский список тегов смешивал три разных вопроса, и разрезы по нему
   врали: проект попадал сразу в «3D» и в «ивент», выручка делилась дважды,
   доли не складывались в 100%.
     • «Что продали» (product) — строка в счёте, РОВНО ОДНА на проект.
       Только по ней можно делить выручку без дублей.
     • «Чем делали» (tags) — техника: съёмка, 3D, AI, мэппинг. Их несколько,
       выручку по ним делить нельзя, но можно сравнивать маржу.
     • «Кто платит» (payerType) — прямой клиент, агентство или площадка.
   Старые теги НЕ трогаем и никуда не переносим: они и были ближе всего
   к технике. Два новых поля просто добавляются пустыми.
   Продукт — свободная строка с подсказками, а не второй справочник:
   при 10–20 проектах справочник дороже пользы. Подсказки собираются из
   уже введённых значений, поэтому список сам сходится к 5–7 вариантам. */
const PAYER_TYPES = [
  { id:"direct", label:"Напрямую от клиента",   short:"Напрямую" },
  { id:"agency", label:"Через агентство",       short:"Агентство" },
  { id:"venue",  label:"Субподряд у площадки",  short:"Площадка" },
];
const payerLabel = id => (PAYER_TYPES.find(x => x.id === id) || {}).short || "";
/* ─── ЛЕСЕНКА ДИАГРАММ ───────────────────────────────────────────────────────
   Одна на всё приложение: один тон, три ступени от тёмной к светлой
   (--color-text-tertiary → --color-text-5 → --color-background-tertiary). Проверена валидатором палитр:
   монотонная светлота, шаг ΔL ≥ 0.06, светлый конец 2.25:1 к фону.

   Почему лесенка, а не набор разных цветов: обе диаграммы ниже РАНЖИРОВАННЫЕ
   («по убыванию доли», «по убыванию выручки»). Разный оттенок обещал бы разный
   смысл, которого нет, — это ранг, а ранг передаётся насыщенностью одного тона.

   До 2026-07-31 здесь жили восемь случайных цветов (два бледных синих, салатовый,
   охра, два песочных). Валидатор проваливал их по всем пяти проверкам: #DEE9F7 и
   #E7DCC4 различались на ΔE 6.5 при норме 15 (не различает и здоровый глаз), а
   #D6A24A и #97C459 при дальтонизме давали ΔE 2.1 — то есть один цвет.

   ⚠ Не индексировать по кругу (`i % length`). Так было раньше: продуктов семь,
   цветов шесть — седьмой получал цвет первого, две разные категории на одной
   диаграмме сливались. Всё, что за третьей ступенью, — «Остальное». */
/* ⚠ ЛЕСЕНКА ПЕРЕСОБРАНА ПО МАКЕТУ 17.08.2026. Была [text-tertiary, text-5,
   background-tertiary] — от среднего серого к почти-фону, то есть светлела к
   хвосту и на песочной странице сливалась в одно пятно. Макет строит её в
   другую сторону: ранг №1 почти чёрный (#2B2924), дальше средний и светлый.
   Порядок массива — по РАНГУ (0 = самый крупный), поэтому chart-3 стоит первым. */
const CHART_RAMP = [tok("--chart-3"), tok("--chart-2"), tok("--chart-1")];
const CHART_REST = tok("--chart-rest");
const rampColor  = i => CHART_RAMP[i] || CHART_REST;
// Кольцо «кто платит»: плательщиков ровно три, ступеней ровно три.
// Раньше цветов было четыре — четвёртый красил сегмент, которого не бывает.
const PAYER_COLORS = CHART_RAMP;
// Полные названия каналов для карточки «кто платит» — как в макете, во множественном числе
const PAYER_FULL = { direct:"Бренды напрямую", agency:"Агентства", venue:"Площадки" };
// Сегменты на карте «что выгодно делать» — по убыванию выручки, та же лесенка
const SEG_COLORS = CHART_RAMP;
const PRODUCT_SEED = ["Ролик","Айдентика","Инсталляция","Мэппинг-шоу","Ивент-контент","Рекламная кампания","Сайт"];
// Подсказки = что уже вводили + сиды. Своё значение вводится руками и попадает в список
const productOptions = (projects = []) => {
  const used = projects.map(p => (p && p.product || "").trim()).filter(Boolean);
  return [...new Set([...used, ...PRODUCT_SEED])].sort((a,b)=>a.localeCompare(b,"ru"));
};

// ─── HELPERS ─────────────────────────────────────────────────────────────────
// Единый изинг перехода «список → карточка» клиента (по хэндоффу): cubic-bezier(.45,.05,.15,1)
function clxEase(x1, y1, x2, y2) {
  const cx=3*x1, bx=3*(x2-x1)-cx, ax=1-cx-bx, cy=3*y1, by=3*(y2-y1)-cy, ay=1-cy-by;
  const sx=t=>((ax*t+bx)*t+cx)*t, sy=t=>((ay*t+by)*t+cy)*t, dx=t=>(3*ax*t+2*bx)*t+cx;
  return x=>{ let t=x; for(let i=0;i<6;i++){ const d=dx(t); if(!d)break; const e=sx(t)-x; if(Math.abs(e)<1e-4)break; t-=e/d; } return sy(t); };
}
function fmt(n)  { return (+n||0).toLocaleString("ru-RU", {maximumFractionDigits:0}) + " ₽"; }
/* ─── ВАЛЮТЫ ─────────────────────────────────────────────────────────────────
   Рубль — БАЗА: к нему приводятся все суммы, и менять это нельзя, не переписав
   аналитику целиком. Курс живёт НА ПРОЕКТЕ (rate) — это курс на дату сделки, а не
   «текущий»: договорились по 92, значит проект считается по 92, даже если завтра 95.
   Здесь — только каталог того, что можно выбрать. Раньше выбор был зашит в код парой
   RUB/USD, и всё, что не доллар, молча считалось рублями.
   Свои валюты пользователь добавляет в «Правилах учёта» (prefs.currencies). */
const CUR_BASE = "RUB";
const CURRENCIES = [
  { code:"RUB", sym:"₽", name:"Рубль" },
  { code:"USD", sym:"$", name:"Доллар" },
  { code:"EUR", sym:"€", name:"Евро" },
  { code:"KZT", sym:"₸", name:"Тенге" },
  { code:"CNY", sym:"¥", name:"Юань" },
  { code:"AED", sym:"د.إ", name:"Дирхам" },
  { code:"RSD", sym:"дин", name:"Динар" },
];
// Активный список: что пользователь включил + рубль всегда первым (без базы никак).
function curList(prefs) {
  const on = (prefs && Array.isArray(prefs.currencies) && prefs.currencies.length) ? prefs.currencies : ["RUB", "USD"];
  const seen = new Set(), out = [];
  [CUR_BASE, ...on].forEach(code => {
    if (seen.has(code)) return; seen.add(code);
    out.push(CURRENCIES.find(x => x.code === code) || { code, sym:code, name:code });
  });
  return out;
}
const curSymbol = code => (CURRENCIES.find(x => x.code === (code || CUR_BASE)) || {}).sym || String(code || "");
function fmtN(n) { return (+n||0).toLocaleString("ru-RU", {maximumFractionDigits:0}); }

// ── Ввод денег: разряды прямо при наборе ─────────────────────────────────────
// Правило: в интерфейсе не бывает «50000», бывает «50 000» — и в выводе, и в поле.
// `<input type="number">` так не умеет (пробел для него не число), поэтому денежные
// поля — текстовые: цифры вычищаем сами, разряды расставляем на каждом нажатии.
// Каретку возвращаем по ЧИСЛУ ЦИФР слева от неё, а не по индексу символа: вставленный
// пробел иначе сдвигал бы её на разряд назад на каждой тысяче.
function onDigitsInput(e, ref, apply) {
  const el = e.target, pos = el.selectionStart || 0;
  const digitsLeft = el.value.slice(0, pos).replace(/\D/g, "").length;
  const digits = el.value.replace(/\D/g, "");
  const next = digits ? fmtN(digits) : "";
  apply(next);
  requestAnimationFrame(() => {
    const node = ref.current;
    if (!node || node !== document.activeElement) return;
    let seen = 0, i = 0;
    while (i < next.length && seen < digitsLeft) { if (/\d/.test(next[i])) seen++; i++; }
    node.setSelectionRange(i, i);
  });
}

// Денежное поле. Ноль показываем ПЛЕЙСХОЛДЕРОМ (серым), а не значением: настоящий
// «0» в поле прилипал слева к набранному числу — «0» + «50000» = «050000».
// Пустое и нулевое для денег — одно и то же, поэтому оба рисуются как пустое.
/* ⚠ `autoFocus` ПРИНИМАЕТСЯ И ПЕРЕДАЁТСЯ (18.08.2026). Форма создания операции
   ставила `<MoneyInput autoFocus …>` с комментарием «окно открывают ради суммы —
   печатать можно сразу, без клика», а поле его не знало: проп молча падал в пустоту,
   и курсор никуда не вставал. Комментарий обещал поведение, которого не было.
   Найдено сторожем `tools/check-props-passed.py`. */
function MoneyInput({ value, onChange, placeholder = "0", className, style, title, inputRef, autoFocus }) {
  const own = useRef(null);
  const ref = inputRef || own;
  const shown = v => (v == null || v === "" || +v === 0) ? "" : fmtN(v);
  const [val, setVal] = useState(() => shown(value));
  useEffect(() => { if (ref.current !== document.activeElement) setVal(shown(value)); }, [value]);
  return (
    <input ref={ref} className={className} style={style} title={title} autoFocus={autoFocus || undefined} data-num
      type="text" inputMode="numeric" value={val} placeholder={placeholder}
      onChange={e => onDigitsInput(e, ref, next => { setVal(next); onChange(next.replace(/\D/g, "")); })} />
  );
}
function fmtShort(n) {
  const abs = Math.abs(n);
  if (abs >= 1e6) return (n / 1e6).toFixed(1).replace(".0","") + " млн";
  if (abs >= 1e3) return Math.round(n).toLocaleString("ru-RU");
  return String(Math.round(n));
}
function fmtD(d) { if (!d) return ""; const p = d.split("-"); return `${p[2]}.${p[1]}.${p[0].slice(2)}`; }
// Дата документа терпит оба формата: ISO «2026-06-21» → «21.06.2026», уже точечная «21.06.2026» → как есть.
function fmtDocDate(s){ if(!s) return ""; s=String(s); if(s.includes("-")){ const p=s.split("-"); return p.length===3 ? `${p[2]}.${p[1]}.${p[0]}` : s; } return s; }
// Дата документа → ISO для сортировки: в реестре смешаны «ДД.ММ.ГГГГ» (сгенерённые)
// и «ГГГГ-ММ-ДД» (ссылки/файлы) — лексикографика по сырым строкам путала порядок.
function docDateISO(s){ if(!s) return ""; s=String(s); const m=/^(\d{2})\.(\d{2})\.(\d{4})/.exec(s); return m ? `${m[3]}-${m[2]}-${m[1]}` : s.slice(0,10); }
// Смена значения метрики при смене периода — одно лаконичное движение: новое число всплывает (fade + подъём),
// реализовано через key-ремоунт + keyframe fm-pop-in прямо в рендере метрик (без count-up/rAF).
function ini(n) { return (n||"?").split(" ").map(w=>w[0]).join("").toUpperCase().slice(0,2); }
function avc(id) {
  let n = typeof id === "number" ? id : 0;
  if (typeof id === "string") { n = 0; for (let i = 0; i < id.length; i++) n = (n * 31 + id.charCodeAt(i)) | 0; }
  return AVC[Math.abs(n) % AVC.length] || AVC[0];
}
// Контрагент в таблицах платежей: все имена одним чернильным цветом, без брендинга по клиенту.
function partyColor() {
  return "var(--color-text-primary)";
}
// Короткий ярлык для превью ленты: длинное «назначение» не должно уродовать строку.
// Полное показываем по клику в LedgerRowModal. Данные НЕ меняем — режем только для показа.
function previewLabel(text, max = 54) {
  const s = String(text || "").trim();
  if (s.length <= max) return s;
  const cut = s.slice(0, max);
  const sp = cut.lastIndexOf(" ");
  return (sp > max * 0.6 ? cut.slice(0, sp) : cut).replace(/[\s.,;:—–-]+$/, "") + "…";
}
function roleById(id) { return ROLES.find(r => r.id === id); }
// Роли подрядчика — массив (обратная совместимость со старым строковым p.role)
function rolesOf(p) { return Array.isArray(p.roles) ? p.roles : (p.role ? [p.role] : []); }
function roleLabel(id) { const r = ROLES.find(x => x.id === id); return r ? r.label : id; }
function sLabel(id) { return ([...S3,...S2].find(s => s.id===id)||{}).l||id; }
function mKey(d) { if (!d) return null; const p = d.split("-"); return `${p[0]}-${p[1]}`; }
function isoOf(dt){const p=n=>String(n).padStart(2,"0");return `${dt.getFullYear()}-${p(dt.getMonth()+1)}-${p(dt.getDate())}`;}
function addDaysISO(iso,n){const a=iso.split("-").map(Number);return isoOf(new Date(a[0],a[1]-1,a[2]+n));}
function mLabel(k) { const [y,m] = k.split("-"); return `${MONTHS[+m-1]} ${y}`; }

/* ⚠ ДАТЫ СИДА СЧИТАЮТСЯ ОТ СЕГОДНЯ, А НЕ ЗАШИТЫ (2026-08-16). Раньше сид стоял на
   январе–мае 2025. Период по умолчанию — ТЕКУЩИЙ месяц (periodDefault), поэтому через
   год после написания демо открывалось пустым дашбордом: данные есть, а в окне их нет.
   «Сбросить на демо-данные» при этом выглядело как поломка приложения. Считаем от
   сегодняшнего дня: sd(0, …) — этот месяц, sd(-2, …) — позапрошлый, sd(1, …) — следующий.
   День подрезаем по длине месяца, иначе 31-е в сентябре уехало бы на 1 октября. */
const SEED_NOW = new Date();
const sd = (mOff, day) => {
  const d = new Date(SEED_NOW.getFullYear(), SEED_NOW.getMonth() + mOff, 1);
  const y = d.getFullYear(), m = d.getMonth() + 1;
  return `${y}-${pad2(m)}-${pad2(Math.min(day, lastDayOf(y, m)))}`;
};

// ─── SEED DATA ───────────────────────────────────────────────────────────────
/* Сид — не «три строчки для проверки рендера», а рабочая витрина: на нём смотрят
   приложение клиенты и на нём же проверяются расчёты. Поэтому он держит полный
   набор состояний, которые в жизни встречаются вместе: прямые заказчики и работа
   через агентства, сданные и текущие проекты, запрос без денег, начисленная но
   неоплаченная дебиторка (в том числе просроченная), незакрытые обязательства
   перед подрядчиками, накладные расходы компании и остаток на трёх счетах.
   Пустой сид врал ровно тем, что все эти механики выглядели незаполненными. */

const SEED_CLIENTS = [
  { id:101, name:"Альфа Банк",          contacts:[{type:"email",value:"anna@alfabank.ru"},{type:"phone",value:"+7 495 120 40 40"}], notes:"Крупный клиент, нужна быстрая реакция. Тендер раз в год, дальше работаем по рамочному договору" },
  { id:102, name:"Сфера ООО",           contacts:[{type:"phone",value:"+7 999 123 45 67"},{type:"email",value:"d.orlov@sfera.ru"}], notes:"Платят день в день, но правки любят присылать пачкой в пятницу" },
  { id:103, name:"Студия «Ромашка»",    contacts:[{type:"telegram",value:"@romashka_ceo"}], notes:"Постоянный клиент, работаем с 2023. Отдают свой перегруз, брифы устные" },
  { id:104, name:"Агентство «Восход»",  contacts:[{type:"telegram",value:"@voskhod_prod"},{type:"email",value:"prod@voskhod.agency"}], notes:"Агентский договор, постоплата 45 дней. Держать это в голове при планировании кассы" },
  { id:105, name:"Nord Digital",        contacts:[{type:"email",value:"hello@norddigital.ru"},{type:"phone",value:"+7 812 400 11 22"}], notes:"Ивенты и конференции, работа сезонная — осень и весна" },
];

const SEED_PEOPLE = [
  { id:1,  name:"Алексей Громов",       role:"ad",  contacts:[{type:"phone",value:"+7 916 100 22 33"},{type:"telegram",value:"@gromov_art"}], rate:12000, portfolio:"https://behance.net/gromov",   skills:["gd","mot2d"],                customSkills:["UI/UX"],                  notes:"Сильный концепт, работает только по брифу",              req:{inn:"771100223344",rs:"40802810012345678901",bik:"044525225",bank:"Тинькофф"} },
  { id:2,  name:"Иван Смирнов",         role:"cgg", contacts:[{type:"phone",value:"+7 916 234 56 78"}],                                       rate:6000,  portfolio:"https://behance.net/ivan",      skills:["viz3d","mod","sim","mot3d"], customSkills:["Houdini","Redshift"],     notes:"Работает чисто, всегда в дедлайн",                       req:{inn:"771234567890",rs:"40802810099887766554",bik:"044525974",bank:"Сбербанк"} },
  { id:3,  name:"Марина Лис",           role:"sup", contacts:[{type:"telegram",value:"@marina_lis"},{type:"email",value:"marina@studio.ru"}],  rate:9000,  portfolio:"",                              skills:["viz3d","sim","mod"],         customSkills:["Houdini","Nuke"],         notes:"Знает Houdini и Nuke, вытягивает тех.часть на сложных",  req:{inn:"772233445566",rs:"40817810055544332211",bik:"044525225",bank:"Тинькофф"} },
  { id:4,  name:"Аня Козлова",          role:"md",  contacts:[{type:"telegram",value:"@anya_k"}],                                             rate:4500,  portfolio:"",                              skills:["mot2d","mot3d","ill"],       customSkills:[],                         notes:"Лучше давать референсы заранее",                          req:{inn:"772244556677",rs:"40817810011223344556",bik:"044525974",bank:"Сбербанк"} },
  { id:5,  name:"Олег Петров",          role:"d2",  contacts:[{type:"phone",value:"+7 925 300 44 55"},{type:"email",value:"oleg@olegpetrov.ru"}], rate:5000, portfolio:"https://olegpetrov.ru",     skills:["gd","ill","mot2d"],          customSkills:["After Effects"],          notes:"Аккуратная вёрстка носителей, медленно на концептах",     req:{inn:"772987654321",rs:"40802810055443322110",bik:"044525225",bank:"Тинькофф"} },
  { id:6,  name:"Даша Веретенникова",   role:"md",  contacts:[{type:"telegram",value:"@dasha_motion"},{type:"email",value:"dasha.v@mail.ru"}], rate:7000,  portfolio:"https://vimeo.com/dashav",     skills:["mot2d","mot3d"],             customSkills:["After Effects","Cinema 4D"], notes:"Быстрая на заставках и подложках, берёт объём",        req:{inn:"780112233445",rs:"40817810700001234567",bik:"044030653",bank:"Точка"} },
  { id:7,  name:"Кирилл Ушаков",        role:"cgg", contacts:[{type:"telegram",value:"@ushakov_cg"}],                                         rate:8500,  portfolio:"https://artstation.com/ushakov", skills:["viz3d","sim","mod","mot3d"], customSkills:["Houdini","Karma","Substance"], notes:"Симуляции и разрушения. Дорогой, но переделок не бывает", req:{inn:"773344556677",rs:"40802810400000112233",bik:"044525974",bank:"Сбербанк"} },
  { id:8,  name:"Тимур Аглиуллин",      role:"d2",  contacts:[{type:"telegram",value:"@timur_draws"},{type:"phone",value:"+7 917 505 60 70"}], rate:3800,  portfolio:"https://behance.net/timurag",  skills:["ill","gd"],                  customSkills:["Procreate"],              notes:"Иллюстрация и раскадровки. Самый доступный по ставке",   req:{inn:"027712345678",rs:"40817810900005556677",bik:"048073601",bank:"Уралсиб"} },
  { id:9,  name:"Ника Соболева",        role:"ad",  contacts:[{type:"email",value:"nika@sobolev.studio"},{type:"telegram",value:"@nika_cd"}],  rate:15000, portfolio:"https://sobolev.studio",       skills:["gd","mot2d","ill"],          customSkills:["Креативная стратегия"],   notes:"Креативный директор на крупные тендеры. Ставка высокая, но выигрывает питчи", req:{inn:"770099887766",rs:"40802810300009998877",bik:"044030653",bank:"Точка"} },
  { id:10, name:"Павел Дёмин",          role:"sup", contacts:[{type:"telegram",value:"@pavel_pipeline"}],                                     rate:11000, portfolio:"",                              skills:["viz3d","mod","sim"],         customSkills:["Deadline","Рендер-ферма"], notes:"Пайплайн и рендер-ферма. Нужен на всём, что длиннее месяца", req:{inn:"501234567890",rs:"40817810100002223344",bik:"044525225",bank:"Тинькофф"} },
];

const SEED_PROJECTS = [
  {
    id:1, name:"Альфа — ролик к запуску вклада", status:"done", tags:["motion","video"], product:"Ролик", payerType:"direct",
    clientId:101, client:"Альфа Банк", clientContact:"anna@alfabank.ru",
    dateStart:sd(-4,12), dateEnd:sd(-2,20), contract:"№ 14/АБ",
    estimate:[{label:"Сценарий и раскадровка",amount:250000},{label:"3D-анимация",amount:700000},{label:"Озвучка и сведение",amount:300000}],
    budget:1250000, currency:"RUB", rate:0, documents:[],
    expenses:[
      { rowId:"r1e1", label:"Аренда съёмочной студии", amount:45000, date:sd(-3,14), paid:true, accrued:true, accrualDate:sd(-3,14) },
      { rowId:"r1e2", label:"Стоки и лицензии",        amount:18000, date:sd(-3,20), paid:true, accrued:true, accrualDate:sd(-3,20) },
    ],
    payments:[
      { rowId:"r1p1", label:"Аванс 50%",      amount:625000, date:sd(-4,20), received:true, accrued:true, accrualDate:sd(-4,20) },
      { rowId:"r1p2", label:"Постоплата 50%", amount:625000, date:sd(-2,25), received:true, accrued:true, accrualDate:sd(-2,20) },
    ],
    contractors:[
      { cid:9, name:"Ника Соболева",  rateType:"fixed",   dayRate:0,    startDate:sd(-4,12), endDate:sd(-4,28), tranches:[
        { rowId:"r1t1", label:"Концепт и питч", amount:180000, date:sd(-3,5),  paid:true, accrued:true, accrualDate:sd(-4,28) }]},
      { cid:2, name:"Иван Смирнов",   rateType:"dayrate", dayRate:6000, startDate:sd(-4,20), endDate:sd(-2,15), tranches:[
        { rowId:"r1t2", label:"Оплата 100%",    amount:240000, date:sd(-2,10), paid:true, accrued:true, accrualDate:sd(-2,10) }]},
      { cid:4, name:"Аня Козлова",    rateType:"fixed",   dayRate:0,    startDate:sd(-3,1),  endDate:sd(-2,12), tranches:[
        { rowId:"r1t3", label:"Оплата 100%",    amount:90000,  date:sd(-2,15), paid:true, accrued:true, accrualDate:sd(-2,12) }]},
      { cid:3, name:"Марина Лис",     rateType:"dayrate", dayRate:9000, startDate:sd(-3,10), endDate:sd(-2,18), tranches:[
        { rowId:"r1t4", label:"Тех.надзор и сведение", amount:150000, date:sd(-2,18), paid:true, accrued:true, accrualDate:sd(-2,18) }]},
    ],
  },
  {
    /* Сдан В ТЕКУЩЕМ месяце — намеренно. «Экономика проектов», «Откуда приходят деньги»
       и «Что выгодно делать» считаются ТОЛЬКО по завершённым в периоде: без такого
       проекта три блока дашборда молчат, и демо выглядит недоделанным. */
    id:2, name:"Сфера — фирменный стиль и брендбук", status:"done", tags:["brand"], product:"Айдентика", payerType:"direct",
    clientId:102, client:"Сфера ООО", clientContact:"+7 999 123 45 67",
    dateStart:sd(-2,3), dateEnd:sd(0,12), contract:"№ 21/СФ",
    estimate:[{label:"Логотип и константы",amount:300000},{label:"Носители",amount:200000},{label:"Брендбук (вёрстка)",amount:280000}],
    budget:780000, currency:"RUB", rate:0, documents:[],
    expenses:[
      { rowId:"r2e1", label:"Печать макетов", amount:22000, date:sd(-1,18), paid:true, accrued:true, accrualDate:sd(-1,18) },
    ],
    payments:[
      { rowId:"r2p1", label:"Аванс 40%",       amount:312000, date:sd(-2,8),  received:true,  accrued:true, accrualDate:sd(-2,8) },
      { rowId:"r2p2", label:"Второй платёж 30%", amount:234000, date:sd(0,10), received:true,  accrued:true, accrualDate:sd(0,10) },
      /* Работа сдана, деньги по договору позже — начислено, но не получено.
         Это и есть дебиторка: без такой строки экран «С кого ждём» пустой. */
      { rowId:"r2p3", label:"Остаток 30%",     amount:234000, date:sd(0,28), received:false, accrued:true, accrualDate:sd(0,12) },
    ],
    contractors:[
      { cid:1, name:"Алексей Громов", rateType:"dayrate", dayRate:12000, startDate:sd(-2,3), endDate:sd(-1,2), tranches:[
        { rowId:"r2t1", label:"Логотип и константы", amount:180000, date:sd(-1,5), paid:true, accrued:true, accrualDate:sd(-1,2) }]},
      { cid:5, name:"Олег Петров",    rateType:"fixed",   dayRate:0,     startDate:sd(-1,10), endDate:sd(0,10), tranches:[
        { rowId:"r2t2", label:"Носители, часть 1", amount:60000, date:sd(-1,20), paid:true,  accrued:true, accrualDate:sd(-1,20) },
        /* Сдал, счёт выставил, мы ещё не заплатили — кредиторка. */
        { rowId:"r2t3", label:"Носители, часть 2", amount:60000, date:sd(0,25),  paid:false, accrued:true, accrualDate:sd(0,10) }]},
    ],
  },
  {
    id:3, name:"Ромашка — иконки 3D", status:"active", tags:["3d","motion"], product:"Инсталляция", payerType:"agency",
    clientId:103, client:"Студия «Ромашка»", clientContact:"@romashka_ceo",
    dateStart:sd(-2,1), dateEnd:sd(0,30), contract:"№ 7",
    estimate:[{label:"Концепт и стиль",amount:140000},{label:"Анимация иконок",amount:240000},{label:"Финальный рендер",amount:100000}],
    budget:480000, currency:"RUB", rate:0, documents:[],
    expenses:[
      { rowId:"r3e1", label:"Покупка 3D-ассетов", amount:12000, date:sd(-2,15), paid:true, accrued:true, accrualDate:sd(-2,15) },
    ],
    payments:[
      { rowId:"r3p1", label:"Постоплата 100%", amount:480000, date:sd(0,15), received:true, accrued:true, accrualDate:sd(0,15) },
    ],
    contractors:[
      { cid:2, name:"Иван Смирнов", rateType:"fixed",   dayRate:0,    startDate:sd(-2,10), endDate:sd(0,30), tranches:[
        /* Оплачен В ТЕКУЩЕМ месяце — намеренно: «Топ проекты · маржа» считает только там,
           где в периоде есть И полученные деньги, И оплаченные затраты по одному проекту.
           Без этой строки карточка молчала при двух заполненных соседках. */
        { rowId:"r3t1", label:"Предоплата 50%", amount:40000, date:sd(0,6),  paid:true,  accrued:true,  accrualDate:sd(-1,20) },
        { rowId:"r3t2", label:"Постоплата 50%", amount:40000, date:sd(0,30),  paid:false, accrued:false, accrualDate:"" }]},
      /* Срок прошёл, а деньги не ушли — просроченное обязательство. */
      { cid:4, name:"Аня Козлова",  rateType:"dayrate", dayRate:4500, startDate:sd(-1,1),  endDate:sd(0,8),  tranches:[
        { rowId:"r3t3", label:"Оплата 100%", amount:68000, date:sd(0,10), paid:false, accrued:true, accrualDate:sd(0,8) }]},
    ],
  },
  {
    id:4, name:"Восход — мэппинг-шоу на форуме", status:"active", tags:["3d","motion"], product:"Мэппинг-шоу", payerType:"agency",
    clientId:104, client:"Агентство «Восход»", clientContact:"@voskhod_prod",
    dateStart:sd(-1,5), dateEnd:sd(1,28), contract:"№ 33/ВСХ",
    estimate:[{label:"Препродакшн и раскадровка",amount:250000},{label:"3D и симуляции",amount:700000},{label:"Сведение и тех.прогон",amount:450000}],
    budget:1400000, currency:"RUB", rate:0, documents:[],
    expenses:[
      { rowId:"r4e1", label:"Аренда проекторов на тест", amount:60000, date:sd(0,5),  paid:true,  accrued:true,  accrualDate:sd(0,5) },
      { rowId:"r4e2", label:"Логистика оборудования",    amount:25000, date:sd(1,20), paid:false, accrued:false, accrualDate:"" },
    ],
    payments:[
      { rowId:"r4p1", label:"Аванс 30%",         amount:420000, date:sd(-1,12), received:true,  accrued:true,  accrualDate:sd(-1,12) },
      /* Препродакшн сдан и принят, срок оплаты прошёл — просроченная дебиторка.
         Агентство платит через 45 дней, и именно это должно быть видно на дашборде. */
      { rowId:"r4p2", label:"Второй транш 40%",  amount:560000, date:sd(0,5),   received:false, accrued:true,  accrualDate:sd(-1,28) },
      { rowId:"r4p3", label:"Остаток 30%",       amount:420000, date:sd(2,5),   received:false, accrued:false, accrualDate:"" },
    ],
    contractors:[
      { cid:7,  name:"Кирилл Ушаков", rateType:"dayrate", dayRate:8500,  startDate:sd(-1,5), endDate:sd(1,10), tranches:[
        { rowId:"r4t1", label:"Первый этап", amount:255000, date:sd(0,3),  paid:true,  accrued:true,  accrualDate:sd(0,1) },
        { rowId:"r4t2", label:"Второй этап", amount:170000, date:sd(1,15), paid:false, accrued:false, accrualDate:"" }]},
      { cid:10, name:"Павел Дёмин",   rateType:"dayrate", dayRate:11000, startDate:sd(0,10), endDate:sd(1,18), tranches:[
        { rowId:"r4t3", label:"Пайплайн и рендер", amount:220000, date:sd(1,20), paid:false, accrued:false, accrualDate:"" }]},
      { cid:3,  name:"Марина Лис",    rateType:"dayrate", dayRate:9000,  startDate:sd(1,5),  endDate:sd(1,26), tranches:[
        { rowId:"r4t4", label:"Тех.прогон на площадке", amount:135000, date:sd(1,28), paid:false, accrued:false, accrualDate:"" }]},
    ],
  },
  {
    id:5, name:"Nord — ивент-контент для конференции", status:"done", tags:["motion","video"], product:"Ивент-контент", payerType:"agency",
    clientId:105, client:"Nord Digital", clientContact:"hello@norddigital.ru",
    dateStart:sd(-4,1), dateEnd:sd(-3,22), contract:"№ 9/ND",
    estimate:[{label:"Заставки и подложки",amount:260000},{label:"Анимация докладов",amount:220000},{label:"Финальный пакет",amount:140000}],
    budget:620000, currency:"RUB", rate:0, documents:[],
    expenses:[
      { rowId:"r5e1", label:"Шрифты и лицензии", amount:15000, date:sd(-4,10), paid:true, accrued:true, accrualDate:sd(-4,10) },
    ],
    payments:[
      { rowId:"r5p1", label:"Постоплата 100%", amount:620000, date:sd(-3,28), received:true, accrued:true, accrualDate:sd(-3,22) },
    ],
    contractors:[
      { cid:6, name:"Даша Веретенникова", rateType:"dayrate", dayRate:7000, startDate:sd(-4,1), endDate:sd(-3,20), tranches:[
        { rowId:"r5t1", label:"Оплата 100%", amount:210000, date:sd(-3,25), paid:true, accrued:true, accrualDate:sd(-3,20) }]},
      { cid:8, name:"Тимур Аглиуллин",    rateType:"fixed",   dayRate:0,    startDate:sd(-4,5), endDate:sd(-3,18), tranches:[
        { rowId:"r5t2", label:"Раскадровки", amount:76000, date:sd(-3,26), paid:true, accrued:true, accrualDate:sd(-3,18) }]},
    ],
  },
  {
    /* Запрос — воронка не должна быть пустой. Денег нет вообще: ни платежей, ни
       подрядчиков, только смета и бюджет. Проект в статусе «Запрос» не имеет права
       попадать ни в выручку, ни в дебиторку — на нём это и проверяется. */
    id:6, name:"Альфа — рекламная кампания, три ролика", status:"request", tags:["video","motion"], product:"Рекламная кампания", payerType:"direct",
    clientId:101, client:"Альфа Банк", clientContact:"anna@alfabank.ru",
    dateStart:sd(1,1), dateEnd:sd(3,30), contract:"",
    estimate:[{label:"Три ролика по 30 секунд",amount:1500000},{label:"Адаптации под площадки",amount:350000},{label:"Саунд-дизайн",amount:250000}],
    budget:2100000, currency:"RUB", rate:0, documents:[],
    expenses:[], payments:[], contractors:[],
  },
];

/* Счета. Остаток = opening + все ФАКТИЧЕСКИЕ операции счёта (accountsSplit), поэтому
   opening здесь — деньги, накопленные ДО окна сида, а не «сколько показать в шапке».
   Итог по трём счетам вместе с операциями ниже даёт около 1,5 млн. */
const SEED_ACCOUNTS = [
  { id:"acct-seed-1", name:"Точка · ООО",    kind:"bank", entity:"ООО", rs:"40702810301500012345", bank:"Точка",    currency:"RUB", opening:1100000, openingDate:sd(-5,1), order:0 },
  { id:"acct-seed-2", name:"Тинькофф · ИП",  kind:"bank", entity:"ИП",  rs:"40802810700000098765", bank:"Тинькофф", currency:"RUB", opening:150000,  openingDate:sd(-5,1), order:1 },
  { id:"acct-seed-3", name:"Наличная касса", kind:"cash", entity:"",    rs:"",                     bank:"",         currency:"RUB", opening:40000,   openingDate:sd(-5,1), order:2 },
];

/* ⚠ ФУНКЦИЯ, А НЕ КОНСТАНТА — НАМЕРЕННО. Операции ссылаются на статьи (CAT_CONTRACTORS,
   CAT_TAX, CAT_TRANSFER), которые объявлены ниже по файлу. Константа вычислилась бы
   прямо здесь и упала бы на временной мёртвой зоне (ReferenceError при загрузке).
   Функция вызывается из useState уже после того, как весь модуль выполнен.

   Что здесь есть и зачем:
   • операции с `allocations` — банковская сторона строк проекта. Из общих корзин они
     выпадают (opIsCounted), деньги приходят на дашборд через сами строки, а счёт
     двигают. Задвоения нет — это и есть правило дедупликации.
   • операции без аллокаций со статьёй — накладные компании: аренда, зарплата, софт,
     реклама, налоги, банк. Они режут прибыль компании, но не маржу проекта.
   • три операции без статьи — очередь разбора: без них экран «Неразобранное» пуст и
     непонятно, зачем нужен импорт выписки.
   • плановые (planned:true) — не в остатке, а в прогнозе кассового разрыва. */
function seedOperations() {
  const A1 = "acct-seed-1", A2 = "acct-seed-2", A3 = "acct-seed-3";
  const RS1 = "40702810301500012345", RS2 = "40802810700000098765";
  const cp = (name, inn) => ({ name, inn: inn || "", account: "" });
  // Приход/расход по строке проекта: сумма аллокации всегда равна сумме операции.
  const tied = (id, date, dir, amount, purpose, party, category, pid, kind, rowId, acct, rs) => ({
    id, source:"import", date, direction:dir, amount, purpose, counterparty:party, category,
    planned:false, status:"matched", allocations:[{ pid, kind, rowId, amount }],
    acctId:acct, ourAccount:rs || "", tags:[], docs:[],
  });
  // Накладная компании: проекта нет и быть не должно, статья закрывает разбор.
  const over = (id, date, dir, amount, purpose, party, category, acct, rs) => ({
    id, source:"import", date, direction:dir, amount, purpose, counterparty:party, category,
    planned:false, status:"matched", allocations:[], accrued:true, accrualDate:date,
    acctId:acct, ourAccount:rs || "", tags:[], docs:[],
  });
  return [
    // ── Приходы от клиентов, привязаны к платежам проектов ──
    tied("op-s01", sd(-4,20), "in", 625000, "Оплата по счёту 112 от " + sd(-4,15) + ", договор 14/АБ, аванс", cp("АО «Альфа Банк»","7728168971"), "Оплата от клиентов", 1, "payment", "r1p1", A1, RS1),
    tied("op-s02", sd(-3,28), "in", 620000, "Оплата по счёту 118, договор 9/ND, за ивент-контент",           cp("ООО «Норд Диджитал»","7801234567"), "Оплата от клиентов", 5, "payment", "r5p1", A1, RS1),
    tied("op-s03", sd(-2,8),  "in", 312000, "Оплата по счёту 124, договор 21/СФ, аванс 40%",                 cp("ООО «Сфера»","7716901234"),         "Оплата от клиентов", 2, "payment", "r2p1", A1, RS1),
    tied("op-s04", sd(-2,25), "in", 625000, "Оплата по счёту 127, договор 14/АБ, окончательный расчёт",      cp("АО «Альфа Банк»","7728168971"),     "Оплата от клиентов", 1, "payment", "r1p2", A1, RS1),
    tied("op-s05", sd(-1,12), "in", 420000, "Оплата по счёту 131, договор 33/ВСХ, аванс 30%",                cp("ООО «Восход»","7703456789"),        "Оплата от клиентов", 4, "payment", "r4p1", A1, RS1),
    tied("op-s06", sd(0,10),  "in", 234000, "Оплата по счёту 138, договор 21/СФ, второй платёж",             cp("ООО «Сфера»","7716901234"),         "Оплата от клиентов", 2, "payment", "r2p2", A1, RS1),
    tied("op-s07", sd(0,15),  "in", 480000, "Оплата по счёту 140, договор 7, за иконки",                     cp("ООО «Ромашка Студия»","7710009988"),"Оплата от клиентов", 3, "payment", "r3p1", A1, RS1),

    // ── Выплаты подрядчикам, привязаны к траншам ──
    tied("op-s08", sd(-3,5),  "out", 180000, "Оплата по договору ГПХ, концепт и питч",     cp("Соболева Ника Андреевна","770099887766"),  CAT_CONTRACTORS, 1, "tranche", "r1t1", A1, RS1),
    tied("op-s09", sd(-3,25), "out", 210000, "Оплата по договору ГПХ, анимация заставок",  cp("Веретенникова Дарья Игоревна","780112233445"), CAT_CONTRACTORS, 5, "tranche", "r5t1", A1, RS1),
    tied("op-s10", sd(-3,26), "out", 76000,  "Оплата по договору ГПХ, раскадровки",        cp("Аглиуллин Тимур Ринатович","027712345678"), CAT_CONTRACTORS, 5, "tranche", "r5t2", A1, RS1),
    tied("op-s11", sd(-2,10), "out", 240000, "Оплата по договору ГПХ, 3D-анимация",        cp("Смирнов Иван Петрович","771234567890"),    CAT_CONTRACTORS, 1, "tranche", "r1t2", A1, RS1),
    tied("op-s12", sd(-2,15), "out", 90000,  "Оплата по договору ГПХ, моушн",              cp("Козлова Анна Сергеевна","772244556677"),   CAT_CONTRACTORS, 1, "tranche", "r1t3", A1, RS1),
    tied("op-s13", sd(-2,18), "out", 150000, "Оплата по договору ГПХ, тех.надзор",         cp("Лис Марина Олеговна","772233445566"),      CAT_CONTRACTORS, 1, "tranche", "r1t4", A1, RS1),
    tied("op-s14", sd(-1,5),  "out", 180000, "Оплата по договору ГПХ, логотип и константы", cp("Громов Алексей Викторович","771100223344"), CAT_CONTRACTORS, 2, "tranche", "r2t1", A1, RS1),
    tied("op-s15", sd(-1,20), "out", 60000,  "Оплата по договору ГПХ, носители часть 1",   cp("Петров Олег Дмитриевич","772987654321"),   CAT_CONTRACTORS, 2, "tranche", "r2t2", A1, RS1),
    tied("op-s16", sd(0,6),   "out", 40000,  "Оплата по договору ГПХ, предоплата за иконки", cp("Смирнов Иван Петрович","771234567890"),  CAT_CONTRACTORS, 3, "tranche", "r3t1", A1, RS1),
    tied("op-s17", sd(0,3),   "out", 255000, "Оплата по договору ГПХ, симуляции первый этап", cp("Ушаков Кирилл Андреевич","773344556677"), CAT_CONTRACTORS, 4, "tranche", "r4t1", A1, RS1),

    // ── Прямые расходы проектов ──
    tied("op-s18", sd(-4,10), "out", 15000, "Лицензии на шрифты, счёт 4412",       cp("ООО «Паратайп»","7701234509"),      "Прочее",  5, "expense", "r5e1", A2, RS2),
    tied("op-s19", sd(-3,14), "out", 45000, "Аренда съёмочного павильона, 2 смены", cp("ООО «Студия 12»","7725004411"),    "Аренда",  1, "expense", "r1e1", A1, RS1),
    tied("op-s20", sd(-3,20), "out", 18000, "Стоковые материалы и лицензии",        cp("ООО «Сток Медиа»","7736112233"),   "Прочее",  1, "expense", "r1e2", A2, RS2),
    tied("op-s21", sd(-2,15), "out", 12000, "Покупка 3D-ассетов",                   cp("ООО «Ассет Маркет»","7743009911"), "Прочее",  3, "expense", "r3e1", A1, RS1),
    tied("op-s22", sd(-1,18), "out", 22000, "Печать макетов и пробников",           cp("ИП Кузнецов А.А.","772500110022"), "Прочее",  2, "expense", "r2e1", A3, ""),
    tied("op-s23", sd(0,5),   "out", 60000, "Аренда проекторов на тестовый прогон", cp("ООО «Лайт Про»","7719887766"),     "Аренда",  4, "expense", "r4e1", A1, RS1),

    // ── Накладные компании: аренда ──
    over("op-s24", sd(-3,5), "out", 85000, "Аренда офиса, " + sd(-3,1),  cp("ООО «Лофт Проджект»","7708112244"), "Аренда", A1, RS1),
    over("op-s25", sd(-2,5), "out", 85000, "Аренда офиса, " + sd(-2,1),  cp("ООО «Лофт Проджект»","7708112244"), "Аренда", A1, RS1),
    over("op-s26", sd(-1,5), "out", 85000, "Аренда офиса, " + sd(-1,1),  cp("ООО «Лофт Проджект»","7708112244"), "Аренда", A1, RS1),
    over("op-s27", sd(0,5),  "out", 85000, "Аренда офиса, " + sd(0,1),   cp("ООО «Лофт Проджект»","7708112244"), "Аренда", A1, RS1),

    // ── Накладные компании: зарплата штата ──
    over("op-s28", sd(-3,10), "out", 195000, "Заработная плата за " + sd(-4,1), cp("Зарплатный реестр", ""), "Зарплата", A1, RS1),
    over("op-s29", sd(-2,10), "out", 195000, "Заработная плата за " + sd(-3,1), cp("Зарплатный реестр", ""), "Зарплата", A1, RS1),
    over("op-s30", sd(-1,10), "out", 195000, "Заработная плата за " + sd(-2,1), cp("Зарплатный реестр", ""), "Зарплата", A1, RS1),
    over("op-s31", sd(0,10),  "out", 195000, "Заработная плата за " + sd(-1,1), cp("Зарплатный реестр", ""), "Зарплата", A1, RS1),

    // ── Накладные компании: софт, реклама, налоги, банк ──
    over("op-s32", sd(-2,7),  "out", 38000,  "Подписка Adobe Creative Cloud, годовая",   cp("Adobe Systems", ""),                "Софт и сервисы",     A2, RS2),
    over("op-s33", sd(-1,9),  "out", 62000,  "Рендер-ферма, пакет часов",                cp("ООО «Рендер Клауд»","7725889900"),  "Софт и сервисы",     A2, RS2),
    over("op-s34", sd(0,7),   "out", 14000,  "Облачное хранилище и почта, месяц",        cp("ООО «Яндекс»","7736207543"),        "Софт и сервисы",     A2, RS2),
    over("op-s35", sd(-2,22), "out", 45000,  "Продвижение сайта студии",                 cp("ООО «Директ Медиа»","7714556677"),  "Реклама",            A2, RS2),
    over("op-s36", sd(-1,14), "out", 90000,  "Съёмка и монтаж шоурила студии",           cp("ИП Лебедев М.С.","770912345678"),   "Реклама",            A1, RS1),
    over("op-s37", sd(-1,25), "out", 148000, "УСН 6% за II квартал",                     cp("УФК по г. Москве","7707083893"),    CAT_TAX,              A1, RS1),
    over("op-s38", sd(0,12),  "out", 34000,  "Страховые взносы за сотрудников",          cp("УФК по г. Москве","7707083893"),    CAT_TAX,              A1, RS1),
    over("op-s39", sd(-1,1),  "out", 2900,   "Обслуживание счёта, тариф «Бизнес»",       cp("АО «Точка»","7707083893"),          "Банковские услуги",  A1, RS1),
    over("op-s40", sd(0,1),   "out", 2900,   "Обслуживание счёта, тариф «Бизнес»",       cp("АО «Точка»","7707083893"),          "Банковские услуги",  A1, RS1),

    // ── Перевод между своими счетами: пара записей, иначе деньги уходят в никуда ──
    { id:"op-s41", source:"manual", xferId:"xf-seed-1", date:sd(-1,3), direction:"out", amount:300000,
      counterparty:cp("",""), purpose:"Перевод на счёт ИП", category:CAT_TRANSFER, planned:false,
      allocations:[], status:"ignored", acctId:"acct-seed-1", ourAccount:RS1, xferAcct:"acct-seed-2", tags:[], docs:[] },
    { id:"op-s42", source:"manual", xferId:"xf-seed-1", date:sd(-1,3), direction:"in",  amount:300000,
      counterparty:cp("",""), purpose:"Перевод со счёта ООО", category:CAT_TRANSFER, planned:false,
      allocations:[], status:"ignored", acctId:"acct-seed-2", ourAccount:RS2, xferAcct:"acct-seed-1", tags:[], docs:[] },

    // ── Очередь разбора: статьи нет, проект не выбран ──
    { id:"op-s43", source:"import", date:sd(0,9),  direction:"out", amount:9900,   counterparty:cp("ООО «Яндекс.Облако»","7704357909"),
      purpose:"Оплата по счёту 5512 за облачные вычисления", category:"", planned:false, allocations:[], status:"unmatched",
      acctId:"acct-seed-2", ourAccount:RS2, tags:[], docs:[] },
    { id:"op-s44", source:"import", date:sd(0,12), direction:"in",  amount:145000, counterparty:cp("ООО «Мираж Продакшн»","7702998877"),
      purpose:"Оплата по счёту 214 от " + sd(0,8) + " за видеопродакшн, без НДС", category:"", planned:false, allocations:[], status:"unmatched",
      acctId:"acct-seed-1", ourAccount:RS1, tags:[], docs:[] },
    { id:"op-s45", source:"import", date:sd(0,14), direction:"out", amount:28400,  counterparty:cp("ООО «Техносервис»","7706334455"),
      purpose:"Оплата по счёту 881, ремонт и обслуживание техники", category:"", planned:false, allocations:[], status:"unmatched",
      acctId:"acct-seed-1", ourAccount:RS1, tags:[], docs:[] },

    // ── План на следующий месяц: в остаток не идёт, идёт в прогноз разрыва ──
    { id:"op-s46", source:"manual", date:sd(1,1),  direction:"out", amount:2900,   counterparty:cp("АО «Точка»","7707083893"),
      purpose:"Обслуживание счёта", category:"Банковские услуги", planned:true, allocations:[], status:"matched",
      accrued:false, accrualDate:"", acctId:"acct-seed-1", ourAccount:RS1, tags:[], docs:[] },
    { id:"op-s47", source:"manual", date:sd(1,5),  direction:"out", amount:85000,  counterparty:cp("ООО «Лофт Проджект»","7708112244"),
      purpose:"Аренда офиса", category:"Аренда", planned:true, allocations:[], status:"matched",
      accrued:false, accrualDate:"", acctId:"acct-seed-1", ourAccount:RS1, tags:[], docs:[] },
    { id:"op-s48", source:"manual", date:sd(1,10), direction:"out", amount:195000, counterparty:cp("Зарплатный реестр",""),
      purpose:"Заработная плата", category:"Зарплата", planned:true, allocations:[], status:"matched",
      accrued:false, accrualDate:"", acctId:"acct-seed-1", ourAccount:RS1, tags:[], docs:[] },
    { id:"op-s49", source:"manual", date:sd(1,7),  direction:"out", amount:38000,  counterparty:cp("ООО «Рендер Клауд»","7725889900"),
      purpose:"Рендер-ферма, пакет часов", category:"Софт и сервисы", planned:true, allocations:[], status:"matched",
      accrued:false, accrualDate:"", acctId:"acct-seed-2", ourAccount:RS2, tags:[], docs:[] },
    { id:"op-s50", source:"manual", date:sd(2,25), direction:"out", amount:210000, counterparty:cp("УФК по г. Москве","7707083893"),
      purpose:"УСН 6% за III квартал", category:CAT_TAX, planned:true, allocations:[], status:"matched",
      accrued:false, accrualDate:"", acctId:"acct-seed-1", ourAccount:RS1, tags:[], docs:[] },
  ];
}
// ─── PRIMITIVES ──────────────────────────────────────────────────────────────
function Avatar({ name, id, size=36 }) {
  const [bg, tc] = avc(id);
  return (
    <div style={{ width:size, height:size, borderRadius: "var(--radius-full)", display:"flex", alignItems:"center", justifyContent:"center", background:bg, color:tc, fontWeight:600, fontSize:size*0.34, flexShrink:0 }}>{ini(name)}</div>
  );
}

function Pill({ label, bg, tc }) {
  return <span className="pill" style={{ display:"inline-block", fontSize:12, padding: "3px var(--space-2)", borderRadius: "var(--radius)", background:bg, color:tc, fontWeight:500 }}>{label}</span>;
}

function StatusBadge({ status }) {
  const sc = statusSC(status);
  return <Pill label={statusLabel(status)} bg={sc.bg} tc={sc.tc} />;
}

// ── Месячный график ───────────────────────────────────────────────────────────
// Расчёт помесячных рядов для графика «Динамика» (единый источник правды).
// rev/exp — все платежи/расходы (план+факт); revFact/expFact — только оплаченные.
function computeMonthlySeries(projects, startYM, endYM, clipStart, clipEnd, mode) {
  mode = mode || "month";
  const rangeMonths = [];
  let ry = startYM.year, rm = startYM.month;
  while (ry < endYM.year || (ry === endYM.year && rm <= endYM.month)) {
    rangeMonths.push({ year: ry, month: rm });
    rm++; if (rm > 11) { rm = 0; ry++; }
    if (rangeMonths.length > 120) break; // safety
  }
  const bucketKey = ({ year, month }) =>
    mode === "month"    ? `${year}-${month}` :
    mode === "quarter"  ? `${year}-Q${Math.floor(month/3)+1}` :
                          `${year}`;
  const seenKeys = [];
  const keySet = new Set();
  rangeMonths.forEach(ym => { const k = bucketKey(ym); if (!keySet.has(k)) { keySet.add(k); seenKeys.push(k); } });
  const keyIdx = Object.fromEntries(seenKeys.map((k,i) => [k,i]));
  const N = seenKeys.length;
  const rev = Array(N).fill(0);
  const exp = Array(N).fill(0);
  const revFact = Array(N).fill(0);
  const expFact = Array(N).fill(0);
  const inRange = d => {
    const y = d.getFullYear(), m = d.getMonth();
    const ge = y > startYM.year || (y === startYM.year && m >= startYM.month);
    const le = y < endYM.year   || (y === endYM.year   && m <= endYM.month);
    return ge && le;
  };
  projects.filter(p => p.status !== "request" && p.status !== "rejected").forEach(p => {
    const pRate = +p.rate || 1;
    // Налог — по движку проекта (та же projTaxRatio, что и в карточках метрик и в
    // таблице П&Л). Раньше здесь была плоская константа TAX=7%: линия «Расходы» на
    // графике спорила с цифрой «Расходы» над ним — проект без налога всё равно получал
    // 7 % расхода, а при УСН 15 % расход занижался. Налога «по умолчанию» нет: не
    // завёл налог в проекте — ставка 0.
    const txr = projTaxRatio(p);
    p.payments.forEach(pay => {
      if (!pay.date || (clipStart && (pay.date < clipStart || pay.date > clipEnd))) return;
      const d = new Date(pay.date);
      if (!inRange(d)) return;
      const k = bucketKey({ year: d.getFullYear(), month: d.getMonth() });
      const pc = pay.currency || p.currency || "RUB";
      const amt = pc !== "RUB" ? (+pay.amount||0) * pRate : (+pay.amount||0);
      if (keyIdx[k] !== undefined) { rev[keyIdx[k]] += amt; if (pay.received) { revFact[keyIdx[k]] += amt; const tx = Math.round(amt * txr); exp[keyIdx[k]] += tx; expFact[keyIdx[k]] += tx; } }
    });
    (p.contractors||[]).forEach(c => {
      (c.tranches||[]).forEach(t => {
        if (!t.date || (clipStart && (t.date < clipStart || t.date > clipEnd))) return;
        const d = new Date(t.date);
        if (!inRange(d)) return;
        const k = bucketKey({ year: d.getFullYear(), month: d.getMonth() });
        const tc = t.currency || "RUB";
        const tAmt = tc !== "RUB" ? (+t.amount||0) * pRate : (+t.amount||0);
        if (keyIdx[k] !== undefined) { exp[keyIdx[k]] += tAmt; if (t.paid) expFact[keyIdx[k]] += tAmt; }
      });
    });
    (p.expenses||[]).forEach(e => {
      if (!e.date || (clipStart && (e.date < clipStart || e.date > clipEnd))) return;
      const d = new Date(e.date);
      if (!inRange(d)) return;
      const k = bucketKey({ year: d.getFullYear(), month: d.getMonth() });
      const ec = e.currency || "RUB";
      const eAmt = ec !== "RUB" ? (+e.amount||0) * pRate : (+e.amount||0);
      if (keyIdx[k] !== undefined) { exp[keyIdx[k]] += eAmt; if (e.paid) expFact[keyIdx[k]] += eAmt; }
    });
  });
  const profit = rev.map((r, i) => r - exp[i]);
  const labels = seenKeys.map(k => {
    if (mode === "month")   { const parts = k.split("-"); return MONTHS_SH[Number(parts[1])]; }
    if (mode === "quarter") return k.split("-")[1];
    return k;
  });
  return { rev, exp, revFact, expFact, profit, labels, buckets: N, keys: seenKeys };
}

// График «Динамика» (Аналитика): сглаженные доход/расход + интерактивный hover-тултип (доход и расход)
function DynamicsChart({ income, expense, months, year }) {
  const [hi, setHi] = useState(null);
  const grpNum = n => Math.round(+n||0).toLocaleString("ru-RU");
  const hasData = income.some(v=>v>0) || expense.some(v=>v>0);
  const dataMax = Math.max(...income, ...expense, 1);
  const step = (()=>{ const raw=dataMax/4; const mag=Math.pow(10,Math.floor(Math.log10(raw||1))); return [1,1.5,2,2.5,3,4,5,7.5,10].map(f=>f*mag).find(f=>f>=raw)||raw; })();
  const max = step*4;
  /* H=340, а не 260 (2026-08-06). Высота на экране = ширина × H/W, потому что svg отдан
     width:"100%" без height. При 260 график занимал 0.26 ширины и читался эскизом рядом с
     крупными метриками над ним. Растёт только поле графика: PL/PT/PB и кегли подписей заданы
     в единицах viewBox, а масштаб зависит ТОЛЬКО от ширины — подписи на экране не изменились. */
  /* ⚠ H по замеру макета: отношение высоты к ширине 0.264 (720×190). При 340 график
     был вдвое выше макетного и съедал экран. */
  /* ⚠ ЛЕВОГО ПОЛЯ ПОД ПОДПИСИ ОСИ БОЛЬШЕ НЕТ (2026-08-17, перенос макета). У макета
     под графиком только сетка: четыре линии и ни одной цифры. Точные значения
     даёт наведение (подсказка с доходом и расходом за месяц), а лесенка «0 / 250
     тыс / 500 тыс» стояла постоянно, съедала 68px слева и отвечала на вопрос,
     который на дашборде не задают: здесь смотрят ФОРМУ года, а не отсчитывают
     деления. PL=16 — половина ширины крайней подписи месяца, она центрована. */
  const W=1000,H=264,PL=16,PR=16,PT=16,PB=34,iW=W-PL-PR,iH=H-PT-PB;
  const n=Math.max(months.length,1), xOf=i=>PL+iW*(i/Math.max(n-1,1)), yOf=v=>PT+iH*(1-v/max);
  const incPts=income.map((v,i)=>[xOf(i),yOf(v)]), expPts=expense.map((v,i)=>[xOf(i),yOf(v)]);
  /* ⚠ ЛОМАНАЯ, А НЕ СПЛАЙН (2026-08-15). `smoothPath` раздувал годовой ряд в одну
     гладкую каплю: пик июля превращался в холм шириной в квартал, а месяцы между
     точками читались как измеренные, хотя их никто не мерил. Прямые отрезки говорят
     ровно то, что известно: двенадцать значений и переходы между ними. */
  const polyPath = pts => pts.map((q,i)=>(i?"L":"M")+" "+q[0].toFixed(1)+" "+q[1].toFixed(1)).join(" ");
  const lineD=polyPath(incPts), areaD=lineD+` L ${xOf(n-1).toFixed(1)} ${(PT+iH).toFixed(1)} L ${PL} ${(PT+iH).toFixed(1)} Z`;
  let tip=null;
  if(hi!==null){
    const l1=`Доходы ${grpNum(income[hi])}`, l2=`Расходы ${grpNum(expense[hi])}`;
    const tw=Math.max(String(months[hi]).length, l1.length, l2.length)*7+36, th=70;
    let tx=xOf(hi)-tw-12; if(tx<PL) tx=xOf(hi)+12; if(tx+tw>W) tx=W-tw-2;
    let ty=yOf(Math.max(income[hi],expense[hi]))-th-12; if(ty<PT) ty=PT+4;
    tip={tw,th,tx,ty,l1,l2};
  }
  return (
    <svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display:"block", fontFamily:"'Inter',system-ui,sans-serif", overflow:"visible" }} onMouseLeave={()=>setHi(null)}>
      {/* Заливка ПЛОСКАЯ: градиент в прозрачность — тот самый приём, который вычищали из кнопок. */}
      {/* Появление графика (motion-design): линии «прочерчиваются» stroke-dashoffset, area наполняется под кривой. ease-out-cubic, low-freq → richer. key={year} переигрывает при смене года. */}
      <style>{`
        @keyframes dynDraw{from{stroke-dashoffset:1}to{stroke-dashoffset:0}}
        @keyframes dynArea{from{opacity:0}to{opacity:1}}
        .dyn-line{stroke-dasharray:1;stroke-dashoffset:1}
        .dyn-line-inc{animation:dynDraw 1500ms cubic-bezier(.215,.61,.355,1) 200ms forwards}
        .dyn-line-exp{animation:dynDraw 1200ms cubic-bezier(.215,.61,.355,1) 550ms forwards}
        .dyn-area{animation:dynArea 900ms cubic-bezier(.165,.84,.44,1) 650ms both}
        @media (prefers-reduced-motion: reduce){.dyn-line,.dyn-line-inc,.dyn-line-exp{stroke-dasharray:none;stroke-dashoffset:0;animation:none}.dyn-area{animation:none;opacity:1}}
      `}
: правишь одно — правь второе. */
const EF_LBL_S = { display:"block", fontFamily:"Inter, sans-serif", fontSize:11.5,
  color:"var(--color-text-tertiary)", marginBottom: "var(--space-1)" };

/* ⚠ ПОРЯДОК ПОЛЕЙ ОПЕРАЦИИ — ОДНА ТАБЛИЦА НА ВСЕ ОКНА (14.08.2026, просьба Игоря
   «сделай этот дизайн единым для всех операций»). Три окна об одной операции
   («Разбор», «Новая операция», правка строки) держали ТРИ разных раскладки: разбор
   шёл дата+отметка → сумма → начисление → счёт|контрагент → проект|статья →
   назначение, а создание и правка — сумма|дата → статья|проект → счёт|контрагент →
   назначение → начисление, да ещё с «Состоянием» отдельной колонкой рядом с
   «Направлением». Одна операция выглядела как три разные программы.
   Порядок теперь НЕ задаётся разметкой: ячейки лежат так, как удобно условиям
   (ручная запись против выписки, транш против расхода), а на экране их расставляет
   `order` из этой таблицы. Хочешь передвинуть поле — правь ЗДЕСЬ, и передвинется
   во всех трёх окнах сразу.
   Шаг между номерами — 10: между двумя соседями всегда есть место под то, что
   бывает только в одном окне (график траншей, таблица разбивки). */
const OP_ORD = {
  date:      10,   // дата платежа + отметка «Получено/Оплачено»
  amount:    20,   // сумма (и «Разбить сумму» под ней)
  split:     25,   // таблица разбивки / график траншей — сразу под суммой
  accrual:   30,   // дата начисления + отметка «Начислено»
  account:   40,   // счёт и юрлицо  (наша сторона)
  party:     50,   // контрагент     (чужая сторона)
  project:   60,
  row:       70,   // строка проекта / подрядчик
  category:  80,
  purpose:   90,   // назначение платежа — на всю ширину
  docs:     100,
  extra:    110,   // комиссия банку, поля правила налога
};

/* Пунктирная ссылка-раскрытие («Разбить сумму», «Что будет без статьи?»). Была
   объявлена внутри `OperationsView`, поэтому соседние окна рисовали такие же
   ссылки своими объектами и расходились по кеглю. */
const DOT_BTN = { border:0, background:"transparent", padding:0, cursor:"pointer", fontFamily:"inherit",
  fontSize:13.5, color:"var(--color-text-tertiary)", borderBottom:"1px dashed var(--color-line-1)" };

/* ⚠ ПОЛЕ-ФАКТ И ТУМБЛЕР-ФАКТ — ОДНА РЕАЛИЗАЦИЯ НА ТРИ ОКНА ОПЕРАЦИИ (2026-08-12).
   «Новая операция», правка строки и разбор из выписки показывают одно и то же:
   значение, которое менять нельзя. Каждое окно рисовало это по-своему — разбор
   песочной коробкой с замком (.ef-ro), правка голым текстом 15px без коробки, — и
   три окна об одной сущности выглядели как три программы. Сюда же уехал замок:
   пока иконок было две копии, они разъезжались по размеру.  */
function EfRo({ children, wrap, lock = true, title, minH }) {
  return (
    <div className="ef-ro" data-wrap={wrap ? "true" : undefined} title={title}
      style={minH ? { minHeight:minH, alignItems:"flex-start" } : undefined}>
      <span className="ef-ro-v">{children}</span>
      {lock && (
        <span className="ef-ro-lock"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><rect x="4.5" y="10.5" width="15" height="10" rx="2"></rect><path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" strokeLinecap="round"></path></svg></span>
      )}
    </div>
  );
}
/* Тумблер-факт: разметка `seg()` из формы создания, но без интерактивности —
   сегменты `span`, а не `button`. Подробности и ловушка ширины — в CSS у
   `.fm-seg.is-ro`. ⚠ Колонки только `minmax(0,1fr)`: у `1fr` минимум = содержимое,
   и дорожку распирает, а пилюля (её ширина в % от дорожки) уезжает с сегмента. */
/* Живой брат `SegRO`: та же дорожка, но сегменты нажимаются. ⚠ У «Новой операции»
   свой `seg()` — он умеет ещё и squash-анимацию пилюли, которой управляет состояние
   формы (перерисовка полей на смену тумблера). Здесь этого нет и не нужно: тумблер
   меняет одно поле, а не состав формы. */
function SegLive({ label, opts, active, onPick }) {
  const i = Math.max(0, opts.findIndex(o => o.v === active));
  return (
    <div style={{ minWidth:0 }}>
      <label className="ef-lbl fm-op-lbl">{label}</label>
      <div className="fm-seg is-block" style={{ gridTemplateColumns:`repeat(${opts.length}, minmax(0,1fr))`, "--n":opts.length, "--i":i }}>
        <span className="fm-seg-pill" aria-hidden="true"><i /></span>
        {opts.map(o => (
          <button key={o.v} type="button" className={"fm-seg-btn" + (o.v === active ? " on" : "")}
            onClick={()=>{ if (o.v !== active) onPick(o.v); }}>{o.t}</button>
        ))}
      </div>
    </div>
  );
}
/* ⚠ ОТМЕТКА-ПОДТВЕРЖДЕНИЕ вместо тумблера «План | Факт» (13.08.2026, просьба Игоря:
   «сделать подтверждение символом напротив — и то же самое рядом с датой оплаты вместо
   плана и факта»). Тумблер спрашивал «в каком состоянии деньги», хотя вопрос ровно один:
   случилось или нет. Один щелчок вместо выбора из двух — и он стоит ПРИ дате, к которой
   относится.
   Форма: кружок с галочкой + слово. Слово обязательно (правило «состояние называют
   словом, а не точкой»); кружок даёт мишень и мгновенную читаемость состояния.
   Движение — по Emil: `scale(.97)` под нажатием, 160мс ease-out, цвет 150мс; ничего
   не выезжает и не появляется из ничего. */
/* ⚠ КРУЖОК С ГАЛОЧКОЙ УБРАН (макет `.mark`, 18.08.2026). У макета отметка — это
   слово и заливка, больше ничего: включённая мятная, выключенная белая с рамкой.
   Кружок съедал 26px справа (90 против 117 по замеру) и дублировал то, что уже
   сказано заливкой и цветом слова. Правило «состояние называют СЛОВОМ, а не
   значком» он же и нарушал — знак стоял впереди слова и читался первым. */
function ConfirmMark({ on, label, onToggle, title, ro }) {
  if (ro) return <span className="fm-mark ro on" title={title}>{label}</span>;
  return (
    <button type="button" onClick={onToggle} title={title} className={"fm-mark" + (on ? " on" : "")}>
      {label}
    </button>
  );
}
/* ══ ПОЛЯ ОПЕРАЦИИ — ОДНА РЕАЛИЗАЦИЯ НА ТРИ ОКНА (14.08.2026) ══════════════════
   Прямой вопрос Игоря: «почему нельзя прозеркалить, а не делать всё заново?».
   До этого «Разбор операции», «Новая операция» и правка строки рисовали одни и те
   же вещи тремя копиями разметки — и копии расходились на КАЖДОЙ правке: назначение
   было текстовой областью в 64px в разборе и однострочным полем 37px в создании;
   теги и файлы были только в разборе, потому что дописывать их в два других окна
   значило скопировать сорок строк ещё дважды.
   Теперь окна ОТЛИЧАЮТСЯ ТОЛЬКО ИСТОЧНИКОМ ДАННЫХ (операция · форма · строка
   проекта) и передают его пропами `value/onChange`. Правишь поле здесь — оно
   меняется во всех трёх окнах сразу; заводишь четвёртое окно — берёшь эти же.
   ⚠ Своей разметки назначения, тегов и файлов в окнах больше не заводить. */

/* Назначение платежа: текстовая область, а не строка. У банка это три строки с
   договором, НДС и периодом — в поле высотой 37 они прячутся за прокруткой ввода. */
function OpPurposeField({ label = "Назначение платежа", value, onChange, ro, order, placeholder = "За что деньги" }) {
  return (
    <div className="fm-op-wide" style={{ minWidth:0, order }}>
      <label className="ef-lbl">{label}</label>
      {/* ⚠ Поле РАСТЁТ САМО (`AutoTextarea`), а не ручкой в углу (14.08.2026):
          ручку у назначения убрали вместе со всеми остальными — размеры полей
          задаём мы, — но у банковского назначения бывает три строки, и молча
          прятать их за прокруткой нельзя. Стартовая высота 64 = высота дропзоны
          и двух строк текста. */}
      {ro
        ? <EfRo wrap minH={64}>{value || "—"}</EfRo>
        : <AutoTextarea className="ef-in" style={{ minHeight:64 }}
            value={value || ""} placeholder={placeholder}
            onChange={e=>onChange(e.target.value)} />}
    </div>
  );
}

/* Теги — ПОЛЕ-КОРОБКА (та же высота, рамка и радиус, что у `.ef-in`), чипы внутри,
   ввод плейсхолдером. Одинокий чип «+ тег» 24px рядом с полями 37 читался как
   недоделанная вёрстка. Черновик ввода живёт ВНУТРИ компонента: он нужен только
   ему, а в окне это была лишняя строка состояния (и в разборе её нельзя завести —
   там хуки запрещены). */
function OpTagsField({ tags = [], onChange, order }) {
  const [draft, setDraft] = React.useState("");
  const add = () => { const v = draft.trim(); if (!v) return; if (!tags.includes(v)) onChange([...tags, v]); setDraft(""); };
  return (
    /* Во всю ширину формы — как «Назначение» и «Файлы»: чипы набегают строками, и
       половина ряда обрывала их на середине, оставляя пустой соседний столбец. */
    <div className="fm-op-wide" style={{ minWidth:0, marginTop:"var(--space-3)", order }}>
      <label className="ef-lbl">Теги</label>
      <div className="ef-in" onClick={e => { const f = e.currentTarget.querySelector("input"); if (f) f.focus(); }}
        style={{ display:"flex", alignItems:"center", flexWrap:"wrap", gap: "var(--space-2)", minHeight:37, boxSizing:"border-box", padding:"5px var(--space-2)", cursor:"text" }}>
        {tags.map((t, i) => (
          <span key={t + i} style={{ display:"inline-flex", alignItems:"center", gap: "var(--space-1)", background:"var(--color-background-secondary)",
            color:"var(--color-text-secondary)", borderRadius: "var(--radius)", padding: "3px var(--space-2)", fontSize:13.5, lineHeight:"19px" }}>
            {t}
            <button type="button" onClick={e=>{ e.stopPropagation(); onChange(tags.filter((_, j) => j !== i)); }}
              title="Убрать тег" style={{ border:0, background:"transparent", padding:0, cursor:"pointer",
                color:"var(--color-text-tertiary)", fontSize:13.5, lineHeight:1 }}>×</button>
          </span>
        ))}
        {/* ⚠ `boxShadow:"none"` обязателен: глобальное правило `input:focus-visible`
            рисует голому инпуту своё кольцо поверх кольца коробки — два прямоугольника. */}
        <input value={draft} onChange={e=>setDraft(e.target.value)}
          onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
          onBlur={add}
          placeholder={tags.length ? "ещё тег" : "Своя метка — Enter, чтобы добавить"}
          style={{ flex:"1 1 120px", minWidth:0, border:0, outline:"none", boxShadow:"none", background:"transparent",
            fontFamily:"inherit", fontSize:13.5, color:"var(--color-text-primary)", padding:"3px 0", lineHeight:"19px" }} />
      </div>
    </div>
  );
}

/* Файлы: список прикреплённых + дропзона 88px. Строка файла двухэтажная (имя
   чернилами, размер и судьба строкой ниже) — по правилу ленты «Операции».
   `extra(file, i)` — необязательная приписка окна: в разборе это «В документы».
   Загрузчик всегда пустой и перемонтируется счётчиком: он добавляет ОЧЕРЕДНОЙ
   файл в список, а не хранит один. */
function OpFilesField({ files = [], onChange, extra, order }) {
  const [tick, setTick] = React.useState(0);
  return (
    /* ⚠ Во всю ширину формы (16.08.2026, прямая просьба «и для загрузки файла»).
       Поле стояло в ОДНОЙ колонке сетки: дропзона 88px шириной в половину ряда —
       мишень меньше, чем строка имени файла над ней, а длинные имена обрывались
       на середине при пустом соседнем столбце. */
    <div className="fm-op-wide" style={{ minWidth:0, marginTop:"var(--space-3)", order }}>
      <label className="ef-lbl">Файлы</label>
      {files.map((d, i) => (
        <div key={(d && d.path) || i} style={{ display:"flex", alignItems:"center", gap: "var(--space-2)", padding: "var(--space-2) 0",
          borderTop: i ? "1px solid var(--color-background-hover)" : "none" }}>
          {/* Та же скрепка, что в дропзоне: строка прикреплённого файла и приглашение
              прикрепить обязаны говорить одним знаком. */}
          <span style={{ flex:"none", display:"inline-flex", color:"var(--color-text-tertiary)" }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21.4 11.05 12.25 20.2a5.5 5.5 0 0 1-7.78-7.78l9.2-9.19a3.67 3.67 0 1 1 5.18 5.18l-9.2 9.2a1.83 1.83 0 1 1-2.59-2.6l8.5-8.49"></path></svg></span>
          <div style={{ flex:1, minWidth:0 }}>
            <div style={{ fontSize:13.5, color:"var(--color-text-primary)", whiteSpace:"nowrap", overflow:"hidden", textOverflow:"ellipsis" }}>{d.name}</div>
            <div style={{ fontSize:11.5, color:"var(--color-text-tertiary)", display:"flex", alignItems:"center", gap:"var(--space-1)", flexWrap:"wrap" }}>
              <span data-num>{fmtFileSize(d.size)}</span>
              {extra ? extra(d, i) : null}
            </div>
          </div>
          {/* ⚠ Стиль кнопки — свой, а не `ghostBtn`: тот объявлен ВНУТРИ `OperationsView`
              и сюда не виден. Значения те же (песочная подложка, чернила, 13.5). */}
          <button type="button" onClick={()=> d.public_url ? window.open(d.public_url, "_blank", "noopener") : openYaFile(d.path)}
            style={{ display:"inline-flex", alignItems:"center", gap:"var(--space-2)", fontFamily:"Inter, sans-serif", fontSize:13.5,
              padding:"3px var(--space-2)", borderRadius:"var(--radius)", border:"1px solid transparent", cursor:"pointer",
              background:"var(--color-background-secondary)", color:"var(--color-text-secondary)", whiteSpace:"nowrap" }}>Открыть ↗</button>
          <button type="button" onClick={()=>onChange(files.filter((_, j) => j !== i))}
            title="Открепить файл" style={{ border:0, background:"transparent", padding:0, cursor:"pointer",
              color:"var(--color-text-tertiary)", fontSize:13.5, lineHeight:1 }}>×</button>
        </div>
      ))}
      <DocUpload key={"opfile-" + tick} lockFile content={{}}
        onChange={c => { if (c && c.ya) { onChange([...files, c.ya]); setTick(t => t + 1); } }} />
    </div>
  );
}

function SegRO({ label, opts, active, hint, title }) {
  const i = Math.max(0, opts.findIndex(o => o.v === active));
  return (
    <div style={{ minWidth:0 }}>
      <label className="ef-lbl">{label}</label>
      <div className="fm-seg is-block is-ro" data-dir="0" title={title || "Это факт, менять нечем"}
        style={{ gridTemplateColumns:`repeat(${opts.length}, minmax(0,1fr))`, "--n":opts.length, "--i":i }}>
        <span className="fm-seg-pill" aria-hidden="true"><i /></span>
        {opts.map(o => <span key={o.v} className={"fm-seg-btn" + (o.v === active ? " on" : "")}>{o.t}</span>)}
      </div>
      {hint ? <div className="ef-hint">{hint}</div> : null}
    </div>
  );
}

// Смысловая группа полей внутри формы; между соседними группами — хайрлайн.
function EfSection({ title, children, style }) {
  return <div className="ef-sec" style={style}>{title ? <div className="ef-sec-t">{title}</div> : null}{children}</div>;
}

/* ═══ ПОЛЯ-ВЫПАДАШКИ ФОРМЫ (2026-07-31) ══════════════════════════════════════
   Статья и дата — два места, где форма до сих пор отдавала управление
   операционной системе: select и input[type=date] рисует она, своим шрифтом и
   своим календарём. Стили к ним не пристают, поиска в списке нет, новую статью
   из формы не завести — человек упирается в это ровно там, где заполняет.
   Оба поля собраны на одном каркасе: кнопка ростом с .ef-in + панель под ней.
   Разметка и движение — в CSS (.fp-* / .fcal-*). */

// Панель живёт в DOM ещё 160мс после закрытия, иначе анимация ухода не успевает
// проиграться. Флаг entering снимается по таймеру: появление пунктов лесенкой —
// событие ОТКРЫТИЯ, повторять его на каждую букву в поиске нельзя.
function usePopover(open) {
  const [mounted, setMounted]   = useState(false);
  const [entering, setEntering] = useState(false);
  useEffect(() => {
    if (open) {
      setMounted(true); setEntering(true);
      const t = setTimeout(() => setEntering(false), 340);
      return () => clearTimeout(t);
    }
    const t = setTimeout(() => setMounted(false), 170);
    return () => clearTimeout(t);
  }, [open]);
  return { mounted, entering };
}

/* ⚠ ПАНЕЛЬ ВЫПАДАШКИ ВНУТРИ ПРОКРУЧИВАЕМОГО БЛОКА (13.08.2026).
   Блок с `overflow-x:auto` по спецификации клипает и по вертикали тоже — панель
   выпадашки срезается краем блока, и список выглядит сломанным (жалоба Игоря на
   таблицу разбивки: «всплывающее окно сломано, не всплывает должным образом»).
   Лечится единственным способом: панель уходит из потока в `position:fixed` и
   позиционируется от триггера. Координаты пересчитываются на прокрутке (лист под
   панелью едет) и на изменении окна.
   ⚠ Включается ТОЛЬКО пропом `fixedPop`: остальные два десятка выпадашек приложения
   живут абсолютным позиционированием внутри своего поля, и трогать их незачем —
   fixed ломается в трансформированном предке и не умеет ехать вместе с полем. */
function useFixedPop(enabled, open, trigRef, side) {
  const [pos, setPos] = useState(null);
  useEffect(() => {
    if (!enabled || !open) { setPos(null); return; }
    const calc = () => {
      const el = trigRef.current;
      if (!el || !el.getBoundingClientRect) return;
      const r  = el.getBoundingClientRect();
      const vw = window.innerWidth || 1440, vh = window.innerHeight || 900;
      // Панель шире поля (до 360) — не даём ей уехать за правый край экрана.
      const left = Math.max(8, Math.min(r.left, vw - 372));
      setPos(side === "up"
        ? { position:"fixed", left, right:"auto", top:"auto", bottom: vh - r.top + 6, minWidth:r.width }
        : { position:"fixed", left, right:"auto", bottom:"auto", top: r.bottom + 6, minWidth:r.width });
    };
    calc();
    window.addEventListener("scroll", calc, true);
    window.addEventListener("resize", calc);
    return () => { window.removeEventListener("scroll", calc, true); window.removeEventListener("resize", calc); };
  }, [enabled, open, side]);
  return pos;
}

// Клик мимо закрывает. Колбэк держим в ref: иначе подписка пересоздаётся на
// каждый рендер (а рендер тут случается на каждую букву в поиске).
function useOutsideClose(ref, active, close) {
  const cb = useRef(close); cb.current = close;
  useEffect(() => {
    if (!active) return;
    const h = e => { if (ref.current && !ref.current.contains(e.target)) cb.current(); };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, [active]);
}

/* ── Замок прокрутки под модалкой ─────────────────────────────────────────────
   ПРАВИЛО: пока открыт слой со шторкой (модалка, вылет, панель справа), колесо
   работает ВНУТРИ слоя, а страница под ним стоит. Иначе получается то, на что
   пожаловался пользователь: разбираешь операцию, крутишь колесо — и уезжает
   лента за шторкой, а разбор остаётся на месте.

   Счётчик, а не флаг: слои накладываются друг на друга (карточка проекта →
   окно строки → выпадашка), и снятие замка первым же закрывшимся слоем
   разморозило бы страницу под ещё открытым.

   `paddingRight` компенсирует ширину исчезнувшей полосы прокрутки — без него
   в момент открытия и закрытия весь контент прыгает вбок. На macOS с
   наложенными полосами `sbw` = 0, и компенсация не срабатывает.

   ⚠ Ставить хук в САМ компонент слоя, а не в место вызова: тогда правило
   действует само собой, и о нём нельзя забыть, добавляя новую модалку.
   ⚠ Хук должен вызываться безусловно (правила хуков), поэтому условие —
   в аргументе: `useScrollLock(!!pickFor)`, а не `if (pickFor) useScrollLock()`. */
let fmScrollLocks = 0;
function useScrollLock(on) {
  useEffect(() => {
    if (!on || typeof document === "undefined") return;
    if (fmScrollLocks++ === 0) {
      const sbw = window.innerWidth - document.documentElement.clientWidth;
      document.body.style.overflow = "hidden";
      if (sbw > 0) document.body.style.paddingRight = sbw + "px";
    }
    return () => {
      if (--fmScrollLocks <= 0) {
        fmScrollLocks = 0;
        document.body.style.overflow = "";
        document.body.style.paddingRight = "";
      }
    };
  }, [on]);
}

// Панель выше нижнего края экрана? Открываем вверх. Без этого поле в конце
// длинной формы роняет список за сгиб, и выбрать в нём нечего.
function popSide(el, h) {
  if (!el) return "down";
  const r = el.getBoundingClientRect();
  return (window.innerHeight - r.bottom < h + 16 && r.top > h + 16) ? "up" : "down";
}
const POP_POS = side => side === "up" ? { bottom:"calc(100% + 6px)" } : { top:"calc(100% + 6px)" };

/* ─── ПОДСКАЗКИ ────────────────────────────────────────────────────────────────
   Пояснения механики («эти операции не привязаны к счёту, потому что…») уезжают
   из вёрстки под значок «?». Три сорта текста разведены НАМЕРЕННО и по-разному:
   · механика — сюда, в Hint: нужна один раз, а занимает место всегда;
   · дубли (период в шапке карточки при живом переключателе периода) — удалены;
   · выводы («почти все деньги с повторных сделок») — ОСТАЮТСЯ на экране.
     Это не пояснение, а интерпретация данных: спрятать её значит превратить
     дашборд в таблицу. Не переносить их в Hint даже ради чистоты экрана.

   Значение тумблера читается модульной переменной, а не пропом: Hint стоит
   глубоко в разметке десятка карточек, и протаскивать флаг через все слои
   пришлось бы ради одного булева. Событие нужно, чтобы уже смонтированные
   значки исчезли сразу, а не после следующего рендера родителя. */
const HINTS_EV = "fm-hints-change";
let HINTS_ON = null;
function hintsOn() {
  if (HINTS_ON === null) { try { HINTS_ON = loadPrefs().hints !== false; } catch { HINTS_ON = true; } }
  return HINTS_ON;
}
function setHintsOn(v) { HINTS_ON = !!v; window.dispatchEvent(new Event(HINTS_EV)); }
function useHintsOn() {
  const [on, setOn] = useState(hintsOn);
  useEffect(() => {
    const h = () => setOn(hintsOn());
    window.addEventListener(HINTS_EV, h);
    return () => window.removeEventListener(HINTS_EV, h);
  }, []);
  return on;
}

/* Вывод — интерпретация данных («почти все деньги с первых сделок»). НЕ подсказка:
   подсказка объясняет приложение, вывод говорит о деньгах пользователя, и прятать его
   за значком «?» значит записать содержание в справку.
   Механика другая и намеренно: полоса по низу карточки, которая проявляется при
   наведении на ВСЮ карточку. В покое — ноль пикселей текста; целиться некуда не надо;
   полоса лежит слоем, поэтому ряд не прыгает. Карточке нужен класс fm-take-host.
   На тач-устройствах наведения нет — там медиазапрос возвращает вывод в поток. */
function Take({ children }) {
  if (!children) return null;
  return <span className="fm-take">{children}</span>;
}

/* Значок и поповер. Открывается по КЛИКУ, не по наведению: подсказка часто висит
   на строке внутри кликабельной карточки, и hover-раскрытие срабатывало бы, пока
   курсор просто едет к нужному месту. stopPropagation обязателен — иначе клик по
   «?» проваливается в карточку и открывает её. */
function Hint({ children, w }) {
  const on = useHintsOn();
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  const { mounted } = usePopover(open);
  useOutsideClose(ref, open, () => setOpen(false));
  if (!on || !children) return null;
  const side = open ? popSide(ref.current, 140) : "down";
  return (
    <span className="fm-hint" ref={ref}>
      <button type="button" className="fm-hint-b" aria-label="Подсказка" aria-expanded={open}
        onClick={e => { e.stopPropagation(); e.preventDefault(); setOpen(v => !v); }}>?</button>
      {mounted && (
        <span className="fm-hint-p" data-side={side} data-off={!open || undefined}
          style={{ ...POP_POS(side), width: w || 268 }} onClick={e => e.stopPropagation()}>{children}</span>
      )}
    </span>
  );
}
const CARET = <span className="fp-car"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9l6 6 6-6"></path></svg></span>;

/* ⚠ ОДНА ВЫПАДАШКА НА ПРИЛОЖЕНИЕ (2026-08-11). Раньше их было две: `ArtSelect`
   в формах и `OpCombo` в разборе операции — с разной механикой панели, разными
   кеглями списка и разной строкой «создать». `OpCombo` УДАЛЁН, его умения здесь:

   · `sections={(ql) => [{label, items:[{key,node,active,onPick}]}]}` — свой список
     вместо `options`. Нужен там, где пункт рисуется сложно (проект с клиентом и
     счётчиком незакрытых строк) или список собирается по своим правилам.
   · `inlineSearch` — поиск НЕ строкой внутри панели, а прямо в поле: клик не меняет
     размер поля, только цвет рамки, список ложится слоем поверх. Так устроен разбор
     операции; в формах остаётся прежний вид (поиск внутри панели).
   · `onClear` — ✕ в заполненном поле.
   · `label` — что показать в поле, когда значение не из `options` (разбор хранит
     выбранный проект отдельно и передаёт готовую подпись).
   · `hasExactMatch(ql)` — в режиме `sections` компонент не знает списка и не может
     сам решить, есть ли точное совпадение.

   Строка «создать» ВСЕГДА в конце списка и говорит просто «Создать статью» —
   без подстановки набранного текста: он и так стоит в строке поиска выше. Пока
   поиск пуст, строка не создаёт (имени нет), а ставит курсор в поиск. */
function ArtSelect({ value, list = [], options, extra = [], extraLabel = "", frequent = [], onChange, onCreate,
                     placeholder = "Выберите статью", emptyLabel = "Без статьи", search = true,
                     searchPlaceholder = "Найти статью", createLabel = "Создать статью",
                     sections, inlineSearch, onClear, label, hasExactMatch, fixedPop, multi }) {
  const opts  = options || list.map(c => ({ v:c, t:c }));
  const labOf = v => { const o = opts.find(x => String(x.v) === String(v)); return o ? o.t : (v || ""); };
  const [open, setOpen] = useState(false);
  const [q, setQ]       = useState("");
  const [cur, setCur]   = useState(0);
  const [side, setSide] = useState("down");
  const [alignRight, setAlignRight] = useState(false);
  const [hov, setHov] = useState(false);   // курсор над полем: под ним ✕ занимает слот шеврона
  const wrapRef = useRef(null), trigRef = useRef(null), inRef = useRef(null);
  const { mounted, entering } = usePopover(open);
  useOutsideClose(wrapRef, mounted, () => setOpen(false));
  useEffect(() => { if (open && inRef.current) inRef.current.focus(); }, [open]);
  // Поле в прокручиваемой таблице: панель уходит в fixed, иначе её срежет край блока.
  const fixPos = useFixedPop(fixedPop, mounted, trigRef, side);

  const ql    = q.trim().toLowerCase();
  const hit   = o => String(o.t).toLowerCase().includes(ql) || String(o.sub || "").toLowerCase().includes(ql);
  const freqO = frequent.map(f => opts.find(o => String(o.v) === String(f))).filter(Boolean).slice(0, 3);

  /* Обе ветки сводятся к ОДНОЙ внутренней форме `[{ t, items:[{key,node,on,onPick}] }]`.
     Без этого пришлось бы держать два цикла отрисовки и две клавиатурные навигации —
     то есть ровно те два компонента, которые тут и сводятся в один. */
  const optNode = o => (
    <span style={{ flex:1, minWidth:0 }}>
      <span style={{ display:"block", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{ql ? hlMatch(String(o.t), ql) : o.t}</span>
      {/* Уточнение — строкой ниже и мельче: правило строки контрагента в ленте. */}
      {o.sub ? <span style={{ display:"block", marginTop:2, fontSize:12, color:"var(--color-text-4)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{o.sub}</span> : null}
    </span>
  );
  const optItem = o => ({ key:String(o.v), node:optNode(o), on:String(value) === String(o.v), onPick:()=>choose(o.v) });

  let secs = [];
  if (sections) {
    secs = (sections(ql) || []).map(s => ({ t:s.label, items:(s.items || []).map(it => ({
      key:it.key, node:it.node, on:!!it.active, onPick:it.onPick })) })).filter(s => s.items.length);
  } else {
    if (ql) {
      secs.push({ t:null, items:opts.filter(hit).map(optItem) });
    } else if (freqO.length) {
      secs.push({ t:"Частые", items:freqO.map(optItem) });
      secs.push({ t:"Все",    items:opts.filter(o => !freqO.includes(o)).map(optItem) });
    } else {
      secs.push({ t:null, items:opts.map(optItem) });
    }
    /* `extra` — пункты-ДЕЙСТВИЯ отдельной секцией внизу («Создать новый: Договор…»).
       Не в общем списке: выбрать существующее и завести новое — разные намерения,
       вперемешку они читаются как один список равных вариантов. */
    if (extra.length) { const e = ql ? extra.filter(hit) : extra; if (e.length) secs.push({ t:extraLabel, items:e.map(optItem) }); }
    secs = secs.filter(s => s.items.length);
  }

  const showEmpty  = !sections && !ql && !!value && !!emptyLabel;
  /* ⚠ СТРОКА «СОЗДАТЬ …» СТОИТ ВСЕГДА, кроме точного совпадения (18.08.2026, Игорь:
     «если тут нет нужного проекта, надо добавить кнопку создать проект»). Раньше в
     режиме `options` она появлялась ТОЛЬКО после набора текста — то есть человек,
     открывший список и не нашедший своего проекта, видел тупик: список кончился, а
     где завести новый — неизвестно. Догадаться, что надо начать печатать имя,
     которого в списке нет, невозможно. В режиме `sections` правило уже было таким —
     теперь оба режима ведут себя одинаково. Пустой запрос строка обрабатывает сама:
     она приглушена, говорит «наберите название» и ставит курсор в поиск. */
  const showCreate = !!onCreate && !(ql && (hasExactMatch
    ? hasExactMatch(ql)
    : opts.some(o => String(o.t).toLowerCase() === ql)));
  const flat  = secs.reduce((a, s) => a.concat(s.items), []);
  const picks = [...(showEmpty ? ["__empty__"] : []), ...flat.map(it => it.key), ...(showCreate ? ["__new__"] : [])];
  // Поиск нужен длинному списку. Над тремя проектами строка поиска — лишний шум.
  const withSearch = search && !inlineSearch && (opts.length > 6 || !!onCreate);

  const openPanel = () => {
    setQ(""); setSide(popSide(trigRef.current, 300));
    /* ⚠ Панель прибита к `left:0` и растёт вправо до 360. У поля в правом столбце
       узкого drawer'а она вылезала за край и срезалась его `overflow:hidden` —
       нижние пункты становились недоступны. Меряем на открытии по правому краю окна. */
    const r = trigRef.current && trigRef.current.getBoundingClientRect();
    setAlignRight(!!r && (r.left + 360 > document.documentElement.clientWidth - 8));
    // Курсор встаёт на выбранный пункт — Enter сразу после открытия ничего не меняет.
    setCur(Math.max(0, flat.findIndex(it => it.on)) + (showEmpty ? 1 : 0));
    setOpen(true);
  };
  const doCreate = () => {
    // Имени ещё нет — не создаём вслепую, а зовём набрать его.
    if (!ql) { if (inRef.current) inRef.current.focus(); return; }
    onCreate(q.trim()); setOpen(false);
  };
  const choose = v => { if (v === "__new__") { doCreate(); return; } onChange(v); setOpen(false); };
  const pickKey = k => {
    if (k === "__new__")   { doCreate(); return; }
    if (k === "__empty__") { onChange(""); setOpen(false); return; }
    const it = flat.find(x => x.key === k); if (it) { it.onPick(); if (!multi) setOpen(false); }
  };
  const onKey = e => {
    if (e.key === "Escape")   { e.stopPropagation(); e.preventDefault(); setOpen(false); return; }
    if (e.key === "ArrowDown"){ e.preventDefault(); setCur(i => Math.min(picks.length - 1, i + 1)); return; }
    if (e.key === "ArrowUp")  { e.preventDefault(); setCur(i => Math.max(0, i - 1)); return; }
    if (e.key === "Enter")    { e.preventDefault(); if (picks.length) pickKey(picks[Math.min(cur, picks.length - 1)]); }
  };

  const shownLabel = label != null ? label : labOf(value);
  const isEmpty    = !shownLabel;
  const mag = <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.2-3.2" strokeLinecap="round"></path></svg>;
  /* ⚠ ГЕОМЕТРИЯ ПОЛЯ ОДНА НА ВСЕ ТРИ СОСТОЯНИЯ (пусто / выбрано / открыто).
     Поле НЕ разворачивается в панель: клик менял бы его высоту, и всё под ним
     уезжало вниз. Лупа стоит во всех трёх состояниях намеренно — убери её у
     заполненного, и текст поедет влево ровно в момент открытия. */
  const bigBox = {
    display:"flex", alignItems:"center", gap:"var(--space-2)", width:"100%", boxSizing:"border-box",
    border:"1px solid " + (open ? "var(--color-blue)" : "var(--color-line-1)"),
    /* ⚠ ПУСТОЕ ПОЛЕ НЕ ТЕМНЕЕ ЗАПОЛНЕННОГО (13.08.2026, просьба Игоря: «почему проект и
       статья подсвечиваются, а остальное нет? не подсвечивай их тоже тёмным»). Заливка
       песочной страницей помечала «здесь пусто», но в ряду с белыми полями ввода читалась
       как другое состояние — будто эти два поля заблокированы. Пустоту называет
       плейсхолдер, а не фон. */
    background:"var(--color-background-primary)",
    boxShadow: open ? "var(--focus-ring)" : "none",
    /* ⚠ 37px и 13.5 — как у `.ef-in`, `.fp-trig` и `.ef-ro`. Свои 46/15.5 делали это
       поле выше и крупнее соседей по строке сетки: ряд «Статья | Дата» стоял ступенькой. */
    borderRadius:"var(--radius)", padding:"var(--space-2) var(--space-3)", minHeight:37,
    fontFamily:"Inter, sans-serif", fontSize:13.5, textAlign:"left", cursor:"pointer",
    transition:"border-color 150ms ease, box-shadow 150ms ease, background-color 150ms ease"
  };

  let ii = 0;  // сквозной номер пункта — только для лесенки появления
  return (
    <div className="fp-wrap" ref={wrapRef}>
      {inlineSearch ? (
        <div ref={trigRef} onClick={()=> open ? setOpen(false) : openPanel()} title={!open && !isEmpty ? String(shownLabel) : undefined}
          onMouseEnter={()=>setHov(true)} onMouseLeave={()=>setHov(false)} style={bigBox}>
          {/* ⚠ ЛУПЫ В ПОЛЕ НЕТ ВООБЩЕ (13.08.2026, прямая просьба Игоря: «убери эти иконки
              поиска, выглядит ублюдски»). Сначала её прятали у заполненного поля, потом
              стало ясно, что она не нужна и у пустого: поле и так открывается кликом, а
              что в нём можно набирать — говорит курсор, вставший в строку. Справа
              остаётся один знак — шеврон, как у всех выпадашек. */}
          {open ? (
            /* ⚠ `boxShadow:none` обязателен: глобальное правило
               `input:focus-visible { box-shadow: var(--focus-ring) }` вешает кольцо на
               голый input, а кольцо уже нарисовано вокруг всего поля — получались два
               вложенных прямоугольника (поймано на скриншоте 11.08.2026). */
            <input ref={inRef} value={q} onChange={e=>{ setQ(e.target.value); setCur(0); }} onKeyDown={onKey}
              placeholder={placeholder} autoComplete="off" onClick={e=>e.stopPropagation()}
              style={{ flex:1, minWidth:0, border:0, outline:0, boxShadow:"none", background:"none", fontFamily:"Inter, sans-serif", fontSize:13.5, color:"var(--color-text-primary)", height:19 }} />
          ) : (
            <span style={{ flex:1, minWidth:0, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap", lineHeight:"19px",
              color: isEmpty ? "var(--color-text-tertiary)" : "var(--color-text-primary)" }}>{shownLabel || placeholder}</span>
          )}
          {/* ⚠ ✕ И ШЕВРОН ДЕЛЯТ ОДИН СЛОТ (13.08.2026, претензия Игоря: «почему не
              помещается слово? там стоит какой-то лишний блок — надо вырезать»). Два
              значка подряд съедали 34px справа — на них уходило название проекта
              («Motion-ролик — Альф…»), хотя целиком оно помещалось. Крестик показываем
              под курсором и НА МЕСТЕ шеврона: пока читаешь — читаешь имя, потянулся
              стирать — там крестик. Ничего не прячем безвозвратно: очистка живёт и в
              самой панели выбора. */}
          {!open && !isEmpty && onClear && hov ? (
            <span onClick={e=>{ e.stopPropagation(); onClear(); }} title="Очистить" style={{ display:"inline-flex", alignItems:"center", justifyContent:"center", width:18, height:18, borderRadius:"var(--radius)", color:"var(--color-text-tertiary)", flex:"none", cursor:"pointer" }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M6 6l12 12M18 6 6 18"></path></svg></span>
          ) : !open ? (
            <span style={{ color:"var(--color-text-tertiary)", display:"inline-flex", flex:"none" }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9l6 6 6-6"></path></svg></span>
          ) : null}
        </div>
      ) : (
        /* ⚠ ✕ БЫЛ ТОЛЬКО У `inlineSearch`-ВАРИАНТА (исправлено 14.08.2026). Обычная
           выпадашка — а это «Проект», «Статья», «Счёт», то есть почти все поля форм —
           рисовала один шеврон, и снять выбранное можно было, лишь открыв список и
           найдя в нём тихую серую строку «Без проекта». Человек тянется к крестику,
           не находит и считает поле заклинившим (прямая жалоба). Механика та же, что
           у соседнего варианта: крестик встаёт НА МЕСТО шеврона под курсором — два
           значка подряд съедали бы 34 px справа, а там название проекта. */
        <button type="button" ref={trigRef} className="fp-trig" data-open={open ? "true" : undefined}
          data-empty={isEmpty ? "true" : undefined} onClick={()=> open ? setOpen(false) : openPanel()}
          onMouseEnter={()=>setHov(true)} onMouseLeave={()=>setHov(false)}>
          <span className="fp-val">{shownLabel || placeholder}</span>
          {!open && !isEmpty && onClear && hov ? (
            <span className="fp-car" role="button" tabIndex={-1} aria-label="Очистить" title="Очистить"
              onClick={e=>{ e.stopPropagation(); onClear(); }} style={{ cursor:"pointer" }}>
              <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M6 6l12 12M18 6 6 18"></path></svg>
            </span>
          ) : CARET}
        </button>
      )}
      {mounted && (
        <div className="fp-pop" data-closing={open ? undefined : "true"} data-side={side}
          data-enter={entering ? "true" : undefined} onKeyDown={onKey}
          style={{ ...POP_POS(side),
            ...(alignRight ? { left:"auto", right:0, transformOrigin: side === "up" ? "bottom right" : "top right" } : null),
            minWidth:"100%", width:"max-content", maxWidth:360,
            ...(fixPos || null) }}>
          {withSearch && (
          /* ⚠ ЛУПЫ НЕТ (18.08.2026, Игорь: «а в „найти статью“ убей иконку поиска»).
             Ровно то же решение уже приняли 13.08 для поля-триггера с поиском внутри
             («убери эти иконки поиска, выглядит ублюдски») — здесь копия пережила ту
             правку. Что в строку можно набирать, говорит курсор, вставший в неё, и сам
             плейсхолдер «Найти статью»; значок повторял слово картинкой. */
          <div className="fp-search">
            <input ref={inRef} value={q} placeholder={searchPlaceholder} autoComplete="off"
              onChange={e=>{ setQ(e.target.value); setCur(0); }} />
          </div>
          )}
          <div className="fp-list">
            {showEmpty && (
              <div className="fp-item mute" data-cur={cur === 0 ? "true" : undefined}
                style={{ "--i":ii++ }} onMouseEnter={()=>setCur(0)} onClick={()=>pickKey("__empty__")}>{emptyLabel}</div>
            )}
            {secs.map((s, si) => (
              <div key={si}>
                {s.t && <div className="fp-sec">{s.t}</div>}
                {s.items.map(it => { const idx = picks.indexOf(it.key); const i = ii++; return (
                  <div key={it.key} className="fp-item" data-on={it.on ? "true" : undefined}
                    data-cur={cur === idx && !it.on ? "true" : undefined} style={{ "--i":i }}
                    /* ⚠ Многовыборное поле закрывают кликом мимо, а не выбором: закрыть
                       список после первой же техники значит заставить открывать его
                       заново на каждую следующую. Правило взято у макета (`data-multi`). */
                    onMouseEnter={()=>setCur(idx)} onClick={()=>{ it.onPick(); if (!multi) setOpen(false); }}>
                    {sections ? <div style={{ flex:1, minWidth:0 }}>{it.node}</div> : it.node}
                    {it.on && <span style={{ display:"inline-flex", flex:"none" }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12.5l4.5 4.5L19 7"></path></svg></span>}
                  </div>
                ); })}
              </div>
            ))}
            {!picks.length && <div className="fp-none">Ничего не найдено</div>}
          </div>
          {showCreate && (
            /* ⚠ Пока в поиске пусто, создавать НЕЧЕГО — имени нет. Раньше строка при
               этом выглядела обычной кнопкой и по клику молча ничего не делала («кнопка
               создать не работает», 11.08.2026). Теперь она честно говорит, чего ждёт,
               и приглушена; клик ставит курсор в поиск. Как только набрано — это
               полноценное действие. */
            <div className="fp-new" onMouseEnter={()=>setCur(picks.length - 1)} onClick={doCreate}
              style={ql ? null : { color:"var(--color-text-4)" }}>
              <span style={{ display:"inline-flex", flex:"none" }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M12 5v14M5 12h14"></path></svg></span>
              {createLabel}
              {!ql && <span style={{ marginLeft:"auto", fontSize:12, color:"var(--color-text-4)", whiteSpace:"nowrap" }}>наберите название</span>}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

/* Поле даты с календарём. У нативного input[type=date] две беды: он рисуется
   системой (в вёрстке — чужеродный блок) и пускает опечатку в годе — «202»
   вместо «2026» давало дату 0202 года, строка висла в дебиторке и выпадала из
   всех графиков. Календарь сделать такую дату не даёт, поэтому min/max больше
   не нужны. Отрезки внизу — то, чем даты и ставят: сегодня, завтра, конец месяца. */
const FD_WD = ["пн", "вт", "ср", "чт", "пт", "сб", "вс"];
const fdPad   = n => String(n).padStart(2, "0");
const fdISO   = (y, m, d) => y + "-" + fdPad(m + 1) + "-" + fdPad(d);
const fdToday = () => { const d = new Date(); return fdISO(d.getFullYear(), d.getMonth(), d.getDate()); };
const fdParse = s => { const p = String(s || "").split("-"); const y = +p[0], m = +p[1] - 1, d = +p[2];
  return (p.length === 3 && y > 1900 && m >= 0 && m < 12 && d > 0) ? { y, m, d } : null; };
// Арифметика через локальный Date, не через UTC: toISOString у полуночи сдвигает день.
const fdShift = (iso, n) => { const p = fdParse(iso) || fdParse(fdToday()); const d = new Date(p.y, p.m, p.d + n);
  return fdISO(d.getFullYear(), d.getMonth(), d.getDate()); };
const fdEom   = iso => { const p = fdParse(iso) || fdParse(fdToday()); const d = new Date(p.y, p.m + 1, 0);
  return fdISO(d.getFullYear(), d.getMonth(), d.getDate()); };
const fdShow  = iso => { const p = fdParse(iso); return p ? fdPad(p.d) + "." + fdPad(p.m + 1) + "." + p.y : ""; };
/* ⚠ Длинная запись даты — для полей формы, где места вдоволь (13.08.2026, просьба Игоря:
   «в дате много места — может, писать 09 августа 2026?»). В таблице разбивки колонка 126px,
   туда влезает только короткая, поэтому формат — ОПЦИЯ поля, а не глобальная замена. */
const FD_MON = ["января","февраля","марта","апреля","мая","июня","июля","августа","сентября","октября","ноября","декабря"];
const fdShowLong = iso => { const p = fdParse(iso); return p ? fdPad(p.d) + " " + FD_MON[p.m] + " " + p.y : ""; };

function DateField({ value, onChange, placeholder = "Выберите дату", style, fixedPop, long }) {
  const [open, setOpen] = useState(false);
  const [side, setSide] = useState("down");
  const [alignRight, setAlignRight] = useState(false);
  const [view, setView] = useState(() => { const p = fdParse(value) || fdParse(fdToday()); return { y:p.y, m:p.m }; });
  const [swap, setSwap] = useState({ dir:0, tick:0 });
  const [cur,  setCur]  = useState(() => value || fdToday());
  const wrapRef = useRef(null), trigRef = useRef(null), popRef = useRef(null);
  const { mounted } = usePopover(open);
  useOutsideClose(wrapRef, mounted, () => setOpen(false));
  useEffect(() => { if (open && popRef.current) popRef.current.focus(); }, [open]);
  // Календарь в прокручиваемой таблице разбивки — тот же приём, что у ArtSelect.
  const fixPos = useFixedPop(fixedPop, mounted, trigRef, side);

  const today = fdToday();
  const goMonth = n => { setSwap(s => ({ dir:n > 0 ? 1 : -1, tick:s.tick + 1 }));
    setView(v => { const d = new Date(v.y, v.m + n, 1); return { y:d.getFullYear(), m:d.getMonth() }; }); };
  const openPanel = () => {
    const p = fdParse(value) || fdParse(today);
    setView({ y:p.y, m:p.m }); setCur(value || today);
    setSide(popSide(trigRef.current, 330));   // высота панели; ширина календаря ~260 (порог переворота 340 — с запасом)
    const r = trigRef.current && trigRef.current.getBoundingClientRect();
    setAlignRight(!!r && (r.left + 340 > document.documentElement.clientWidth - 8));
    setSwap({ dir:0, tick:0 }); setOpen(true);
  };
  const pick = iso => { onChange(iso); setOpen(false); };
  // Курсор ходит стрелками и сам перелистывает месяц, если ушёл за его край.
  const move = n => {
    const nx = fdShift(cur, n), p = fdParse(nx);
    setCur(nx);
    if (p.y !== view.y || p.m !== view.m) { setSwap(s => ({ dir:n > 0 ? 1 : -1, tick:s.tick + 1 })); setView({ y:p.y, m:p.m }); }
  };
  const onKey = e => {
    if (e.key === "Escape")     { e.stopPropagation(); e.preventDefault(); setOpen(false); return; }
    if (e.key === "ArrowLeft")  { e.preventDefault(); move(-1); return; }
    if (e.key === "ArrowRight") { e.preventDefault(); move(1);  return; }
    if (e.key === "ArrowUp")    { e.preventDefault(); move(-7); return; }
    if (e.key === "ArrowDown")  { e.preventDefault(); move(7);  return; }
    if (e.key === "Enter")      { e.preventDefault(); pick(cur); }
  };

  const first = new Date(view.y, view.m, 1);
  const lead  = (first.getDay() + 6) % 7;            // понедельник первым
  const mName = first.toLocaleDateString("ru-RU", { month:"long" });
  const rel   = value === today ? "сегодня" : value === fdShift(today, 1) ? "завтра" : value === fdShift(today, -1) ? "вчера" : "";
  // Ровно шесть строк всегда: иначе панель прыгает по высоте от месяца к месяцу.
  const cells = Array.from({ length:42 }, (_, i) => {
    const d = new Date(view.y, view.m, 1 - lead + i);
    return { iso:fdISO(d.getFullYear(), d.getMonth(), d.getDate()), d:d.getDate(), out:d.getMonth() !== view.m, wknd:((d.getDay() + 6) % 7) > 4 };
  });

  return (
    <div className="fp-wrap" ref={wrapRef}>
      <button type="button" ref={trigRef} className="fp-trig" data-open={open ? "true" : undefined} style={style}
        data-empty={value ? undefined : "true"} onClick={()=> open ? setOpen(false) : openPanel()}>
        <span className="fp-val" data-num={value && !long ? "" : undefined}>{value ? (long ? fdShowLong(value) : fdShow(value)) : placeholder}</span>
        {rel && <span className="fp-sub">{rel}</span>}
        <span className="fp-car" style={{ transform:"none" }}>
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
            <rect x="3.5" y="5" width="17" height="15.5" rx="2.5"></rect><path d="M3.5 10h17M8 3.5V6.5M16 3.5V6.5" strokeLinecap="round"></path>
          </svg>
        </span>
      </button>
      {mounted && (
        /* ⚠ Календарь прижимается ВПРАВО, если справа мало места — то же правило, что
            у `ArtSelect`, и ровно по той же причине (12.08.2026). Панель ~330px, а поле
            «Когда» стоит в правом столбце drawer'а шириной 620: календарь вылезал за
            край окна. У прокручиваемого тела `overflow-y:auto` — значит по CSS
            `overflow-x` тоже становится `auto`, и вылезший календарь давал ГОРИЗОНТАЛЬНУЮ
            прокрутку: окно «уезжало вбок» (жалоба Игоря). Клипать по X нельзя — срежет
            сам календарь; поэтому он переворачивается, а не обрезается. */
        <div ref={popRef} tabIndex={-1} className="fp-pop" data-closing={open ? undefined : "true"} data-side={side}
          onKeyDown={onKey} style={{ ...POP_POS(side), outline:"none",
            ...(alignRight ? { left:"auto", right:0, transformOrigin: side === "up" ? "bottom right" : "top right" } : null),
            ...(fixPos || null) }}>
          <div className="fcal">
            <div className="fcal-h">
              <button type="button" className="fcal-nav" title="Прошлый месяц" onClick={()=>goMonth(-1)}>
                <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.5 5.5 8 12l6.5 6.5"></path></svg>
              </button>
              <span className="fcal-m">{mName.charAt(0).toUpperCase() + mName.slice(1)} {view.y}</span>
              <button type="button" className="fcal-nav" title="Следующий месяц" onClick={()=>goMonth(1)}>
                <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M9.5 5.5 16 12l-6.5 6.5"></path></svg>
              </button>
            </div>
            <div className="fcal-wd">{FD_WD.map(w => <span key={w}>{w}</span>)}</div>
            <div className="fcal-g" key={swap.tick} data-dir={swap.tick ? swap.dir : 0}>
              {cells.map(c => (
                <button key={c.iso} type="button" className="fcal-d" data-num
                  data-out={c.out ? "true" : undefined} data-wknd={!c.out && c.wknd ? "true" : undefined}
                  data-today={c.iso === today ? "true" : undefined} data-on={c.iso === value ? "true" : undefined}
                  data-cur={c.iso === cur && c.iso !== value ? "true" : undefined} onMouseEnter={()=>setCur(c.iso)}
                  onClick={()=>pick(c.iso)}>{c.d}</button>
              ))}
            </div>
            <div className="fcal-f">
              <button type="button" onClick={()=>pick(today)}>Сегодня</button>
              <button type="button" onClick={()=>pick(fdShift(today, 1))}>Завтра</button>
              <button type="button" onClick={()=>pick(fdEom(value || today))}>Конец месяца</button>
            </div>
            {/* Дату можно и снять: у задачи или документа срок бывает не назначен. */}
            {value && <div className="fcal-f" style={{ borderTop:0, paddingTop:0, marginTop: "var(--space-1)" }}>
              <button type="button" style={{ background:"transparent", color:"var(--color-text-tertiary)" }} onClick={()=>pick("")}>Очистить</button>
            </div>}
          </div>
        </div>
      )}
    </div>
  );
}

// ─── РЕЖИМ ПРАВКИ ПАНЕЛИ: аккордеон (вариант Б, выбран пользователем 2026-07-19) ──────
// Почему аккордеон, а не одна длинная форма: на большом проекте (много строк, подрядчиков,
// позиций сметы) сплошная лента становится непроходимой, а свёрнутые секции показывают
// структуру целиком и сразу выдают дыры — «Подрядчики · не добавлены» красным.
// Открыта одна секция за раз: две открытые снова дают бесконечную прокрутку.
function EditAcc({ id, title, summary, warn, open, onOpen, children }) {
  // Обрезка нужна только пока едет анимация: с постоянным overflow:hidden выпадашки
  // добавления обрезались бы границей секции (проверено — «Прочий расход» не влезал).
  const [settled, setSettled] = useState(open);
  useEffect(() => {
    if (!open) { setSettled(false); return; }
    const t = setTimeout(() => setSettled(true), 300);   // чуть больше 260мс анимации
    return () => clearTimeout(t);
  }, [open]);
  return (
    <div className={"pe-acc" + (open ? " on" : "")}>
      <div className="pe-head" onClick={()=>onOpen(open ? null : id)}>
        <div className="pe-name">{title}</div>
        <div style={{ flex:1 }} />
        {summary ? <div className={"pe-sum" + (warn ? " warn" : "")}>{summary}</div> : null}
        <span className="pe-car">▾</span>
      </div>
      {/* Тело монтируется всегда — иначе анимация раскрытия не с чего стартовать */}
      <div className="pe-slot"><div style={{ overflow: (open && settled) ? "visible" : "hidden" }}>
        <div className="pe-body">{children}</div>
      </div></div>
    </div>
  );
}

// Человеческие названия старых налоговых режимов (те же, что в TAX_MAP внутри
// migrateToBlocks) — чтобы в интерфейсе не светился код «usn6».
const TAX_MODE_LABEL = { usn6:"УСН 6 %", usn15:"УСН 15 %", ausn8:"АУСН 8 %",
                         ausn20:"АУСН 20 %", osn:"ОСН 20 %" };

// ⚠ ЛОВУШКА, стоившая расследования: налог у проекта прописан в ТРЁХ местах —
// tax-блоки, легаси (taxMode/vatEnabled/sv1/customTaxes) и расчёт в projFinance.
// Побеждают блоки, легаси читается ТОЛЬКО когда блоков нет. Поэтому удаление
// ПОСЛЕДНЕГО налога не убирает налог, а молча включает легаси — и налог может
// стать БОЛЬШЕ (у «Motion-ролик — Альфа»: было 31 915 по блоку, стало 35 000 по
// УСН 6 % + СВ 1 %, плановая прибыль упала на 3 085 ₽ «от удаления налога»).
// Хелпер разворачивает легаси в такие же блоки — теми же правилами, что и
// migrateToBlocks, чтобы цифры совпали с тем, что легаси уже считает.
function legacyTaxToBlocks(p) {
  const MAP = { usn6:{ name:"УСН 6%", base:"revenue", rate:"6" },
                usn15:{ name:"УСН 15%", base:"profit", rate:"15" },
                ausn8:{ name:"АУСН 8%", base:"revenue", rate:"8" },
                ausn20:{ name:"АУСН 20%", base:"profit", rate:"20" },
                osn:{ name:"ОСН 20%", base:"profit", rate:"20" } };
  const out = [];
  const m = MAP[p.taxMode];
  if (m) out.push({ type:"tax", name:m.name, content:{ base:m.base, rate:m.rate, rateType:"pct" } });
  if (p.vatEnabled) out.push({ type:"tax", name:"НДС " + (p.vatRate || 20) + "%",
                               content:{ base:"revenue", rate:String(p.vatRate || 20), rateType:"pct" } });
  if (p.sv1) out.push({ type:"tax", name:"СВ 1%", content:{ base:"revenue", rate:"1", rateType:"pct" } });
  (p.customTaxes || []).filter(ct => ct.label || +ct.value > 0).forEach(ct =>
    out.push({ type:"tax", name: ct.label || "Налог",
               content:{ base:"revenue", rate:String(ct.value || 0), rateType: ct.type === "fixed" ? "fixed" : "pct" } }));
  return out;
}

// Русское склонение по числу: plur(1,"строка","строки","строк") → «строка».
function plur(n, one, few, many) {
  const n10 = Math.abs(n) % 10, n100 = Math.abs(n) % 100;
  if (n100 >= 11 && n100 <= 14) return many;
  if (n10 === 1) return one;
  if (n10 >= 2 && n10 <= 4) return few;
  return many;
}

/* ── ЧИСЛО СЛОВОМ В ЗАГОЛОВКЕ ─────────────────────────────────────────────────
   «В работе четыре проекта», а не «В работе 4 проекта» — приём макета.
   Заголовок набран серифом и читается ФРАЗОЙ; цифра посреди неё работает
   иначе всего остального текста: глаз цепляется за неё как за величину,
   которую надо сравнить, хотя сравнивать не с чем — это просто счёт.
   Величины (деньги, проценты, сроки) словом НЕ пишем никогда: там цифра и
   нужна, потому что её как раз сравнивают. Отсюда потолок: до десяти. Дальше
   слово становится длиннее числа и хуже читается («семнадцать проектов»),
   поэтому возвращаем цифру.
   ⚠ Формы именительного падежа. Согласование существительного остаётся за
   `plur`: «один проект», «два проекта», «пять проектов». */
const NUM_WORDS_RU = ["ноль","один","два","три","четыре","пять","шесть","семь","восемь","девять","десять"];
function numWord(n) {
  const i = Math.round(Math.abs(+n || 0));
  return i <= 10 ? NUM_WORDS_RU[i] : String(i);
}

// Под-шапка группы внутри секции: название + счётчик + своя кнопка добавления справа.
function EditBar({ title, count, children }) {
  return (
    <div className="pe-bar">
      <div className="pe-bar-t">{title}</div>
      {count ? <div className="pe-bar-c">{count}</div> : null}
      <div style={{ flex:1 }} />
      {children}
    </div>
  );
}

// Кнопка добавления: одна на группу. Если вариант один — `plain` (просто действие),
// если несколько — выпадашка, а не выводок кнопок в ряд (пользователь забраковал ряд
// «+ Платёж / + Транш / + Расход» в сводке).
function AddMenu({ label = "Добавить", groups, onPick, plain }) {
  const [open, setOpen] = useState(false);
  const box = useRef(null);
  useEffect(() => {
    if (!open) return;
    // mousedown, а не click: click от самой кнопки долетел бы сюда и сразу закрыл меню
    const away = e => { if (box.current && !box.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", away);
    return () => document.removeEventListener("mousedown", away);
  }, [open]);
  if (plain) return <button className="pe-add" onClick={plain}>＋ {label}</button>;
  return (
    <div className="pe-menuwrap" ref={box}>
      <button className="pe-add" onClick={()=>setOpen(v=>!v)}>＋ {label} <span className="car">▾</span></button>
      {open && (
        <div className="pe-menu">
          {(groups || []).map((g, gi) => (
            <React.Fragment key={gi}>
              {gi > 0 ? <div className="pe-msep" /> : null}
              {g.head ? <div className="mh">{g.head}</div> : null}
              {(g.items || []).map(it => (
                <button key={it.k} className="pe-mi" onClick={()=>{ setOpen(false); onPick(it.k); }}>
                  {it.t}{it.sub ? <small>{it.sub}</small> : null}
                </button>
              ))}
            </React.Fragment>
          ))}
        </div>
      )}
    </div>
  );
}

// ── Аккуратный кастом-календарь (замена нативного <input type="date">) ──
const WD_MON = ["Пн","Вт","Ср","Чт","Пт","Сб","Вс"];
// ─── ИНЛАЙН-ПРАВКА ЗНАЧЕНИЯ В КАРТОЧКЕ ПАНЕЛИ ────────────────────────────────
// Клик по значению превращает его в поле; Enter и уход фокуса сохраняют, Esc
// откатывает. Общего черновика и кнопки «Сохранить» намеренно НЕТ: панель — не
// модалка, из неё уходят кликом мимо, и незакоммиченный черновик просто пропал бы.
// По той же причине единое окно создания строки пишет в projects напрямую.
// Каждое поле коммитится своим патчем — updateProject обязан переживать частичный
// патч (см. гард по patch.contractors).
// inputClass — вид поля берётся из класса (.ef-in) вместо инлайн-стиля «подхватить
// шрифт у окружения». Нужно в аккордеоне правки, где поля стоят в сетке формы,
// а не подменяют собой текст карточки.
/* digitsOnly — денежное поле: в него не должно попадать ничего, кроме цифр.
   Чистим в onChange, а не при коммите: он ловит и печать, и вставку, и перетаскивание
   текста мышью, поэтому буква просто не появляется в поле, вместо того чтобы молча
   исчезнуть при потере фокуса (это выглядело бы как «приложение съело мой ввод»). */
function InlineEdit({ value, onCommit, type="text", render, inputWidth, align="left", title="Нажмите, чтобы изменить", forceOpen=false, inputClass, digitsOnly=false, placeholder }) {
  const [editing, setEditing] = useState(false);
  const [val, setVal]         = useState(value);
  const [hover, setHover]     = useState(false);
  const ref = useRef(null);
  const open = editing || forceOpen;   // forceOpen — режим правки панели: поля видны сразу

  // Подтягиваем внешнее значение, пока пользователь не печатает именно в этом поле.
  useEffect(() => { if (!open || ref.current !== document.activeElement) setVal(value); }, [value, open]);
  // Фокус только на клик: в режиме правки поля открыты все сразу и дрались бы за фокус.
  useEffect(() => { if (editing && ref.current) { ref.current.focus(); if (ref.current.select) ref.current.select(); } }, [editing]);

  const commit = () => { setEditing(false); if (String(val == null ? "" : val) !== String(value == null ? "" : value)) onCommit(val); };

  if (!open) return (
    <span onClick={()=>setEditing(true)} title={title}
      onMouseEnter={()=>setHover(true)} onMouseLeave={()=>setHover(false)}
      style={{ cursor:"text", borderRadius: "var(--radius)", padding: "1px var(--space-1)", margin:"-1px -5px",
               background: hover ? "var(--color-background-hover)" : "transparent", transition:"background var(--t-fast)" }}>
      {render ? render(value) : (value === "" || value == null ? "—" : value)}
    </span>
  );
  /* ⚠ ДАТА — ТОЛЬКО НАШ КАЛЕНДАРЬ (2026-08-10). Здесь стоял нативный <input type="date">,
     и во всех формах, где InlineEdit держит дату (проект «Начало»/«Сдача», периоды
     подрядчика, дата транша), открывался системный календарь ОС: чужая типографика,
     чужие цвета, «дд.мм.гггг» в поле. Правило «нативные select и input[type=date] не
     ставить» существовало, но эта точка его обходила — а через неё проходит пять мест.
     Лечим здесь, а не в каждой форме: DateField принимает тот же value/onChange. */
  if (type === "date") return (
    <DateField value={val == null ? "" : val} placeholder={placeholder || "Выберите дату"}
      onChange={v => { setVal(v); setEditing(false); if (String(v == null ? "" : v) !== String(value == null ? "" : value)) onCommit(v); }} />
  );
  return (
    <input ref={ref} type={type} value={val == null ? "" : val} className={inputClass || undefined}
      inputMode={digitsOnly ? "numeric" : undefined} placeholder={placeholder}
      /* Разряды при вводе — общим движком `onDigitsInput` (тот же, что у MoneyInput). */
      onChange={e=> digitsOnly ? onDigitsInput(e, ref, setVal) : setVal(e.target.value)} onBlur={commit}
      onKeyDown={e=>{ if (e.key === "Enter") { e.preventDefault(); commit(); }
                      if (e.key === "Escape") { e.stopPropagation(); setVal(value); setEditing(false); } }}
      style={inputClass
        ? { textAlign:align }
        : { font:"inherit", color:"inherit", textAlign:align, width: inputWidth || "100%",
            border:"1px solid var(--color-line-1)", borderRadius: "var(--radius)", padding: "2px var(--space-1)", margin:"-3px -7px",
            background:"var(--color-background-primary)", outline:"none", fontFamily:"inherit" }} />
  );
}


// Числовой инпут с форматированием при потере фокуса
function NumInput({ value, onChange, placeholder, style, className }) {
  const [focused, setFocused] = useState(false);

  const formatDisplay = raw => {
    if (raw === "" || raw === null || raw === undefined) return "";
    const n = +String(raw).replace(/\s/g, "").replace(/,/g, ".");
    if (isNaN(n)) return String(raw);
    return n.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  };

  const handleChange = e => {
    const raw = e.target.value.replace(/[\s\u00a0\u202f]/g, "").replace(/,/g, ".");
    if (/^-?\d*\.?\d*$/.test(raw) || raw === "") onChange(raw);
  };

  return (
    <input
      inputMode="decimal"
      className={className}
      value={focused ? (value ?? "") : formatDisplay(value)}
      onFocus={() => setFocused(true)}
      onBlur={() => setFocused(false)}
      onChange={handleChange}
      placeholder={placeholder}
      style={{ width:"100%", height:32, padding: "0 var(--space-2)", fontSize:13, border:"none", borderRadius:"var(--radius)", background:"var(--color-background-secondary)", color:"var(--color-text-primary)", fontFamily:"inherit", outline:"none", textAlign:"left", boxSizing:"border-box", ...style }} />
  );
}
function FormLabel({ children }) {
  return <div style={{ fontSize:13, color:"var(--color-text-secondary)", marginBottom: "var(--space-1)", fontWeight:500 }}>{children}</div>;
}

function FormGroup({ label, children }) {
  return <div style={{ marginBottom: "var(--space-3)" }}><FormLabel>{label}</FormLabel>{children}</div>;
}

function EmptyState({ glyph, children }) {
  return (
    <div className="empty">
      <div className="glyph">{glyph}</div>
      <div>{children}</div>
    </div>
  );
}

// ─── NAV ICONS ───────────────────────────────────────────────────────────────
const IC = {
  operations:   <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="7 4 7 20"/><polyline points="3.5 16.5 7 20 10.5 16.5"/><polyline points="17 20 17 4"/><polyline points="13.5 7.5 17 4 20.5 7.5"/></svg>,
  projects:     <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
  analytics:    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></svg>,
  flow:         <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M2 15c3.2 0 3.2-6 6.4-6s3.2 6 6.4 6 3.2-6 6.4-6"/><line x1="2" y1="20" x2="22" y2="20"/></svg>,
  reports:      <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="9" x2="9" y2="21"/></svg>,
  loans:        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><circle cx="12" cy="12" r="2.5"/><line x1="6" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="18" y2="12"/></svg>,
  settings:     <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1.08-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c.14.35.4.64.74.83.3.17.64.26 1 .26H21a2 2 0 1 1 0 4h-.09c-.36 0-.7.09-1 .26-.34.19-.6.48-.74.83z"/></svg>,
  income:       <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>,
  expenses:     <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>,
  clients:      <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
  contractors:  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/><line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/></svg>,
  tasks:        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>,
  documents:    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>,
  account:      <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>,
  bank:         <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="m3 9 9-6 9 6"/><path d="M4 9v10M20 9v10M8 12v4M12 12v4M16 12v4"/><path d="M2 21h20"/></svg>,
  // «Контрагенты» — встречные стрелки, а не человечки: экран про ДВИЖЕНИЕ денег, а не
  // про людей. С иконкой клиентов (стояла до 2026-07-25) пункт читался как второй
  // справочник клиентов — отсюда и вопрос «клиенты это тоже контрагенты?».
  parties:      <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="17 3 21 7 17 11"/><line x1="3" y1="7" x2="21" y2="7"/><polyline points="7 13 3 17 7 21"/><line x1="21" y1="17" x2="3" y2="17"/></svg>,
};

// ─── NAV ─────────────────────────────────────────────────────────────────────
/* `sep` — первый пункт новой группы. Группы в сайдбаре больше не подписаны
   («Финансы» / «База» убраны 2026-08-04): подписи занимали 78 px и заставляли
   колонку скроллиться на невысоких окнах, а группировку и так видно по отбивке.
   Разрыв = 3 px общего gap + 24 px margin; внутри группы остаются те же 3 px.
   Отбивка теперь единственный признак группы — на 12 px группы слипались. */
function NavItem({ label, icon, active, onClick, tourId, badge, sep, chevron, open, btnRef }) {
  return (
    <button ref={btnRef} onClick={onClick} data-tour={tourId} className={`nav-item ${active?"is-active":""}`}
      aria-haspopup={chevron ? "menu" : undefined} aria-expanded={chevron ? !!open : undefined}
      style={{ display:"flex", alignItems:"center", gap:10, padding: "9px 10px", marginTop: sep ? "var(--space-5)" : 0, cursor:"default", fontSize:14.5, fontWeight: active?600:500, width:"100%", textAlign:"left",
        color: active ? "var(--color-accent)" : "var(--color-text-secondary)",
        border:"none", fontFamily:"inherit", borderRadius:8 }}>
      {/* ⚠ ПОЛОСКА ВМЕСТО ИКОНКИ (2026-08-16, перенос макета). Десять разных
          пиктограмм в столбце — десять фигур, которые глаз разбирает вместо того,
          чтобы читать слова. Ни одна из них не сообщает того, чего не сообщает
          подпись рядом. Полоска 14×3 — тот же маркер, что в легендах диаграмм:
          она не изображает раздел, а отмечает строку и подсвечивается у активного. */}
      <span style={{ width:14, height:3, borderRadius:2, background:"currentColor",
        opacity: active ? 1 : .55, flexShrink:0 }} />
      <span className="sidebar-label">{label}</span>
      {badge>0 && (
        <span className="sidebar-label" data-num style={{ marginLeft:"auto", minWidth:18, height:18, padding: "0 var(--space-1)", fontSize:11.5, fontWeight:600, display:"inline-flex", alignItems:"center", justifyContent:"center", borderRadius: "var(--radius)",
          background: active ? "var(--color-accent)" : "var(--color-negative-soft)", color: active ? "var(--color-accent-soft)" : "var(--color-negative)" }}>{badge}</span>
      )}
      {chevron && (
        <span className="nav-chev sidebar-label" data-open={open ? "true" : "false"} style={{ marginLeft: badge>0 ? "var(--space-1)" : "auto" }}>
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 6 15 12 9 18" /></svg>
        </span>
      )}
    </button>
  );
}

/* ВЫЛЕТ ИЗ САЙДБАРА — меню второго уровня (см. .fm-flyout).
   Координаты берутся у кнопки-якоря, а не у сайдбара: у сайдбара своя прокрутка,
   и пункт может стоять не там, где стоял на маунте. Пересчитываем на открытии и
   на resize; закрытие — клик мимо (Esc живёт в общем каскаде DvaScheta).
   ⚠ Клик по самому якорю пропускаем: mousedown прилетает РАНЬШЕ click, и меню
   успело бы закрыться, а тумблер тут же открыл бы его снова — кнопка перестала
   бы закрывать собственное меню. */
function Flyout({ anchorRef, items, value, onPick, onClose }) {
  const popRef = React.useRef(null);
  const [pos, setPos] = React.useState(null);

  React.useLayoutEffect(() => {
    const place = () => {
      const el = anchorRef.current; if (!el) return;
      const r    = el.getBoundingClientRect();
      const side = el.closest(".app-sidebar");
      const left = (side ? side.getBoundingClientRect().right : r.right) + 8;
      const h    = items.length * 36 + 16;   // высота пункта = высоте NavItem
      setPos({ left, top: Math.max(12, Math.min(r.top - 8, window.innerHeight - h - 12)) });
    };
    place();
    window.addEventListener("resize", place);
    return () => window.removeEventListener("resize", place);
  }, [anchorRef, items.length]);

  React.useEffect(() => {
    const onDown = e => {
      if (popRef.current && popRef.current.contains(e.target)) return;
      if (anchorRef.current && anchorRef.current.contains(e.target)) return;
      onClose();
    };
    document.addEventListener("mousedown", onDown);
    return () => document.removeEventListener("mousedown", onDown);
  }, [anchorRef, onClose]);

  if (!pos) return null;
  /* ⚠ ПОРТАЛ В body ОБЯЗАТЕЛЕН, НЕ УКРАШЕНИЕ. У .app-sidebar position:sticky, а
     sticky создаёт СВОЙ контекст наложения при любом z-index. Слой, отрисованный
     внутри сайдбара, живёт в этом контексте целиком — и любой sticky из контента
     ложится поверх него: .pnl-stick (z-index:1, левая колонка «Прибылей и убытков»)
     перехватывал клики по нижним пунктам меню. Видно это не было — под курсором
     оказывалась прозрачная для глаза, но не для мыши, ячейка таблицы. */
  return ReactDOM.createPortal(
    <div ref={popRef} className="fm-flyout" role="menu" style={{ left: pos.left, top: pos.top }}>
      {items.map(([id, label]) => (
        /* Стиль — построчно тот же, что у NavItem: кегль 16.5, паддинг 8/12, курсор
           default, цвет второй ступени. Расходиться им нельзя (см. комментарий у CSS). */
        <button key={id} role="menuitem" className="nav-item" aria-current={value === id ? "true" : undefined}
          onClick={() => onPick(id)}
          style={{ display:"flex", alignItems:"center", padding:"var(--space-2) var(--space-3)", cursor:"default",
                   fontSize:16.5, fontWeight:500, width:"100%", textAlign:"left", whiteSpace:"nowrap",
                   color:"var(--color-text-secondary)", border:"none", fontFamily:"inherit", borderRadius:"var(--radius)" }}>
          {label}
        </button>
      ))}
    </div>, document.body);
}

// ─── PROJECT CARD ─────────────────────────────────────────────────────────────
function ProjectCard({ p, selected, onClick, onRename, onDelete, onDuplicate, allTags=[] }) {
  const budget   = p.budget || 0;
  const currency = projCur(p);
  const rate     = +p.rate || 0;
  const budgetRub = budgetOf(p);
  const toRubC = (amount, cur) => rowRub(amount, cur, p);
  const recv   = recvOf(p);
  const pct    = budgetRub>0 ? Math.round((recv/budgetRub)*100) : 0;
  const [menuOpen, setMenuOpen] = useState(false);
  const [renaming, setRenaming] = useState(false);
  const [renameVal, setRenameVal] = useState(p.name);
  const menuRef = React.useRef(null);

  React.useEffect(() => {
    if (!menuOpen) return;
    const handler = e => { if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false); };
    document.addEventListener("mousedown", handler);
    return () => document.removeEventListener("mousedown", handler);
  }, [menuOpen]);

  const submitRename = () => {
    const v = renameVal.trim();
    if (v && v !== p.name && onRename) onRename(p.id, v);
    setRenaming(false);
  };

  // ── Карточка «бюджет-герой» (без аватаров) ──
  const isRej  = p.status === "rejected";
  const isDone = p.status === "done";
  const clientName = (p.client||"").trim();
  /* Клиент и название набраны ОДНОЙ ступенью (13 / --color-text-secondary): в макете это
     одна пара «кто и что», а главным в карточке остаётся бюджет. Пустой клиент — тише. */
  const cCol   = clientName ? "var(--color-text-secondary)" : "var(--color-text-tertiary)";
  const budgetStr = currency!==CUR_BASE && rate<=0
    ? Math.round(budget).toLocaleString("ru-RU") + " $"
    : Math.round(budgetRub).toLocaleString("ru-RU") + " ₽";
  // Чип категории: сначала «что продали» (это ответ на вопрос «что это за проект»),
  // и только если не заполнено — первый тег техники, как было раньше
  const cat = (p.product || "").trim()
    || ((p.tags && p.tags[0]) ? ((allTags.find(t=>t.id===p.tags[0])||{}).label || null) : null);
  // Процент ушёл из пилюли — прогресс теперь рассказывает слэш-пара «получено / бюджет»
  const statusPart = isDone ? "сдан" : isRej ? "отменён" : (p.status==="request" ? "новый" : statusLabel(p.status).toLowerCase());
  const metaText = cat ? `${cat} · ${statusPart}` : statusPart;

  return (
    <div onClick={e=>{ if (!menuOpen && !renaming) onClick(e); }}
      className={`fm-pcard ${selected?"is-selected":""}${isRej?" is-off":""}`}
      style={{ position:"relative" }}>
      {/* Клиент + меню */}
      <div style={{ display:"flex", alignItems:"center", justifyContent:"space-between", gap: "var(--space-2)" }}>
        <span style={{ fontSize:13, lineHeight:1.5, color:cCol, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{clientName || "Без клиента"}</span>
        <div ref={menuRef} style={{ position:"relative", flexShrink:0 }}>
          <button onClick={e=>{ e.stopPropagation(); setMenuOpen(v=>!v); }}
            style={{ border:"none", background:"none", cursor:"pointer", padding:"0 2px", fontSize:17, lineHeight:1, color:"var(--color-text-tertiary)" }}>⋯</button>
          {menuOpen && (
            <div style={{ position:"absolute", right:0, top:"calc(100% + 4px)", zIndex:600,
              background:"var(--color-background-primary)", border:"1px solid var(--color-border-secondary)",
              borderRadius:"var(--radius)", boxShadow:"0 4px 16px rgba(0,0,0,.12)", minWidth:140, padding: "var(--space-1)" }}>
              <button onClick={e=>{ e.stopPropagation(); setMenuOpen(false); setRenaming(true); setRenameVal(p.name); }}
                style={{ width:"100%", textAlign:"left", padding: "var(--space-2) var(--space-3)", border:"none", background:"none", cursor:"pointer", fontSize:14, borderRadius: "var(--radius)", color:"var(--color-text-primary)", fontFamily:"inherit" }}
                onMouseEnter={e=>e.currentTarget.style.background="var(--color-background-secondary)"}
                onMouseLeave={e=>e.currentTarget.style.background="none"}>Переименовать</button>
              {onDuplicate && (
                <button onClick={e=>{ e.stopPropagation(); setMenuOpen(false); onDuplicate(p.id); }}
                  style={{ width:"100%", textAlign:"left", padding: "var(--space-2) var(--space-3)", border:"none", background:"none", cursor:"pointer", fontSize:14, borderRadius: "var(--radius)", color:"var(--color-text-primary)", fontFamily:"inherit" }}
                  onMouseEnter={e=>e.currentTarget.style.background="var(--color-background-secondary)"}
                  onMouseLeave={e=>e.currentTarget.style.background="none"}>Дублировать</button>
              )}
              {onDelete && (
                <button onClick={e=>{ e.stopPropagation(); setMenuOpen(false); if(window.confirm(`Удалить проект «${p.name}»?`)) onDelete(p.id); }}
                  style={{ width:"100%", textAlign:"left", padding: "var(--space-2) var(--space-3)", border:"none", background:"none", cursor:"pointer", fontSize:14, borderRadius: "var(--radius)", color:"var(--color-negative)", fontFamily:"inherit" }}
                  onMouseEnter={e=>e.currentTarget.style.background="var(--color-background-secondary)"}
                  onMouseLeave={e=>e.currentTarget.style.background="none"}>Удалить</button>
              )}
            </div>
          )}
        </div>
      </div>
      {/* Бюджет — герой. ⚠ ДВА АТРИБУТА ОБЯЗАТЕЛЬНЫ: `data-disp` даёт сериф, но правило
          цифр (`#root [data-num]`) объявлено ниже и с тем же весом вернуло бы Golos —
          пару `[data-disp][data-num]` сеть шрифтов знает и отдаёт Prata с табличными
          цифрами. Инлайновый Space Grotesk снят: шрифта в приложении больше нет. */}
      <div data-disp data-num style={{ fontSize:26, lineHeight:1.15, color:"var(--color-text-primary)", whiteSpace:"nowrap", margin: "var(--space-3) 0 var(--space-1)" }}>{budgetStr}</div>
      {/* Название проекта — тихой строкой */}
      {renaming ? (
        <input autoFocus value={renameVal}
          onChange={e=>setRenameVal(e.target.value)}
          onKeyDown={e=>{ if(e.key==="Enter") submitRename(); if(e.key==="Escape"){ setRenaming(false); setRenameVal(p.name); } }}
          onBlur={submitRename} onClick={e=>e.stopPropagation()}
          style={{ width:"100%", fontSize:13, fontWeight:500, padding: "3px var(--space-2)", border:"1px solid var(--color-accent)", borderRadius: "var(--radius)", outline:"none", background:"var(--color-background-primary)", color:"var(--color-text-primary)", fontFamily:"inherit", boxSizing:"border-box" }} />
      ) : (
        <div style={{ fontSize:13, color: p.name?"var(--color-text-secondary)":"var(--color-text-5)", lineHeight:1.5, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{p.name || "Без названия"}</div>
      )}
      {/* Слэш-пара «получено / бюджет» + двухсегментная полоса — фирменный приём.
          Чернила = деньги пришли, песок = ещё ждём; процент не пишем нигде, его
          рассказывает сама пара. Ничего не пришло — вся пара тихая, чернил в ней нет. */}
      {budgetRub>0 && !isRej && (
        <div style={{ marginTop: "var(--space-3)" }}>
          <div style={{ display:"flex", height:6, borderRadius: "var(--radius)", overflow:"hidden", background:"var(--color-background-tertiary)" }}>
            <div style={{ flex:Math.max(recv,0.0001), background:"var(--color-text-primary)" }} />
            {/* ⚠ ЛИТЕРАЛ — ПЯТОЕ МЕСТО ТОЙ ЖЕ ИЗВЕСТНОЙ ДЫРЫ, что у .fm-cta-g.on-sand,
                CARD_LOUD и линеек «С кого ждём» (см. комментарий там). #E4D3AE — «песок,
                который видно»: у --color-accent-soft на молочной карточке в полосе 6px
                не остаётся контраста, и «сколько ещё ждём» читаться перестаёт.
                Токенизировать все пять мест разом — после синхронизации tokens.json. */}
            <div style={{ flex:Math.max(budgetRub-recv,0.0001), background:"#E4D3AE" }} />
          </div>
          <div className="num" style={{ marginTop: "var(--space-2)", fontSize:12.5, lineHeight:1.5, whiteSpace:"nowrap" }}>
            <span style={{ color: recv>0 ? "var(--color-text-primary)" : "var(--color-text-tertiary)" }}>{fmtN(recv)}</span>
            <span style={{ color:"var(--color-text-tertiary)" }}> / {fmtN(budgetRub)} ₽</span>
          </div>
          {/* Отметка «маржа ниже цели» УДАЛЕНА 2026-08-05: глобальной цели больше нет.
              Цель у маржи может быть только проектная, и она НЕ настройка — это плановая
              маржа, которая уже получается сама из плановой выручки и плановых затрат.
              Сравнивать проект с одним числом на всю студию было неверно: ретейнер на 20%
              горел бы всегда, разовый на 60% молчал бы при провале до 35%. */}
        </div>
      )}
      {/* Мета-тег: категория · статус */}
      <div style={{ display:"inline-block", marginTop: "var(--space-3)", fontSize:11.5, lineHeight:1.5, padding: "3px var(--space-2)", borderRadius: "var(--radius)", border:`1px solid ${isDone?"var(--color-positive-line)":"var(--color-line-1)"}`, color: isDone?"var(--color-positive)":"var(--color-text-tertiary)" }}>{metaText}</div>
    </div>
  );
}

// ─── CONTRACTOR COMBOBOX ─────────────────────────────────────────────────────
// ─── КОНТРАГЕНТЫ — производный справочник ────────────────────────────────────
// Отдельного хранилища НЕТ намеренно (решение 2026-07-25). Контрагенты уже лежат
// в данных: выписка банка несёт имя и ИНН, клиенты и подрядчики — свои справочники,
// а имена, введённые руками, остаются в самих операциях. Заводить четвёртое место
// хранения значило бы плодить сущности, миграцию и ещё одну дыру в синхронизации.
// Поэтому список считается из того, что уже есть, — как остаток из операций.
//
// Возвращает [{ name, inn, kind, count, last }] — kind: client | contractor | other.
function partiesFrom(operations, clients, people) {
  const map = new Map();
  const norm = n => String(n || "").trim().replace(/\s+/g, " ");
  const key  = n => norm(n).toLowerCase();
  const put = (name, inn, kind, date) => {
    const nm = norm(name); if (!nm) return;
    const k = key(nm);
    const cur = map.get(k);
    if (!cur) { map.set(k, { name:nm, inn:String(inn || "").trim(), kind, count:0, last:date || "" }); return; }
    // Роль из справочника важнее «прочего»: подрядчик остаётся подрядчиком,
    // даже если он же встретился строкой в выписке.
    if (cur.kind === "other" && kind !== "other") cur.kind = kind;
    if (!cur.inn && inn) cur.inn = String(inn).trim();
    if (date && date > cur.last) cur.last = date;
  };
  (clients || []).forEach(c => put(c.name, (c.req || {}).inn, "client", ""));
  (people  || []).forEach(p => put(p.name, (p.req  || {}).inn, "contractor", ""));
  (operations || []).forEach(o => {
    const cp = (o && o.counterparty) || {};
    put(cp.name, cp.inn, "other", String(o.date || "").slice(0, 10));
    const k = key(cp.name); const rec = map.get(k); if (rec) rec.count++;
  });
  // Сначала те, с кем чаще имеем дело, затем по алфавиту.
  return [...map.values()].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, "ru"));
}
const PARTY_KIND_LABEL = { client:"клиент", contractor:"подрядчик", other:"" };

// Обороты по контрагентам за период. Считаем ФАКТ (то, что реально прошло).
// ⚠ Двойного счёта нет: банковская операция, СОПОСТАВЛЕННАЯ со строкой проекта,
// пропускается — эти деньги уже посчитаны строкой. Берём только несопоставленные
// операции (аренда, подписки, налоги — они и живут вне проектов) плюс сами строки.
function partyStats(operations, projects, clients, people, range) {
  const list = partiesFrom(operations, clients, people);
  const key  = n => String(n || "").trim().replace(/\s+/g, " ").toLowerCase();
  const idx  = new Map(list.map(p => [key(p.name), { ...p, in:0, out:0, ops:0 }]));
  const add = (name, dir, amount, date) => {
    const k = key(name); if (!k) return;
    if (range && !inPeriod(date, range)) return;
    let rec = idx.get(k);
    if (!rec) { rec = { name:String(name).trim(), inn:"", kind:"other", count:0, last:"", in:0, out:0, ops:0 }; idx.set(k, rec); }
    rec[dir] += Math.abs(+amount || 0);
    rec.ops++;
    const d = String(date || "").slice(0,10);
    if (d && d > rec.last) rec.last = d;
  };
  /* Разложенная по строкам часть операции считается СТРОКОЙ, неразложенная — операцией.
     Прежде операция с ЛЮБОЙ аллокацией пропускалась целиком, а строка входила только
     по флагу received — при частичной сверке оборот контрагента показывал ноль. */
  const gotBy = allocSumsByRow(operations);
  (operations || []).forEach(o => {
    if (!o || o.planned) return;
    if ((o.category || "") === CAT_TRANSFER) return;
    const rest = opAllocations(o).length ? opRemainder(o) : Math.abs(+o.amount || 0);
    const dir  = o.direction === "out" ? "out" : "in";
    if (rest >= 0.01) add((o.counterparty || {}).name, dir, rest, o.date);
    /* ⚠ Части «на компанию» идут КАЖДАЯ на СВОЕГО человека (12.08.2026). Ради этого
       разбивка и заводилась: реестр самозанятых приходит одной платёжкой на банк, а
       в своде обязан лечь пятью оборотами по пяти исполнителям. Часть без имени
       падает обратно на контрагента платёжки — тогда сумма по операции сходится
       ровно в `amount` при любом наборе частей (остаток + части). */
    companyAllocs(o).forEach(a => {
      const amt = Math.abs(+a.amount || 0);
      if (amt < 0.01) return;
      add(String(a.party || "").trim() || (o.counterparty || {}).name, dir, amt, o.date);
    });
  });
  (projects || []).forEach(p => {
    incomeRowsForProject(p).forEach(r => {
      const got = r.received ? Math.abs(+r.amount || 0)
                             : (gotBy.get(allocRowKey(r.pid, "payment", r.rowId)) || 0);
      if (got > 0) add(r.client, "in", got, r.date);
    });
    expenseRowsForProject(p).forEach(r => {
      const got = r.paid ? Math.abs(+r.amount || 0)
                         : (gotBy.get(allocRowKey(r.pid, r.kind === "contractor" ? "tranche" : "expense", r.rowId)) || 0);
      if (got > 0) add(r.vendor, "out", got, r.date);
    });
  });
  return [...idx.values()].sort((a, b) => (b.in + b.out) - (a.in + a.out) || a.name.localeCompare(b.name, "ru"));
}

// Отдельные движения по ОДНОМУ контрагенту — для карточки в своде.
// ⚠ Правила включения обязаны совпадать с partyStats буква в букву (несопоставленные
// операции + оплаченные строки проектов, без переводов между счетами и без плана):
// разъедутся — сумма в карточке не сойдётся с суммой в строке таблицы, и это первое,
// на что пользователь посмотрит. Меняешь одно — меняй оба.
function partyRows(operations, projects, name, range) {
  const key  = n => String(n || "").trim().replace(/\s+/g, " ").toLowerCase();
  const want = key(name);
  if (!want) return [];
  const rows = [];
  const gotBy = allocSumsByRow(operations);
  (operations || []).forEach(o => {
    if (!o || o.planned) return;
    if ((o.category || "") === CAT_TRANSFER) return;
    const rest = opAllocations(o).length ? opRemainder(o) : Math.abs(+o.amount || 0);
    if (range && !inPeriod(o.date, range)) return;
    /* Части «на компанию» — отдельными строками движения, по своему человеку.
       Правила включения обязаны совпадать с partyStats буква в букву (см. шапку),
       поэтому имя-фолбэк здесь тот же: пусто → контрагент платёжки. */
    companyAllocs(o).forEach((a, ai) => {
      const amt = Math.abs(+a.amount || 0);
      if (amt < 0.01) return;
      if (key(String(a.party || "").trim() || (o.counterparty || {}).name) !== want) return;
      rows.push({ key:"o-"+(o.id || rows.length)+"-a"+ai, opId:o.id, date:String(o.date || "").slice(0,10),
        dir: o.direction === "out" ? "out" : "in", amount:amt,
        article:a.category || o.category || "", project:"",
        desc:String(a.note || "").trim() || o.purpose || "", src:"Банк" });
    });
    if (rest < 0.01) return;
    if (key((o.counterparty || {}).name) !== want) return;
    // opId нужен «заведению как клиента»: из карточки контрагента операции превращаются
    // в строки нового проекта, а для этого их надо назвать по id, а не по ключу строки.
    rows.push({ key:"o-"+(o.id || rows.length), opId:o.id, date:String(o.date || "").slice(0,10),
      dir: o.direction === "out" ? "out" : "in", amount:rest,
      article:o.category || "", project:"", desc:o.purpose || "", src:"Банк" });
  });
  (projects || []).forEach(p => {
    incomeRowsForProject(p).forEach(r => {
      if (key(r.client) !== want) return;
      const got = r.received ? Math.abs(+r.amount || 0)
                             : (gotBy.get(allocRowKey(r.pid, "payment", r.rowId)) || 0);
      if (!(got > 0)) return;
      if (range && !inPeriod(r.date, range)) return;
      rows.push({ key:r.key, date:String(r.date || "").slice(0,10), dir:"in", amount:got,
        article:r.article || "", project:r.projectName || "", pid:r.projectId, desc:r.desc || "", src:"Проект" });
    });
    expenseRowsForProject(p).forEach(r => {
      if (key(r.vendor) !== want) return;
      const got = r.paid ? Math.abs(+r.amount || 0)
                         : (gotBy.get(allocRowKey(r.pid, r.kind === "contractor" ? "tranche" : "expense", r.rowId)) || 0);
      if (!(got > 0)) return;
      if (range && !inPeriod(r.date, range)) return;
      rows.push({ key:r.key, date:String(r.date || "").slice(0,10), dir:"out", amount:got,
        // ⚠ Статья строки ленты живёт ТОЛЬКО в r.article. Поля r.category у неё нет:
        // тип строки несёт r.kind, а фолбэк на «category» когда-то подсовывал в
        // разбивку «за что мы ему платим» несуществующую статью «Расход».
        article:r.article || "", project:r.projectName || "", pid:r.projectId, desc:r.desc || "", src:"Проект" });
    });
  });
  // Без даты — в конец: у ручных строк её может не быть, и пустая строка не должна
  // вставать выше реальных движений.
  return rows.sort((a, b) => (b.date || "").localeCompare(a.date || ""));
}

// Поле контрагента с историей: подсказывает всех, с кем уже имели дело, и не мешает
// вписать нового. Ничего не сохраняет само — имя уезжает в операцию, и в следующий
// раз партия найдётся в списке уже оттуда.
function PartyCombobox({ value, onChange, parties, placeholder }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, []);
  const q = String(value || "").trim().toLowerCase();
  const list = (q ? parties.filter(p => p.name.toLowerCase().includes(q) || String(p.inn||"").includes(q)) : parties).slice(0, 8);
  /* Имени нет в списке — панель раньше просто исчезала, и было непонятно, законно ли
     вписывать своё: справочник контрагентов производный, отдельной кнопки «создать» у
     него нет. Показываем строку-подтверждение: набранное имя станет новым контрагентом. */
  const isNew = !!q && !parties.some(p => p.name.trim().toLowerCase() === q);
  /* ⚠ СТРОКА «СОЗДАТЬ КОНТРАГЕНТА» СТОИТ ВСЕГДА (18.08.2026, то же правило, что у
     ArtSelect: «если тут нет нужного — надо добавить кнопку создать»). Раньше она
     появлялась ТОЛЬКО после набора текста, которого нет в списке — открывший пустое
     поле человек видел просто список из восьми частых контрагентов и не понимал, что
     своего можно вписать прямо сюда. Пустой запрос — строка приглушена и ждёт имя
     (курсор уже стоит в поле — оно и есть строка поиска); набранное без совпадения —
     полноценное действие. При точном совпадении строка прячется — эту запись и так
     видно в списке.
     ⚠ Заводя третью выпадашку с «создать вручную», сверяйся с ArtSelect (4362), а не
     копируй эту: `showCreate` там читает `hasExactMatch`/`opts`, здесь — `parties`. */
  const showCreate = !q || isNew;
  return (
    <div ref={ref} style={{ position:"relative" }}>
      <input className="ef-in" value={value || ""} placeholder={placeholder || "Имя или компания"}
        onChange={e=>{ onChange(e.target.value); setOpen(true); }} onFocus={()=>setOpen(true)} autoComplete="off" />
      {open && (list.length > 0 || showCreate) && (
        /* ⚠ РАМКА И ТЕНЬ — КАК У КАЛЕНДАРЯ И ВСЕХ ВЫПАДАШЕК (`.fp-pop`, 18.08.2026,
           Игорь: «обведи выпадающую строку в контрагентах как в календаре, чтобы не
           терялось»). Здесь стояла своя пара: рамка в полпикселя цветом
           `--border-secondary` (он прозрачный) и общая тень `--shadow-md`. На белом
           фоне окна панель не читалась слоем — список висел в воздухе, и было
           непонятно, где он кончается. Значения не выдуманы: это ровно то, чем
           обведён календарь. */
        <div style={{ position:"absolute", top:"calc(100% + 4px)", left:0, right:0, zIndex:70, background:"var(--color-background-primary)",
          border:"1px solid var(--color-line-1)", borderRadius:"var(--radius)",
          boxShadow:"0 14px 32px -18px rgba(44,46,49,.34), 0 2px 8px -4px rgba(44,46,49,.12)",
          padding: "var(--space-1)" }}>
          {/* ⚠ ПРОКРУЧИВАЕТСЯ СПИСОК, А НЕ ВСЯ ПАНЕЛЬ — иначе строка «Создать» уезжает
              под нижний край и её снова не видно (ровно та беда, ради которой она и
              заведена: восемь частых контрагентов ровно заполняют 230px). Так же
              устроен `ArtSelect`: `.fp-new` стоит СНАРУЖИ прокручиваемого `.fp-list`. */}
          <div style={{ maxHeight:230, overflowY:"auto" }}>
          {list.map(p => (
            <button key={p.name} onClick={()=>{ onChange(p.name); setOpen(false); }}
              style={{ display:"flex", alignItems:"center", justifyContent:"space-between", gap: "var(--space-2)", width:"100%", textAlign:"left",
                padding: "var(--space-2) var(--space-2)", fontSize:13.5, fontFamily:"inherit", border:"none", background:"transparent", cursor:"pointer", borderRadius:"var(--radius)" }}
              onMouseEnter={e=>e.currentTarget.style.background="var(--color-background-hover)"}
              onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
              <span style={{ overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{p.name}</span>
              <span style={{ fontSize:11.5, color:"var(--color-text-tertiary)", flexShrink:0 }}>
                {PARTY_KIND_LABEL[p.kind] || (p.count ? p.count + " оп." : "")}
              </span>
            </button>
          ))}
          </div>
          {showCreate && (
            <div onClick={()=>{ if (isNew) setOpen(false); }}
              style={{ display:"flex", alignItems:"center", gap: "var(--space-2)", width:"100%", textAlign:"left",
                padding: "var(--space-2) var(--space-2)", fontSize:13.5, fontFamily:"inherit", borderRadius:"var(--radius)",
                /* Отбивка сверху — как у `.fp-new`: выбрать существующее и завести
                   новое суть разные намерения, и подряд они читаются одним списком. */
                borderTop: list.length ? "1px solid var(--color-background-hover)" : "none",
                cursor: isNew ? "pointer" : "default", color: isNew ? "var(--color-accent)" : "var(--color-text-4)" }}
              onMouseEnter={e=>{ if (isNew) e.currentTarget.style.background="var(--color-background-hover)"; }}
              onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
              <span style={{ display:"inline-flex", flex:"none" }}>
                <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M12 5v14M5 12h14"></path></svg>
              </span>
              <span style={{ overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>
                {isNew ? `Создать контрагента «${String(value).trim()}»` : "Создать контрагента"}
              </span>
              {!q && <span style={{ marginLeft:"auto", fontSize:12, color:"var(--color-text-4)", whiteSpace:"nowrap" }}>наберите имя</span>}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

// autoFocus — поле, ПОЯВИВШЕЕСЯ по кнопке «＋ Добавить»: без него после клика по
// кнопке пришлось бы вторым кликом попадать в поле, которого секунду назад не было.
function ConCombobox({ cid, name, people, onChange, onOpenAddForm, inputStyle, autoFocus }) {
  const [open, setOpen]   = useState(false);
  const [query, setQuery] = useState(name || "");
  const ref = useRef(null);

  useEffect(() => { setQuery(name || ""); }, [name]);
  useEffect(() => {
    const handler = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", handler);
    return () => document.removeEventListener("mousedown", handler);
  }, []);

  const inSt = inputStyle || { padding: "var(--space-2) var(--space-2)", fontSize:13, border:"none", borderRadius:"var(--radius)", background:"var(--color-background-secondary)", color:"var(--color-text-primary)", fontFamily:"inherit", outline:"none", width:"100%" };
  const q = query.trim().toLowerCase();
  // String(...) обязателен: персона без имени (легаси/импорт) роняла весь экран —
  // exactMatch считается на каждом рендере, независимо от запроса.
  const filtered = q ? people.filter(p => String(p.name||"").toLowerCase().includes(q)) : people;
  const exactMatch = people.find(p => String(p.name||"").toLowerCase() === q);
  /* ⚠ СТРОКА «ДОБАВИТЬ» СТОИТ ВСЕГДА (18.08.2026) — то же правило, что у ArtSelect и
     PartyCombobox. Была видна ТОЛЬКО после набора имени, которого нет в списке: человек
     открывал поле, видел список подрядчиков без своего и упирался в тупик. Догадаться,
     что надо напечатать несуществующее имя, невозможно. Пустой запрос — строка приглушена
     и ждёт имя; точное совпадение — прячется (эта запись и так в списке). */
  const isNew      = !!q && !exactMatch;
  const showCreate = !q || isNew;

  const select = person => { onChange(person.id, person.name); setQuery(person.name); setOpen(false); };
  const addNew = () => {
    if (!isNew) return;          // имени ещё нет — заводить нечего
    setOpen(false);
    /* ⚠ Без `onOpenAddForm` это НЕ пустышка: имя уже уехало в форму через onChange,
       и закрытие панели его подтверждает — подрядчика заведёт `createLedgerRow`
       на «Создать». Так работает вызов в окне новой операции (19168). */
    if (onOpenAddForm) onOpenAddForm(query.trim());
  };

  return (
    <div ref={ref} style={{ position:"relative" }}>
      <input
        autoFocus={autoFocus || undefined}
        value={query}
        onChange={e => { setQuery(e.target.value); onChange(null, e.target.value); setOpen(true); }}
        onFocus={() => setOpen(true)}
        placeholder="Поиск или новое имя…"
        style={inSt}
      />
      {open && (
        /* ⚠ РАМКА И ТЕНЬ — КАК У КАЛЕНДАРЯ И ВСЕХ ВЫПАДАШЕК (18.08.2026). Стояла своя
           пара: рамка в полпикселя цветом `--color-border-secondary` (он ПРОЗРАЧНЫЙ —
           держит место, а не рисует линию) и `--shadow-lg`. На белом фоне окна панель
           не читалась слоем. Значения не выдуманы — это ровно то, чем обведён календарь
           и что уже поставили полю контрагента. */
        <div style={{ position:"absolute", top:"calc(100% + 4px)", left:0, right:0, zIndex:400,
          background:"var(--color-background-primary)", border:"1px solid var(--color-line-1)",
          borderRadius:"var(--radius)",
          boxShadow:"0 14px 32px -18px rgba(44,46,49,.34), 0 2px 8px -4px rgba(44,46,49,.12)" }}>
          {/* ⚠ ПРОКРУЧИВАЕТСЯ СПИСОК, А НЕ ВСЯ ПАНЕЛЬ — иначе строка «Добавить» уезжает
              под нижний край, и её снова не видно (ровно та беда, ради которой она и
              заведена). Так же устроен `ArtSelect`: `.fp-new` снаружи `.fp-list`. */}
          <div style={{ maxHeight:200, overflowY:"auto" }}>
          {filtered.length === 0 && !query.trim() && (
            <div style={{ padding: "var(--space-2) var(--space-3)", fontSize:13, color:"var(--color-text-tertiary)" }}>Нет подрядчиков в базе</div>
          )}
          {filtered.map(per => (
            <div key={per.id} onClick={() => select(per)}
              style={{ padding: "var(--space-2) var(--space-3)", fontSize:13, cursor:"pointer", display:"flex", alignItems:"center", gap: "var(--space-2)",
                background: per.id===cid ? "var(--color-background-secondary)" : "transparent",
                borderBottom:"0.5px solid var(--color-border-tertiary)" }}
              onMouseEnter={e=>e.currentTarget.style.background="var(--color-background-secondary)"}
              onMouseLeave={e=>e.currentTarget.style.background=per.id===cid?"var(--color-background-secondary)":"transparent"}>
              <Avatar name={per.name} id={per.id} size={22} />
              <span>{per.name}</span>
            </div>
          ))}
          </div>
          {showCreate && (
            <div onClick={addNew}
              style={{ padding: "var(--space-2) var(--space-3)", fontSize:13, display:"flex", alignItems:"center", justifyContent:"space-between", gap: "var(--space-1)",
                borderTop: filtered.length ? "1px solid var(--color-background-hover)" : "none",
                cursor: isNew ? "pointer" : "default", color: isNew ? "var(--color-accent)" : "var(--color-text-4)" }}
              onMouseEnter={e=>{ if (isNew) e.currentTarget.style.background="var(--color-background-hover)"; }}
              onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
              <span style={{ display:"flex", alignItems:"center", gap: "var(--space-1)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>
                <span style={{ fontSize:16, lineHeight:1 }}>+</span>
                {isNew ? `Добавить «${query.trim()}» в базу` : "Добавить подрядчика"}
              </span>
              {isNew && onOpenAddForm
                ? <span style={{ fontSize:11, color:"var(--color-text-tertiary)", whiteSpace:"nowrap" }}>→ откроется форма</span>
                : !q ? <span style={{ fontSize:11, color:"var(--color-text-4)", whiteSpace:"nowrap" }}>наберите имя</span> : null}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

// ─── CLIENT COMBOBOX ─────────────────────────────────────────────────────────
/* noCreate — спрятать строку «+ Добавить «X» как клиента». В окне операции заводить
   карточку клиента нельзя: addClient уводит на вкладку «Клиенты» и бросает начатую
   операцию. Имя там сохраняется свободным текстом, карточку человек заведёт потом. */
function ClientCombobox({ clientId, clientName, clients, onChange, onOpenAddForm, inputStyle, deferText, noCreate }) {
  const [open, setOpen]   = useState(false);
  const [query, setQuery] = useState(clientName || "");
  const ref = useRef(null);
  // deferText: onChange НЕ стреляет на каждый символ (панель пишет прямо в проект —
  // посимвольная запись заливала журнал и ломала пропагацию клиента в документы);
  // свободный текст коммитится один раз при закрытии списка кликом мимо.
  const stateRef = useRef({});
  stateRef.current = { query, clientName, onChange, deferText };

  useEffect(() => { setQuery(clientName || ""); }, [clientName]);
  useEffect(() => {
    const handler = e => {
      if (ref.current && !ref.current.contains(e.target)) {
        setOpen(false);
        const s = stateRef.current;
        const txt = s.query.trim();
        if (s.deferText && txt !== (s.clientName || "")) {
          /* Имя без единой буквы («-618», «50000») — это промах: вставка из буфера
             или набор в соседнее поле. Раньше такой текст молча становился клиентом
             проекта и потом всплывал в карточке операции и в документах как имя
             стороны. Пустую строку пропускаем — это осознанная очистка поля. */
          if (txt && !/\p{L}/u.test(txt)) { setQuery(s.clientName || ""); return; }
          s.onChange(null, txt, "");
        }
      }
    };
    document.addEventListener("mousedown", handler);
    return () => document.removeEventListener("mousedown", handler);
  }, []);

  const inSt = inputStyle || { padding: "var(--space-2) var(--space-2)", fontSize:13, border:"none", borderRadius:"var(--radius)", background:"var(--color-background-secondary)", color:"var(--color-text-primary)", fontFamily:"inherit", outline:"none", width:"100%" };
  const q = query.trim().toLowerCase();
  const filtered = q ? clients.filter(c => c.name.toLowerCase().includes(q)) : clients;
  const exactMatch = clients.find(c => c.name.toLowerCase() === q);
  /* ⚠ СТРОКА «ДОБАВИТЬ» СТОИТ ВСЕГДА (18.08.2026) — то же правило, что у ArtSelect,
     PartyCombobox и ConCombobox: открывший пустое поле человек видел список клиентов
     без своего и не понимал, что имя можно вписать прямо сюда. `noCreate` (окно
     операции) по-прежнему гасит строку целиком — там карточку клиента заводить нельзя. */
  const isNew      = !!q && !exactMatch;
  const showCreate = !noCreate && (!q || isNew);

  const select = cl => {
    const primaryContact = (cl.contacts||[])[0];
    onChange(cl.id, cl.name, primaryContact ? primaryContact.value : "");
    setQuery(cl.name); setOpen(false);
  };
  const addNew = () => { if (!isNew) return; setOpen(false); if (onOpenAddForm) onOpenAddForm(query.trim()); };

  return (
    <div ref={ref} style={{ position:"relative" }}>
      <input
        value={query}
        onChange={e => { setQuery(e.target.value); if (!deferText) onChange(null, e.target.value, ""); setOpen(true); }}
        onFocus={() => setOpen(true)}
        placeholder="Поиск клиента…"
        style={inSt}
      />
      {open && (
        /* Рамка и тень — общие для всех выпадашек (см. ConCombobox выше). */
        <div style={{ position:"absolute", top:"calc(100% + 4px)", left:0, right:0, zIndex:400,
          background:"var(--color-background-primary)", border:"1px solid var(--color-line-1)",
          borderRadius:"var(--radius)",
          boxShadow:"0 14px 32px -18px rgba(44,46,49,.34), 0 2px 8px -4px rgba(44,46,49,.12)" }}>
          {/* Прокручивается список, а не панель: иначе строка «Добавить» уезжает под край. */}
          <div style={{ maxHeight:200, overflowY:"auto" }}>
          {filtered.length === 0 && !query.trim() && (
            <div style={{ padding: "var(--space-2) var(--space-3)", fontSize:13, color:"var(--color-text-tertiary)" }}>Нет клиентов в базе</div>
          )}
          {filtered.map(cl => (
            <div key={cl.id} onClick={() => select(cl)}
              style={{ padding: "var(--space-2) var(--space-3)", fontSize:13, cursor:"pointer", display:"flex", alignItems:"center", justifyContent:"space-between", gap: "var(--space-2)",
                background: cl.id===clientId ? "var(--color-background-secondary)" : "transparent",
                borderBottom:"0.5px solid var(--color-border-tertiary)" }}
              onMouseEnter={e=>e.currentTarget.style.background="var(--color-background-secondary)"}
              onMouseLeave={e=>e.currentTarget.style.background=cl.id===clientId?"var(--color-background-secondary)":"transparent"}>
              <span style={{ fontWeight:500 }}>{cl.name}</span>
              {(cl.contacts||[])[0] && <span style={{ fontSize:12, color:"var(--color-text-tertiary)" }}>{cl.contacts[0].value}</span>}
            </div>
          ))}
          </div>
          {showCreate && (
            <div onClick={addNew}
              style={{ padding: "var(--space-2) var(--space-3)", fontSize:13, display:"flex", alignItems:"center", justifyContent:"space-between", gap: "var(--space-1)",
                borderTop: filtered.length ? "1px solid var(--color-background-hover)" : "none",
                cursor: isNew ? "pointer" : "default", color: isNew ? "var(--color-accent)" : "var(--color-text-4)" }}
              onMouseEnter={e=>{ if (isNew) e.currentTarget.style.background="var(--color-background-hover)"; }}
              onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
              <span style={{ display:"flex", alignItems:"center", gap: "var(--space-1)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>
                <span style={{ fontSize:16, lineHeight:1 }}>+</span>
                {isNew ? `Добавить «${query.trim()}» как клиента` : "Добавить клиента"}
              </span>
              {isNew && onOpenAddForm
                ? <span style={{ fontSize:11, color:"var(--color-text-tertiary)", whiteSpace:"nowrap" }}>→ форма</span>
                : !q ? <span style={{ fontSize:11, color:"var(--color-text-4)", whiteSpace:"nowrap" }}>наберите имя</span> : null}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

// ─── DAY EXCLUDER — выбор нерабочих дней для дейрейта ────────────────────────
function DayExcluder({ startDate, endDate, excluded=[], onChange }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);

  useEffect(() => {
    if (!open) return;
    const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, [open]);

  if (!startDate || !endDate) return null;

  // All days in range as a Set
  const inRangeSet = new Set();
  const c0 = new Date(startDate+"T00:00:00"), c1 = new Date(endDate+"T00:00:00");
  /* локальное форматирование: toISOString в поясе UTC+ сдвигал каждую дату на день назад —
     «Сб+Вс» исключал день ДО старта, последний день исключить было нельзя, план завышался на дневную ставку */
  { const c = new Date(c0); while(c<=c1){ inRangeSet.add(fdISO(c.getFullYear(), c.getMonth(), c.getDate())); c.setDate(c.getDate()+1); } }

  const excSet = new Set((excluded||[]).filter(d=>inRangeSet.has(d)));
  const toggle = d => { if(!inRangeSet.has(d)) return; const s=new Set(excSet); s.has(d)?s.delete(d):s.add(d); onChange([...s]); };
  const toggleWeekends = () => {
    const wknd = [...inRangeSet].filter(d=>{ const dw=new Date(d+"T00:00:00").getDay(); return dw===0||dw===6; });
    const allExcl = wknd.every(d=>excSet.has(d));
    if (allExcl) onChange([...excSet].filter(d=>!wknd.includes(d)));
    else { const s=new Set(excSet); wknd.forEach(d=>s.add(d)); onChange([...s]); }
  };

  const excCount = excSet.size;
  const totalWork = inRangeSet.size - excCount;

  // Build full-month grids for each month in the range
  const MONTH_NAMES = ["Январь","Февраль","Март","Апрель","Май","Июнь","Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"];
  const DOW_LABELS  = ["Пн","Вт","Ср","Чт","Пт","Сб","Вс"];

  const buildMonth = (year, month) => {
    const first = new Date(year, month, 1);
    const last  = new Date(year, month+1, 0);
    const startDow = (first.getDay()+6)%7;
    const grid = Array(startDow).fill(null);
    for (let d=1; d<=last.getDate(); d++)
      grid.push(`${year}-${String(month+1).padStart(2,"0")}-${String(d).padStart(2,"0")}`);
    while(grid.length%7!==0) grid.push(null);
    return Array.from({length:grid.length/7},(_,i)=>grid.slice(i*7,i*7+7));
  };

  const months = [];
  let my=c0.getFullYear(), mm=c0.getMonth();
  const ey=c1.getFullYear(), em=c1.getMonth();
  while(my<ey||(my===ey&&mm<=em)){ months.push({year:my,month:mm}); mm++; if(mm>11){mm=0;my++;} }

  return (
    <div ref={ref} style={{ position:"relative", display:"inline-flex", alignItems:"center" }}>
      {/* Trigger button */}
      <button onClick={()=>setOpen(o=>!o)} style={{
        fontSize:12, padding: "3px var(--space-2)", borderRadius: "var(--radius)", cursor:"pointer", fontFamily:"inherit",
        border: excCount>0 ? "0.5px solid var(--color-text-primary)" : "0.5px solid var(--color-border-secondary)",
        background: excCount>0 ? "var(--color-background-tertiary)" : "transparent",
        color: excCount>0 ? "var(--color-text-primary)" : "var(--color-text-tertiary)", fontWeight: excCount>0?600:400,
        transition:"background-color .12s, border-color .12s, color .12s, box-shadow .12s, opacity .12s, transform .12s", display:"flex", alignItems:"center", gap: "var(--space-1)"
      }}>
        <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
          <rect x=".5" y="1.5" width="9" height="8" rx="1.5" stroke="currentColor" strokeWidth="1"/>
          <line x1="3" y1=".5" x2="3" y2="3" stroke="currentColor" strokeWidth="1" strokeLinecap="round"/>
          <line x1="7" y1=".5" x2="7" y2="3" stroke="currentColor" strokeWidth="1" strokeLinecap="round"/>
          <line x1=".5" y1="4.5" x2="9.5" y2="4.5" stroke="currentColor" strokeWidth="1"/>
        </svg>
        {excCount>0 ? `−${excCount} дн` : "Выходные"}
      </button>

      {open && (
        <div style={{
          position:"absolute", top:"calc(100% + 6px)", left:0, zIndex:600,
          background:"var(--color-background-primary)", border:"0.5px solid var(--color-border-secondary)",
          borderRadius:"var(--radius)", boxShadow:"var(--shadow-xl)",
          padding: "var(--space-3) var(--space-4)", minWidth:252
        }}>
          {/* Header */}
          <div style={{ display:"flex", justifyContent:"space-between", alignItems:"center", marginBottom: "var(--space-3)" }}>
            <span style={{ fontSize:13, fontWeight:600, color:"var(--color-text-primary)" }}>Рабочие дни</span>
            <button onClick={toggleWeekends} style={{
              fontSize:11, padding: "3px var(--space-2)", borderRadius: "var(--radius)", cursor:"pointer", fontFamily:"inherit",
              border:"0.5px solid var(--color-border-secondary)", background:"var(--color-background-secondary)",
              color:"var(--color-text-secondary)", fontWeight:500
            }}>Сб+Вс</button>
          </div>

          {/* Month grids */}
          <div style={{ display:"flex", gap: "var(--space-4)", flexWrap:"wrap" }}>
            {months.map(({year,month})=>{
              const weeks = buildMonth(year, month);
              return (
                <div key={`${year}-${month}`} style={{ minWidth:220 }}>
                  {/* Month title */}
                  <div style={{ fontSize:12, fontWeight:700, color:"var(--color-text-secondary)", marginBottom: "var(--space-2)", textTransform:"none" }}>
                    {MONTH_NAMES[month]} {year}
                  </div>
                  <table style={{ borderCollapse:"collapse" }}>
                    <thead>
                      <tr>
                        {DOW_LABELS.map(d=>(
                          <th key={d} style={{ width:32, fontSize:10, padding: "0 0 var(--space-1)", textAlign:"center", fontWeight:700, 
                            color: d==="Сб"||d==="Вс" ? "var(--color-text-tertiary)" : "var(--color-text-tertiary)" }}>{d}</th>
                        ))}
                      </tr>
                    </thead>
                    <tbody>
                      {weeks.map((week,wi)=>(
                        <tr key={wi}>
                          {week.map((date,di)=>{
                            if (!date) return <td key={di} style={{width:32}}/>;
                            const inRange = inRangeSet.has(date);
                            const isExcl  = excSet.has(date);
                            const isWork  = inRange && !isExcl;
                            const isWeekend = di===5||di===6;
                            const day = +date.slice(8);
                            return (
                              <td key={di} onClick={()=>toggle(date)}
                                style={{ padding:"2px", textAlign:"center", cursor:inRange?"pointer":"default" }}>
                                <div style={{
                                  width:30, height:30, display:"flex", alignItems:"center", justifyContent:"center",
                                  borderRadius: "var(--radius-full)", fontSize:13, margin:"0 auto", userSelect:"none",
                                  fontWeight: isWork ? 600 : 400, transition:"background-color .12s, border-color .12s, color .12s, box-shadow .12s, opacity .12s, transform .12s",
                                  // Working days in range: blue circle; excluded or out-of-range: plain
                                  background: isWork ? "var(--color-accent)" : "transparent",
                                  color: isWork ? "var(--color-text-primary)"
                                       : isExcl ? "var(--color-text-tertiary)"
                                       : inRange ? (isWeekend ? "var(--color-text-tertiary)" : "var(--color-text-primary)")
                                       : "var(--color-text-tertiary)",
                                  opacity: inRange ? 1 : 0.3,
                                  border: isExcl && inRange ? "1.5px dashed var(--color-text-tertiary)" : "1.5px solid transparent"
                                }}>
                                  {day}
                                </div>
                              </td>
                            );
                          })}
                        </tr>
                      ))}
                    </tbody>
                  </table>
                </div>
              );
            })}
          </div>

          {/* Footer */}
          <div style={{ marginTop: "var(--space-3)", paddingTop: "var(--space-2)", borderTop:"0.5px solid var(--color-border-tertiary)", display:"flex", justifyContent:"space-between", alignItems:"center" }}>
            <span style={{ fontSize:12, color:"var(--color-text-secondary)" }}>
              Рабочих: <b style={{ color:"var(--color-text-primary)" }}>{totalWork} дн</b>
              {excCount>0 && <span style={{ color:"var(--color-text-secondary)", marginLeft: "var(--space-1)" }}>−{excCount} нераб.</span>}
            </span>
            {excCount>0 && (
              <button onClick={()=>onChange([])} style={{ fontSize:11, color:"var(--color-text-tertiary)", background:"none", border:"none", cursor:"pointer", fontFamily:"inherit" }}>Сбросить</button>
            )}
          </div>
        </div>
      )}
    </div>
  );
}

// ─── MINI CON FORM (inline inside project modal) ─────────────────────────────
// ── ContactsEditor ─────────────────────────────────────────────────────────
// Раскладка по мотивам СнупДока: НАЗВАННЫЕ СЛОТЫ вместо «+ добавить → выбрать
// тип → ввести значение → Добавить». Четыре канала контрагента лежат сеткой
// 2×2 и заполняются просто набором в поле — ни выбора типа, ни кнопки.
//
// ⚠ Взята их РАСКЛАДКА, а не их МОДЕЛЬ. У СнупДока один набор контактов на
// контрагента, людей нет. У нас контакт может принадлежать ЧЕЛОВЕКУ
// (contactName/contactRole) — у юрлица это продюсер и бухгалтер с разными
// телефонами, и терять это нельзя. Поэтому: слоты = общие каналы фирмы,
// ниже отдельным списком — контактные лица.
//
// Что было плохо в прежней версии:
//   · под КАЖДЫМ контактом висели два всегда-пустых поля «Имя контакта» и
//     «Должность» — на двух контактах это четыре серых пустых прямоугольника,
//     весивших на экране больше самих адресов;
//   · значение контакта рисовалось <span> и НЕ редактировалось: опечатку в
//     почте можно было исправить только удалив контакт и создав заново;
//   · тип канала стоял в одну строку со значением и тем же кеглем, так что
//     «Telegram» читался громче самого адреса — против эталона «Операций»
//     (значение сверху чернилами, уточнение строкой ниже серым и мельче).
const CT_TYPES = [
  { id:"phone",    label:"Телефон",  icon:"☎", ph:"+7 900 000 00 00" },
  { id:"email",    label:"Email",    icon:"✉", ph:"name@example.com" },
  { id:"telegram", label:"Telegram", icon:"✈", ph:"@username" },
  { id:"other",    label:"Другое",   icon:"·", ph:"WhatsApp, Viber, …" },
];
const ctType = id => CT_TYPES.find(t => t.id === id) || CT_TYPES[0];
// Контакт «принадлежит человеку», если у него есть имя или должность. Такие
// в слоты не попадают — им место в списке контактных лиц.
const ctIsPerson = c => !!(((c && c.contactName) || "").trim() || ((c && c.contactRole) || "").trim());
// Тип канала ОПРЕДЕЛЯЕТСЯ ПО ЗНАЧЕНИЮ, а не выбирается тумблером: у строки
// контакта достаточно формы, чтобы её узнать, и выбор типа руками был лишним
// контролом в каждой строке. Ошиблись — правится дописыванием «@».
const ctGuess = (v) => {
  const s = String(v || "").trim();
  if (!s) return "phone";
  if (s.startsWith("@")) return "telegram";
  if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s)) return "email";
  if (/^\+?[\d\s()\-]{5,}$/.test(s)) return "phone";
  return "other";
};
function ContactsEditor({ contacts, onChange }) {
  const inSt = { height:"var(--h-control)", padding: "0 var(--space-3)", fontSize:14, border:"1px solid var(--color-line-1)", borderRadius:"var(--radius)", background:"var(--color-background-primary)", color:"var(--color-text-primary)", fontFamily:"inherit", outline:"none", width:"100%", boxSizing:"border-box" };
  const lbl  = { fontSize:FS_MICRO, color:"var(--color-text-secondary)", marginBottom: "var(--space-1)" };

  // Слот занимает ПЕРВЫЙ заполненный безымянный контакт своего типа. Всё
  // остальное — контактные лица и лишние дубликаты — уходит в список ниже,
  // поэтому спрятаться не может ни один контакт.
  const slot = {};
  CT_TYPES.forEach(t => { slot[t.id] = contacts.findIndex(c => c.type === t.id && !ctIsPerson(c) && String(c.value||"").trim()); });
  const taken = new Set(Object.values(slot).filter(i => i >= 0));
  const restIdx = contacts.map((_,i) => i).filter(i => !taken.has(i));

  const patch = (i, p) => onChange(contacts.map((c,k) => k===i ? {...c, ...p} : c));
  const drop  = (i)    => onChange(contacts.filter((_,k) => k!==i));

  const setSlot = (typeId, v) => {
    const i = slot[typeId];
    if (i < 0) { if (v.trim()) onChange([...contacts, { type:typeId, value:v, contactName:"", contactRole:"" }]); return; }
    // Слот опустошили — контакт исчезает. Имени у него по определению слота нет,
    // так что терять нечего, и пустая запись в данных не остаётся.
    if (!v.trim()) { drop(i); return; }
    patch(i, { value:v });
  };
  // Новая строка лица создаётся ПУСТОЙ, поэтому не перехватывается слотом
  // (слот требует непустое значение) и сразу видна в списке ниже.
  const addPerson = () => onChange([...contacts, { type:"phone", value:"", contactName:"", contactRole:"" }]);

  const xBtn = { background:"none", border:"none", padding:"0 var(--space-1)", fontSize:18, lineHeight:1, color:"var(--color-text-tertiary)", cursor:"pointer", fontFamily:"inherit", flexShrink:0 };

  return (
    <div className="cff-grid">
      {contacts.length === 0 && (
        <div style={{ gridColumn:"1 / -1", fontSize:FS_MICRO, color:"var(--color-text-tertiary)" }}>
          Укажите хотя бы один способ связи
        </div>
      )}

      {CT_TYPES.map(t => {
        const i = slot[t.id];
        return (
          <div key={t.id} style={{ minWidth:0 }}>
            <div style={lbl}>{t.label}</div>
            <input
              value={i >= 0 ? (contacts[i].value || "") : ""}
              onChange={e => setSlot(t.id, e.target.value)}
              placeholder={t.ph}
              style={inSt}
            />
          </div>
        );
      })}

      {restIdx.length > 0 && (
        <div style={{ gridColumn:"1 / -1", fontSize:FS_MICRO, color:"var(--color-text-secondary)", marginTop: "var(--space-2)" }}>
          Контактные лица
        </div>
      )}
      {restIdx.map(i => {
        const c = contacts[i];
        const t = ctType(c.value ? ctGuess(c.value) : c.type);
        return (
          <div key={i} style={{ gridColumn:"1 / -1", border:"1px solid var(--color-line-1)", borderRadius:"var(--radius)", padding: "var(--space-3)", display:"flex", flexDirection:"column", gap: "var(--space-2)" }}>
            <div style={{ display:"flex", gap: "var(--space-2)", alignItems:"center" }}>
              <input value={c.contactName||""} onChange={e=>patch(i,{contactName:e.target.value})} placeholder="Имя контакта" style={{ ...inSt, flex:"2 1 160px" }} />
              <input value={c.contactRole||""} onChange={e=>patch(i,{contactRole:e.target.value})} placeholder="Должность" style={{ ...inSt, flex:"1 1 120px", color:"var(--color-text-secondary)" }} />
              <button type="button" onClick={()=>drop(i)} title="Убрать контакт" aria-label="Убрать контакт" style={xBtn}>×</button>
            </div>
            <div style={{ display:"flex", gap: "var(--space-2)", alignItems:"center", flexWrap:"wrap" }}>
              <input
                value={c.value||""}
                onChange={e=>patch(i,{ value:e.target.value, type: ctGuess(e.target.value) })}
                placeholder="Телефон, почта или @телеграм"
                style={{ ...inSt, flex:"1 1 200px" }}
              />
              <span style={{ fontSize:FS_MICRO, color:"var(--color-text-tertiary)", whiteSpace:"nowrap", flexShrink:0 }}>
                {t.icon} {t.label}
              </span>
            </div>
          </div>
        );
      })}

      <div style={{ gridColumn:"1 / -1" }}>
        <button type="button" onClick={addPerson}
          style={{ background:"none", border:"none", padding:0, fontFamily:"inherit", fontSize:13.5, color:"var(--color-accent)", cursor:"pointer" }}>
          + Контактное лицо
        </button>
      </div>
    </div>
  );
}

function MiniConForm({ initialName, onSave, onCancel }) {
  const [name,         setName]        = useState(initialName);
  const [contacts,     setContacts]    = useState([]);
  const [rate,         setRate]        = useState("");
  const [port,         setPort]        = useState("");
  const [notes,        setNotes]       = useState("");
  const [roles,        setRoles]       = useState([]);
  const [skills,       setSkills]      = useState(new Set());
  const [customSkills, setCustomSkills]= useState([]);
  const [newSkill,     setNewSkill]    = useState("");
  const [req,          setReq]         = useState({inn:"",rs:"",bik:"",bank:""});

  const inSt = { padding: "var(--space-2) var(--space-2)", fontSize:13, border:"none", borderRadius:"var(--radius)", background:"var(--color-background-secondary)", color:"var(--color-text-primary)", fontFamily:"inherit", outline:"none", width:"100%" };
  const setR = (k,v) => setReq(r=>({...r,[k]:v.replace(/\D/g,"")}));
  const toggleSkill = id => setSkills(prev=>{ const n=new Set(prev); n.has(id)?n.delete(id):n.add(id); return n; });
  const addCustomSkill = () => { const v=newSkill.trim(); if(!v||customSkills.includes(v)) return; setCustomSkills(cs=>[...cs,v]); setNewSkill(""); };
  const removeCustomSkill = i => setCustomSkills(cs=>cs.filter((_,idx)=>idx!==i));
  const [errs, setErrs] = useState({});
  const validate = () => {
    const e = {};
    if (!name.trim()) e.name = "Введите имя";
    if (req.inn && (req.inn.length < 10 || req.inn.length > 12)) e.inn = "ИНН: 10 или 12 цифр";
    if (req.bik && req.bik.length !== 9) e.bik = "БИК: ровно 9 цифр";
    if (req.rs  && req.rs.length  !== 20) e.rs  = "Р/С: ровно 20 цифр";
    setErrs(e);
    return Object.keys(e).length === 0;
  };
  const save = () => {
    if (!validate()) return;
    onSave({ id:Date.now(), name, roles, contacts, rate:+rate||0, portfolio:port, skills:[...skills], customSkills, notes, req });
  };

  const LabelSm = ({children}) => <div style={{ fontSize:11, color:"var(--color-text-secondary)", marginBottom:3 }}>{children}</div>;

  return (
    <div>
      <div style={{ marginBottom: "var(--space-4)", paddingBottom: "var(--space-3)", borderBottom:"0.5px solid var(--color-border-tertiary)" }}>
        <span style={{ fontSize:16, fontWeight:600 }}>Новый подрядчик</span>
      </div>

      <div style={{ display:"flex", flexDirection:"column", gap: "var(--space-3)" }}>
        <div><LabelSm>Имя</LabelSm><input value={name} onChange={e=>setName(e.target.value)} style={inSt} /></div>

        <div>
          <LabelSm>Контакты</LabelSm>
          <ContactsEditor contacts={contacts} onChange={setContacts} />
        </div>
        <div><LabelSm>Ставка, ₽/день</LabelSm><MoneyInput value={rate} onChange={setRate} placeholder="6 000" style={inSt} /></div>

        <div><LabelSm>Портфолио</LabelSm><input value={port} onChange={e=>setPort(e.target.value)} placeholder="https://behance.net/..." style={inSt} /></div>

        <div>
          <LabelSm>Роль</LabelSm>
          <TagMultiCombo value={roles} options={ROLES} onChange={setRoles} placeholder="Начните вводить роль…" />
        </div>

        <div style={{ background:"var(--color-background-secondary)", borderRadius:"var(--radius)", padding: "var(--space-3) var(--space-3)" }}>
          <div style={{ fontSize:12, fontWeight:600, textTransform:"none",  color:"var(--color-text-secondary)", marginBottom: "var(--space-2)" }}>Реквизиты</div>
          <div style={{ display:"grid", gridTemplateColumns:"1fr 1fr", gap: "var(--space-2)", marginBottom: "var(--space-2)" }}>
            <div>
              <LabelSm>ИНН</LabelSm>
              <input value={req.inn} onChange={e=>setR("inn",e.target.value)} placeholder="123456789012" maxLength={12} style={{...inSt, borderColor: errs.inn ? "var(--color-negative)" : undefined}} />
              {errs.inn && <div style={{ fontSize:11, color:"var(--color-negative)", marginTop:3 }}>{errs.inn}</div>}
            </div>
            <div>
              <LabelSm>БИК</LabelSm>
              <input value={req.bik} onChange={e=>setR("bik",e.target.value)} placeholder="044525225" maxLength={9} style={{...inSt, borderColor: errs.bik ? "var(--color-negative)" : undefined}} />
              {errs.bik && <div style={{ fontSize:11, color:"var(--color-negative)", marginTop:3 }}>{errs.bik}</div>}
            </div>
          </div>
          <div style={{ marginBottom: "var(--space-2)" }}><LabelSm>Расчётный счёт</LabelSm><input value={req.rs} onChange={e=>setR("rs",e.target.value)} placeholder="40802810000000000000" style={inSt} /></div>
          <div><LabelSm>Банк</LabelSm><input value={req.bank} onChange={e=>setR("bank",e.target.value)} placeholder="Тинькофф" style={inSt} /></div>
        </div>

        <div>
          <LabelSm>Компетенции</LabelSm>
          {/* Активные теги */}
          {([...skills].length > 0 || customSkills.length > 0) && (
            <div style={{ display:"flex", flexWrap:"wrap", gap: "var(--space-1)", marginBottom: "var(--space-2)" }}>
              {[...skills].map(id => { const s=[...S3,...S2].find(x=>x.id===id); if(!s) return null; return (
                <span key={id} style={{ display:"inline-flex", alignItems:"center", gap: "var(--space-1)", padding: "var(--space-1) var(--space-1) var(--space-1) var(--space-2)", fontSize:13, borderRadius: "var(--radius)", background:"var(--color-accent-soft)", color:"var(--color-text-primary)", border:"0.5px solid transparent", fontWeight:500 }}>
                  {s.l}
                  <button onClick={()=>toggleSkill(id)} style={{ display:"flex", alignItems:"center", justifyContent:"center", width:14, height:14, borderRadius: "var(--radius-full)", border:"none", background:"var(--color-accent)", color:"var(--color-accent-soft)", fontSize:10, cursor:"pointer", padding:0, lineHeight:1, fontFamily:"inherit" }}>×</button>
                </span>
              ); })}
              {customSkills.map((cs,i) => (
                <span key={i} style={{ display:"inline-flex", alignItems:"center", gap: "var(--space-1)", padding: "var(--space-1) var(--space-1) var(--space-1) var(--space-2)", fontSize:13, borderRadius: "var(--radius)", background:"var(--color-background-tertiary)", color:"var(--color-text-primary)", border:"0.5px solid var(--color-border-secondary)", fontWeight:500 }}>
                  {cs}
                  <button onClick={()=>removeCustomSkill(i)} style={{ display:"flex", alignItems:"center", justifyContent:"center", width:14, height:14, borderRadius: "var(--radius-full)", border:"none", background:"var(--color-text-secondary)", color:"var(--color-background-primary)", fontSize:10, cursor:"pointer", padding:0, lineHeight:1, fontFamily:"inherit" }}>×</button>
                </span>
              ))}
            </div>
          )}
          {/* Поле кастомного тега */}
          <div style={{ display:"flex", gap: "var(--space-1)", marginBottom: "var(--space-2)" }}>
            <input value={newSkill} onChange={e=>setNewSkill(e.target.value)} onKeyDown={e=>{if(e.key==="Enter"){e.preventDefault();addCustomSkill();}}}
              placeholder="Добавить свой тег…"
              style={{ flex:1, padding: "var(--space-1) var(--space-2)", fontSize:13, border:"0.5px dashed var(--color-border-secondary)", borderRadius:"var(--radius)", background:"var(--color-background-secondary)", color:"var(--color-text-primary)", fontFamily:"inherit", outline:"none" }} />
            <button onClick={addCustomSkill} style={{ padding: "var(--space-1) var(--space-3)", fontSize:13, fontWeight:600, borderRadius:"var(--radius)", border:"0.5px solid var(--color-border-secondary)", background:"var(--color-background-primary)", color:"var(--color-text-primary)", cursor:"pointer", fontFamily:"inherit" }}>+</button>
          </div>
          {/* Пресеты — все вместе */}
          <div style={{ display:"flex", flexWrap:"wrap", gap: "var(--space-1)" }}>
            {[...S3,...S2].map(s=>{ const on=skills.has(s.id); return (
              <span key={s.id} onClick={()=>toggleSkill(s.id)} style={{ padding: "var(--space-1) var(--space-2)", fontSize:12, borderRadius: "var(--radius)", cursor:"pointer", background:on?"var(--color-accent)":"var(--color-background-primary)", color:on?"var(--color-text-primary)":"var(--color-text-secondary)", border:`0.5px solid ${on?"var(--color-accent)":"var(--color-border-secondary)"}`, fontWeight:on?500:400, transition:"background-color var(--t-fast), border-color var(--t-fast), color var(--t-fast), box-shadow var(--t-fast), opacity var(--t-fast), transform var(--t-fast)" }}>{s.l}</span>
            ); })}
          </div>
        </div>

        <div>
          <LabelSm>Заметки</LabelSm>
          <textarea value={notes} onChange={e=>setNotes(e.target.value)} placeholder="Работает быстро, любит чёткое ТЗ" rows={2}
            style={{ ...inSt, resize:"none" }} />
        </div>
      </div>

      <div style={{ marginTop: "var(--space-4)", display:"flex", justifyContent:"flex-end", gap: "var(--space-2)", borderTop:"0.5px solid var(--color-border-tertiary)", paddingTop: "var(--space-3)" }}>
        <GhostBtn onClick={onCancel} style={{ fontSize:13 }}>Отмена</GhostBtn>
        <ActionBtn onClick={save}   style={{ fontSize:13 }}>Сохранить подрядчика</ActionBtn>
      </div>
    </div>
  );
}

// ─── NOTION-LIKE БЛОКИ: СВОЙСТВА / ЗАМЕТКИ / КОММЕНТАРИИ ──────────────────────
const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2,7);
// Стабильный id денежной строки (payments/expenses/tranches): банковские аллокации ссылаются
// на rowId вместо позиции в массиве — удаление/фильтрация соседних строк ссылку не сдвигает.
const newRowId = () => "r-" + uid();

// Сравнение идентификаторов сущностей. id проектов/клиентов/людей — ЧИСЛА (Date.now()),
// а формы, выпадашки и документы отдают их СТРОКАМИ. Строгое === молча не находит объект:
// так терялась история подрядчика («подрядчик есть, а траншей нет») и список проектов клиента.
// null/undefined с обеих сторон не совпадают никогда — иначе две пустые ссылки «нашли» бы друг друга.
const sameId = (a, b) => a != null && b != null && String(a) === String(b);

// Миграция старых полей проекта в blocks[] удалена: normalizeProject всегда делает
// blocks массивом, поэтому условие «blocks не определён» не выполнялось никогда, и
// перенос не срабатывал ни разу. Вместе с ней ушли поля properties/props/noteBlocks/
// comments — их читала ТОЛЬКО она, а заводились они в каждом новом проекте пустыми.
// ─── PROJECT BLOCKS ZONE ─────────────────────────────────────────────────────
// ВАЖНО: эти компоненты объявлены на верхнем уровне (НЕ внутри render),
// иначе при каждом ререндере создаётся новый тип → React пересоздаёт инпут
// и фокус слетает после первой буквы.
function BlockTypeBadge({ label }) {
  return <span style={{ fontSize:11, color:"var(--color-text-tertiary)", background:"var(--color-background-secondary)", padding: "2px var(--space-2)", flexShrink:0 }}>{label}</span>;
}
function BlockCard({ label, name, onName, children }) {
  const nameSt = { flex:1, fontSize:13, fontWeight:600, background:"transparent", border:"none", outline:"none", color:"var(--color-text-primary)", fontFamily:"inherit", padding:0 };
  return (
    <div style={{ border:"1px solid var(--color-line-1)", borderRadius:"var(--radius)", padding: "var(--space-3) var(--space-3)" }}>
      <div style={{ display:"flex", alignItems:"center", gap: "var(--space-2)", marginBottom: "var(--space-2)" }}>
        <input value={name||""} onChange={e=>onName(e.target.value)} placeholder={label} style={nameSt} />
        <BlockTypeBadge label={label} />
      </div>
      {children}
    </div>
  );
}

// Печатный инвойс из сметы — открывается в новом окне (Печать → Сохранить в PDF)
/* columns — набор колонок сметы. Раньше инвойс считал строки СВОЕЙ формулой qty×price:
   вторая копия расчёта, которая ничего не знала о коэффициенте и наценке, и счёт клиенту
   уходил на меньшую сумму, чем смета. Теперь расчёт один — estimateCompute. */
function openInvoice(p, rows, total, title, meSide, columns) {
  // Легаси-строки {label,amount} без qty/price → qty=1, price=amount: модалка сметы
  // передаёт сырые items, и у старых смет инвойс печатал все позиции по 0,00.
  rows = (rows || []).map(r => (r && !r.section && !r.tax
    && (r.qty === undefined || r.qty === "") && (r.price === undefined || r.price === "")
    && r.amount != null && r.amount !== "") ? { ...r, qty: "1", price: String(r.amount) } : r);
  const esc = s => String(s==null?"":s).replace(/[&<>"]/g, m=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[m]));
  const curSym = curSymbol(p && p.currency);
  const money = n => (+n||0).toLocaleString("ru-RU",{minimumFractionDigits:2,maximumFractionDigits:2}) + " " + curSym;
  const today = new Date().toLocaleDateString("ru-RU");
  const num   = ((p.contract||"").trim().replace(/^№\s*/,"")) || (new Date().getFullYear()+"-"+String(Date.now()).slice(-4));
  const meLines = meSide ? docReqLines("me", partyVars("me", meSide)) : []; // реквизиты получателя платежа
  // Метка FM-… в назначении платежа: клиент копирует строку целиком → входящая операция
  // в выписке несёт код → Банк авто-привязывает оплату к проекту (bankCodeProject).
  const payCode = projectPayCode(p);
  // «согласно договору № … от …»: приоритет — сгенерённый договор проекта из реестра
  // (точные номер и дата), фолбэк — поле «Договор» на проекте (номер без даты).
  let dogovor = "";
  try {
    const dd = JSON.parse(localStorage.getItem("fm_documents") || "[]");
    const c = (dd || []).find(d => d && d.projectId === p.id && d.type === "contract" && d.number);
    if (c) dogovor = ` согласно договору № ${c.number}${c.date ? ` от ${c.date}` : ""}`;
  } catch (e) {}
  if (!dogovor) { const cn = String(p.contract || "").trim().replace(/^№\s*/, ""); if (cn) dogovor = ` согласно договору № ${cn}`; }
  const purposeFor = lead => `${lead} по счёту № ${num} от ${today}${dogovor}${p.name ? ` по проекту «${p.name}»` : ""}. Код ${payCode}`;
  const purposeText = purposeFor("Оплата");
  // Условия оплаты проекта (клиентские транши: «Предоплата 50%»…) → кнопки-варианты назначения.
  const payVariants = (p.payments || []).filter(x => x && +x.amount > 0 && String(x.label || "").trim())
    .map(x => ({ chip: `${String(x.label).trim()} · ${money(+x.amount)}`, text: purposeFor(String(x.label).trim()) }));
  const isSection = r => !!(r && r.section);
  const isTax = r => !!(r && r.tax);
  const invCols = (columns && columns.length) ? columns : estimateColumns({});
  const comp = estimateCompute(rows, invCols);
  const subtotal = comp.subtotal;
  const taxAmtOf = r => estimateTaxAmt(r, subtotal);
  const taxRows = rows.filter(isTax);
  const taxSum = comp.taxSum;
  let n=0;
  /* Цена в счёте — итог строки на единицу, а не сырая ставка: коэффициент и наценка уже
     в сумме, и печатать рядом ставку 50 000 при сумме 165 000 значит показать клиенту
     арифметику, которая у него не сойдётся. */
  const lineRows = comp.rows.map((r)=>{
    if (isTax(r)) return "";
    if (isSection(r)) {
      const sub = r._sub || 0;
      return `<tr><td></td><td colspan="4" style="font-weight:800;text-transform:none;font-size:12px;padding-top:16px">${esc(r.label||"")}</td><td class="r b" style="padding-top:16px">${sub>0?money(sub):""}</td></tr>`;
    }
    n++;
    const amt = r._total || 0;
    const q   = +r.qty || 0;
    const unitPrice = q > 0 ? amt / q : amt;
    return `<tr><td class="c muted">${n}</td><td>${esc(r.label||"—")}</td><td class="c muted">${esc(r.unit||"")}</td><td class="r">${money(unitPrice)}</td><td class="c">${esc(r.qty||"")}</td><td class="r b">${money(amt)}</td></tr>`;
  }).join("");
  const html = `<!doctype html><html lang="ru"><head><meta charset="utf-8"><title>Счёт № ${esc(num)}</title>
<style>
  *{box-sizing:border-box;margin:0;padding:0}
  body{font-family:-apple-system,'Segoe UI',Inter,Roboto,sans-serif;color:var(--color-text-primary);background:var(--color-background-page);padding: var(--space-6);-webkit-print-color-adjust:exact;print-color-adjust:exact}
  .sheet{max-width:760px;margin:0 auto;background:var(--color-background-primary);padding:48px 52px;box-shadow:0 4px 24px rgba(0,0,0,.08)}
  .top{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:2px solid var(--color-text-primary);padding-bottom: var(--space-4);margin-bottom: var(--space-5)}
  .title{font-size:26px;font-weight:800;}
  .meta{text-align:right;font-size:13px;color:var(--color-text-secondary);line-height:1.7}
  .meta b{color:var(--color-text-primary)}
  .parties{display:grid;grid-template-columns:1fr 1fr;gap: var(--space-5);margin-bottom: var(--space-5)}
  .pl{font-size:10px;font-weight:700;text-transform:none;color:var(--color-text-tertiary);margin-bottom: var(--space-1)}
  .pv{font-size:14px;line-height:1.6}
  .pv .name{font-weight:600;font-size:15px}
  .payee{margin-bottom: var(--space-5);padding: var(--space-4) var(--space-4);background:var(--color-background-primary);border:1px solid var(--color-line-2);border-radius: var(--radius)}
  .payee .pv{font-size:13px;color:var(--color-text-primary)}
  .purpose{margin-bottom: var(--space-5);padding: var(--space-4) var(--space-4);background:var(--color-background-secondary);border:1px solid var(--color-accent-soft);border-radius: var(--radius)}
  .purpose .pv{font-size:13px;color:var(--color-text-primary);line-height:1.55}
  .purpose .row{display:flex;gap: var(--space-3);align-items:flex-start;justify-content:space-between}
  .copybtn{flex-shrink:0;font:inherit;font-size:12px;font-weight:600;padding: var(--space-2) var(--space-3);border-radius: var(--radius);border:1px solid var(--color-blue);background:var(--color-background-primary);color:var(--color-accent);cursor:pointer;white-space:nowrap}
  .pchips{margin-top: var(--space-2);display:flex;gap: var(--space-1);flex-wrap:wrap}
  .pchip{font:inherit;font-size:12px;font-weight:600;padding: var(--space-1) var(--space-3);border-radius: var(--radius);border:1px solid var(--color-blue);background:var(--color-background-primary);color:var(--color-accent);cursor:pointer}
  .pchip.on{background:var(--color-accent);border-color:var(--color-accent);color:var(--color-background-primary)}
  table{width:100%;border-collapse:collapse;margin-bottom: var(--space-5);font-size:13px}
  thead th{font-size:10px;font-weight:700;text-transform:none;color:var(--color-text-tertiary);text-align:left;padding: 0 var(--space-2) var(--space-2);border-bottom:1px solid var(--color-background-tertiary)}
  tbody td{padding: var(--space-3) var(--space-2);border-bottom:1px solid var(--color-line-2);vertical-align:top}
  .r{text-align:right;white-space:nowrap}.c{text-align:center}.b{font-weight:700}.muted{color:var(--color-text-4)}
  .totals{display:flex;justify-content:flex-end;margin-top: var(--space-1)}
  .totbox{min-width:260px}
  .totrow{display:flex;justify-content:space-between;padding: var(--space-2) var(--space-2);font-size:14px}
  .totrow.grand{border-top:2px solid var(--color-text-primary);margin-top: var(--space-1);font-size:17px;font-weight:800}
  .note{margin-top:40px;font-size:12px;color:var(--color-text-tertiary);line-height:1.6;border-top:1px solid var(--color-line-2);padding-top: var(--space-4)}
  .bar{max-width:760px;margin:0 auto 16px;display:flex;gap: var(--space-2);justify-content:flex-end}
  .bar button{font:inherit;font-size:13px;font-weight:600;padding: var(--space-2) var(--space-4);border-radius: var(--radius);border:none;cursor:pointer}
  .btn-print{background:var(--color-text-primary);color:var(--color-background-primary)}
  .btn-close{background:var(--color-background-primary);color:var(--color-text-secondary);border:1px solid var(--color-line-1)}
  @media print{body{background:var(--color-background-primary);padding:0}.sheet{box-shadow:none;padding: var(--space-5)}.bar{display:none}.copybtn{display:none}.pchips{display:none}}

{`.nb-block:hover .block-del { opacity: 1 !important; } .nb-block .drag-handle { opacity: 0; transition: opacity .15s; } .nb-block:hover .drag-handle { opacity: 0.45; } .drag-handle:hover { opacity: 0.9 !important; } .drag-handle:active { cursor: grabbing !important; }`}
{CLX_CSS}

  *{box-sizing:border-box}
  body{font-family:'Times New Roman',Georgia,serif;color:var(--color-text-primary);background:var(--color-background-secondary);margin:0;padding: var(--space-5);-webkit-print-color-adjust:exact;print-color-adjust:exact}
  .sheet{max-width:760px;margin:0 auto;background:var(--color-background-primary);padding:52px 64px;box-shadow:0 4px 24px rgba(0,0,0,.08);font-size:13.5px;line-height:1.6}
  .dt{font-size:15.5px;font-weight:700;text-align:center;text-transform:none;margin:0 0 4px}
  .dmeta{display:flex;justify-content:space-between;font-size:13px;margin:14px 0 22px}
  .dh{font-size:14px;font-weight:700;margin:20px 0 8px}
  .dp{margin: 0 0 var(--space-2);text-align:justify}
  .dparties{margin-bottom: var(--space-3)}
  .dsign{display:grid;grid-template-columns:1fr 1fr;gap:40px;margin-top: var(--space-5)}
  .drole{font-weight:700;margin-bottom: var(--space-2)}
  .dreq{font-size:12px;line-height:1.55;color:var(--color-text-primary);min-height:104px}
  .dpos{font-size:12.5px;margin-top: var(--space-2)}
  .dsig{margin-top: var(--space-2);font-size:12.5px;display:flex;justify-content:space-between;align-items:flex-end;gap: var(--space-2);border-top:1px solid var(--color-text-primary);padding-top: var(--space-1)}
  .dsn{color:var(--color-text-secondary)}
  .dest{width:100%;border-collapse:collapse;margin: var(--space-2) 0 var(--space-1);font-size:12.5px}
  .dest th,.dest td{border:1px solid var(--color-text-primary);padding: var(--space-1) var(--space-2);text-align:left;vertical-align:top}
  .dest th{background:var(--color-background-secondary);font-weight:700;font-size:11.5px}
  .destnum{text-align:right;white-space:nowrap}
  .destc{text-align:center}
  .destsec td{background:var(--color-background-page);font-weight:700;text-transform:none;font-size:11px;}
  .desttax td{font-style:italic}
  .destsub td{font-weight:600}
  .desttot td{font-weight:700;background:var(--color-background-secondary)}
  .destwords{font-size:12.5px;margin:4px 0 10px}
  .destnote{font-size:12.5px;color:var(--color-text-secondary);font-style:italic;margin: var(--space-2) 0;padding: var(--space-2);border:1px dashed var(--color-text-5);text-align:center}
  .bar{max-width:760px;margin:0 auto 14px;display:flex;gap: var(--space-2);justify-content:flex-end}
  .bar button{font-family:-apple-system,'Segoe UI',sans-serif;font-size:13px;font-weight:600;padding: var(--space-2) var(--space-4);border-radius: var(--radius);border:none;cursor:pointer}
  .btn-print{background:var(--color-text-primary);color:var(--color-background-primary)}.btn-close{background:var(--color-background-primary);color:var(--color-text-secondary);border:1px solid var(--color-line-1)}
  @media print{body{background:var(--color-background-primary);padding:0}.sheet{box-shadow:none;padding:24px 8px}.bar{display:none}}
  

  @page{size:A4;margin:2cm}
  body{font-family:'Times New Roman',serif;font-size:12pt;color:var(--color-text-primary);line-height:1.5}
  .dt{font-size:14pt;font-weight:bold;text-align:center;text-transform:none;margin:0 0 6pt}
  .dmeta{margin:10pt 0 16pt}
  .dmeta span:first-child{float:left}.dmeta span:last-child{float:right}
  .dh{font-size:12pt;font-weight:bold;margin:14pt 0 6pt}
  .dp{margin:0 0 7pt;text-align:justify}
  .dparties{margin-bottom:10pt}
  .dsign{width:100%;margin-top:22pt}
  .dcol{width:48%;vertical-align:top;display:inline-block}
  .drole{font-weight:bold;margin-bottom:6pt}
  .dreq{font-size:10pt;line-height:1.4}
  .dpos{font-size:10.5pt;margin-top:8pt}
  .dsig{margin-top:8pt;font-size:10pt;border-top:1px solid var(--color-text-primary);padding-top:4pt}
  .dsig .dsn{color:var(--color-text-secondary);margin-left:10pt}
  table.dest{width:100%;border-collapse:collapse;margin:8pt 0 6pt;font-size:10.5pt}
  .dest th,.dest td{border:1px solid var(--color-text-primary);padding:3pt 5pt;text-align:left;vertical-align:top}
  .dest th{background:var(--color-background-secondary);font-weight:bold}
  .destnum{text-align:right}
  .destc{text-align:center}
  .destsec td{background:var(--color-background-secondary);font-weight:bold;text-transform:none}
  .desttax td{font-style:italic}
  .desttot td{font-weight:bold;background:var(--color-background-secondary)}
  .destwords{font-size:10.5pt;margin:4pt 0 8pt}
  .destnote{font-size:10.5pt;color:var(--color-text-secondary);font-style:italic}

{`
        .dreg-folder{background:var(--color-background-primary);border-radius: var(--radius);border:0.5px solid var(--color-line-1);overflow:hidden;margin-bottom: var(--space-4)}
        .dreg-fhead{display:flex;align-items:center;gap: var(--space-3);padding: var(--space-4) var(--space-5);cursor:pointer;transition:background .12s}
        .dreg-fhead:hover{background:var(--color-background-page)}
        .dreg-chev{color:var(--color-text-5);flex:0 0 auto;transition:transform .2s cubic-bezier(.165,.84,.44,1)}
        .dreg-fname{font:700 17px 'Inter';color:var(--color-text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
        .dreg-fmeta{font:400 14px 'Inter';color:var(--color-text-tertiary);margin-top: var(--space-1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
        .dreg-pstat{font:600 14px 'Inter';height:30px;padding: 0 var(--space-3);border-radius: var(--radius);white-space:nowrap;display:inline-flex;align-items:center;gap: var(--space-2);flex:0 0 auto}
        .dreg-ps-work{background:var(--color-accent-soft);color:var(--color-accent)}
        .dreg-ps-done{background:var(--color-positive-soft);color:var(--color-positive)}
        .dreg-bodywrap{display:grid;grid-template-rows:0fr;transition:grid-template-rows 260ms cubic-bezier(.645,.045,.355,1)}
        .dreg-bodywrap[data-open="true"]{grid-template-rows:1fr}
        .dreg-bodyclip{overflow:hidden;min-height:0}
        .dreg-body{background:var(--color-background-primary);overflow-x:auto;opacity:0;transform:translateY(-4px);transition:opacity 180ms cubic-bezier(.25,.46,.45,.94), transform 180ms cubic-bezier(.25,.46,.45,.94)}
        .dreg-bodywrap[data-open="true"] .dreg-body{opacity:1;transform:none}
        @media (prefers-reduced-motion: reduce){.dreg-bodywrap,.dreg-body,.dreg-chev{transition:none}.dreg-body{opacity:1;transform:none}}
        /* Сетка колонок — одна на строку заголовков и на строки документов.
           min-width как в таблицах Банка/Доходов: уже него колонка «Документ» (1fr) схлопывается в 0 */
        /* Сторона и Документ — гибкие (minmax), чтобы длинные «ИП + ФИО» помещались целиком, а не резались
           ради 8px. Ширины остальных выставлены по самому длинному значению + небольшой запас. */
        .dreg-grid{display:grid;grid-template-columns:40px 104px minmax(200px,1.2fr) 110px minmax(165px,1fr) 124px 100px 78px;gap: var(--space-3);align-items:center;min-width:1070px}
        .dreg-cols{padding: var(--space-3) var(--space-5);background:var(--color-background-page);border-bottom:1px solid var(--color-background-secondary)}
        .dreg-cols span{font:600 13px 'Inter';color:var(--color-text-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
        .dreg-drow{padding: var(--space-4) var(--space-5);transition:background .1s;position:relative;cursor:pointer}
        .dreg-drow + .dreg-drow{border-top:1px solid var(--color-background-hover)}
        .dreg-drow:hover{background:var(--color-background-page)}
        .dreg-drow.dreg-on{background:var(--color-background-primary)}
        /* Чекбокс */
        /* !important — исключение из глобального правила button{border-radius: var(--radius)}: на 20×20 это почти круг,
           а круг читается как «выбери одно» (радио). Чекбокс должен остаться квадратом. */
        .dreg-cb{width:20px;height:20px;border-radius: var(--radius) !important;border:1px solid var(--color-text-5);background:var(--color-background-primary);cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;flex:0 0 auto;transition:background .1s,border-color .1s;color:var(--color-background-primary)}
        .dreg-cb:hover{border-color:var(--color-text-tertiary)}
        .dreg-cb[data-on="true"]{background:var(--color-accent);border-color:var(--color-accent)}
        .dreg-date{font:400 15px 'Space Grotesk','Inter',sans-serif;color:var(--color-text-primary);white-space:nowrap}
        /* Сторона: имя контрагента сверху, роль подписью снизу — как в лентах */
        .dreg-side{min-width:0}
        /* Кегли — по ленте Доходов/Расходов: имя 15px чернилами, уточнение 12px серым.
           Цвет роли больше не кодирует клиента и подрядчика: слово и так написано,
           а коричневый спорил с семантикой сумм. */
        .dreg-role{font:400 12px 'Inter';color:var(--color-text-tertiary);white-space:nowrap;display:block;margin-top:3px}
        /* display:block обязателен: у строчных span обрезка с многоточием не работает — текст уезжал в соседнюю колонку */
        .dreg-party{display:block;font:400 15px 'Inter';line-height:1.3;color:var(--color-text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
        .dreg-type{font:600 13px 'Inter';background:var(--color-background-secondary);color:var(--color-text-secondary);height:24px;border-radius: var(--radius);padding: 0 var(--space-2);display:inline-flex;align-items:center;white-space:nowrap;justify-self:start;max-width:100%;overflow:hidden;text-overflow:ellipsis}
        /* Документ: номер + текстовая связь «к Договору №…» */
        .dreg-doc{min-width:0}
        .dreg-num{font:600 15px 'Inter';color:var(--color-text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
        .dreg-rel{display:block;font:400 13px 'Inter';color:var(--color-text-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}
        .dreg-sum{font:700 16px 'Space Grotesk','Inter',sans-serif;color:var(--color-text-primary);text-align:right;padding-right: var(--space-5);white-space:nowrap}
        .dreg-sum.muted{color:var(--color-text-5);font-weight:400}
        /* Статус — цветной текст, по клику раскрывается список */
        .dreg-stwrap{position:relative;justify-self:start}
        .dreg-st{font:600 14px 'Inter';background:none;border:none;padding:2px 0;cursor:pointer;font-family:inherit;white-space:nowrap;text-align:left}
        .dreg-st:hover{text-decoration:underline;text-underline-offset:3px}
        .dreg-stmenu{position:absolute;top:calc(100% + 5px);left:-8px;z-index:40;min-width:150px;background:var(--color-background-primary);border:0.5px solid var(--color-line-1);border-radius: var(--radius);box-shadow:var(--shadow-lg);padding: var(--space-1);overflow:hidden}
        .dreg-stmenu button{display:block;width:100%;text-align:left;border:none;background:none;font:600 13.5px 'Inter';padding: var(--space-2) var(--space-2);border-radius: var(--radius);cursor:pointer;font-family:inherit;white-space:nowrap}
        .dreg-stmenu button:hover{background:var(--color-background-page)}
        /* Действия строки */
        .dreg-acts{display:flex;gap: var(--space-1);justify-content:flex-end;position:relative}
        .dreg-open{width:34px;height:34px;border-radius: var(--radius);border:none;background:var(--color-accent-soft);color:var(--color-accent);display:flex;align-items:center;justify-content:center;cursor:pointer;flex:0 0 auto;transition:background .1s}
        .dreg-open:hover{background:var(--color-accent-soft)}
        .dreg-more{width:34px;height:34px;border-radius: var(--radius);border:none;background:transparent;color:var(--color-text-tertiary);display:flex;align-items:center;justify-content:center;cursor:pointer;flex:0 0 auto;font-size:17px;line-height:1;font-family:inherit;transition:background .1s}
        .dreg-more:hover{background:var(--color-background-hover)}
        .dreg-menu{position:absolute;top:calc(100% + 6px);right:0;z-index:40;width:210px;background:var(--color-background-primary);border:0.5px solid var(--color-line-1);border-radius: var(--radius);box-shadow:var(--shadow-lg);padding: var(--space-1)}
        .dreg-menu button{display:flex;align-items:center;gap: var(--space-2);width:100%;text-align:left;border:none;background:none;font:600 13.5px 'Inter';color:var(--color-text-primary);padding: var(--space-2) var(--space-2);border-radius: var(--radius);cursor:pointer;font-family:inherit;white-space:nowrap}
        .dreg-menu button:hover{background:var(--color-background-page)}
        .dreg-menu button:disabled{opacity:.45;cursor:default}
        .dreg-menu button.dreg-del{color:var(--color-negative)}
        .dreg-menu button.dreg-del:hover{background:var(--color-negative-soft)}
        .dreg-msep{height:0.5px;background:var(--color-background-secondary);margin:5px 8px}
        /* Панель массовых действий */
        .dreg-bulk{display:flex;align-items:center;gap: var(--space-2);height:54px;background:var(--color-accent-soft);border:0.5px solid var(--color-blue);border-radius: var(--radius);padding: 0 var(--space-4);margin-bottom: var(--space-3)}
        .dreg-bulk .n{font:600 15px 'Inter';color:var(--color-accent);margin-right:auto}
        .dreg-bulk button{height:36px;border-radius: var(--radius);border:0.5px solid var(--color-line-1);background:var(--color-background-primary);font:600 13.5px 'Inter';color:var(--color-text-primary);padding: 0 var(--space-3);cursor:pointer;font-family:inherit;flex:0 0 auto}
        .dreg-bulk button:hover{background:var(--color-background-page)}
        .dreg-bulk button.dreg-del{color:var(--color-negative);border-color:var(--color-negative-soft)}
        .dreg-bulk button.dreg-del:hover{background:var(--color-negative-soft)}
        .dreg-bulk button.dreg-x{width:36px;padding:0;font-size:16px;line-height:1;color:var(--color-text-tertiary)}
      `}
{CLX_CSS}
{CLX_CSS}
{CLX_CSS}