/* ctor-template-axes.jsx — «Шаблоны и пакеты» → «Тип объекта», «Исходное состояние» и «Комнаты»
   (спеки docs/superpowers/specs/2026-10-05-template-axes-design.md, §4, 2026-10-05-object-types-design.md, §4 и
   2026-10-19-object-rooms-design.md, §1).
   Данные: ConstructorData.loadObjectRules/saveObjectRules (ctor_object_rules_v1), loadStateRules/saveStateRules
   (ctor_state_rules_v1) и loadRoomTypes/saveRoomTypes (ctor_room_types_v1); калькулятор читает правила через Calc2Axes
   (lk/calc2-template-axes.js). Правила «Одной комнаты» (ctor_room_rules_v1) остались данными движка без редактора — до В3.
   · Тип объекта: { id, name, note, match: { propertyType, floors?, purpose? }, off: [nodeId], on: [nodeId] } —
     какие этапы дома и коммерции включать в смету и какие выключать; идёт до исходного состояния.
   · Состояние: { id, name, note, match: [{ objectType, finishState }], off: [nodeId], on: [nodeId] } —
     этапы/работы каталога, которые правило выключает и включает поверх шаблона сметы.
   · Тип помещения: { id, name, group, wet, outdoor } — справочник «Комнаты»; какие работы идут в комнату, отмечается
     во «Всех работах» (поле rooms у работы, набор колонок «Комнаты»).
   Каркас — как constructor/ctor-packages.jsx (карточки + drawer, focusId из глобального поиска).
   Префикс имён — tax (общий top-level scope ctor-*.jsx). */
const { useState: taxUseState, useEffect: taxUseEffect, useRef: taxUseRef } = React;

const TAX_OBJECT_TYPES = [['new_build', 'Новостройка'], ['secondary', 'Вторичка'], ['*', 'Любой']];
const TAX_FINISH_STATES = [['without', 'Без отделки'], ['partial', 'Черновая'], ['renovation', 'С ремонтом'], ['keep', 'Без демонтажа'], ['', 'Не указано']];
const TAX_TRI = [['inherit', 'как в шаблоне'], ['off', 'выключить'], ['on', 'включить']];

