// Shared UI primitives
const { useState, useEffect, useMemo, useRef, useCallback } = React;

// Семантические action-tones для иконок. CSS-правила в styles.jsx
// красят SVG в зависимости от data-tone (синий редакт, красный удал, зелёный плюс/чек),
// автоматически перекрашиваясь в белый внутри btn-primary/btn-cta.
const ICON_TONES = {
  edit: 'edit', trash: 'del', plus: 'add', minus: 'del',
  check: 'ok', close: 'mute', x: 'mute', alert: 'warn',
  download: 'edit', sync: 'edit',
};
function Icon({ name, size = 16, stroke = 1.75, tone }) {
  const paths = {
    scan: <><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M7 12h10"/></>,
    box: <><path d="M21 8L12 3 3 8v8l9 5 9-5V8z"/><path d="M3 8l9 5 9-5"/><path d="M12 13v8"/></>,
    warehouse: <><path d="M22 8.35V20a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V8.35a1 1 0 0 1 .56-.9l9-4.5a1 1 0 0 1 .88 0l9 4.5a1 1 0 0 1 .56.9z"/><path d="M6 18V9"/><path d="M10 18V9"/><path d="M14 18V9"/><path d="M18 18V9"/></>,
    factory: <><path d="M2 20h20"/><path d="M17 20V10l-6 4V10l-6 4V4H2v16"/><path d="M13 20v-4"/><path d="M17 20v-4"/></>,
    truck: <><path d="M10 17h4V5H2v12h3"/><path d="M20 17h2v-3.34a4 4 0 0 0-1.17-2.83L19 9h-5v8h1"/><circle cx="7.5" cy="17.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/></>,
    search: <><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></>,
    bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></>,
    settings: <><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 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.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 0 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 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 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-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></>,
    close: <><path d="M18 6L6 18"/><path d="M6 6l12 12"/></>,
    x: <><path d="M18 6L6 18"/><path d="M6 6l12 12"/></>,
    check: <><path d="M20 6L9 17l-5-5"/></>,
    plus: <><path d="M12 5v14"/><path d="M5 12h14"/></>,
    minus: <><path d="M5 12h14"/></>,
    arrow_right: <><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></>,
    arrow_left: <><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></>,
    calculator: <><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="16" y1="14" x2="16" y2="18"/><line x1="8" y1="14" x2="8" y2="14"/><line x1="12" y1="14" x2="12" y2="14"/><line x1="8" y1="18" x2="8" y2="18"/><line x1="12" y1="18" x2="12" y2="18"/></>,
    alert: <><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></>,
    sync: <><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></>,
    cloud: <><path d="M17.5 19a4.5 4.5 0 1 0 0-9h-1.8A7 7 0 1 0 4 15.3"/></>,
    clock: <><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></>,
    user: <><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></>,
    printer: <><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></>,
    camera: <><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></>,
    flash: <><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></>,
    list: <><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></>,
    grid: <><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></>,
    dot: <circle cx="12" cy="12" r="4"/>,
    trending_down: <><polyline points="23 18 13.5 8.5 8.5 13.5 1 6"/><polyline points="17 18 23 18 23 12"/></>,
    trending_up: <><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></>,
    download: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></>,
    phone: <><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></>,
    mobile: <><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></>,
    crown: <><path d="M2 18h20l-2-10-5 4-5-7-5 7-5-4z"/></>,
    shield: <><path d="M12 2l9 4v6c0 5-3.5 9-9 10-5.5-1-9-5-9-10V6l9-4z"/></>,
    server: <><rect x="2" y="4" width="20" height="7" rx="1"/><rect x="2" y="13" width="20" height="7" rx="1"/><line x1="6" y1="7.5" x2="6.01" y2="7.5"/><line x1="6" y1="16.5" x2="6.01" y2="16.5"/></>,
    users: <><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"/></>,
    paperclip: <><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.49"/></>,
    send: <><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></>,
    mic: <><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></>,
    edit: <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></>,
    trash: <><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></>,
    play: <><polygon points="5 3 19 12 5 21 5 3"/></>,
    key: <><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></>,
    database: <><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v6c0 1.66 4 3 9 3s9-1.34 9-3V5"/><path d="M3 11v6c0 1.66 4 3 9 3s9-1.34 9-3v-6"/></>,
    plug: <><path d="M9 2v6M15 2v6M6 9h12l-1 7a5 5 0 0 1-10 0L6 9zM12 16v6"/></>,
    palette: <><path d="M12 2a10 10 0 1 0 0 20 1.5 1.5 0 0 0 1.06-2.56A1.5 1.5 0 0 1 14.12 17H16a6 6 0 0 0 6-6c0-5-4.48-9-10-9z"/><circle cx="7.5" cy="10.5" r="1" fill="currentColor"/><circle cx="12" cy="7.5" r="1" fill="currentColor"/><circle cx="16.5" cy="10.5" r="1" fill="currentColor"/></>,
    copy: <><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></>,
    move: <><polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></>,
  };
  const resolvedTone = tone || ICON_TONES[name] || null;
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" style={{flexShrink:0}} data-tone={resolvedTone}>
      {paths[name] || paths.dot}
    </svg>
  );
}

// Logo — minimalist beam mark
function Logo({ size = 22 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
      <circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.5" />
      <circle cx="12" cy="12" r="4" fill="currentColor" />
      <path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
    </svg>
  );
}

// ============================================================================
// ViewToggle — переиспользуемый сегмент-контрол «иконки ↔ список».
// Две кнопки: grid («иконки») и list («список»). Активная подсвечена --accent.
// useViewMode(storageKey, defaultMode) — хранит выбор в localStorage.
// CSS инжектится локально (по образцу injectCechCss), а не в styles.jsx.
// ============================================================================
const VIEW_TOGGLE_CSS = `
.view-toggle {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  padding: 2px;
  border: 1px solid var(--border);
  border-radius: 9px;
  background: var(--bg-sunk);
}
.view-toggle__btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  background: transparent;
  color: var(--fg-muted);
  border-radius: 7px;
  padding: 5px 9px;
  cursor: pointer;
  transition: background .15s, color .15s, box-shadow .15s;
}
.view-toggle__btn:hover:not(.is-active) { color: var(--fg); }
.view-toggle__btn.is-active {
  background: var(--accent);
  color: #fff;
  box-shadow: 0 1px 3px color-mix(in oklch, var(--accent) 40%, transparent);
}
.view-toggle--sm .view-toggle__btn { padding: 4px 7px; }
.view-toggle--lg .view-toggle__btn { padding: 7px 12px; }
`;
function injectViewToggleCss() {
  const id = '__view_toggle_css';
  if (typeof document === 'undefined' || document.getElementById(id)) return;
  const el = document.createElement('style');
  el.id = id;
  el.textContent = VIEW_TOGGLE_CSS;
  document.head.appendChild(el);
}

// ViewToggle — { value: 'grid'|'list', onChange(mode), size: 'sm'|'md'|'lg' }.
function ViewToggle({ value = 'grid', onChange, size = 'md' }) {
  React.useEffect(() => { injectViewToggleCss(); }, []);
  const iconSize = size === 'sm' ? 14 : size === 'lg' ? 18 : 16;
  const sizeCls = size === 'sm' ? ' view-toggle--sm' : size === 'lg' ? ' view-toggle--lg' : '';
  const handle = (mode) => () => { if (onChange && mode !== value) onChange(mode); };
  return (
    <div className={'view-toggle' + sizeCls} role="group" aria-label="Вид отображения">
      <button type="button"
              className={'view-toggle__btn' + (value === 'grid' ? ' is-active' : '')}
              onClick={handle('grid')}
              aria-pressed={value === 'grid'}
              title="Иконки">
        <Icon name="grid" size={iconSize} tone="mute"/>
      </button>
      <button type="button"
              className={'view-toggle__btn' + (value === 'list' ? ' is-active' : '')}
              onClick={handle('list')}
              aria-pressed={value === 'list'}
              title="Список">
        <Icon name="list" size={iconSize} tone="mute"/>
      </button>
    </div>
  );
}

// useViewMode — выбор вида с персистом в localStorage по ключу storageKey.
// Возвращает [mode, setMode]; mode ∈ ('grid'|'list').
function useViewMode(storageKey, defaultMode = 'grid') {
  const read = () => {
    try {
      const v = window.localStorage.getItem(storageKey);
      return (v === 'grid' || v === 'list') ? v : defaultMode;
    } catch (_) { return defaultMode; }
  };
  const [mode, setModeState] = React.useState(read);
  const setMode = React.useCallback((next) => {
    const v = (next === 'grid' || next === 'list') ? next : defaultMode;
    setModeState(v);
    try { window.localStorage.setItem(storageKey, v); } catch (_) {}
  }, [storageKey, defaultMode]);
  return [mode, setMode];
}

// Toast system
const ToastCtx = React.createContext({ push: () => {} });
function ToastProvider({ children }) {
  const [toasts, setToasts] = useState([]);
  const push = useCallback((msg, kind='info') => {
    const id = Math.random().toString(36).slice(2);
    setToasts(ts => [...ts, { id, msg, kind }]);
    setTimeout(() => setToasts(ts => ts.filter(t => t.id !== id)), 2800);
  }, []);
  // A4: мост к window.toast(...) — позволяет тригерить тосты из
  // не-React кода (data_materials.jsx wrappers, API client при оффлайне).
  // Используется CustomEvent('app-toast') чтобы не дублировать состояние.
  useEffect(() => {
    const h = (e) => {
      const d = (e && e.detail) || {};
      push(d.msg || '', d.kind || 'info');
    };
    window.addEventListener('app-toast', h);
    return () => window.removeEventListener('app-toast', h);
  }, [push]);
  return (
    <ToastCtx.Provider value={{ push }}>
      {children}
      <div className="toast-stack">
        {toasts.map(t => (
          <div key={t.id} className="toast">
            <Icon name={t.kind === 'error' ? 'alert' : 'check'} size={14} />
            {t.msg}
          </div>
        ))}
      </div>
    </ToastCtx.Provider>
  );
}
const useToast = () => React.useContext(ToastCtx);

// Глобальный shortcut — для не-React кода (data_materials, API client).
// Сам тост покажет ToastProvider, который слушает 'app-toast'.
(function installGlobalToast() {
  if (typeof window === 'undefined') return;
  if (typeof window.toast === 'function') return;
  window.toast = function (msg, kind) {
    try {
      window.dispatchEvent(new CustomEvent('app-toast', {
        detail: { msg: String(msg || ''), kind: kind || 'info' },
      }));
    } catch (_) {}
  };
})();

// Drawer
function Drawer({ open, onClose, children, width = 520 }) {
  useEffect(() => {
    if (!open) return;
    const h = e => e.key === 'Escape' && onClose();
    document.addEventListener('keydown', h);
    return () => document.removeEventListener('keydown', h);
  }, [open, onClose]);
  if (!open) return null;
  return (
    <>
      <div className="drawer-backdrop" onClick={onClose} />
      <div className="drawer" style={{width}}>{children}</div>
    </>
  );
}

// Vertical procurement bar
function VBar({ pct, label, sub, onClick }) {
  return (
    <button className="vbar" onClick={onClick} style={{background:'transparent', padding:0}}>
      <div className="vbar-pct">{pct}%</div>
      <div className="vbar-track">
        <div className="vbar-fill" style={{height: `${Math.max(pct, 3)}%`}}/>
      </div>
      <div className="vbar-label">
        {label}
        {sub && <div style={{fontSize:10, color:'var(--fg-muted)', marginTop:1}}>{sub}</div>}
      </div>
    </button>
  );
}

// Horizontal progress bar with label + value
function HBar({ label, value, max, color = 'var(--accent)' }) {
  const pct = Math.min(100, (value / max) * 100);
  return (
    <div style={{display:'flex', alignItems:'center', gap:12, padding:'4px 0'}}>
      <div style={{width:70, fontSize:13, color:'var(--fg)'}}>{label}</div>
      <div className="hbar">
        <div className="hbar-fill" style={{width:`${pct}%`, background: color}}/>
      </div>
      <div className="num" style={{width:48, textAlign:'right', fontSize:13, fontWeight:500}}>{value}</div>
    </div>
  );
}

// Section header
function SectionHeader({ caps, title, right }) {
  return (
    <div style={{display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:16}}>
      <div>
        {caps && <div className="caps" style={{display:'flex', alignItems:'center', gap:6, marginBottom: title ? 4 : 0}}>
          <span style={{width:6,height:6,borderRadius:'50%',background:'var(--red)',display:'inline-block'}}/>
          {caps}
        </div>}
        {title && <div style={{fontSize:15, fontWeight:600}}>{title}</div>}
      </div>
      {right}
    </div>
  );
}

// Material swatch
const MATERIAL_COLORS = {
  POM: '#8b5cf6',
  ABS: '#0ea5e9',
  PP:  '#10b981',
  PC:  '#f59e0b',
  PPS: '#ef4444',
  PVC: '#ec4899',
};
function MatChip({ m }) {
  if (!m) return <span className="chip">Закупной</span>;
  return (
    <span className="chip" style={{
      color: MATERIAL_COLORS[m],
      background: `color-mix(in oklch, ${MATERIAL_COLORS[m]} 12%, var(--bg-panel))`,
      borderColor: `color-mix(in oklch, ${MATERIAL_COLORS[m]} 35%, transparent)`,
    }}>{m}</span>
  );
}

// Origin flag (CN/RU)
function OriginFlag({ origin }) {
  if (!origin) return null;
  const label = origin === 'CN' ? 'КНР' : 'РФ';
  return <span className="chip" style={{fontSize:10}}>{label}</span>;
}

// Формат числа BOM-строки: целое — без дробей, дробное — до 3 знаков (для кг/мл).
function _bcNum(v) {
  const x = Number(v);
  if (!Number.isFinite(x)) return v;
  return (Number.isInteger(x) ? x : Math.round(x * 1000) / 1000).toLocaleString('ru');
}
// Суффикс единицы: показываем кг/г/мл/л/т; «шт» и пустое — без подписи (чисто).
function _bcUnit(u) { return (u && u !== 'шт') ? ' ' + u : ''; }