const taxS = {
  phase: { fontSize: 11, fontWeight: 700, color: 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: '.05em', padding: '10px 10px 4px' },
  row: { display: 'flex', alignItems: 'center', gap: 9, padding: '6px 10px', borderRadius: 8 },
  taskRow: { display: 'flex', alignItems: 'center', gap: 9, padding: '4px 10px 4px 52px', borderRadius: 8 },
  name: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
  chev: { width: 20, height: 20, padding: 0, border: 'none', background: 'transparent', borderRadius: 6, cursor: 'pointer', color: 'var(--text-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
  mute: { fontSize: 11, color: 'var(--text-tertiary)', whiteSpace: 'nowrap', flexShrink: 0 },
  tag: { fontSize: 10.5, fontWeight: 600, color: 'var(--text-tertiary)', background: 'var(--secondary)', padding: '1px 8px', borderRadius: 980, whiteSpace: 'nowrap', flexShrink: 0 },
  tri: { display: 'inline-flex', gap: 2, padding: 2, background: 'var(--secondary)', borderRadius: 8, flexShrink: 0 },
  triBtn: { padding: '3px 8px', border: 'none', borderRadius: 6, background: 'transparent', fontFamily: 'inherit', fontSize: 11, fontWeight: 600, color: 'var(--text-tertiary)', cursor: 'pointer', whiteSpace: 'nowrap' },
  triOn: {
    inherit: { background: 'var(--card)', color: 'var(--foreground)', boxShadow: '0 1px 2px rgba(0,0,0,.08)' },
    off: { background: '#fde8e8', color: '#d05858' },
    on: { background: '#e7f6ed', color: '#2f7d4a' },
  },
  flash: { boxShadow: '0 0 0 3px var(--primary)', borderRadius: 14 },
};

function taxPlural(n, one, few, many) {
  const m = Math.abs(n) % 100, d = m % 10;
  return m > 10 && m < 20 ? many : d === 1 ? one : d >= 2 && d <= 4 ? few : many;
}
function taxNewId(prefix) { return prefix + Date.now().toString(36); }
function taxUniq(list) { return Array.from(new Set(list)); }
function taxClone(x) { return JSON.parse(JSON.stringify(x)); }

/* ============ каталог: id узлов ============ */
function taxSubIds(tk) { return (tk.sub || []).map((s) => s.id); }
function taxStageIds(st) {
  const out = [st.id];
  (st.tasks || []).forEach((tk) => { out.push(tk.id); taxSubIds(tk).forEach((x) => out.push(x)); });
  return out;
}
function taxAllNodeIds(catalog) {
  const all = new Set();
  (catalog || []).forEach((ph) => (ph.stages || []).forEach((st) => taxStageIds(st).forEach((id) => all.add(id))));
  return all;
}
// id из правила, которых уже нет в каталоге (узел удалён) — они ни на что не влияют
function taxOrphans(catalog, ids) {
  const all = taxAllNodeIds(catalog);
  return (ids || []).filter((id) => !all.has(id));
}

/* три положения узла в правиле состояния: value = { off: [], on: [] } */
function taxTriGet(value, id) {
  return (value.off || []).indexOf(id) >= 0 ? 'off' : (value.on || []).indexOf(id) >= 0 ? 'on' : 'inherit';
}
function taxTriSet(value, id, mode) {
  const off = (value.off || []).filter((x) => x !== id);
  const on = (value.on || []).filter((x) => x !== id);
  if (mode === 'off') off.push(id); else if (mode === 'on') on.push(id);
  return { off, on };
}

// этапы, у которых есть работа с правилом, раскрыты сразу
function taxInitialOpen(catalog, value) {
  const open = {};
  (catalog || []).forEach((ph) => (ph.stages || []).forEach((st) => {
    if ((st.tasks || []).some((tk) => taxTriGet(value, tk.id) !== 'inherit')) open[st.id] = true;
  }));
  return open;
}

/* ============ мелкие элементы ============ */
function taxUseFlash(focusId, prefix) {
  const [flashId, setFlashId] = taxUseState(null);
  taxUseEffect(() => {
    if (!focusId) return;
    const el = document.getElementById(prefix + focusId);
    if (el) el.scrollIntoView({ behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'center' });
    setFlashId(focusId);
    const t = setTimeout(() => setFlashId(null), 1800);
    return () => clearTimeout(t);
  }, [focusId]);
  return flashId;
}

/* иконка lucide по имени (data-lucide + createIcons, как в ctor-nav.jsx) */
function TaxIcon({ name, size = 20, color }) {
  const ref = taxUseRef(null);
  taxUseEffect(() => {
    if (!ref.current || !window.lucide) return;
    ref.current.innerHTML = '';
    const el = document.createElement('i');
    el.setAttribute('data-lucide', name || 'door-open');
    ref.current.appendChild(el);
    try { window.lucide.createIcons({ nameAttr: 'data-lucide', icons: window.lucide.icons, attrs: { width: size, height: size } }); } catch (e) { /* ignore */ }
  }, [name, size]);
  return <span ref={ref} style={{ display: 'inline-flex', lineHeight: 0, color: color || 'var(--text-secondary)' }} aria-hidden="true"></span>;
}

function TaxChip({ active, onClick, children, title }) {
  return (
    <button type="button" className="chip chip--control" data-active={active ? 'true' : 'false'} aria-pressed={!!active} onClick={onClick} title={title}>
      {children}
    </button>
  );
}

function TaxTri({ state, onChange, label }) {
  return (
    <span role="group" aria-label={label} style={taxS.tri}>
      {TAX_TRI.map(([k, l]) => (
        <button key={k} type="button" aria-pressed={state === k} onClick={() => onChange(k)}
          style={{ ...taxS.triBtn, ...(state === k ? taxS.triOn[k] : null) }}>{l}</button>
      ))}
    </span>
  );
}

function TaxChevron({ open, onClick, hasChildren }) {
  if (!hasChildren) return <span style={{ width: 20, flexShrink: 0 }} aria-hidden="true"></span>;
  return (
    <button type="button" aria-expanded={open} aria-label={open ? 'Свернуть этап' : 'Развернуть этап'}
      onClick={(e) => { e.stopPropagation(); onClick(); }} style={taxS.chev}>
      <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
        style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .12s' }}><polyline points="9 18 15 12 9 6"></polyline></svg>
    </button>
  );
}

function TaxDot({ color }) {
  return <span style={{ width: 8, height: 8, borderRadius: '50%', background: color || '#a3a3a3', flexShrink: 0 }} aria-hidden="true"></span>;
}

/* ============ TaxNodeTree — дерево каталога: фазы → этапы (сворачиваются) → работы ============
   У этапа и работы три положения правила: «как в шаблоне» / «выключить» / «включить»;
   value = { off: [], on: [] }, onChange получает такой же объект.
   fixed = { [nodeId]: подпись } — узлы, которые решает условие («от 2 этажей»), а не правило: вместо переключателя метка. */
function TaxNodeTree({ catalog, value, onChange, fixed }) {
  const [open, setOpen] = taxUseState(() => taxInitialOpen(catalog, value));
  const setAll = (on) => {
    const next = {};
    if (on) (catalog || []).forEach((ph) => (ph.stages || []).forEach((st) => { next[st.id] = true; }));
    setOpen(next);
  };
  const stageRows = (st) => {
    const tasks = st.tasks || [];
    const isOpen = !!open[st.id];
    const toggleOpen = () => setOpen((o) => ({ ...o, [st.id]: !o[st.id] }));
    const stState = taxTriGet(value, st.id);
    const overrides = tasks.filter((tk) => taxTriGet(value, tk.id) !== 'inherit').length;
    return (
      <div key={st.id}>
        <div style={{ ...taxS.row, background: stState === 'off' ? 'rgba(208,88,88,.07)' : stState === 'on' ? 'rgba(47,125,74,.08)' : 'transparent' }}>
          <TaxChevron open={isOpen} onClick={toggleOpen} hasChildren={tasks.length > 0} />
          <TaxDot color={st.color} />
          <span style={{ ...taxS.name, fontSize: 13, fontWeight: 600, color: 'var(--foreground)' }} title={st.name}>{st.name}</span>
          {overrides > 0 && !isOpen && <span style={taxS.mute}>{overrides} {taxPlural(overrides, 'работа', 'работы', 'работ')} с правилом</span>}
          {fixed && fixed[st.id] ? <span style={taxS.tag} title="Решает анкета — здесь не меняется">{fixed[st.id]}</span> : <TaxTri state={stState} label={'Этап «' + st.name + '»'} onChange={(m) => onChange(taxTriSet(value, st.id, m))} />}
        </div>
        {isOpen && tasks.map((tk) => {
          const tkState = taxTriGet(value, tk.id);
          const via = tkState === 'inherit' && stState !== 'inherit';
          return (
            <div key={tk.id} style={{ ...taxS.taskRow, background: tkState === 'off' ? 'rgba(208,88,88,.07)' : tkState === 'on' ? 'rgba(47,125,74,.08)' : 'transparent' }}>
              <span style={{ ...taxS.name, fontSize: 12.5, color: 'var(--text-secondary)' }} title={tk.name}>{tk.name}</span>
              {via && <span style={taxS.mute}>{stState === 'off' ? 'выкл. вместе с этапом' : 'вкл. вместе с этапом'}</span>}
              {fixed && fixed[tk.id] ? <span style={taxS.tag} title="Решает анкета — здесь не меняется">{fixed[tk.id]}</span> : <TaxTri state={tkState} label={'Работа «' + tk.name + '»'} onChange={(m) => onChange(taxTriSet(value, tk.id, m))} />}
            </div>
          );
        })}
      </div>
    );
  };
  return (
    <div>
      <div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
        <button type="button" className="chip chip--control" onClick={() => setAll(true)}>Развернуть всё</button>
        <button type="button" className="chip chip--control" onClick={() => setAll(false)}>Свернуть всё</button>
      </div>
      <div className="ins-picker-list" style={{ maxHeight: 'min(520px, 58vh)' }}>
        {(catalog || []).map((ph) => (
          <div key={ph.id}>
            <div style={taxS.phase}>{ph.name}</div>
            {(ph.stages || []).map(stageRows)}
          </div>
        ))}
        {!(catalog || []).length && <div style={{ padding: 12, fontSize: 12.5, color: 'var(--text-tertiary)' }}>Каталог пуст.</div>}
      </div>
    </div>
  );
}

/* узлы правила, удалённые из каталога: подсказка + «Убрать» */
function TaxOrphanNote({ count, onClean }) {
  if (!count) return null;
  return (
    <InspectorCallout variant="warn">
      {count} {taxPlural(count, 'узел удалён', 'узла удалены', 'узлов удалено')} из каталога — на смету они не влияют.{' '}
      <button type="button" className="chip chip--control" onClick={onClean}>Убрать из правила</button>
    </InspectorCallout>
  );
}

/* ============ Исходное состояние ============ */
function taxNewState() { return { id: taxNewId('st_'), name: '', note: '', match: [], off: [], on: [] }; }
function taxMatchLabel(m) {
  const ot = TAX_OBJECT_TYPES.find((x) => x[0] === m.objectType);
  const fs = TAX_FINISH_STATES.find((x) => x[0] === (m.finishState || ''));
  return (ot ? ot[1] : m.objectType) + ' · ' + (fs ? fs[1] : m.finishState);
}
// те же ответы анкеты уже закрывает другое правило (в анкете срабатывает первое подходящее)
function taxMatchConflicts(others, match) {
  const out = [];
  (others || []).forEach((r) => {
    const hit = (r.match || []).some((a) => (match || []).some((b) =>
      (a.finishState || '') === (b.finishState || '') && (a.objectType === '*' || b.objectType === '*' || a.objectType === b.objectType)));
    if (hit) out.push(r.name || 'Без названия');
  });
  return out;
}

function TaxMatchEditor({ value, onChange }) {
  const same = (m, ot, fs) => m.objectType === ot && (m.finishState || '') === fs;
  const has = (ot, fs) => value.some((m) => same(m, ot, fs));
  const toggle = (ot, fs) => onChange(has(ot, fs) ? value.filter((m) => !same(m, ot, fs)) : value.concat([{ objectType: ot, finishState: fs }]));
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
      {TAX_OBJECT_TYPES.map(([ot, otLabel]) => (
        <div key={ot} style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
          <span style={{ ...insStyles.label, width: 92, flexShrink: 0 }}>{otLabel}</span>
          <span style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
            {TAX_FINISH_STATES.map(([fs, fsLabel]) => <TaxChip key={fs || 'none'} active={has(ot, fs)} onClick={() => toggle(ot, fs)}>{fsLabel}</TaxChip>)}
          </span>
        </div>
      ))}
    </div>
  );
}