// Список позиций BOM с цветным статусом. Заменяет сетку-квадратики на читаемый список.
// bom — массив результата checkBOM (есть name, qty, n, status, available, need, shortage,
//       а также _dispUnit/_dispPerUnit — единица и расход-на-изделие для показа)
// dense=true — компактный стиль (для мобильных фреймов, высота ограничена)
function BomChecklist({ bom, dense = false, maxHeight = 320 }) {
  const colorFor = (s) => s === 'green' ? 'var(--green)' : s === 'yellow' ? 'var(--amber)' : 'var(--red)';
  const greens = bom.filter(l => l.status === 'green').length;
  const reds = bom.filter(l => l.status === 'red').length;
  return (
    <div style={{border:'1px solid var(--border)', borderRadius:10, overflow:'hidden', background:'var(--bg-panel)'}}>
      <div style={{padding:'8px 12px', display:'flex', justifyContent:'space-between', alignItems:'center',
                   borderBottom:'1px solid var(--border)', fontSize:11, color:'var(--fg-muted)', background:'var(--bg-sunk)'}}>
        <span>{bom.length} позиций</span>
        <span style={{display:'flex', gap:10}}>
          <span style={{color:'var(--green)'}}>● {greens} в наличии</span>
          {reds > 0 && <span style={{color:'var(--red)'}}>● {reds} нехватка</span>}
        </span>
      </div>
      <div style={{maxHeight, overflowY:'auto'}}>
        {bom.map(l => (
          <div key={l.n} style={{
            display:'grid',
            gridTemplateColumns: dense ? '22px 1fr auto auto' : '28px 1fr auto auto',
            gap: dense ? 8 : 10, alignItems:'center',
            padding: dense ? '7px 10px' : '8px 12px',
            fontSize: dense ? 12 : 13,
            borderBottom:'1px solid var(--border)',
          }}>
            <span className="num" style={{fontSize: dense ? 10 : 11, color:'var(--fg-muted)', textAlign:'right'}}>{l.n}</span>
            <div style={{display:'flex', alignItems:'center', gap:8, minWidth:0}}>
              <span style={{width:8, height:8, borderRadius:'50%', background: colorFor(l.status), flexShrink:0}}/>
              <span style={{whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis'}}>{l.name}</span>
            </div>
            <span className="num" style={{fontSize: dense ? 11 : 12, color:'var(--fg-muted)'}}>× {_bcNum(l._dispPerUnit != null ? l._dispPerUnit : l.qty)}{_bcUnit(l._dispUnit)}</span>
            <span className="num" style={{fontSize: dense ? 10 : 11, color: colorFor(l.status), fontWeight:500, minWidth:dense ? 56 : 72, textAlign:'right'}}>
              {l.status === 'green'
                ? 'есть'
                : l.shortage != null
                  ? `нехв. ${_bcNum(l.shortage)}${_bcUnit(l._dispUnit)}`
                  : 'нехватка'}
            </span>
          </div>
        ))}
      </div>
    </div>
  );
}

// ============================================================================
// === FX-1: warehouse icon layout — IconTile · IconGrid · WarehouseStripe ====
// === · ItemPopover. Визуально наследуем v2-паттерн (плитка + анимация),    ==
// === в палитре v1 (#1d4ed8 без смены токенов).                              ==
// ============================================================================

// Мини-набор SVG-иконок групп/складов — самодостаточный, чтобы не зависеть
// от порядка загрузки warehouse_pages.jsx. Возвращает <svg>, цвет — через
// currentColor из стиля обёртки.
const TILE_ICON_SVG = {
  flask:   <path d="M9 2v6L4 20a2 2 0 0 0 2 3h12a2 2 0 0 0 2-3L15 8V2M8 2h8M6 14h12"/>,
  drop:    <path d="M12 2.5c4 5 7 8.5 7 12a7 7 0 0 1-14 0c0-3.5 3-7 7-12z"/>,
  screw:   <><path d="M12 2v4M8 6h8M9 9h6M8 12h8M9 15h6M8 18h8M12 18l0 4"/></>,
  lamp:    <><path d="M9 21h6M10 17h4M7 11a5 5 0 0 1 10 0c0 2-1 3-2 4H9c-1-1-2-2-2-4z"/><path d="M12 3v2"/></>,
  package: <><path d="M21 8L12 3 3 8v8l9 5 9-5V8z"/><path d="M3 8l9 5 9-5M12 13v8"/></>,
  chip:    <><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2"/></>,
  oring:   <><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/></>,
  box:     <><rect x="3" y="7" width="18" height="14" rx="1"/><path d="M3 11h18M8 7V3M16 7V3"/></>,
  factory: <><path d="M2 20h20"/><path d="M17 20V10l-6 4V10l-6 4V4H2v16"/><path d="M13 20v-4M17 20v-4"/></>,
  headlight: <><circle cx="12" cy="12" r="8"/><path d="M4 12h2M18 12h2M12 4v2M12 18v2"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></>,
  lens:    <><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/><circle cx="12" cy="12" r="1.25" fill="currentColor" stroke="none"/></>,
  gear:    <><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/></>,
};
function TileIcon({ name = 'box', size = 22 }) {
  const el = TILE_ICON_SVG[name] || TILE_ICON_SVG.box;
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none"
         stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
      {el}
    </svg>
  );
}

// Цвет/иконка по groupId — индустриально-пластиковая палитра.
// Fallback для неизвестных groupId — accent blue.
const TILE_STYLE_FOR_GROUP = {
  // Склад №1 — сырьё
  'w1-plastics':   { icon: 'flask',     color: '#7c3aed' }, // PE/PP фиолет
  'w1-metal':      { icon: 'screw',     color: '#475569' }, // сталь
  'w1-lamps':      { icon: 'lamp',      color: '#eab308' }, // янтарь нити накала
  'w1-g6':         { icon: 'chip',      color: '#10b981' }, // PCB-зелёный (Электрика)
  'w1-chemistry':  { icon: 'drop',      color: '#0891b2' }, // растворитель-бирюза
  'w1-g5':         { icon: 'package',   color: '#b45309' }, // крафт-картон
  // Склад №2 — полуфабрикаты
  'w2-corpus':     { icon: 'box',       color: '#334155' }, // корпус — тёмный пластик
  'w2-reflector':  { icon: 'headlight', color: '#94a3b8' }, // зеркало
  'w2-glass':      { icon: 'lens',      color: '#38bdf8' }, // прозрачный PC
  'w2-mech':       { icon: 'gear',      color: '#64748b' }, // POM-моторика (шестерни/опоры)
  // Склад №3 — готовая продукция
  'w3-headlights':  { icon: 'headlight', color: '#f59e0b' }, // готовая фара — янтарь
  'w3-spare-glass': { icon: 'lens',      color: '#0ea5e9' }, // запчасть-стекло — небо
  // Legacy (если ещё встречаются в старых экранах)
  'w2-pf':         { icon: 'factory',   color: '#2563eb' },
  'w3-stock':      { icon: 'headlight', color: '#f59e0b' },
  'w3-live':       { icon: 'headlight', color: '#ea580c' },
  'w3-spare':      { icon: 'headlight', color: '#0ea5e9' },
};
function tileStyleForItem(item) {
  return TILE_STYLE_FOR_GROUP[item.groupId] || { icon: 'box', color: '#1d4ed8' };
}

// ============================================================================
// «Вес примерный» — маркер позиций, у которых масса выдумана/взята по аналогу
// и требует ручной сверки. Контракт с данными (SQL-процесс пометит позиции):
//   1) source содержит подстроку 'approx' (например 'approx-weight') — явный
//      флаг, проставленный процессом нормализации;
//   2) ЛИБО вес отсутствует у позиции, которая по смыслу обязана иметь вес —
//      ПФ Склада №2 / отражатели / рассеиватели / корпуса: material задан,
//      но weight пуст (0 / null / '').
// _isWeightApprox(item) — единый предикат, используется во всех местах склада.
// ============================================================================
// Группы Склада №2 (ПФ), для которых отсутствие веса при заданном материале
// считается «примерным/недостающим» весом. Корпуса/отражатели/стёкла/механизмы.
const _WEIGHT_REQUIRED_GROUPS = new Set([
  'w2-corpus', 'w2-reflector', 'w2-glass', 'w2-mech', 'w2-pf',
]);
function _weightIsEmpty(item) {
  // Пусто = null/undefined/'' или числовой 0 (нулевой вес физической детали
  // невозможен → трактуем как «не указан»).
  if (item == null) return true;
  const w = item.weight;
  if (w == null || w === '') return true;
  const n = Number(w);
  return !Number.isFinite(n) || n <= 0;
}
function _isWeightApprox(item) {
  if (!item) return false;
  // (1) Явный флаг в source.
  if (String(item.source || item._source || '').toLowerCase().includes('approx')) return true;
  // (2) ПФ Склада №2 (или иная позиция с материалом) без веса.
  const isPfGroup = _WEIGHT_REQUIRED_GROUPS.has(item.groupId)
    || item.warehouseId === 2 || item.warehouseId === '2';
  const hasMaterial = !!item.material;
  if (isPfGroup && hasMaterial && _weightIsEmpty(item)) return true;
  return false;
}

// _approxWeightCountInItems(items) — сколько позиций из набора «примерны».
// Используется для агрегатного бейджа на группе/подгруппе/подподгруппе.
function _approxWeightCountInItems(items) {
  if (!Array.isArray(items)) return 0;
  let n = 0;
  for (const it of items) if (_isWeightApprox(it)) n++;
  return n;
}

// WtApproxBadge — маленький фиолетовый бейдж «вес примерный», absolute снизу-слева
// внутри плитки/строки. mode='item' — одиночная позиция (иконка ⚖?), mode='count'
// — агрегат на группе/подгруппе с числом примерных позиций (~N).
// Контейнер-родитель ДОЛЖЕН быть position:relative (.icon-tile уже relative).
function WtApproxBadge({ count = null, inline = false }) {
  const isCount = count != null;
  const title = isCount
    ? `Вес примерный (по аналогу) — требует корректировки: позиций ${count}`
    : 'Вес примерный (по аналогу) — требует корректировки';
  return (
    <span className={'wt-approx-badge' + (inline ? ' wt-approx-badge--inline' : '')}
          title={title} aria-label={title}>
      {isCount ? <>~{count}</> : <>⚖?</>}
    </span>
  );
}

// useReserveTick — подписка на событие 'reserve-changed', которое шлёт
// data_materials.reserveForRequest / releaseForRequest. Компонент получает
// bump при каждом изменении резерва (без setInterval-polling).
function useReserveTick() {
  const [, bump] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => {
    const h = () => bump();
    window.addEventListener('reserve-changed', h);
    return () => window.removeEventListener('reserve-changed', h);
  }, []);
}

// useTaxonomyTick — подписка на 'taxonomy-changed' (ребилд таксономии/спек/
// оборудования от Settings или SSE). Bump на каждое событие, без polling.
function useTaxonomyTick() {
  const [, bump] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => {
    const h = () => bump();
    window.addEventListener('taxonomy-changed', h);
    return () => window.removeEventListener('taxonomy-changed', h);
  }, []);
}

// useCechTick — подписка на 'cech:state-changed' (запуск партии в ЛФ,
// SSE-snapshot из backend). Bump на каждое событие, без polling. Зеркало
// useReserveTick, но для данных Цеха (window.CECH_STATE).
function useCechTick() {
  const [, bump] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => {
    const h = () => bump();
    window.addEventListener('cech:state-changed', h);
    return () => window.removeEventListener('cech:state-changed', h);
  }, []);
}

// ─── Цех: производные ФГ-наряды (read-only проекция CECH_STATE) ──────────────
// fgOrdersInProduction() — активные ФГ-наряды «в производстве».
// READ-ONLY проекция window.CECH_STATE.items (клиентское зеркало
// manufacturingBatches) + window.REQUESTS. Готовая фара при запуске
// разузловывается launch_flow в N ПФ-партий (по числу изготавливаемых строк
// BOM), каждая со своим этапом цеха и общим _requestId. Здесь они сводятся
// обратно в один «наряд» = одна запускаемая модель.
//
// ФГ-партия детектится по productKind ('fg' / 'fg-pf' — backend-snapshot)
// либо по префиксу id 'fg-' (локальный item из launch_flow до round-trip,
// где productKind ещё не проставлен). ПФ-партии ('pf-' / 'pf') исключены.
//
// «В производстве» = у наряда есть хоть одна деталь не на Складе №3
// (sklad3 — финальная зона маршрута ФГ). Полностью прибывшие на W3 наряды
// уже видны как реальный сток INVENTORY и здесь не дублируются.
//
// Возвращает массив: { key, name, qty, partsCount, stageCounts,
//   activeStages:[{key,name,short,qty}], inProductionQty, doneQty }.
function fgOrdersInProduction() {
  const cs = window.CECH_STATE || {};
  const rawItems = Array.isArray(cs.items) ? cs.items : [];
  // Дедуп по id (защита от транзитной гонки local↔snapshot).
  const seen = new Set();
  const items = [];
  for (const it of rawItems) {
    if (!it || (it.id != null && seen.has(it.id))) continue;
    if (it.id != null) seen.add(it.id);
    items.push(it);
  }
  const isFg = (it) => it.productKind === 'fg' || it.productKind === 'fg-pf'
    || (typeof it.id === 'string' && it.id.indexOf('fg-') === 0);
  const reqs = window.REQUESTS || [];
  // requestId на партии = либо id заявки, либо локальный key (externalId).
  const findReq = (rid) => rid
    ? reqs.find(r => r.id === rid || r.externalId === rid)
    : null;

  const groups = new Map();   // canonicalKey → наряд
  for (const it of items) {
    if (!isFg(it)) continue;
    const req = findReq(it._requestId);
    // Взаимосвязь «нет заявки → нет производства»: партию-сироту (без живой
    // заявки) НЕ показываем как наряд в работе — иначе на витрине «в
    // производстве» висит то, чего нет в буфере заявок (рассинхрон).
    // + Архивный наряд (досрочно завершён и убран в «Архив») тоже НЕ висит
    //   «в производстве» — иначе архивированный корпус-наряд оставался тут
    //   (жалоба Александра 2026-06-24, скрин 4).
    if (!req || req.archived) continue;
    const key = req.id;
    let order = groups.get(key);
    if (!order) {
      order = {
        key, name: req.productName || '—',
        qty: req.qty || null,
        partsCount: 0, stageCounts: {},
        inProductionQty: 0, doneQty: 0,
      };
      groups.set(key, order);
    }
    order.partsCount += 1;
    const stages = it.stages || {};
    for (const sk of Object.keys(stages)) {
      const q = Number(stages[sk]) || 0;
      if (q <= 0) continue;
      order.stageCounts[sk] = (order.stageCounts[sk] || 0) + q;
      if (sk === 'sklad3') order.doneQty += q;
      else order.inProductionQty += q;
    }
  }

  const stageKeys = Object.keys(window.CECH_STAGES || {});
  const stagesMeta = window.CECH_STAGES || {};
  const out = [];
  for (const o of groups.values()) {
    if (o.inProductionQty <= 0) continue;   // полностью на W3 — не «в производстве»
    o.activeStages = Object.keys(o.stageCounts)
      .filter(k => o.stageCounts[k] > 0)
      .sort((a, b) => stageKeys.indexOf(a) - stageKeys.indexOf(b))
      .map(k => ({
        key: k,
        name: (stagesMeta[k] && stagesMeta[k].name) || k,
        short: (stagesMeta[k] && stagesMeta[k].short) || k,
        qty: o.stageCounts[k],
      }));
    out.push(o);
  }
  return out;
}