function TaxStateDrawer({ rule, rules, catalog, onSave, onClose }) {
  const [draft, setDraft] = taxUseState(rule ? taxClone(rule) : taxNewState());
  const p = (k, v) => setDraft((d) => ({ ...d, [k]: v }));
  const tree = { off: draft.off || [], on: draft.on || [] };
  const orphans = taxOrphans(catalog, tree.off.concat(tree.on));
  const conflicts = taxMatchConflicts((rules || []).filter((r) => r.id !== draft.id), draft.match);
  const errors = draft.name && draft.name.trim() ? [] : ['Название обязательно'];
  const cleanOrphans = () => { const gone = new Set(orphans); setDraft((d) => ({ ...d, off: (d.off || []).filter((x) => !gone.has(x)), on: (d.on || []).filter((x) => !gone.has(x)) })); };
  return (
    <InspectorDrawer title={rule ? 'Редактировать состояние' : 'Новое состояние'} onClose={onClose} width={680}
      footer={
        <React.Fragment>
          <button type="button" onClick={onClose} style={ctorStyles.btnGhost}>Отмена</button>
          <button type="button" onClick={() => { onSave({ ...draft, name: draft.name.trim(), match: draft.match || [], off: tree.off, on: tree.on }); onClose(); }} disabled={errors.length > 0}
            style={{ ...ctorStyles.btnPrimary, opacity: errors.length > 0 ? 0.45 : 1 }}>Сохранить</button>
        </React.Fragment>
      }>
      <InspectorSection title="Состояние">
        <FText label="Название" value={draft.name} onChange={(v) => p('name', v)} placeholder="White box" />
        <FText label="Подсказка" value={draft.note} onChange={(v) => p('note', v)} multiline
          placeholder="Что происходит со сметой в этом состоянии" hint="Показывается в анкете объекта и в мастере сметы" />
      </InspectorSection>
      <InspectorSection title="Для анкеты" lead="Когда ответы анкеты подходят под отмеченные сочетания, правило применяется само. Без отметок оно срабатывает только по выбору менеджера в мастере сметы.">
        <TaxMatchEditor value={draft.match || []} onChange={(v) => p('match', v)} />
        {conflicts.length > 0 && (
          <InspectorCallout variant="warn">
            Эти ответы анкеты уже закрывает {conflicts.map((n) => '«' + n + '»').join(', ')} — в анкете сработает то правило, что выше в списке.
          </InspectorCallout>
        )}
      </InspectorSection>
      <InspectorSection full title={'Что меняется в смете · выкл ' + tree.off.length + ' · вкл ' + tree.on.length}
        lead="Работы и этапы каталога, которые правило выключает или включает поверх шаблона сметы. «Как в шаблоне» — правило узел не трогает. У выключенного этапа отдельную работу можно включить.">
        <TaxNodeTree catalog={catalog} value={tree} onChange={(v) => setDraft((d) => ({ ...d, off: v.off, on: v.on }))} />
        <TaxOrphanNote count={orphans.length} onClean={cleanOrphans} />
      </InspectorSection>
      {errors.length > 0 && <div style={{ color: 'var(--destructive)', fontSize: 12.5 }}>{errors.join(' · ')}</div>}
    </InspectorDrawer>
  );
}