// reservationsByProductForGroup(groupId) — разбивка резерва подгруппы склада
// по ЦЕЛЕВОМУ изделию (наряду/заявке). READ-ONLY: для каждой позиции
// подгруппы с reserved>0 берёт window.reservationsForInventory(invId)
// (заявки, держащие эту позицию) и группирует по productName.
// Возвращает [{ productName, productQty, status, byUnit:{unit:amount},
//   posCount, _total }] — сортировка по суммарному резерву (крупные изделия
// первыми). byUnit даёт разбивку «X кг сырья · Y шт деталей» на изделие.
function reservationsByProductForGroup(groupId) {
  if (!groupId || typeof window.reservedItemsInGroup !== 'function'
      || typeof window.reservationsForInventory !== 'function') return [];
  const reserved = window.reservedItemsInGroup(groupId);
  const byProduct = new Map();
  for (const it of reserved) {
    const recs = window.reservationsForInventory(it.id) || [];
    for (const r of recs) {
      const pname = r.productName || '—';
      let p = byProduct.get(pname);
      if (!p) {
        p = { productName: pname, productQty: r.productQty || null,
              status: r.status, requestId: r.requestId || null, byUnit: {}, posCount: 0, _total: 0 };
        byProduct.set(pname, p);
      }
      const unit = r.unit || it.unit || 'шт';
      const amt = Number(r.amount) || 0;
      p.byUnit[unit] = (p.byUnit[unit] || 0) + amt;
      p.posCount += 1;
      p._total += amt;
    }
  }
  return Array.from(byProduct.values()).sort((a, b) => b._total - a._total);
}

// formatQty — единый форматтер количеств: кг округляется до 0.1, остальное
// до целого; всегда ru-локаль (разделитель тысяч). Дедуп 4 inline-копий.
function formatQty(v, unit) {
  return unit === 'кг'
    ? (Math.round(v * 10) / 10).toLocaleString('ru')
    : Math.round(v).toLocaleString('ru');
}

// useCurrentRole — текущая роль из window.CURRENT_USER (data_server_sync.jsx).
// Подписан на 'current-user-changed' event'ы, чтобы CanWrite-обёртки сами
// перерисовывались когда whoami приедет/изменится.
const ROLE_ORDER = { anonymous: 0, viewer: 1, manager: 2, admin: 3 };
function useCurrentRole() {
  const [, bump] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => {
    const h = () => bump();
    window.addEventListener('current-user-changed', h);
    return () => window.removeEventListener('current-user-changed', h);
  }, []);
  const cu = window.CURRENT_USER || {};
  return cu.role || 'viewer';
}

// CanWrite — UI-гейт по ролям для write-кнопок. Если текущая роль ниже
// требуемой — рендерит child как disabled (greyed-out + tooltip + click-block).
// mode='hide' — полностью скрывает (для пунктов меню, где disabled не имеет
// смысла). Поддерживает single React-элемент в children (button/<a>/<div>).
//
//   <CanWrite role="manager">
//     <button onClick={...}>Запуск</button>
//   </CanWrite>
//
//   <CanWrite role="admin" mode="hide">
//     <MenuItem>Сбросить базу</MenuItem>
//   </CanWrite>
function CanWrite({ role = 'manager', mode = 'disabled', children }) {
  const current = useCurrentRole();
  const allowed = (ROLE_ORDER[current] || 0) >= (ROLE_ORDER[role] || 0);
  if (allowed) return children;
  if (mode === 'hide') return null;

  if (React.Children.count(children) !== 1 || !React.isValidElement(children)) {
    // Несколько детей или не-элемент — fallback: оборачиваем в div
    // с пониженной прозрачностью и tooltip.
    return (
      <span title={'недостаточно прав: требуется роль ' + role}
            style={{opacity: 0.45, cursor: 'not-allowed'}}
            aria-disabled="true">
        {children}
      </span>
    );
  }
  const child = React.Children.only(children);
  return React.cloneElement(child, {
    disabled: true,
    onClick: undefined,
    title: 'недостаточно прав: требуется роль ' + role,
    'aria-disabled': true,
    style: Object.assign(
      {},
      child.props.style || {},
      { opacity: 0.45, cursor: 'not-allowed', pointerEvents: 'auto' },
    ),
  });
}

// useApiSyncStatus — reactive снимок состояния window.API. Подписывается на
// 'api-status-changed' + tick (1с) чтобы поля age/pending корректно реактивно
// устаревали без новых ивентов. Возвращает:
//   online      — успешный sync < 10с назад
//   lastSyncAt  — ms timestamp последнего успеха (0 = ни разу не было)
//   pendingCount — кол-во ops в _offlineQueue
//   syncing      — true пока есть незавершённый fetch
//   ageMs        — сколько прошло с последнего успешного sync
function useApiSyncStatus() {
  const [, bump] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => {
    const h = () => bump();
    window.addEventListener('api-status-changed', h);
    window.addEventListener('reserve-changed', h);
    const t = setInterval(h, 1000);
    return () => {
      window.removeEventListener('api-status-changed', h);
      window.removeEventListener('reserve-changed', h);
      clearInterval(t);
    };
  }, []);
  const api = window.API || {};
  const lastSyncAt = api._lastSuccessAt || 0;
  const ageMs = lastSyncAt ? (Date.now() - lastSyncAt) : Infinity;
  return {
    online: lastSyncAt > 0 && ageMs < 10000,
    lastSyncAt,
    ageMs,
    pendingCount: (api._offlineQueue && api._offlineQueue.length) || 0,
    syncing: (api._pending || 0) > 0,
  };
}

// SyncIndicator — статус-дот синхронизации с backend (Phase 2 shared state).
// Зелёный: успешный sync < 5с. Жёлтый: запрос в полёте или 5-30с с момента
// последнего sync. Красный: оффлайн > 30с или backend никогда не отвечал.
// compact=true — мобильная версия (без tooltip и счётчика очереди).
function SyncIndicator({ compact = false }) {
  const [, bump] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => {
    const h = () => bump();
    window.addEventListener('api-status-changed', h);
    window.addEventListener('reserve-changed', h);
    // tick раз в секунду — иначе цвет "застрянет" между событиями (синхронизация
    // была 4с назад → должен переключиться на amber/red без новых ивентов)
    const t = setInterval(h, 1000);
    return () => {
      window.removeEventListener('api-status-changed', h);
      window.removeEventListener('reserve-changed', h);
      clearInterval(t);
    };
  }, []);

  const api = window.API || {};
  const lastAt = api._lastSuccessAt || 0;
  const pending = api._pending || 0;
  const queueLen = (api._offlineQueue && api._offlineQueue.length) || 0;
  const ageMs = lastAt ? (Date.now() - lastAt) : Infinity;

  let color, label;
  if (pending > 0) {
    color = '#f59e0b'; label = 'синхронизация…';
  } else if (lastAt && ageMs < 5000) {
    color = '#10b981'; label = 'синхронизировано';
  } else if (lastAt && ageMs < 30000) {
    color = '#f59e0b'; label = 'устаревшая синхронизация';
  } else {
    color = '#ef4444'; label = lastAt ? 'оффлайн' : 'оффлайн (нет связи с backend)';
  }

  let tooltipText = label;
  if (lastAt) {
    const dt = new Date(lastAt).toLocaleTimeString('ru-RU', {
      hour: '2-digit', minute: '2-digit', second: '2-digit',
    });
    tooltipText += ' · последний sync: ' + dt;
  }
  if (queueLen > 0) tooltipText += ' · ожидают отправки: ' + queueLen;

  const size = compact ? 8 : 10;
  return (
    <div
      title={compact ? undefined : tooltipText}
      aria-label={tooltipText}
      style={{
        display: 'inline-flex',
        alignItems: 'center',
        gap: 6,
        padding: compact ? 4 : 6,
      }}
    >
      <span style={{
        width: size,
        height: size,
        borderRadius: '50%',
        background: color,
        boxShadow: '0 0 0 2px var(--bg-panel, #fff), 0 0 6px ' + color + '66',
        transition: 'background 200ms',
      }}/>
      {!compact && queueLen > 0 && (
        <span style={{
          fontSize: 11,
          color: 'var(--fg-muted)',
          fontVariantNumeric: 'tabular-nums',
        }}>{queueLen}</span>
      )}
    </div>
  );
}

// IconTile — одна плитка в полосе/сетке.
// Props: icon, color, title, code, count, size, onClick.
// Новые опциональные:
//   hoverContent  — React-нода, всплывает при наведении (для подгрупп склада).
//   reserveBadge  — { reserved, unit } → маленькая amber-метка поверх плитки.
//   reserveTone   — 'amber' | 'red' — стиль плитки и счётчика (для блока «Сырьё в резерве»).
function IconTile({ icon, color = '#1d4ed8', title, code, count, countLabel = 'шт',
                    size = 'md', hot = false, stub = false, active = false,
                    index = 0, onClick, hoverContent = null,
                    reserveBadge = null, reserveTone = null, shipBadge = null,
                    spareBadge = false, framed = false,
                    weightApprox = false, approxCount = null, cornerButton = null }) {
  const [hovered, setHovered] = React.useState(false);
  const [hoverPos, setHoverPos] = React.useState(null);
  const hoverTimer = React.useRef(null);
  const wrapRef   = React.useRef(null);
  const show = () => {
    clearTimeout(hoverTimer.current);
    hoverTimer.current = setTimeout(() => {
      // На момент показа вычисляем позицию тултипа относительно viewport, чтобы
      // обойти overflow:auto у родительской полосы склада.
      if (wrapRef.current) {
        const r = wrapRef.current.getBoundingClientRect();
        setHoverPos({ top: r.bottom + 6, left: r.left });
      }
      setHovered(true);
    }, 180);
  };
  const hide = () => {
    clearTimeout(hoverTimer.current);
    setHovered(false);
  };
  React.useEffect(() => () => clearTimeout(hoverTimer.current), []);

  // Пока hover активен — следим за scroll/resize и пересчитываем координаты
  // тултипа, чтобы он визуально оставался прикреплённым к плитке.
  // position:fixed даёт стабильный z-index поверх overflow:auto родителей,
  // а listener с capture ловит прокрутку внутренних скроллеров (.wh-stripe-row).
  React.useEffect(() => {
    if (!hovered || !hoverContent) return;
    const update = () => {
      if (!wrapRef.current) return;
      const r = wrapRef.current.getBoundingClientRect();
      setHoverPos({ top: r.bottom + 6, left: r.left });
    };
    window.addEventListener('scroll', update, true);
    window.addEventListener('resize', update);
    return () => {
      window.removeEventListener('scroll', update, true);
      window.removeEventListener('resize', update);
    };
  }, [hovered, hoverContent]);

  const sizeCls =
    size === 'xs' ? ' icon-tile--xs' :
    size === 'sm' ? ' icon-tile--sm' :
    size === 'lg' ? ' icon-tile--lg' : '';
  const toneCls = reserveTone === 'red'
    ? ' icon-tile--reserve-red'
    : reserveTone === 'amber'
      ? ' icon-tile--reserve-amber'
      : '';
  const cls = 'icon-tile' + sizeCls + toneCls
    + (active ? ' icon-tile--active' : '')
    + (hoverContent ? ' icon-tile--has-hover' : '');
  // --i управляет задержкой каскадного появления (см. keyframes tile-expand в styles.jsx)
  // framed — зелёная рамка-выделение для ПФ-запчастей на Складе №3 (корпус/рассеиватель).
  const style = { '--i': Math.min(index, 24), '--tile-accent': color,
    ...(framed ? { outline: '2px solid var(--green)', outlineOffset: '2px' } : {}) };

  const fmtBadge = (v, u) => window.formatQty(v, u);

  // Нужна ли обёртка .icon-tile-wrap? Только если есть hover-окошко или
  // amber-бейдж. Без обёртки — плитка рендерится как обычный button, grid
  // сохраняет прежние размеры колонок.
  const needsWrap = !!hoverContent || (reserveBadge && Number(reserveBadge.reserved) > 0) || !!cornerButton;

  const buttonEl = (
    <button className={cls} onClick={onClick} type="button" style={style}>
      <div className="icon-tile__glyph" style={{
        color,
        background: `color-mix(in oklch, ${color} 12%, var(--bg-panel))`,
        borderColor: `color-mix(in oklch, ${color} 30%, transparent)`,
      }}>
        <TileIcon name={icon} size={size === 'sm' ? 18 : size === 'lg' ? 28 : 22}/>
        {hot && <span className="icon-tile__hot" title="Горячая позиция"/>}
        {reserveBadge && Number(reserveBadge.reserved) > 0 && (
          <span className="icon-tile__reserve-badge" title="В резерве">
            {fmtBadge(reserveBadge.reserved, reserveBadge.unit)}
            <small>{reserveBadge.unit}</small>
          </span>
        )}
        {shipBadge != null && Number(shipBadge) > 0 && (
          <span title="Отгружено по открытому наряду" style={{
            position:'absolute', left:4, top:4, background:'var(--accent)', color:'#fff',
            fontSize:9, fontWeight:700, padding:'1px 5px', borderRadius:5, lineHeight:1.35,
            whiteSpace:'nowrap', boxShadow:'0 1px 4px rgba(0,0,0,.22)', zIndex:2,
          }}>↑{Number(shipBadge).toLocaleString('ru')}</span>
        )}
        {spareBadge && (
          <span title="Продаётся как ПФ-запчасть (Склад №3 · Запчасти со Склада №2)" style={{
            position:'absolute', right:4, top:4, background:'var(--green)', color:'#fff',
            fontSize:9, fontWeight:700, padding:'1px 5px', borderRadius:5, lineHeight:1.35,
            whiteSpace:'nowrap', boxShadow:'0 1px 4px rgba(0,0,0,.22)', zIndex:2,
          }}>ПФ-запч.</span>
        )}
      </div>
      <div className="icon-tile__body">
        <div className="icon-tile__title" title={title}>{title}</div>
        {code && <div className="icon-tile__code num">{code}</div>}
        {count != null && (
          <div className="icon-tile__count num">
            {Number(count).toLocaleString('ru')}<small> {countLabel}</small>
          </div>
        )}
        {stub && <div className="icon-tile__stub">демо</div>}
      </div>
      {/* Бейдж «вес примерный» — снизу-слева плитки (.icon-tile position:relative).
          weightApprox — одиночная позиция, approxCount — агрегат группы/подгруппы. */}
      {weightApprox && <WtApproxBadge/>}
      {!weightApprox && approxCount != null && approxCount > 0 && (
        <WtApproxBadge count={approxCount}/>
      )}
    </button>
  );

  if (!needsWrap) return buttonEl;

  return (
    <div className="icon-tile-wrap"
         ref={wrapRef}
         onMouseEnter={hoverContent ? show : undefined}
         onMouseLeave={hoverContent ? hide : undefined}>
      {buttonEl}
      {cornerButton && <span className="icon-tile__corner">{cornerButton}</span>}
      {hoverContent && hovered && hoverPos && (
        <div className="subgroup-hover"
             style={{ top: hoverPos.top, left: hoverPos.left }}
             onMouseEnter={show}
             onMouseLeave={hide}>
          {hoverContent}
        </div>
      )}
    </div>
  );
}

// IconGrid — адаптивная сетка плиток.
function IconGrid({ items, columns, size = 'md', onItemClick, renderExtra }) {
  const cols = columns || (size === 'sm' ? 8 : size === 'lg' ? 4 : 6);
  return (
    <div className="icon-grid" style={{
      gridTemplateColumns: `repeat(auto-fill, minmax(${size === 'sm' ? 140 : size === 'lg' ? 220 : 170}px, 1fr))`,
    }}>
      {items.map((it, idx) => {
        const st = tileStyleForItem(it);
        // Синий бейдж «↑N» (отгружено) на готовой фаре Склада №3 — сумма отгруженного
        // по ОТКРЫТЫМ нарядам этой позиции (status != done/cancelled). Гаснет при
        // закрытии наряда. (Александр 2026-06-17)
        const _shipFg = (it.warehouseId === 3 && typeof window.getRequestProgress === 'function')
          ? (window.REQUESTS || []).reduce((sum, r) => {
              if (!r || r.status === 'done' || r.status === 'cancelled') return sum;
              if ((r.productSKU || r.productSku) !== it.sku) return sum;
              const p = window.getRequestProgress(r.id);
              return sum + ((p && p.shipped > 0) ? p.shipped : 0);
            }, 0)
          : 0;
        // «Вес примерный»: для плитки-агрегата (подгруппа/подподгруппа) — счётчик
        // примерных позиций внутри; для обычной позиции — её собственный флаг.
        const _isAggTile = (it._subsub || it._subgroup) && window.itemsForGroup;
        const _approxCnt = _isAggTile
          ? _approxWeightCountInItems(window.itemsForGroup(it.groupId, it._subsubKey))
          : 0;
        return (
          <IconTile
            key={it.id || it.sku}
            icon={st.icon}
            color={st.color}
            title={it.name}
            code={it.sku}
            count={it.stock}
            countLabel={it.unit || 'шт'}
            size={size}
            hot={!!it.isHot}
            stub={!!it.isStub}
            index={idx}
            onClick={() => onItemClick && onItemClick(it)}
            shipBadge={_shipFg > 0 ? _shipFg : null}
            weightApprox={!_isAggTile && _isWeightApprox(it)}
            approxCount={_isAggTile ? _approxCnt : null}
            cornerButton={(!_isAggTile && it.id && window.MovePositionLauncher)
              ? <window.MovePositionLauncher item={it}/> : null}
          />
        );
      })}
      {renderExtra}
    </div>
  );
}

// WarehouseStripe — одна полоса склада: шапка + плитки подгрупп + опциональный
// «Полный список».
// Props: warehouse {id,name}, hotItems, totalCount, onItemClick, onFullList,
// subtitle, size ('md'|'sm') — регулирует размер плиток.
function WarehouseStripe({ warehouse, hotItems, totalCount, onItemClick, onFullList,
                           subtitle, size = 'md', renderHover = null }) {
  const tileSize = size === 'sm' ? 'sm' : 'md';
  // Подписка на изменения резерва — чтобы amber-бейджи на плитках подгрупп
  // перерисовывались сразу после reserveForRequest / releaseForRequest.
  useReserveTick();
  return (
    <div className={'wh-stripe' + (size === 'sm' ? ' wh-stripe--sm' : '')}>
      <div className="wh-stripe-head">
        <div>
          <div className="caps">Склад №{warehouse.id}</div>
          <div style={{fontSize: size === 'sm' ? 16 : 18, fontWeight:600, marginTop:2, letterSpacing:'-0.01em'}}>
            {warehouse.name.replace(/^Склад №\d+ —\s*/, '') || warehouse.name}
          </div>
          {subtitle && (
            <div style={{fontSize:12, color:'var(--fg-muted)', marginTop:3}}>{subtitle}</div>
          )}
        </div>
        {onFullList && (
          <button className="btn" onClick={onFullList}>
            <Icon name="grid" size={14}/> Полный список
            {totalCount != null && <span className="chip" style={{marginLeft:6, padding:'1px 7px', fontSize:10}}>{totalCount}</span>}
          </button>
        )}
      </div>
      <div className="wh-stripe-row">
        {hotItems.map((it, idx) => {
          const st = tileStyleForItem(it);
          const hover = renderHover ? renderHover(it) : null;
          const reservedAgg = (window.reservedItemsInGroup && it.id)
            ? window.reservedItemsInGroup(it.id)
            : [];
          const totalReserved = reservedAgg.reduce((s, r) => s + (Number(r.reserved) || 0), 0);
          const reserveUnit = reservedAgg[0]?.unit || null;
          // Каскад синего бейджа «↑N» (отгружено по открытому наряду) на плитку
          // группы Склада №3 — сумма отгруженного по всем готовым фарам группы.
          const shipGroup = (window.shippedSumInGroup && it.id) ? window.shippedSumInGroup(it.id) : 0;
          // Агрегат «вес примерный»: сколько позиций внутри группы примерны.
          // Для ПФ-запчасти (дубль W2 на W3) считаем по исходной подгруппе W2.
          const _agGroupId = it._spareItem && it._spareGroupId ? it._spareGroupId : it.id;
          const approxCount = (window.itemsForGroup && _agGroupId)
            ? window._approxWeightCountInItems(window.itemsForGroup(_agGroupId))
            : 0;
          return (
            <IconTile
              key={it.id || it.sku}
              icon={st.icon}
              color={st.color}
              title={it.name}
              code={it.sku}
              count={it.stock}
              countLabel={it.unit || 'шт'}
              size={tileSize}
              hot={!!it.isHot}
              stub={!!it.isStub}
              active={!!it.isActive}
              index={idx}
              onClick={() => onItemClick(it)}
              hoverContent={hover}
              reserveBadge={totalReserved > 0 && reserveUnit
                ? { reserved: totalReserved, unit: reserveUnit }
                : null}
              shipBadge={shipGroup > 0 ? shipGroup : null}
              spareBadge={!!it._spareBadge || !!it._spareItem}
              framed={!!it._spareFramed}
              approxCount={approxCount}
            />
          );
        })}
        {hotItems.length === 0 && (
          <div style={{padding:'24px 16px', color:'var(--fg-muted)', fontSize:13, fontStyle:'italic'}}>
            Пока нет позиций.
          </div>
        )}
      </div>
    </div>
  );
}

// ReserveBlock — блок «Резерв» на карточке любой позиции. Объединяет прежний
// RawReserveWidget (amber при активном резерве) и прежний «Входит в BOM»
// (связь с эталонной фарой). Рисуется ВСЕГДА, меняется тон:
//   reserved > 0  → активный (amber), критичный (red) при ≥ 80%;
//   reserved = 0  → нейтральный, «сейчас не зарезервировано».
// Если у позиции есть bomN — справа чип-справка «× qty на фару ПТФ Гранта L».
function ReserveBlock({ item }) {
  if (!item) return null;
  const reserved = Number(item.reserved) || 0;
  const stock    = Number(item.stock) || 0;
  const free     = Math.max(0, stock - reserved);
  const unit     = item.unit || 'шт';
  const fmt      = v => window.formatQty(v, unit);
  const reservedPct = stock > 0 ? Math.min(100, (reserved / stock) * 100) : 0;
  const active   = reserved > 0;
  const critical = reservedPct >= 80;

  const bomEntry = item.bomN != null
    ? (window.BOM_GRANTA_L || []).find(b => b.n === item.bomN)
    : null;

  const reservations = active && window.reservationsForInventory
    ? window.reservationsForInventory(item.id)
    : [];

  // Разбивка по целевому изделию: несколько заявок на одну фару сливаются в
  // одну строку (× N заявок), сумма резерва складывается. Отвечает «подо что
  // зарезервирована позиция» по изделию, а не по сырому id заявки.
  const reservByProduct = [];
  const _rbpIdx = {};
  for (const r of reservations) {
    const k = r.productName || '—';
    if (_rbpIdx[k] == null) {
      _rbpIdx[k] = reservByProduct.length;
      reservByProduct.push({ productName: k, productQty: r.productQty || null, amount: 0, count: 0, reqId: r.requestId });
    }
    const g = reservByProduct[_rbpIdx[k]];
    g.amount += Number(r.amount) || 0;
    g.count += 1;
  }

  const cls = 'reserve-block'
    + (active   ? ' reserve-block--active'   : ' reserve-block--empty')
    + (critical ? ' reserve-block--critical' : '');

  return (
    <div className={cls}>
      <div className="reserve-block__head">
        <span className="caps">Резерв</span>
        {bomEntry && (
          <span className="reserve-block__bom-chip" title={'Эталонная BOM · позиция №' + bomEntry.n}>
            × {bomEntry.qty} на фару <b>ПТФ Гранта L</b>
          </span>
        )}
      </div>

      <div className="reserve-block__rows">
        <div className="reserve-block__row reserve-block__row--reserved">
          <span>В резерве</span>
          <span className="num">
            {active ? fmt(reserved) : '0'}
            <small> {unit}</small>
          </span>
        </div>
        <div className="reserve-block__row reserve-block__row--free">
          <span>Свободно</span>
          <span className="num">{fmt(free)} <small>{unit}</small></span>
        </div>
      </div>

      <div className="reserve-block__bar" aria-hidden="true">
        <div className="reserve-block__bar-fill" style={{width: reservedPct + '%'}}/>
      </div>

      <div className="reserve-block__meta">
        <span>итого на складе</span>
        <span className="num">{fmt(stock)} <small>{unit}</small></span>
      </div>

      {active && reservByProduct.length > 0 && (
        <div className="reserve-block__list">
          {reservByProduct.map((g, i) => (
            <div key={g.productName + '·' + i} className="reserve-block__list-row">
              <span className="mono reserve-block__list-id" title={g.count > 1 ? g.count + ' заявок на это изделие' : g.reqId}>
                {g.count > 1 ? '×' + g.count : g.reqId}
              </span>
              <span className="reserve-block__list-name">
                {g.productName}{g.productQty ? ' · ' + g.productQty.toLocaleString('ru') + ' шт' : ''}
              </span>
              <span className="num reserve-block__list-amount">{fmt(g.amount)}</span>
            </div>
          ))}
        </div>
      )}

      {!active && (
        <div className="reserve-block__empty-hint">
          Сейчас не зарезервировано. Создайте заявку и переведите в MRP — сырьё уйдёт в резерв.
        </div>
      )}
    </div>
  );
}

// Совместимость со старыми ссылками — RawReserveWidget остаётся как alias.
// Старые вставки в ItemPopover/ItemCardContent удалим отдельно, но на случай
// других мест в коде держим синоним, чтобы не ломать.
const RawReserveWidget = ReserveBlock;