function StateRulesPanel({ rules, setRules, catalog, focusId }) {
  const [editing, setEditing] = taxUseState(null); // null | 'new' | rule
  const flashId = taxUseFlash(focusId, 'tax-st-');
  const save = (rule) => setRules((list) => (list.some((x) => x.id === rule.id) ? list.map((x) => (x.id === rule.id ? rule : x)) : [...list, rule]));
  const duplicate = (rule) => setRules((list) => [...list, { ...taxClone(rule), id: taxNewId('st_'), name: (rule.name || 'Без названия') + ' (копия)', match: [] }]);
  const del = (rule) => {
    if (!confirm('Удалить состояние «' + (rule.name || 'Без названия') + '»? В уже собранных сметах ничего не изменится.')) return;
    setRules((list) => list.filter((x) => x.id !== rule.id));
  };
  return (
    <div className="opt-panel">
      <header className="opt-toolbar">
        <div>
          <h2 className="opt-heading">Исходное состояние</h2>
          <p className="opt-lead">Какие этапы и работы включать и выключать, когда объект приходит в определённом состоянии: новостройка в бетоне, white box, ремонт без демонтажа. Правило ложится поверх шаблона сметы — по ответам анкеты или по выбору менеджера в мастере сметы.</p>
        </div>
        <button type="button" className="opt-add-btn" onClick={() => setEditing('new')}>+ Состояние</button>
      </header>
      {!rules.length && <div className="pkg-empty">Состояний нет — смета строится по шаблону без поправок. Создайте первое: например, «White box».</div>}
      <div className="pkg-grid">
        {rules.map((rule) => {
          const match = rule.match || [];
          const shown = match.slice(0, 3);
          return (
            <div key={rule.id} id={'tax-st-' + rule.id} className="pkg-card" style={flashId === rule.id ? taxS.flash : undefined}>
              <div className="pkg-card-ic" style={{ background: 'var(--secondary)' }}><TaxIcon name="hard-hat" /></div>
              <div className="pkg-card-body">
                <div className="pkg-card-name">{rule.name || 'Без названия'}</div>
                {rule.note && <div className="pkg-card-desc" style={{ display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden' }} title={rule.note}>{rule.note}</div>}
                <div className="pkg-card-meta">
                  {shown.map((m, i) => <span key={i} className="chip chip--data">{taxMatchLabel(m)}</span>)}
                  {match.length > shown.length && <span className="chip chip--data" title={match.map(taxMatchLabel).join('\n')}>+{match.length - shown.length}</span>}
                  {!match.length && <span className="chip chip--ghost" title="Применяется только по выбору менеджера в мастере сметы">только вручную</span>}
                </div>
                <div className="pkg-card-meta">
                  <span>выкл {(rule.off || []).length} · вкл {(rule.on || []).length}</span>
                </div>
              </div>
              <div className="pkg-card-tools">
                <button type="button" onClick={() => setEditing(rule)} title="Редактировать">✎</button>
                <button type="button" onClick={() => duplicate(rule)} title="Дублировать (без привязки к анкете)">⧉</button>
                <button type="button" className="danger" onClick={() => del(rule)} title="Удалить">✕</button>
              </div>
            </div>
          );
        })}
      </div>
      {editing && (
        <TaxStateDrawer rule={editing === 'new' ? null : editing} rules={rules} catalog={catalog}
          onSave={save} onClose={() => setEditing(null)} />
      )}
    </div>
  );
}

/* ============ Комнаты — справочник типов помещений ============ */
// Спека 2026-10-19-object-rooms-design §1: какие помещения бывают в объекте (для замера и сметы). Работа ссылается на тип по id (поле rooms),
// отметки ставятся во «Всех работах», набор колонок «Комнаты». Правила «Одной комнаты» (ctor_room_rules_v1) — данные движка до В3, без редактора.
const TAX_RT_ROW = { display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '4px 8px', padding: '5px 10px' };
const TAX_RT_FIELD = { fontFamily: 'inherit', fontSize: 12.5, color: 'var(--foreground)', background: 'var(--input-background)', border: '1px solid var(--c-border)', borderRadius: 8, padding: '4px 8px', outline: 'none' };
const TAX_RT_DEL = { width: 26, height: 26, padding: 0, border: 'none', borderRadius: 7, background: 'transparent', color: 'var(--text-tertiary)', cursor: 'pointer', flexShrink: 0 };

function TaxRoomTypeRow({ t, count, flash, canDrop, onPatch, onDrop }) {
  const [draft, setDraft] = taxUseState(null); // null — в поле сохранённое название
  const skip = taxUseRef(false); // Esc: blur, который он вызывает, ничего не сохраняет
  const commit = () => {
    if (skip.current) { skip.current = false; setDraft(null); return; }
    if (draft === null) return;
    setDraft(null);
    const v = draft.trim();
    if (v && v !== t.name) onPatch({ name: v }); // пустое возвращает прежнее
  };
  return (
    <div id={'tax-rt-' + t.id} style={{ ...TAX_RT_ROW, ...(flash ? taxS.flash : null) }}>
      <input aria-label="Название" value={draft === null ? t.name : draft}
        style={{ ...TAX_RT_FIELD, flex: '1 1 160px', minWidth: 0 }}
        onChange={(e) => setDraft(e.target.value)} onFocus={(e) => e.target.select()} onBlur={commit}
        onKeyDown={(e) => {
          if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); }
          else if (e.key === 'Escape') { e.preventDefault(); skip.current = true; e.currentTarget.blur(); }
        }} />
      <TaxChip active={t.wet} onClick={() => onPatch({ wet: !t.wet })} title="Помещение с водой">мокрое</TaxChip>
      <TaxChip active={t.outdoor} onClick={() => onPatch({ outdoor: !t.outdoor })} title="Вне здания">наружное</TaxChip>
      <select aria-label="Группа" value={t.group} onChange={(e) => onPatch({ group: e.target.value })}
        style={{ ...TAX_RT_FIELD, appearance: 'auto', cursor: 'pointer' }}>
        {window.ConstructorData.ROOM_GROUPS.map((g) => <option key={g.id} value={g.id}>{g.label}</option>)}
      </select>
      <span style={{ ...taxS.mute, minWidth: 72, textAlign: 'right' }}>{count} {taxPlural(count, 'работа', 'работы', 'работ')}</span>
      {canDrop
        ? <button type="button" title="Удалить" aria-label={'Удалить «' + (t.name || 'Без названия') + '»'} style={TAX_RT_DEL} onClick={onDrop}>✕</button>
        : <button type="button" disabled title="Удаление меняет отметки в каталоге — нужно право на каталог" aria-label={'Удалить «' + (t.name || 'Без названия') + '» — нет права на каталог'} style={{ ...TAX_RT_DEL, opacity: 0.35, cursor: 'not-allowed' }}>✕</button>}
    </div>
  );
}

function RoomTypesPanel({ types, setTypes, catalog, setCatalog, focusId, canDrop }) {
  const CD = window.ConstructorData;
  const [newId, setNewId] = taxUseState(null); // только что добавленный тип — его название сразу в правке
  const flashId = taxUseFlash(focusId, 'tax-rt-');
  taxUseEffect(() => {
    if (!newId) return;
    const row = document.getElementById('tax-rt-' + newId);
    const input = row && row.querySelector('input');
    if (input) input.focus();
    setNewId(null);
  }, [newId]);
  const counts = React.useMemo(() => CD.roomTypeCounts(catalog), [catalog]);
  const add = (group) => {
    const t = CD.newRoomType(types, group);
    setNewId(t.id);
    setTypes((list) => list.concat([t]));
  };
  const patch = (id, change) => setTypes((list) => list.map((x) => (x.id === id ? { ...x, ...change } : x)));
  const drop = (t) => {
    if (!canDrop) return; // удаление типа снимает отметки в каталоге: нужно право на каталог (страница передаёт canDrop)
    const name = t.name || 'Без названия';
    if (!confirm('Удалить «' + name + '»? Отметки во «Всех работах» снимутся. В сметах комната станет «Другое».')) return;
    setTypes((list) => list.filter((x) => x.id !== t.id));
    setCatalog((cat) => window.ConstructorData.dropRoomType(cat, t.id));
  };
  return (
    <div className="opt-panel">
      <header className="opt-toolbar">
        <div>
          <h2 className="opt-heading">Комнаты</h2>
          <p className="opt-lead">Типы помещений для замера и сметы. Какие работы идут в комнату — во «Всех работах», набор колонок «Комнаты».</p>
        </div>
      </header>
      {!types.length && <div className="pkg-empty">Типов нет. Добавьте первый: например, «Кухня».</div>}
      {CD.ROOM_GROUPS.map((g) => {
        const rows = types.filter((t) => t.group === g.id);
        return (
          <section key={g.id} aria-label={g.label} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
            <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
              <h3 style={{ ...taxS.phase, margin: 0, padding: '6px 2px 0' }}>{g.label}</h3>
              <button type="button" className="opt-add-btn" onClick={() => add(g.id)}>+ тип</button>
            </div>
            {rows.length > 0 && (
              <div className="opt-list">
                {rows.map((t, i) => (
                  <div key={t.id} style={i > 0 ? { borderTop: '1px solid color-mix(in srgb, var(--border-subtle) 65%, transparent)' } : undefined}>
                    <TaxRoomTypeRow t={t} count={counts[t.id] || 0} flash={flashId === t.id} canDrop={!!canDrop}
                      onPatch={(change) => patch(t.id, change)} onDrop={() => drop(t)} />
                  </div>
                ))}
              </div>
            )}
          </section>
        );
      })}
    </div>
  );
}

/* ============ Тип объекта ============ */
const TAX_PROPERTY_TYPES = [['apartment', 'Квартира'], ['house', 'Дом'], ['commercial', 'Коммерция']];
const TAX_FLOORS = [['', 'любое'], ['1', '1 этаж'], ['2', '2 этажа']];
const TAX_PURPOSES = [['', 'любое'], ['office', 'офис / студия'], ['other', 'другое']];
const TAX_PROPERTY_ICONS = { apartment: 'building-2', house: 'home', commercial: 'store' };

function taxNewObject() { return { id: taxNewId('ot_'), name: '', note: '', match: {}, off: [], on: [] }; }
function taxObjectMatchLabel(m) {
  const pt = TAX_PROPERTY_TYPES.find((x) => x[0] === (m && m.propertyType));
  if (!pt) return 'только вручную';
  const parts = [pt[1]];
  if (pt[0] === 'house' && m.floors) parts.push(m.floors + ' ' + taxPlural(+m.floors, 'этаж', 'этажа', 'этажей'));
  if (pt[0] === 'commercial' && m.purpose) { const p = TAX_PURPOSES.find((x) => x[0] === m.purpose); parts.push(p ? p[1] : m.purpose); }
  return parts.join(' · ');
}
// a покрывает b: любая анкета, подходящая под b, подходит и под a (a стоит выше → b до анкеты не доходит)
function taxObjectCovers(a, b) {
  if (!a || !b || !a.propertyType || a.propertyType !== b.propertyType) return false;
  if (a.floors != null && a.floors !== '' && String(a.floors) !== String(b.floors == null ? '' : b.floors)) return false;
  if (a.purpose && a.purpose !== (b.purpose || '')) return false;
  return true;
}
// название правила выше в списке, которое перекрывает это (общее правило над точным «съедает» его); null — перекрытия нет
function taxObjectShadow(rules, rule) {
  const list = rules || [];
  const at = list.findIndex((r) => r.id === rule.id);
  const hit = (at < 0 ? list : list.slice(0, at)).find((r) => taxObjectCovers(r.match, rule.match));
  return hit ? (hit.name || 'Без названия') : null;
}

/* «Для анкеты»: тип недвижимости × этажей (у дома) × назначение (у коммерции). Без типа недвижимости — только вручную */
function TaxObjectMatchEditor({ value, onChange }) {
  const m = value || {};
  const setType = (pt) => onChange(pt ? { propertyType: pt } : {}); // другой тип — уточнения сбрасываем
  const setFloors = (f) => { const n = { ...m }; if (f) n.floors = +f; else delete n.floors; onChange(n); };
  const setPurpose = (p) => { const n = { ...m }; if (p) n.purpose = p; else delete n.purpose; onChange(n); };
  const row = (label, children) => (
    <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
      <span style={{ ...insStyles.label, width: 120, flexShrink: 0 }}>{label}</span>
      <span style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>{children}</span>
    </div>
  );
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
      {row('Тип недвижимости', (
        <React.Fragment>
          <TaxChip active={!m.propertyType} onClick={() => setType('')} title="Правило применяется только по выбору менеджера в мастере сметы">только вручную</TaxChip>
          {TAX_PROPERTY_TYPES.map(([k, l]) => <TaxChip key={k} active={m.propertyType === k} onClick={() => setType(k)}>{l}</TaxChip>)}
        </React.Fragment>
      ))}
      {m.propertyType === 'house' && row('Этажей', TAX_FLOORS.map(([k, l]) => <TaxChip key={k || 'any'} active={String(m.floors || '') === k} onClick={() => setFloors(k)}>{l}</TaxChip>))}
      {m.propertyType === 'commercial' && row('Назначение', TAX_PURPOSES.map(([k, l]) => <TaxChip key={k || 'any'} active={(m.purpose || '') === k} onClick={() => setPurpose(k)}>{l}</TaxChip>))}
    </div>
  );
}

// имена этапов и работ каталога по id — для строк условий
function taxNodeNames(catalog) {
  const m = {};
  (catalog || []).forEach((ph) => (ph.stages || []).forEach((st) => { m[st.id] = st.name; (st.tasks || []).forEach((tk) => { m[tk.id] = tk.name; }); }));
  return m;
}
// строка условия типа объекта (запись onWhen): «От 2 этажей: Межэтажное перекрытие, Лестница.»; label — CtorTypeMatrix.whenLabel(c)
function taxWhenText(label, c, names) {
  return label.charAt(0).toUpperCase() + label.slice(1) + ': ' + ((c && c.nodes) || []).map((id) => (names && names[id]) || id).join(', ') + '.';
}
// этапы условий в дереве правила не переключаются: вместо переключателя у них метка условия
function taxFixedLabels(onWhen) {
  const out = {};
  (onWhen || []).forEach((c) => ((c && c.nodes) || []).forEach((id) => { if (!out[id]) out[id] = window.CtorTypeMatrix.whenLabel(c); }));
  return out;
}

/* «Помещения по умолчанию» типа объекта (спека объектов и комнат §4): value — defaultRooms [{ type, name, floor }], types — справочник
   типов помещений (ConstructorData.loadRoomTypes). Группы — CD.roomFloors: «Этаж N», затем «Снаружи» (floor: null).
   «+ этаж» добавляет пустой этаж N+1 только в редакторе: без комнат он не сохраняется. */