// ItemPopover — правая шторка с полной карточкой позиции.
// Базируется на Drawer. Ширина 420px.
// relatedBom — массив {product, qty} или вычисляется из bomN по BOM_GRANTA_L.
function ItemPopover({ item, open, onClose }) {
  useReserveTick();
  if (!item) return null;

  // Связь с BOM: если item.bomN совпадает с номером в BOM_GRANTA_L —
  // показываем, что позиция входит в эталонную фару Гранта L.
  const bomEntry = item.bomN != null
    ? (window.BOM_GRANTA_L || []).find(b => b.n === item.bomN)
    : null;

  const stockLow = item.minStock != null && item.stock < item.minStock;
  const stockCrit = item.minStock != null && item.stock < item.minStock * 0.25;
  const stockColor = stockCrit ? 'var(--red)' : stockLow ? 'var(--amber)' : 'var(--green)';
  const stockPct = item.minStock ? Math.min(100, (item.stock / item.minStock) * 100) : 100;

  const wh = (window.WAREHOUSES || []).find(w => w.id === item.warehouseId);
  const groups = [...(window.GROUPS_W1 || []), ...(window.GROUPS_W2 || []), ...(window.GROUPS_W3 || [])];
  const grp = groups.find(g => g.id === item.groupId);
  const tile = tileStyleForItem(item);

  return (
    <Drawer open={open} onClose={onClose} width={420}>
      <div className="item-popover">
        <div className="item-popover__head">
          <div className="caps">{wh ? wh.name : 'Склад'}</div>
          <button className="btn btn-ghost" onClick={onClose} aria-label="Закрыть">
            <Icon name="x"/>
          </button>
        </div>

        <div className="item-popover__hero">
          <div className="item-popover__glyph" style={{
            color: tile.color,
            background: `color-mix(in oklch, ${tile.color} 14%, var(--bg-panel))`,
            borderColor: `color-mix(in oklch, ${tile.color} 35%, transparent)`,
          }}>
            <TileIcon name={tile.icon} size={36}/>
          </div>
          <div style={{minWidth:0, flex:1}}>
            <div style={{fontSize:17, fontWeight:600, lineHeight:1.25}}>{item.name}</div>
            <div className="num item-popover__sku">{item.sku || ('#' + item.id)}</div>
            <div style={{display:'flex', flexWrap:'wrap', gap:6, marginTop:8}}>
              {wh && <span className="chip chip-blue">{wh.name.split('—')[0].trim()}</span>}
              {grp && <span className="chip">{grp.name}</span>}
              {item.material && <MatChip m={item.material}/>}
              {item.origin && <OriginFlag origin={item.origin}/>}
              {item.isHot && <span className="chip chip-amber">горячая</span>}
              {Number(item.reserved) > 0 && (
                <span className="chip chip-reserve">
                  в резерве {(item.unit === 'кг'
                    ? (Math.round(Number(item.reserved) * 10) / 10)
                    : Math.round(Number(item.reserved))).toLocaleString('ru')} {item.unit || 'шт'}
                </span>
              )}
              {_isWeightApprox(item) && <WtApproxBadge inline/>}
            </div>
          </div>
        </div>

        {item.isStub && (
          <div className="item-popover__stub-banner">
            <Icon name="alert" size={14}/>
            <span>Демо-позиция (сгенерировано для макета)</span>
          </div>
        )}

        <div className="item-popover__body">
          <div className="item-popover__field">
            <div className="caps">Наличие</div>
            <div style={{display:'flex', alignItems:'baseline', gap:6, marginTop:4}}>
              <span className="num" style={{fontSize:24, fontWeight:600, color: stockColor}}>
                {Number(item.stock ?? 0).toLocaleString('ru')}
              </span>
              <span style={{fontSize:12, color:'var(--fg-muted)'}}>{item.unit || 'шт'}</span>
            </div>
            {item.minStock != null && (
              <>
                <div style={{height:4, background:'var(--bg-sunk)', borderRadius:2, marginTop:8, overflow:'hidden'}}>
                  <div style={{height:'100%', width:`${stockPct}%`, background: stockColor, transition:'width .3s'}}/>
                </div>
                <div style={{fontSize:11, color:'var(--fg-muted)', marginTop:4, display:'flex', justifyContent:'space-between'}}>
                  <span>мин.: <b className="num" style={{color:'var(--fg)'}}>{item.minStock.toLocaleString('ru')}</b></span>
                  {stockLow && <span style={{color:'var(--amber)'}}>ниже минимума</span>}
                </div>
              </>
            )}
          </div>

          <ReserveBlock item={item}/>
          {item.warehouseId === 2 && window.W2ReserveWidget && (
            <window.W2ReserveWidget pfSku={item.sku}/>
          )}
          {item.warehouseId === 2 && window.WasteWidget && window.findProcessByOutput && window.findProcessByOutput(item.sku) && (
            <window.WasteWidget process={window.findProcessByOutput(item.sku)}/>
          )}

          <div className="item-popover__row">
            {item.supplier && (
              <div className="item-popover__field">
                <div className="caps">Поставщик</div>
                <div style={{fontSize:13, marginTop:4}}>{item.supplier}</div>
              </div>
            )}
            {(item.weight != null || _isWeightApprox(item)) && (
              <div className="item-popover__field">
                <div className="caps">Вес {_isWeightApprox(item) && <WtApproxBadge inline/>}</div>
                <div className="num" style={{fontSize:13, marginTop:4}}>
                  {_weightIsEmpty(item)
                    ? <span style={{color:'var(--fg-muted)'}}>не указан</span>
                    : <>{window.fmtKg(item.weight)} <small style={{color:'var(--fg-muted)'}}>кг</small></>}
                </div>
              </div>
            )}
            {item.packaging && (
              <div className="item-popover__field">
                <div className="caps">Упаковка</div>
                <div style={{fontSize:13, marginTop:4}}>{item.packaging}</div>
              </div>
            )}
          </div>

          {Array.isArray(item.variants) && item.variants.length > 0 && (
            <div className="item-popover__field">
              <div className="caps">Модификации</div>
              <div style={{display:'flex', flexWrap:'wrap', gap:6, marginTop:6}}>
                {item.variants.map((v, i) => (
                  <span key={i} className="chip chip-blue" style={{fontWeight:500}}>{v}</span>
                ))}
              </div>
            </div>
          )}

          {item.comment && (
            <div className="item-popover__field" style={{background:'color-mix(in oklch, var(--accent) 5%, var(--bg-panel))', padding:'10px 12px', borderRadius:8, borderLeft:'3px solid var(--accent)'}}>
              <div className="caps" style={{color:'var(--accent)'}}>💬 Комментарий</div>
              <div style={{fontSize:12, marginTop:4, lineHeight:1.4, fontStyle:'italic', color:'var(--fg)'}}>{item.comment}</div>
            </div>
          )}

          <div className="item-popover__actions">
            <button className="btn" style={{flex:1}}>
              <Icon name="printer" size={14}/> Штрихкод
            </button>
            <ItemCorrectionLauncher item={item}/>
          </div>
        </div>
      </div>
    </Drawer>
  );
}

// ============================================================================
// FloatingItemCard — перемещаемое окно карточки позиции (вместо drawer).
// Можно открыть несколько одновременно (мультиоконность): drag за заголовок,
// X в правом верхнем углу, клик по окну — поднимает его на передний план.
// ============================================================================
function ItemCardContent({ item }) {
  useReserveTick();
  const bomEntry = item.bomN != null
    ? (window.BOM_GRANTA_L || []).find(b => b.n === item.bomN)
    : null;
  const stockLow = item.minStock != null && item.stock < item.minStock;
  const stockCrit = item.minStock != null && item.stock < item.minStock * 0.25;
  const stockColor = stockCrit ? 'var(--red)' : stockLow ? 'var(--amber)' : 'var(--green)';
  const stockPct = item.minStock ? Math.min(100, (item.stock / item.minStock) * 100) : 100;
  const wh = (window.WAREHOUSES || []).find(w => w.id === item.warehouseId);
  const groups = [...(window.GROUPS_W1 || []), ...(window.GROUPS_W2 || []), ...(window.GROUPS_W3 || [])];
  const grp = groups.find(g => g.id === item.groupId);
  const tile = tileStyleForItem(item);

  return (
    <>
      <div className="item-popover__hero">
        <div className="item-popover__glyph" style={{
          color: tile.color,
          background: `color-mix(in oklch, ${tile.color} 14%, var(--bg-panel))`,
          borderColor: `color-mix(in oklch, ${tile.color} 35%, transparent)`,
        }}>
          <TileIcon name={tile.icon} size={36}/>
        </div>
        <div style={{minWidth:0, flex:1}}>
          <div style={{fontSize:17, fontWeight:600, lineHeight:1.25}}>{item.name}</div>
          <div className="num item-popover__sku">{item.sku || ('#' + item.id)}</div>
          <div style={{display:'flex', flexWrap:'wrap', gap:6, marginTop:8}}>
            {wh && <span className="chip chip-blue">{wh.name.split('—')[0].trim()}</span>}
            {grp && <span className="chip">{grp.name}</span>}
            {item.material && <MatChip m={item.material}/>}
            {item.origin && <OriginFlag origin={item.origin}/>}
            {item.isHot && <span className="chip chip-amber">горячая</span>}
            {Number(item.reserved) > 0 && (
              <span className="chip chip-reserve">
                в резерве {(item.unit === 'кг'
                  ? (Math.round(Number(item.reserved) * 10) / 10)
                  : Math.round(Number(item.reserved))).toLocaleString('ru')} {item.unit || 'шт'}
              </span>
            )}
          </div>
        </div>
      </div>

      {item.isStub && (
        <div className="item-popover__stub-banner">
          <Icon name="alert" size={14}/>
          <span>Демо-позиция (сгенерировано для макета)</span>
        </div>
      )}

      <div className="item-popover__body">
        <div className="item-popover__field">
          <div className="caps">Наличие</div>
          <div style={{display:'flex', alignItems:'baseline', gap:6, marginTop:4}}>
            <span className="num" style={{fontSize:24, fontWeight:600, color: stockColor}}>
              {Number(item.stock ?? 0).toLocaleString('ru')}
            </span>
            <span style={{fontSize:12, color:'var(--fg-muted)'}}>{item.unit || 'шт'}</span>
          </div>
          {item.minStock != null && (
            <>
              <div style={{height:4, background:'var(--bg-sunk)', borderRadius:2, marginTop:8, overflow:'hidden'}}>
                <div style={{height:'100%', width:`${stockPct}%`, background: stockColor, transition:'width .3s'}}/>
              </div>
              <div style={{fontSize:11, color:'var(--fg-muted)', marginTop:4, display:'flex', justifyContent:'space-between'}}>
                <span>мин.: <b className="num" style={{color:'var(--fg)'}}>{item.minStock.toLocaleString('ru')}</b></span>
                {stockLow && <span style={{color:'var(--amber)'}}>ниже минимума</span>}
              </div>
            </>
          )}
        </div>

        <ReserveBlock item={item}/>
        {item.warehouseId === 2 && window.W2ReserveWidget && (
          <window.W2ReserveWidget pfSku={item.sku}/>
        )}
        {item.warehouseId === 2 && window.WasteWidget && window.findProcessByOutput && window.findProcessByOutput(item.sku) && (
          <window.WasteWidget process={window.findProcessByOutput(item.sku)}/>
        )}

        <div className="item-popover__row">
          {item.supplier && (
            <div className="item-popover__field">
              <div className="caps">Поставщик</div>
              <div style={{fontSize:13, marginTop:4}}>{item.supplier}</div>
            </div>
          )}
          {item.weight != null && (
            <div className="item-popover__field">
              <div className="caps">Вес</div>
              <div className="num" style={{fontSize:13, marginTop:4}}>
                {window.fmtKg(item.weight)} <small style={{color:'var(--fg-muted)'}}>кг</small>
              </div>
            </div>
          )}
          {item.packaging && (
            <div className="item-popover__field">
              <div className="caps">Упаковка</div>
              <div style={{fontSize:13, marginTop:4}}>{item.packaging}</div>
            </div>
          )}
        </div>

        {Array.isArray(item.variants) && item.variants.length > 0 && (
          <div className="item-popover__field">
            <div className="caps">Модификации</div>
            <div style={{display:'flex', flexWrap:'wrap', gap:6, marginTop:6}}>
              {item.variants.map((v, i) => (
                <span key={i} className="chip chip-blue" style={{fontWeight:500}}>{v}</span>
              ))}
            </div>
          </div>
        )}

        {item.comment && (
          <div className="item-popover__field" style={{background:'color-mix(in oklch, var(--accent) 5%, var(--bg-panel))', padding:'10px 12px', borderRadius:8, borderLeft:'3px solid var(--accent)'}}>
            <div className="caps" style={{color:'var(--accent)'}}>💬 Комментарий</div>
            <div style={{fontSize:12, marginTop:4, lineHeight:1.4, fontStyle:'italic', color:'var(--fg)'}}>{item.comment}</div>
          </div>
        )}

        <div className="item-popover__actions">
          <button className="btn" style={{flex:1}}>
            <Icon name="printer" size={14}/> Штрихкод
          </button>
          <ItemCorrectionLauncher item={item}/>
        </div>
      </div>
    </>
  );
}

// Одно floating-окно. Позиция хранится внутри (не теряется при ререндере
// родителя). zIndex и фокус — управляются снаружи.
// Размер: по умолчанию 440×520, меняется двумя способами — (1) drag за
// уголок в правом нижнем углу, (2) кнопка max/restore в заголовке.
const _FLOAT_DEFAULT_SIZE = { w: 440, h: 520 };
function FloatingItemCard({ item, initialX, initialY, zIndex, onClose, onFocus }) {
  const [pos, setPos] = useState({ x: initialX, y: initialY });
  const [size, setSize] = useState(_FLOAT_DEFAULT_SIZE);
  const [maximized, setMaximized] = useState(false);
  const dragRef = useRef(null);
  const resizeRef = useRef(null);
  const cardRef = useRef(null);

  const onHeadMouseDown = useCallback((e) => {
    // drag только за пустую часть заголовка (не за кнопки)
    if (e.target.closest('button')) return;
    if (maximized) return;  // в полноэкранном режиме не двигаем
    e.preventDefault();
    onFocus && onFocus();
    dragRef.current = {
      startX: e.clientX, startY: e.clientY,
      offX: pos.x,       offY: pos.y,
    };
    const move = (ev) => {
      if (!dragRef.current) return;
      const dx = ev.clientX - dragRef.current.startX;
      const dy = ev.clientY - dragRef.current.startY;
      const w = cardRef.current?.offsetWidth || 440;
      const h = cardRef.current?.offsetHeight || 400;
      const maxX = window.innerWidth - 40;
      const maxY = window.innerHeight - 40;
      setPos({
        x: Math.max(-(w - 80), Math.min(maxX, dragRef.current.offX + dx)),
        y: Math.max(0, Math.min(maxY, dragRef.current.offY + dy)),
      });
    };
    const up = () => {
      dragRef.current = null;
      document.removeEventListener('mousemove', move);
      document.removeEventListener('mouseup', up);
    };
    document.addEventListener('mousemove', move);
    document.addEventListener('mouseup', up);
  }, [pos, onFocus, maximized]);

  const onResizeMouseDown = useCallback((e) => {
    if (maximized) return;
    e.preventDefault();
    e.stopPropagation();
    onFocus && onFocus();
    resizeRef.current = {
      startX: e.clientX, startY: e.clientY,
      w: size.w, h: size.h,
    };
    const move = (ev) => {
      if (!resizeRef.current) return;
      const dw = ev.clientX - resizeRef.current.startX;
      const dh = ev.clientY - resizeRef.current.startY;
      setSize({
        w: Math.max(320, Math.min(window.innerWidth - 32, resizeRef.current.w + dw)),
        h: Math.max(280, Math.min(window.innerHeight - 32, resizeRef.current.h + dh)),
      });
    };
    const up = () => {
      resizeRef.current = null;
      document.removeEventListener('mousemove', move);
      document.removeEventListener('mouseup', up);
    };
    document.addEventListener('mousemove', move);
    document.addEventListener('mouseup', up);
  }, [size, onFocus, maximized]);

  const toggleMaximize = useCallback(() => {
    setMaximized(m => !m);
  }, []);

  const wh = (window.WAREHOUSES || []).find(w => w.id === item.warehouseId);

  // Полноэкранный режим — занимает почти всё доступное пространство.
  const effPos  = maximized ? { x: 16, y: 60 } : pos;
  const effSize = maximized
    ? { w: window.innerWidth - 32, h: window.innerHeight - 76 }
    : size;

  return (
    <div
      ref={cardRef}
      className="float-card"
      style={{
        left: effPos.x, top: effPos.y, zIndex,
        width: effSize.w, height: effSize.h, maxHeight: 'none',
      }}
      onMouseDown={() => onFocus && onFocus()}
    >
      <div className="float-card__head" onMouseDown={onHeadMouseDown}
           style={{ cursor: maximized ? 'default' : 'grab' }}>
        <div className="float-card__head-meta">
          <div className="caps">{wh ? wh.name.split('—')[0].trim() : 'Склад'}</div>
          <div className="float-card__title" title={item.name}>{item.name}</div>
        </div>
        <div style={{display:'flex', gap:4, alignItems:'center', flexShrink:0}}>
          <button className="float-card__close" onClick={toggleMaximize}
                  aria-label={maximized ? 'Уменьшить' : 'Развернуть'}
                  title={maximized ? 'Свернуть к обычному размеру' : 'Развернуть на весь экран'}
                  style={{fontSize:14, fontWeight:700, lineHeight:1}}>
            {maximized ? '▢' : '⛶'}
          </button>
          <button className="float-card__close" onClick={onClose} aria-label="Закрыть">
            <Icon name="x" size={16}/>
          </button>
        </div>
      </div>
      <div className="float-card__body" style={{flex:1, minHeight:0, overflow:'auto', maxHeight:'none'}}>
        <ItemCardContent item={item}/>
      </div>
      {!maximized && (
        <div
          onMouseDown={onResizeMouseDown}
          title="Потянуть для изменения размера"
          style={{
            position:'absolute', right:2, bottom:2, width:14, height:14,
            cursor:'nwse-resize',
            background:'linear-gradient(135deg, transparent 0 55%, var(--border-strong) 55% 63%, transparent 63% 75%, var(--border-strong) 75% 83%, transparent 83%)',
            borderBottomRightRadius:12,
          }}
        />
      )}
    </div>
  );
}