const TAX_DR_BARE = { border: 'none', background: 'none', padding: 0, font: 'inherit', color: 'inherit', cursor: 'pointer' };
const TAX_DR_PICK = { ...TAX_RT_FIELD, appearance: 'auto', cursor: 'pointer', padding: '2px 6px', fontSize: 11.5 };

function TaxDefaultRooms({ value, onChange, types }) {
  const CD = window.ConstructorData;
  const list = Array.isArray(value) ? value : [];
  const all = Array.isArray(types) ? types : [];
  const [extra, setExtra] = taxUseState([]); // этажи без комнат — живут только в редакторе
  const groups = CD.roomFloors(list);
  const byFloor = new Map(groups.map((g) => [g.floor, g.rooms]));
  const floors = taxUniq(groups.map((g) => g.floor).filter((f) => f !== null).concat(extra)).sort((a, b) => a - b);
  const label = (r) => r.name || CD.roomTypeOf(all, r.type).name;
  const add = (type, floor) => {
    const t = all.find((x) => x.id === type);
    if (t) onChange(list.concat([{ type, name: t.name, floor }]));
  };
  const remove = (r, floor) => {
    onChange(list.filter((x) => x !== r));
    if (floor !== null) setExtra((e) => taxUniq(e.concat([floor]))); // последняя комната этажа: сам этаж остаётся, пока открыт редактор
  };
  const rename = (r) => {
    const v = window.prompt('Название помещения', label(r));
    if (v !== null) onChange(list.map((x) => (x === r ? { ...x, name: String(v).trim() } : x)));
  };
  const addFloor = () => setExtra((e) => taxUniq(e.concat([(floors.length ? floors[floors.length - 1] : 0) + 1])));
  const picker = (floor) => (
    <select aria-label={'Добавить комнату: ' + (floor === null ? 'снаружи' : 'этаж ' + floor)} value="" style={TAX_DR_PICK}
      onChange={(e) => { if (e.target.value) add(e.target.value, floor); }}>
      <option value="">+ комната</option>
      {floor === null
        ? all.filter((t) => t.outdoor).map((t) => <option key={t.id} value={t.id}>{t.name}</option>)
        : CD.ROOM_GROUPS.map((g) => {
          const ts = all.filter((t) => !t.outdoor && t.group === g.id);
          return ts.length ? <optgroup key={g.id} label={g.label}>{ts.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</optgroup> : null;
        })}
    </select>
  );
  const group = (floor) => (
    <div key={floor === null ? 'out' : floor} style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
      <span style={{ ...insStyles.label, width: 64, flexShrink: 0, paddingTop: 3 }}>{floor === null ? 'Снаружи' : 'Этаж ' + floor}</span>
      <span style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', flex: 1, minWidth: 0 }}>
        {(byFloor.get(floor) || []).map((r, i) => (
          <span key={i} className="chip chip--data" style={{ gap: 4 }}>
            <button type="button" style={TAX_DR_BARE} title="Переименовать" onClick={() => rename(r)}>{label(r)}</button>
            <button type="button" style={TAX_DR_BARE} title="Убрать" aria-label={'Убрать «' + label(r) + '»'} onClick={() => remove(r, floor)}>×</button>
          </span>
        ))}
        {picker(floor)}
      </span>
    </div>
  );
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
      {floors.map(group)}
      {group(null)}
      <div><button type="button" className="chip chip--control" onClick={addFloor}>+ этаж</button></div>
    </div>
  );
}

function TaxObjectDrawer({ rule, rules, catalog, onSave, onClose }) {
  const [draft, setDraft] = taxUseState(rule ? taxClone(rule) : taxNewObject());
  const [types] = taxUseState(() => window.ConstructorData.loadRoomTypes());
  const p = (k, v) => setDraft((d) => ({ ...d, [k]: v }));
  const tree = { off: draft.off || [], on: draft.on || [] };
  const orphans = taxOrphans(catalog, tree.off.concat(tree.on));
  const shadow = draft.match && draft.match.propertyType ? taxObjectShadow(rules, draft) : null;
  const names = taxNodeNames(catalog);
  const errors = draft.name && draft.name.trim() ? [] : ['Название обязательно'];
  const cleanOrphans = () => { const gone = new Set(orphans); setDraft((d) => ({ ...d, off: (d.off || []).filter((x) => !gone.has(x)), on: (d.on || []).filter((x) => !gone.has(x)) })); };
  return (
    <InspectorDrawer title={rule ? 'Редактировать тип объекта' : 'Новый тип объекта'} onClose={onClose} width={680}
      footer={
        <React.Fragment>
          <button type="button" onClick={onClose} style={ctorStyles.btnGhost}>Отмена</button>
          <button type="button" onClick={() => { onSave({ ...draft, name: draft.name.trim(), match: draft.match || {}, off: tree.off, on: tree.on }); onClose(); }} disabled={errors.length > 0}
            style={{ ...ctorStyles.btnPrimary, opacity: errors.length > 0 ? 0.45 : 1 }}>Сохранить</button>
        </React.Fragment>
      }>
      <InspectorSection title="Тип объекта">
        <FText label="Название" value={draft.name} onChange={(v) => p('name', v)} placeholder="Частный дом" />
        <FText label="Подсказка" value={draft.note} onChange={(v) => p('note', v)} multiline
          placeholder="Что входит в смету этого типа объекта" hint="Показывается в мастере сметы" />
      </InspectorSection>
      <InspectorSection title="Для анкеты" lead="Когда ответы анкеты («Тип жилья», этажей, назначение) подходят, правило применяется само: берётся первое подходящее сверху, поэтому точные правила (с этажами и назначением) держите выше общих. Без типа недвижимости правило выбирают вручную в мастере сметы.">
        <TaxObjectMatchEditor value={draft.match || {}} onChange={(v) => p('match', v)} />
        {(draft.onWhen || []).map((c, i) => <div key={i} style={insStyles.hint}>{taxWhenText(window.CtorTypeMatrix.whenLabel(c), c, names)}</div>)}
        {shadow && (
          <InspectorCallout variant="warn">
            Эти ответы анкеты уже закрывает «{shadow}» выше в списке — это правило сработает только при выборе вручную. Поднимите его выше.
          </InspectorCallout>
        )}
      </InspectorSection>
      <InspectorSection title="Помещения по умолчанию" lead="Замер предложит эти этажи и комнаты, пока комнат ещё нет. Размеры — на замере.">
        <TaxDefaultRooms value={draft.defaultRooms} types={types} onChange={(v) => p('defaultRooms', v)} />
      </InspectorSection>
      <InspectorSection full title={'Что меняется в смете · выкл ' + tree.off.length + ' · вкл ' + tree.on.length}
        lead="Этапы дома и коммерции в смету сами не попадают — их включает тип объекта, а остальные типы объекта (например, квартира) выключают. Правило ложится поверх шаблона сметы и до исходного состояния. «Как в шаблоне» — правило узел не трогает.">
        <TaxNodeTree catalog={catalog} value={tree} fixed={taxFixedLabels(draft.onWhen)} onChange={(v) => setDraft((d) => ({ ...d, off: v.off, on: v.on }))} />
        <TaxOrphanNote count={orphans.length} onClean={cleanOrphans} />
      </InspectorSection>
      {errors.length > 0 && <div style={{ color: 'var(--destructive)', fontSize: 12.5 }}>{errors.join(' · ')}</div>}
    </InspectorDrawer>
  );
}