// WindowManager — хранит массив открытых окон, рендерит их поверх экрана.
// Экспонирует openItem/close/focus. Позиция каскадная (каждое следующее
// окно слегка смещено, чтобы не полностью перекрывать предыдущее).
function useFloatingWindows() {
  const [windows, setWindows] = useState([]);
  const nextIdRef = useRef(1);
  const nextZRef = useRef(100);

  const openItem = useCallback((item) => {
    setWindows(prev => {
      // если уже открыто окно с тем же item.id — просто поднимем его
      const existingIdx = prev.findIndex(w => (w.item.id || w.item.sku) === (item.id || item.sku));
      nextZRef.current += 1;
      if (existingIdx >= 0) {
        return prev.map((w, i) => i === existingIdx ? { ...w, z: nextZRef.current } : w);
      }
      // Starting position: centered over viewport, cascaded per extra window.
      // Floating card is 440px wide; centering it over vw lands it as an «окошко»,
      // not a right-hand drawer. Each next window is offset down-right by 28px.
      const offset = prev.length * 28;
      const vw = window.innerWidth || 1280;
      const vh = window.innerHeight || 800;
      const cardW = 440;
      const cardH = 520;
      const baseX = Math.max(24, Math.round((vw - cardW) / 2));
      const baseY = Math.max(80, Math.round((vh - cardH) / 2) - 20);
      const id = nextIdRef.current++;
      return [...prev, {
        id, item,
        x: baseX + offset,
        y: baseY + offset,
        z: nextZRef.current,
      }];
    });
  }, []);
  const closeWindow = useCallback((id) =>
    setWindows(prev => prev.filter(w => w.id !== id)), []);
  const focusWindow = useCallback((id) => {
    setWindows(prev => {
      nextZRef.current += 1;
      return prev.map(w => w.id === id ? { ...w, z: nextZRef.current } : w);
    });
  }, []);
  return { windows, openItem, closeWindow, focusWindow };
}

function FloatingWindowsLayer({ windows, onClose, onFocus }) {
  if (!windows || windows.length === 0) return null;
  return (
    <div className="float-layer" aria-live="polite">
      {windows.map(w => (
        <FloatingItemCard
          key={w.id}
          item={w.item}
          initialX={w.x}
          initialY={w.y}
          zIndex={w.z}
          onClose={() => onClose(w.id)}
          onFocus={() => onFocus(w.id)}
        />
      ))}
    </div>
  );
}

// StatusChip — бейдж-табличка со статусом закупки.
// Цвета/подписи берутся из window.PROCUREMENT_STATUSES (v1/data_suppliers.jsx).
// Шаблон {eta} подставляется для in_transit.
function StatusChip({ status, etaDays, size = 'sm' }) {
  const dict = window.PROCUREMENT_STATUSES || {};
  const s = dict[status];
  if (!s) return null;
  const label = s.label.replace('{eta}', etaDays == null ? '?' : etaDays);
  const pad = size === 'sm' ? '3px 8px' : '5px 12px';
  const fs  = size === 'sm' ? 11 : 12;
  return (
    <span title={label} style={{
      display:'inline-flex', alignItems:'center', gap:6,
      padding: pad, borderRadius: 6,
      background: s.bg, color: s.color,
      fontSize: fs, fontWeight: 600,
      whiteSpace: 'nowrap',
      overflow: 'hidden',
      textOverflow: 'ellipsis',
      maxWidth: '100%',
      minWidth: 0,
      letterSpacing: '.01em',
    }}>
      {label}
    </span>
  );
}

// Универсальная модалка подтверждения для удаления / опасных действий.
// Вынесена из settings.jsx чтобы переиспользовать в любых экранах
// (requests_section, settings, и пр.) без window.confirm().
// API: { title, message, confirmLabel?='Подтвердить', cancelLabel?='Отмена',
//        danger?=false, onConfirm, onClose }
function ConfirmModal({ title, message, confirmLabel='Подтвердить', cancelLabel='Отмена', danger=false, onConfirm, onClose }) {
  useEffect(() => {
    const h = (e) => { if (e.key === 'Escape') onClose(); };
    document.addEventListener('keydown', h);
    return () => document.removeEventListener('keydown', h);
  }, [onClose]);
  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal" style={{width: 440}} onClick={e => e.stopPropagation()}>
        <div className="modal-header">
          <div style={{display:'flex', alignItems:'center', gap:10}}>
            <div style={{
              width:32, height:32, borderRadius:8,
              background: danger ? 'color-mix(in oklch, var(--red) 15%, var(--bg-panel))' : 'var(--bg-sunk)',
              color: danger ? 'var(--red)' : 'var(--accent)',
              display:'flex', alignItems:'center', justifyContent:'center',
            }}><Icon name="alert" size={16} tone={danger ? 'del' : 'edit'}/></div>
            <div style={{fontSize:15, fontWeight:600}}>{title}</div>
          </div>
          <button className="btn btn-ghost" onClick={onClose}><Icon name="close" size={14}/></button>
        </div>
        <div style={{padding:'18px 24px', fontSize:13, color:'var(--fg)', lineHeight:1.5}}>{message}</div>
        <div style={{padding:'12px 24px', borderTop:'1px solid var(--border)', display:'flex', justifyContent:'flex-end', gap:8}}>
          <button className="btn" onClick={onClose}>{cancelLabel}</button>
          <button
            className={danger ? 'btn btn-danger' : 'btn btn-primary'}
            onClick={onConfirm}
            style={danger ? {background:'var(--red)', color:'#fff', borderColor:'var(--red)'} : undefined}
          >
            {confirmLabel}
          </button>
        </div>
      </div>
    </div>
  );
}

// useConfirm — удобный хук-обёртка над ConfirmModal.
// const confirm = useConfirm();
// confirm.ask({ title, message, danger, confirmLabel, onConfirm });
// В JSX рендерим {confirm.modal}.
function useConfirm() {
  const [state, setState] = useState(null);
  const ask = useCallback((opts) => setState(opts || null), []);
  const close = useCallback(() => setState(null), []);
  const modal = state ? (
    <ConfirmModal
      title={state.title}
      message={state.message}
      confirmLabel={state.confirmLabel || 'Подтвердить'}
      cancelLabel={state.cancelLabel || 'Отмена'}
      danger={!!state.danger}
      onConfirm={() => {
        try { state.onConfirm && state.onConfirm(); } finally { close(); }
      }}
      onClose={() => { state.onCancel && state.onCancel(); close(); }}
    />
  ) : null;
  return { ask, close, modal };
}

// =============================================================================
// SpecViewerModal — read-only viewer спецификации фары/ПФ. Используется на
// дашборде (рядом с выбором FG в «Заказ на производство») и в шагe 1 LaunchFlow
// (кнопка «Спецификация»). Редактирование — только в Settings → Спецификации;
// этот компонент только ОТОБРАЖАЕТ дерево BOM + фото-слайдер + PDF-ссылку.
//
// Контракт props:
//   sku        — обязательный, ключ window.SPECIFICATIONS / window.PRODUCTS.
//   onClose    — close-callback.
//   onNavigate — опционально; если spec нет, кнопка «Создать в Настройках»
//                вызовет onNavigate('settings').
//
// Источник данных: spec = window.SPECIFICATIONS[sku]. Поддерживаются ДВА
// формата (агент E переводит на расширенный):
//   Старый: Array<BomRow> (текущее состояние, дерево BOM прямо в значении).
//   Новый : { sku, name, model, pathLabel, bom: Array, photos: [], pdfUrl?: string }.
// _normalizeSpec() приводит оба к единому объекту с полем bom: Array.
// =============================================================================

// Hard-coded PDF для Гранты (единственная фара с PDF-спекой на момент
// production-старта). Когда агент E нальёт pdfUrl в spec — этот fallback
// перестанет использоваться.
const _SPEC_PDF_FALLBACK = {
  'GRN-L-H4': 'Блок_фара_Гранта_с_лампой_L1.pdf',
  'GRN-R-H4': 'Блок_фара_Гранта_с_лампой_L1.pdf',
};

function _normalizeSpec(sku) {
  if (!sku) return null;
  const raw = (window.SPECIFICATIONS || {})[sku];
  const product = (window.PRODUCTS || []).find(p => p.sku === sku) || null;
  const fallbackPdf = _SPEC_PDF_FALLBACK[sku] || null;
  // Старый формат: Array
  if (Array.isArray(raw)) {
    if (raw.length === 0) return null;
    return {
      sku,
      name: product ? product.name : sku,
      model: product ? (product.family || '') : '',
      pathLabel: null,
      bom: raw,
      photos: [],
      pdfUrl: fallbackPdf,
    };
  }
  // Новый формат: Object
  if (raw && typeof raw === 'object') {
    if (!Array.isArray(raw.bom) || raw.bom.length === 0) return null;
    return {
      sku: raw.sku || sku,
      name: raw.name || (product ? product.name : sku),
      model: raw.model || (product ? (product.family || '') : ''),
      pathLabel: raw.pathLabel || null,
      bom: raw.bom,
      photos: Array.isArray(raw.photos) ? raw.photos : [],
      pdfUrl: raw.pdfUrl || fallbackPdf,
    };
  }
  return null;
}

function SpecPhotoSlider({ photos }) {
  const [idx, setIdx] = useState(0);
  if (!photos || photos.length === 0) return null;
  const cur = photos[idx] || photos[0];
  const url = cur && (cur.url || cur);
  return (
    <div style={{
      background:'var(--bg-sunk)', border:'1px solid var(--border)',
      borderRadius:10, padding:10, marginBottom:14,
    }}>
      <div style={{position:'relative', width:'100%', height:280, background:'#000',
                   borderRadius:8, overflow:'hidden', display:'flex',
                   alignItems:'center', justifyContent:'center'}}>
        <img src={url} alt=""
             style={{maxWidth:'100%', maxHeight:'100%', objectFit:'contain'}}
             onError={e => { e.currentTarget.style.opacity = '0.2'; }}/>
        {photos.length > 1 && (
          <>
            <button className="btn" onClick={() => setIdx(i => (i - 1 + photos.length) % photos.length)}
                    style={{position:'absolute', left:8, top:'50%', transform:'translateY(-50%)',
                            background:'rgba(0,0,0,.45)', color:'#fff', border:'none', padding:'6px 10px'}}>
              <Icon name="arrow_left" size={14}/>
            </button>
            <button className="btn" onClick={() => setIdx(i => (i + 1) % photos.length)}
                    style={{position:'absolute', right:8, top:'50%', transform:'translateY(-50%)',
                            background:'rgba(0,0,0,.45)', color:'#fff', border:'none', padding:'6px 10px'}}>
              <Icon name="arrow_right" size={14}/>
            </button>
            <div style={{position:'absolute', bottom:8, left:'50%', transform:'translateX(-50%)',
                         background:'rgba(0,0,0,.55)', color:'#fff', padding:'3px 10px',
                         borderRadius:999, fontSize:11}}>
              {idx + 1} / {photos.length}
            </div>
          </>
        )}
      </div>
    </div>
  );
}

// Read-only вариант BomTreeNode. Рендерит дерево без кнопок edit/add/remove.
// Раскрытие — локальный state в SpecViewerModal.
// Найти SKU полуфабриката (W2) для BOM-строки. Маппинг: row.n (BOM-номер) ↔
// NOMENCLATURE.bomN. Возвращает null если это не ПФ или мэппинга нет
// (тогда «Запуск в производство» не показываем).
function _pfSkuForBomRow(row) {
  if (!row || row.type !== 'internal') return null;
  const nom = window.NOMENCLATURE || [];
  const found = nom.find(n => n && n.bomN === row.n && (n.tier === 'W2' || n.warehouseId === 2));
  return found ? found.sku : null;
}

function SpecBomNode({ row, depth, expanded, onToggle, path, onLaunchPf }) {
  const isInternal = row.type === 'internal';
  const hasChildren = isInternal && Array.isArray(row.children) && row.children.length > 0;
  const isExpanded = expanded.has(path);
  const pad = depth * 18;
  const pfSku = isInternal ? _pfSkuForBomRow(row) : null;
  return (
    <>
      <div style={{
        display:'grid', gridTemplateColumns:'28px 28px 1fr 80px 60px 60px 60px 60px 32px',
        gap:6, padding:'9px 12px', alignItems:'center',
        borderBottom:'1px solid var(--border-subtle)', fontSize:12,
        paddingLeft: 12 + pad,
        background: depth > 0 ? 'color-mix(in oklch, var(--accent) 3%, var(--bg-panel))' : 'transparent',
      }}>
        <div>
          {hasChildren && (
            <button className="btn btn-ghost" onClick={() => onToggle(path)}
              style={{padding:'2px 4px', color: isExpanded ? 'var(--accent)' : 'var(--fg-muted)'}}
              title={isExpanded ? 'Свернуть' : 'Развернуть'}>
              <Icon name={isExpanded ? 'minus' : 'arrow_right'} size={11}/>
            </button>
          )}
        </div>
        <div className="num" style={{color:'var(--fg-muted)', fontWeight:600}}>{row.n}</div>
        <div style={{minWidth:0, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap',
                     fontWeight: isInternal ? 600 : 400, display:'flex', alignItems:'center', gap:6}}>
          {Array.isArray(row.photos) && row.photos.length > 0 && (
            <img src={row.photos[0].url} alt=""
                 title={`${row.photos.length} фото`}
                 style={{width:18, height:18, borderRadius:3, objectFit:'cover',
                         border:'1px solid var(--border)', flexShrink:0}}
                 onError={e => { e.currentTarget.style.display = 'none'; }}/>
          )}
          <span style={{overflow:'hidden', textOverflow:'ellipsis'}} title={row.name}>{row.name}</span>
          {row.invId && (
            <span style={{fontFamily:'var(--font-mono)', fontSize:9, color:'var(--fg-muted)',
                          flexShrink:0}}>({row.invId.replace(/^INV-/, '')})</span>
          )}
        </div>
        <div>
          <span className="tag" style={{
            fontSize:9,
            background: isInternal ? 'color-mix(in oklch, var(--cta) 14%, var(--bg-panel))'
                     : row.type === 'raw' ? 'color-mix(in oklch, var(--green) 14%, var(--bg-panel))'
                     : 'color-mix(in oklch, var(--accent) 14%, var(--bg-panel))',
            color: isInternal ? 'var(--cta)' : row.type === 'raw' ? 'var(--green)' : 'var(--accent)',
          }}>{isInternal ? 'ПФ' : row.type === 'raw' ? 'сырьё' : 'закуп'}</span>
        </div>
        <div className="num" style={{textAlign:'right'}}>{row.qty}</div>
        <div style={{color:'var(--fg-muted)', fontSize:11}}>{row.unit || 'шт'}</div>
        <div>{row.material ? <span className="tag" style={{fontSize:9}}>{row.material}</span> : <span style={{color:'var(--fg-muted)'}}>—</span>}</div>
        <div style={{color:'var(--fg-muted)', fontFamily:'var(--font-mono)', fontSize:10}}>{row.origin || '—'}</div>
        <div>
          {pfSku && onLaunchPf && (
            <button
              type="button"
              className="btn"
              onClick={() => onLaunchPf(pfSku, row)}
              title={`Запустить ${row.name} в производство (Цех)`}
              style={{
                padding:'2px 6px', fontSize:14, lineHeight:1,
                color:'var(--cta)', borderColor:'var(--cta)',
                background:'color-mix(in oklch, var(--cta) 8%, var(--bg-panel))',
              }}>
              🏭
            </button>
          )}
        </div>
      </div>
      {isExpanded && hasChildren && row.children.map((c, i) => (
        <SpecBomNode key={c.n + '@' + i} row={c} depth={depth + 1}
          expanded={expanded} onToggle={onToggle}
          path={path + '.' + i}
          onLaunchPf={onLaunchPf}/>
      ))}
    </>
  );
}

function SpecViewerModal({ sku, onClose, onNavigate, onLaunchPf }) {
  const spec = useMemo(() => _normalizeSpec(sku), [sku]);
  const [expanded, setExpanded] = useState(() => {
    // По умолчанию раскрыть все ПФ-узлы первого уровня — пользователь сразу
    // видит из чего состоит каждый ПФ. Глубже — свернуто.
    const s = new Set();
    if (spec && Array.isArray(spec.bom)) {
      spec.bom.forEach((r, i) => {
        if (r && r.type === 'internal' && Array.isArray(r.children) && r.children.length > 0) {
          s.add(String(i));
        }
      });
    }
    return s;
  });
  const toggle = useCallback((path) => {
    setExpanded(prev => {
      const next = new Set(prev);
      if (next.has(path)) next.delete(path); else next.add(path);
      return next;
    });
  }, []);
  // ESC закрывает
  useEffect(() => {
    const h = (e) => { if (e.key === 'Escape') onClose && onClose(); };
    window.addEventListener('keydown', h);
    return () => window.removeEventListener('keydown', h);
  }, [onClose]);

  const product = (window.PRODUCTS || []).find(p => p.sku === sku) || null;
  const title = spec ? spec.name : (product ? product.name : sku);
  const subtitle = spec
    ? (spec.model || product?.family || '') + (spec.pathLabel ? ' · ' + spec.pathLabel : '')
    : (product ? (product.family || '') : '');

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal" style={{width: 760, maxHeight:'90vh', display:'flex', flexDirection:'column'}}
           onClick={e => e.stopPropagation()}>
        <div className="modal-header" style={{flexShrink:0}}>
          <div style={{flex:1, minWidth:0}}>
            <div className="caps" style={{fontSize:11}}>Спецификация</div>
            <div style={{fontSize:17, fontWeight:600, marginTop:2, overflow:'hidden',
                         textOverflow:'ellipsis', whiteSpace:'nowrap'}} title={title}>{title}</div>
            {subtitle && (
              <div style={{fontSize:12, color:'var(--fg-muted)', marginTop:2}}>{subtitle}</div>
            )}
          </div>
          <button className="btn btn-ghost" onClick={onClose}><Icon name="close" size={14}/></button>
        </div>

        <div style={{padding:'16px 24px', overflowY:'auto', flex:1}}>
          {!spec && (
            <div style={{textAlign:'center', padding:'40px 20px'}}>
              <div style={{fontSize:48, marginBottom:12, opacity:.5}}>📋</div>
              <div style={{fontSize:15, fontWeight:600, marginBottom:6}}>Спецификация не создана</div>
              <div style={{fontSize:12, color:'var(--fg-muted)', marginBottom:18}}>
                Для <b>{title}</b> ({sku}) ещё нет BOM-дерева.
                Создайте её в Настройках → Спецификации, добавив узлы-ПФ и детали.
              </div>
              <button className="btn btn-primary"
                onClick={() => {
                  if (typeof onNavigate === 'function') onNavigate('settings');
                  else if (typeof window.setView === 'function') window.setView('settings');
                  onClose && onClose();
                }}>
                <Icon name="arrow_right" size={13}/> Создать в Настройках
              </button>
            </div>
          )}

          {spec && (
            <>
              {spec.photos && spec.photos.length > 0 && (
                <SpecPhotoSlider photos={spec.photos}/>
              )}

              <div style={{fontSize:12, fontWeight:600, marginBottom:8,
                           display:'flex', justifyContent:'space-between'}}>
                <span>BOM · {_countSpecLines(spec.bom)} позиций</span>
                <span style={{color:'var(--fg-muted)', fontWeight:400}}>
                  ПФ-узлов: {spec.bom.filter(r => r && r.type === 'internal').length}
                </span>
              </div>

              <div style={{border:'1px solid var(--border)', borderRadius:8,
                           overflow:'hidden', background:'var(--bg-panel)'}}>
                {/* header — добавлен столбец под кнопку «Запуск в производство» для ПФ */}
                <div style={{
                  display:'grid',
                  gridTemplateColumns:'28px 28px 1fr 80px 60px 60px 60px 60px 32px',
                  gap:6, padding:'8px 12px',
                  background:'var(--bg-sunk)', borderBottom:'1px solid var(--border)',
                  fontSize:10, color:'var(--fg-muted)',
                  textTransform:'uppercase', letterSpacing:'.05em', fontWeight:500,
                }}>
                  <div/>
                  <div>№</div>
                  <div>Название</div>
                  <div>Тип</div>
                  <div style={{textAlign:'right'}}>Кол-во</div>
                  <div>Ед.</div>
                  <div>Мат.</div>
                  <div>Страна</div>
                  <div title="Запуск ПФ в производство">🏭</div>
                </div>
                {spec.bom.map((r, i) => (
                  <SpecBomNode key={(r.n || i) + '@root@' + i} row={r} depth={0}
                    expanded={expanded} onToggle={toggle}
                    path={String(i)}
                    onLaunchPf={onLaunchPf}/>
                ))}
              </div>
            </>
          )}
        </div>

        <div style={{padding:'14px 24px', borderTop:'1px solid var(--border)',
                     display:'flex', justifyContent:'space-between',
                     alignItems:'center', flexShrink:0, gap:8}}>
          <div style={{fontSize:11, color:'var(--fg-muted)'}}>
            {spec ? `SKU ${spec.sku}` : ''}
          </div>
          <div style={{display:'flex', gap:8}}>
            {spec && spec.pdfUrl && (
              <a className="btn" href={spec.pdfUrl} target="_blank" rel="noopener noreferrer"
                 style={{textDecoration:'none', color:'var(--accent)',
                         borderColor:'var(--accent)', display:'inline-flex',
                         alignItems:'center', gap:6}}>
                📥 Скачать PDF
              </a>
            )}
            <button className="btn btn-primary" onClick={onClose}>Закрыть</button>
          </div>
        </div>
      </div>
    </div>
  );
}

function _countSpecLines(tree) {
  if (!Array.isArray(tree)) return 0;
  let n = 0;
  const walk = (rows) => {
    for (const r of rows) {
      if (!r) continue;
      n++;
      if (Array.isArray(r.children)) walk(r.children);
    }
  };
  walk(tree);
  return n;
}

// ============================================================================
// Корректировка позиции склада (кнопка «Корректировка» в карточке позиции).
//
// Решение по типу позиции:
//   • готовая фара (есть в PRODUCTS) ИЛИ составной ПФ (isSubassembly) ИЛИ уже
//     есть спецификация с BOM  →  SpecEditorModal — полное редактируемое
//     дерево спецификации (как в Настройках), правится прямо со склада.
//   • простая позиция (деталь/сырьё без спеки)  →  StockAdjustModal —
//     развесовка/инфо + правка остатка (проводкой движения склада).
// ============================================================================
function _itemHasEditableSpec(item) {
  if (!item || !item.sku) return false;
  const sku = item.sku;
  const isProduct = (window.PRODUCTS || []).some(p => p && p.sku === sku);
  const isSubassembly = !!item.isSubassembly;
  const spec = (typeof _normalizeSpec === 'function') ? _normalizeSpec(sku) : null;
  return isProduct || isSubassembly || !!spec;
}