function ObjectRulesPanel({ rules, setRules, catalog, focusId }) {
  const [editing, setEditing] = taxUseState(null); // null | 'new' | rule
  const flashId = taxUseFlash(focusId, 'tax-ot-');
  const save = (rule) => setRules((list) => (list.some((x) => x.id === rule.id) ? list.map((x) => (x.id === rule.id ? rule : x)) : [...list, rule]));
  const duplicate = (rule) => setRules((list) => [...list, { ...taxClone(rule), id: taxNewId('ot_'), name: (rule.name || 'Без названия') + ' (копия)', match: {} }]);
  const del = (rule) => {
    if (!confirm('Удалить тип объекта «' + (rule.name || 'Без названия') + '»? В уже собранных сметах ничего не изменится.')) return;
    setRules((list) => list.filter((x) => x.id !== rule.id));
  };
  // порядок важен: в анкете срабатывает первое подходящее правило
  const move = (rule, dir) => setRules((list) => {
    const i = list.findIndex((x) => x.id === rule.id), j = i + dir;
    if (i < 0 || j < 0 || j >= list.length) return list;
    const next = list.slice();
    next[i] = list[j]; next[j] = list[i];
    return next;
  });
  return (
    <div className="opt-panel">
      <header className="opt-toolbar">
        <div>
          <h2 className="opt-heading">Тип объекта</h2>
          <p className="opt-lead">Дом и коммерческое помещение — свои этапы сверх квартирного каталога: котельная, скважина, септик и лестница дома; согласования, пожарная сигнализация, вентиляция, СКС и потолок офиса. Правило включает нужные этапы и выключает чужие — по ответам анкеты («Тип жилья», этажей, назначение) или по выбору менеджера в мастере сметы. Квартира выключает все домовые и коммерческие этапы, поэтому квартирные сметы не меняются.</p>
        </div>
        <button type="button" className="opt-add-btn" onClick={() => setEditing('new')}>+ Тип объекта</button>
      </header>
      {!rules.length && <div className="pkg-empty">Типов объекта нет — смета строится по шаблону без домовых и коммерческих этапов. Создайте первый: например, «Частный дом».</div>}
      <div className="pkg-grid">
        {rules.map((rule, i) => {
          const match = rule.match || {};
          const shadow = match.propertyType ? taxObjectShadow(rules, rule) : null;
          return (
            <div key={rule.id} id={'tax-ot-' + rule.id} className="pkg-card" style={flashId === rule.id ? taxS.flash : undefined}>
              <div className="pkg-card-ic" style={{ background: 'var(--secondary)' }}><TaxIcon name={TAX_PROPERTY_ICONS[match.propertyType] || 'layers'} /></div>
              <div className="pkg-card-body">
                <div className="pkg-card-name">{rule.name || 'Без названия'}</div>
                {rule.note && <div className="pkg-card-desc" style={{ display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden' }} title={rule.note}>{rule.note}</div>}
                <div className="pkg-card-meta">
                  <span className={'chip ' + (match.propertyType ? 'chip--data' : 'chip--ghost')} title={match.propertyType ? undefined : 'Применяется только по выбору менеджера в мастере сметы'}>{taxObjectMatchLabel(match)}</span>
                  {shadow && <span className="pkg-err-badge" title={'Эти ответы анкеты уже закрывает «' + shadow + '» выше в списке'}>⚠ перекрыто</span>}
                </div>
                <div className="pkg-card-meta">
                  <span>выкл {(rule.off || []).length} · вкл {(rule.on || []).length}</span>
                </div>
              </div>
              <div className="pkg-card-tools">
                {i > 0 && <button type="button" onClick={() => move(rule, -1)} title="Выше — в анкете проверяется раньше">↑</button>}
                {i < rules.length - 1 && <button type="button" onClick={() => move(rule, 1)} title="Ниже">↓</button>}
                <button type="button" onClick={() => setEditing(rule)} title="Редактировать">✎</button>
                <button type="button" onClick={() => duplicate(rule)} title="Дублировать (без привязки к анкете)">⧉</button>
                <button type="button" className="danger" onClick={() => del(rule)} title="Удалить">✕</button>
              </div>
            </div>
          );
        })}
      </div>
      {editing && (
        <TaxObjectDrawer rule={editing === 'new' ? null : editing} rules={rules} catalog={catalog}
          onSave={save} onClose={() => setEditing(null)} />
      )}
    </div>
  );
}

Object.assign(window, { ObjectRulesPanel, StateRulesPanel, RoomTypesPanel, TaxNodeTree });