function CorrField({ label, value }) {
  return (
    <div style={{minWidth: 0}}>
      <div className="caps" style={{fontSize: 10}}>{label}</div>
      <div style={{fontSize: 13, marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis'}}>{value}</div>
    </div>
  );
}

// Простая позиция: вся информация (развесовка) + корректировка остатка.
// Правку остатка проводим дельтой через тот же канал, что Цех/перенос
// (window._applyStockEditDelta → API.stockMovement), иначе следующий SSE-
// снапшот затрёт прямую запись stock. Правка — под ролью manager.
function StockAdjustModal({ item, onClose }) {
  const { push: toast } = useToast();
  const role = useCurrentRole();
  const canWrite = (ROLE_ORDER[role] || 0) >= (ROLE_ORDER['manager'] || 0);
  const unit = item.unit || 'шт';
  const cur = Number(item.stock ?? 0);
  const [val, setVal] = useState(() => String(cur));

  useEffect(() => {
    const h = (e) => { if (e.key === 'Escape') onClose && onClose(); };
    window.addEventListener('keydown', h);
    return () => window.removeEventListener('keydown', h);
  }, [onClose]);

  const next = Number(val);
  const delta = Number.isFinite(next) ? next - cur : 0;
  const wh = (window.WAREHOUSES || []).find(w => w.id === item.warehouseId);
  const weightG = (item.weight != null && Number(item.weight) > 0) ? Number(item.weight) : null;
  const totalMassG = (weightG != null) ? weightG * cur : null;
  const fmtKg = window.fmtKg || (g => (Number(g) / 1000));

  const bump = (d) => setVal(v => {
    const n = Number(v);
    return String((Number.isFinite(n) ? n : cur) + d);
  });

  const apply = () => {
    if (!Number.isFinite(next)) { toast('Введите число'); return; }
    if (next < 0) { toast('Остаток не может быть отрицательным'); return; }
    if (!item.id) { toast('Нет id позиции — правка остатка недоступна'); return; }
    const moved = window._applyStockEditDelta && window._applyStockEditDelta(item.id, next, cur, unit);
    if (moved) toast(`Остаток «${item.name}»: ${next.toLocaleString('ru')} ${unit}`);
    else toast('Остаток без изменений');
    onClose && onClose();
  };

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal" style={{width: 460, maxWidth: '94vw', maxHeight: '90vh', display: 'flex', flexDirection: 'column'}}
           onClick={e => e.stopPropagation()}>
        <div className="modal-header" style={{flexShrink: 0}}>
          <div style={{flex: 1, minWidth: 0}}>
            <div className="caps" style={{fontSize: 11}}>Корректировка позиции</div>
            <div style={{fontSize: 16, fontWeight: 600, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'}} title={item.name}>{item.name}</div>
            <div style={{fontSize: 11, color: 'var(--fg-muted)', marginTop: 2, fontFamily: 'var(--font-mono)'}}>{item.sku || ('#' + item.id)}</div>
          </div>
          <button className="btn btn-ghost" onClick={onClose}><Icon name="close" size={14}/></button>
        </div>

        <div style={{padding: '16px 20px', overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column', gap: 14}}>
          {/* развесовка / информация о позиции */}
          <div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12}}>
            <CorrField label="Склад" value={wh ? wh.name.split('—')[0].trim() : '—'}/>
            <CorrField label="Материал" value={item.material || '—'}/>
            <CorrField label="Вес единицы" value={weightG != null ? <>{fmtKg(weightG)} <small style={{color: 'var(--fg-muted)'}}>кг</small></> : 'не указан'}/>
            <CorrField label="Масса остатка" value={totalMassG != null ? <>{fmtKg(totalMassG)} <small style={{color: 'var(--fg-muted)'}}>кг</small></> : '—'}/>
            {item.supplier && <CorrField label="Поставщик" value={item.supplier}/>}
            {Number(item.reserved) > 0 && <CorrField label="В резерве" value={`${Math.round(Number(item.reserved)).toLocaleString('ru')} ${unit}`}/>}
          </div>

          {canWrite ? (
            <div style={{borderTop: '1px solid var(--border)', paddingTop: 14}}>
              <div className="caps" style={{marginBottom: 8}}>Корректировка остатка</div>
              <div style={{fontSize: 12, color: 'var(--fg-muted)', marginBottom: 8}}>
                Текущий: <b className="num" style={{color: 'var(--fg)'}}>{cur.toLocaleString('ru')} {unit}</b>
              </div>
              <div style={{display: 'flex', gap: 6, alignItems: 'center'}}>
                <button className="btn" onClick={() => bump(-10)} style={{padding: '8px 10px'}}>−10</button>
                <button className="btn" onClick={() => bump(-1)} style={{padding: '8px 10px'}}>−1</button>
                <input className="input num" value={val} onChange={e => setVal(e.target.value)} inputMode="decimal"
                  style={{flex: 1, textAlign: 'center', fontSize: 17, fontWeight: 600}}/>
                <button className="btn" onClick={() => bump(1)} style={{padding: '8px 10px'}}>+1</button>
                <button className="btn" onClick={() => bump(10)} style={{padding: '8px 10px'}}>+10</button>
              </div>
              <div style={{fontSize: 12, marginTop: 8, fontWeight: 600,
                           color: delta > 0 ? 'var(--green)' : delta < 0 ? 'var(--red)' : 'var(--fg-muted)'}}>
                {delta === 0 ? 'без изменений' : `${delta > 0 ? '+' : ''}${delta.toLocaleString('ru')} ${unit}`}
              </div>
            </div>
          ) : (
            <div style={{fontSize: 12, color: 'var(--fg-muted)', padding: '10px 12px', background: 'var(--bg-sunk)', borderRadius: 8, borderTop: '1px solid var(--border)'}}>
              Текущий остаток: <b className="num" style={{color: 'var(--fg)'}}>{cur.toLocaleString('ru')} {unit}</b>.
              Правка остатка доступна роли «менеджер» и выше.
            </div>
          )}
        </div>

        <div style={{padding: '12px 20px', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'flex-end', gap: 8, flexShrink: 0}}>
          <button className="btn btn-ghost" onClick={onClose}>{canWrite ? 'Отмена' : 'Закрыть'}</button>
          {canWrite && (
            <button className="btn btn-primary" onClick={apply} disabled={delta === 0}>Провести</button>
          )}
        </div>
      </div>
    </div>
  );
}

// Кнопка «Корректировка» + её модалка. Вставляется в карточку позиции
// (ItemPopover, ItemCardContent) вместо мёртвой кнопки. Модалка рендерится
// порталом в body — поверх drawer/floating-окна, без обрезания overflow'ом.
function ItemCorrectionLauncher({ item }) {
  const [mode, setMode] = useState(null); // 'spec' | 'stock' | null
  const open = () => setMode(_itemHasEditableSpec(item) ? 'spec' : 'stock');
  const close = () => setMode(null);
  const portal = (node) => (window.ReactDOM && window.ReactDOM.createPortal && node)
    ? window.ReactDOM.createPortal(node, document.body)
    : node;

  let modal = null;
  if (mode === 'spec') {
    modal = window.SpecEditorModal
      ? <window.SpecEditorModal sku={item.sku} onClose={close}/>
      : (window.SpecViewerModal ? <window.SpecViewerModal sku={item.sku} onClose={close}/> : null);
  } else if (mode === 'stock') {
    modal = <StockAdjustModal item={item} onClose={close}/>;
  }

  return (
    <>
      <button className="btn" style={{flex: 1}} onClick={open}>
        <Icon name="edit" size={14}/> Корректировка
      </button>
      {modal && portal(modal)}
    </>
  );
}

// ============================================================================
// Перенос позиции по иерархии склада (кнопка ⇄ в правом верхнем углу плитки).
//
// Физический перенос меняет НА BACKEND warehouseId + groupId позиции через
// window.API.updateItem (PATCH /api/taxonomy/item/:id — персист + SSE-бродкаст),
// + оптимистичное обновление window.INVENTORY_FULL. Ссылки на позицию в спеках/
// BOM/Цехе идут по артикулу (sku/invId) — они стабильны и при переносе НЕ
// ломаются; производственный маршрут (ТПА→…→Склад№N) — это план цеха, не место
// хранения, его не трогаем. Подподгруппа (3-й уровень) выводится из имени, не
// отдельное поле → перенос идёт на уровень склад + подгруппа.
// ============================================================================
function _moveDestTree() {
  const whs = window.WAREHOUSES || [];
  const byWh = { 1: window.GROUPS_W1 || [], 2: window.GROUPS_W2 || [], 3: window.GROUPS_W3 || [] };
  return whs.map(w => ({
    warehouseId: w.id,
    name: w.name,
    groups: (byWh[w.id] || []).map(g => ({
      groupId: g.id,
      name: g.name,
      subsubs: (typeof window.subsubsForGroup === 'function' ? (window.subsubsForGroup(g.id) || []) : []),
    })),
  }));
}

function _appToast(msg, kind) {
  try { window.dispatchEvent(new CustomEvent('app-toast', { detail: { msg, kind: kind || 'info' } })); } catch (_) {}
}

// Физический перенос позиции. target = { warehouseId, groupId }.
function _performMovePosition(item, target) {
  if (!item || !item.id || !target) return false;
  const sameWh = Number(item.warehouseId) === Number(target.warehouseId);
  const sameGrp = item.groupId === target.groupId;
  if (sameWh && sameGrp) return false; // no-op
  // Оптимистично двигаем в локальной модели (по id ИЛИ sku — что найдётся).
  const pools = [window.INVENTORY_FULL, window.NOMENCLATURE];
  for (const pool of pools) {
    if (!Array.isArray(pool)) continue;
    const inv = pool.find(i => i && (i.id === item.id || (item.sku && i.sku === item.sku)));
    if (inv) { inv.warehouseId = target.warehouseId; inv.groupId = target.groupId; }
  }
  // Персист на backend (+ SSE-бродкаст вернёт авторитетный снапшот).
  if (window.API && typeof window.API.updateItem === 'function') {
    window.API.updateItem(item.id, { warehouseId: target.warehouseId, groupId: target.groupId });
  }
  try { window.dispatchEvent(new Event('taxonomy-changed')); } catch (_) {}
  try { window.dispatchEvent(new Event('reserve-changed')); } catch (_) {}
  return true;
}

// Модалка-пикер: дерево Склад → Подгруппа (подподгруппы показаны контекстом).
function WarehouseHierarchyPicker({ item, onCancel, onConfirm }) {
  const tree = useMemo(() => _moveDestTree(), []);
  const curWh = Number(item.warehouseId);
  const curGid = item.groupId;
  const [expanded, setExpanded] = useState(() => new Set([curWh]));
  const [sel, setSel] = useState(null); // { warehouseId, groupId, name, whName }
  useEffect(() => {
    const h = (e) => { if (e.key === 'Escape') onCancel && onCancel(); };
    window.addEventListener('keydown', h);
    return () => window.removeEventListener('keydown', h);
  }, [onCancel]);
  const toggleWh = (wid) => setExpanded(s => { const n = new Set(s); n.has(wid) ? n.delete(wid) : n.add(wid); return n; });
  const isCurrent = (wid, gid) => Number(wid) === curWh && gid === curGid;
  const canConfirm = sel && !isCurrent(sel.warehouseId, sel.groupId);

  return (
    <div className="modal-backdrop" onClick={onCancel}>
      <div className="modal" style={{ width: 540, maxWidth: '94vw', maxHeight: '88vh', display: 'flex', flexDirection: 'column' }}
           onClick={e => e.stopPropagation()}>
        <div className="modal-header" style={{ flexShrink: 0 }}>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div className="caps" style={{ fontSize: 11 }}>Переместить позицию</div>
            <div style={{ fontSize: 16, fontWeight: 600, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={item.name}>{item.name}</div>
            <div style={{ fontSize: 11, color: 'var(--fg-muted)', marginTop: 2, fontFamily: 'var(--font-mono)' }}>{item.sku || ('#' + item.id)} · сейчас здесь ↓</div>
          </div>
          <button className="btn btn-ghost" onClick={onCancel}><Icon name="close" size={14} /></button>
        </div>

        <div style={{ padding: '10px 16px', overflowY: 'auto', flex: 1 }}>
          {tree.map(w => {
            const open = expanded.has(w.warehouseId);
            return (
              <div key={w.warehouseId} style={{ marginBottom: 6 }}>
                <button className="btn btn-ghost picker-wh" onClick={() => toggleWh(w.warehouseId)}
                        style={{ width: '100%', justifyContent: 'flex-start', gap: 8, fontWeight: 600 }}>
                  <Icon name={open ? 'minus' : 'arrow_right'} size={12} />
                  {w.name}{Number(w.warehouseId) === curWh ? <span className="picker-here">текущий склад</span> : null}
                </button>
                {open && (
                  <div style={{ paddingLeft: 14, marginTop: 2 }}>
                    {w.groups.length === 0 && <div style={{ fontSize: 12, color: 'var(--fg-muted)', padding: '4px 8px' }}>нет подгрупп</div>}
                    {w.groups.map(g => {
                      const cur = isCurrent(w.warehouseId, g.groupId);
                      const picked = sel && sel.warehouseId === w.warehouseId && sel.groupId === g.groupId;
                      return (
                        <div key={g.groupId}>
                          <button className={'picker-node' + (picked ? ' picker-node--sel' : '') + (cur ? ' picker-node--cur' : '')}
                                  disabled={cur}
                                  onClick={() => setSel({ warehouseId: w.warehouseId, groupId: g.groupId, name: g.name, whName: w.name })}>
                            <span style={{ flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{g.name}</span>
                            {cur && <span className="picker-here">здесь сейчас</span>}
                            {picked && <Icon name="check" size={13} />}
                          </button>
                          {g.subsubs.length > 0 && (
                            <div style={{ paddingLeft: 22, fontSize: 11, color: 'var(--fg-muted)', display: 'flex', flexWrap: 'wrap', gap: 6, margin: '2px 0 4px' }}>
                              {g.subsubs.slice(0, 8).map(s => <span key={s.key}>· {s.key}</span>)}
                            </div>
                          )}
                        </div>
                      );
                    })}
                  </div>
                )}
              </div>
            );
          })}
        </div>

        <div style={{ padding: '12px 16px', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexShrink: 0 }}>
          <div style={{ fontSize: 12, color: 'var(--fg-muted)', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
            {sel ? <>→ <b style={{ color: 'var(--fg)' }}>{sel.whName} · {sel.name}</b></> : 'Выберите подгруппу-назначение'}
          </div>
          <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
            <button className="btn btn-ghost" onClick={onCancel}>Отмена</button>
            <button className="btn btn-primary" disabled={!canConfirm} onClick={() => onConfirm(sel)}>Подтвердить</button>
          </div>
        </div>
      </div>
    </div>
  );
}

// Угловая кнопка ⇄ на плитке позиции + пикер (портал в body). Под ролью manager.
function MovePositionLauncher({ item }) {
  const [open, setOpen] = useState(false);
  const role = useCurrentRole();
  const canWrite = (ROLE_ORDER[role] || 0) >= (ROLE_ORDER['manager'] || 0);
  if (!canWrite || !item || !item.id) return null;
  const portal = (node) => (window.ReactDOM && window.ReactDOM.createPortal)
    ? window.ReactDOM.createPortal(node, document.body) : node;
  const confirm = (target) => {
    const moved = _performMovePosition(item, target);
    if (moved) _appToast(`«${item.name}» → ${target.whName} · ${target.name}`, 'success');
    setOpen(false);
  };
  return (
    <>
      <button type="button" className="icon-tile__move-btn" title="Переместить позицию в другую папку/склад"
              onClick={(e) => { e.stopPropagation(); setOpen(true); }}>
        <Icon name="move" size={12} />
      </button>
      {open && portal(<WarehouseHierarchyPicker item={item} onCancel={() => setOpen(false)} onConfirm={confirm} />)}
    </>
  );
}

Object.assign(window, {
  Icon, Logo, ToastProvider, useToast, Drawer, ConfirmModal, useConfirm,
  VBar, HBar, SectionHeader, MatChip, OriginFlag, MATERIAL_COLORS, ToastCtx,
  BomChecklist,
  // FX-1
  TileIcon, IconTile, IconGrid, WarehouseStripe, ItemPopover,
  tileStyleForItem, TILE_STYLE_FOR_GROUP,
  // Корректировка позиции склада (кнопка в карточке позиции)
  ItemCorrectionLauncher, StockAdjustModal,
  // Перенос позиции по иерархии склада (угловая кнопка ⇄ на плитке)
  MovePositionLauncher, WarehouseHierarchyPicker,
  // FX-1 multi-window
  ItemCardContent, FloatingItemCard, FloatingWindowsLayer, useFloatingWindows,
  // FX-RESERVE-V2
  ReserveBlock, useReserveTick, useTaxonomyTick, formatQty,
  // S4 — Цех/резервы для витрины (read-only проекции)
  useCechTick, fgOrdersInProduction, reservationsByProductForGroup,
  // FX-SUPPLIERS
  StatusChip,
  // Phase 2 — sync indicator + role gating
  SyncIndicator, useApiSyncStatus,
  useCurrentRole, CanWrite,
  // Phase 3 — spec viewer (shared между dashboard и launch_flow)
  SpecViewerModal,
  // Переиспользуемый переключатель «иконки ↔ список»
  ViewToggle, useViewMode,
  // «Вес примерный» — маркер + бейдж (требует корректировки массы)
  WtApproxBadge, _isWeightApprox, _weightIsEmpty, _approxWeightCountInItems,
});
