/* lk-widgets.jsx — библиотека виджетов кабинетов в стиле кабинета клиента (client-v2, DESIGN.md v1.3).
   Из неё собираются кабинеты сотрудников: виджет получает готовые данные (views ProjectStore или ForemanViews) и рисует блок
   в тех же классах .cv-*, что главная клиента, — один вид во всех кабинетах. Новые блоки (график, команда, плитки объектов,
   приёмка) — классы .lw-* в lk-widgets.css. Цвет — только по делу: просрочка — охра (--late), всё остальное — графит и серый.
   Состав: Ic, Face, WHead, Header, Modal, FactsBand, NowCards, CalendarW (+ CalAgenda), RowsW, ChatsW, ObjTile, PhaseMini,
   GanttW, TeamW, AcceptRow; помощники rub, plural, cap, dateR, dayLabel, short.
   Exports on window: LKW. */
(function () {
  'use strict';
  const ICONS = {
    arrow: <path d="M5 12h14M13 6l6 6-6 6" />, ne: <path d="M7 17L17 7M9 7h8v8" />, x: <path d="M6 6l12 12M18 6L6 18" />, left: <path d="M15 5l-7 7 7 7" />, right: <path d="M9 5l7 7-7 7" />,
    check: <path d="M5 12l5 5L20 7" />, back: <path d="M9 14L4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3" />, alert: <path d="M12 4l9 16H3zM12 10v4M12 17v.5" />, chat: <path d="M4 5h16v11H9l-5 4z" />,
    camera: <React.Fragment><path d="M4 8h3l2-3h6l2 3h3v11H4z" /><circle cx="12" cy="13" r="3.5" /></React.Fragment>,
    doc: <React.Fragment><path d="M7 3h7l4 4v14H7z" /><path d="M14 3v4h4M10 12h5M10 16h5" /></React.Fragment>,
    box: <React.Fragment><path d="M4 8l8-4 8 4v8l-8 4-8-4z" /><path d="M4 8l8 4 8-4M12 12v8" /></React.Fragment>,
    bell: <React.Fragment><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z" /><path d="M10 20.5a2 2 0 0 0 4 0" /></React.Fragment>,
    calendar: <React.Fragment><rect x="4" y="5.5" width="16" height="14.5" rx="2.5" /><path d="M4 10h16M8.5 3.5v4M15.5 3.5v4" /></React.Fragment>,
    wallet: <React.Fragment><rect x="3.5" y="6" width="17" height="13" rx="2.5" /><path d="M3.5 10h17M15 14.5h2" /></React.Fragment>,
    tool: <path d="M14 4l6 6-3 3-6-6zM11 10l-7 7 3 3 7-7" />, key: <React.Fragment><circle cx="8" cy="15" r="4" /><path d="M11 12l8-8M16 7l2 2M14 9l2 2" /></React.Fragment>,
    clock: <React.Fragment><circle cx="12" cy="12" r="8" /><path d="M12 8v4l3 2" /></React.Fragment>,
    pause: <path d="M9 6v12M15 6v12" />,
    truck: <React.Fragment><path d="M3 7h11v9H3zM14 10h4l3 3v3h-7" /><circle cx="7" cy="17.5" r="1.8" /><circle cx="17" cy="17.5" r="1.8" /></React.Fragment>,
    user: <React.Fragment><circle cx="12" cy="8.5" r="3.5" /><path d="M5 20a7 7 0 0 1 14 0" /></React.Fragment>,
    trash: <path d="M5 7h14M10 7V4.5h4V7M7 7l1 13h8l1-13" />,
    sofa: <React.Fragment><path d="M5 11V8a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v3" /><path d="M3 12a2 2 0 0 1 4 0v2h10v-2a2 2 0 0 1 4 0v5H3zM6 17v2M18 17v2" /></React.Fragment>,
    spark: <path d="M12 3v5M12 16v5M3 12h5M16 12h5M6 6l3 3M15 15l3 3M18 6l-3 3M9 15l-3 3" />,
  };
  function Ic({ n, c = 'currentColor', s = 18 }) {
    return <svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{ICONS[n]}</svg>;
  }
  const rub = (n) => (Math.round(n) || 0).toLocaleString('ru-RU') + ' ₽';
  const plural = (n, a, b, c) => { const m = Math.abs(n) % 100, k = m % 10; return m > 10 && m < 20 ? c : k > 1 && k < 5 ? b : k === 1 ? a : c; };
  // «фронт работ» текущей фазы: доля принятых работ — до неё залита полоса, на краю точка
  const frontPct = (x) => Math.max(3, Math.min(100, Math.round((x.doneN || 0) / Math.max(1, x.allN || 0) * 100)));
  const cap = (t) => (t ? t.charAt(0).toUpperCase() + t.slice(1) : t);
  const isoAdd = (d, n) => { const x = new Date(d + 'T00:00:00Z'); x.setUTCDate(x.getUTCDate() + n); return x.toISOString().slice(0, 10); };
  // год — только когда дата не в текущем году (аудит 22.10), если вызов сам не задал year
  const dateR = (d, opt) => (d ? new Date(d + 'T00:00:00Z').toLocaleDateString('ru-RU', Object.assign({ day: 'numeric', month: 'long', timeZone: 'UTC' }, String(d).slice(0, 4) !== String(new Date().getFullYear()) ? { year: 'numeric' } : {}, opt || {})).replace(' г.', '') : '');
  const dayLabel = (d, t0) => d === t0 ? 'Сегодня' : d === isoAdd(t0, 1) ? 'Завтра' : cap(new Date(d + 'T00:00:00Z').toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'long', timeZone: 'UTC' }));
  // короткое имя объекта: название ЖК в кавычках или адрес до запятой — для плиток, графика и календаря команды
  const short = (title) => { const m = /«([^»]+)»/.exec(title || ''); return m ? m[1] : String(title || '').split(',')[0]; };
  const letter = (title) => short(title).replace(/^[^A-Za-zА-Яа-яЁё0-9]+/, '').charAt(0).toUpperCase();

  function Face({ m, size }) {
    return m.photo ? <img className="cv-face" src={m.photo} alt="" width={size} height={size} style={{ width: size, height: size }} />
      : <span className="cv-face is-ini" style={{ width: size, height: size, fontSize: Math.round(size / 3) }} aria-hidden="true">{m.initials}</span>;
  }

  /* аватар мастера со статусом в штате (st из ForemanViews: на паузе — значок паузы, ушёл из команды — крестик); работает — обычный Face */
  function FaceSt({ m, size }) {
    if (!m.st) return <Face m={m} size={size} />;
    return <span className={'lw-fw is-' + m.st.k}><Face m={m} size={size} /><i className="lw-away" aria-hidden="true"><Ic n={m.st.k === 'left' ? 'x' : 'pause'} s={10} c="#fff" /></i></span>;
  }
  // подпись статуса: «На паузе с 27 окт.», «Ушёл из команды»
  const stLabel = (st) => cap(st.l) + (st.k === 'no_new' && st.since ? ' с ' + dateR(st.since, { month: 'short' }) : '');
  /* отсутствие (KSH-524): идёт — «Отпуск до 12 нояб.», «Больничный, дата выхода не известна»; впереди — «Отпуск с 18 нояб.» */
  const awayLabel = (a) => cap(a.l) + (a.now ? (a.to ? ' до ' + dateR(a.to, { month: 'short' }) : ', дата выхода не известна') : ' с ' + dateR(a.from, { month: 'short' }));

  /* заголовок блока: счётчик, название, ↗ с подписью или без — окно блока (как на главной клиента) */
  function WHead({ id, title, n, label, onOpen, text, sub }) {
    return (
      <div className="hd">
        {n > 0 ? <span className="cv-wno" aria-hidden="true">{n}</span> : null}
        <h2 className="cv-wt" id={id}>{title}{n > 0 ? <span className="sr-only">, {n}</span> : null}{sub ? <span className="cv-wn">{sub}</span> : null}</h2>
        {onOpen ? (text ? <button type="button" className="cv-arrow-btn" onClick={onOpen}>{text}<Ic n="ne" s={14} /></button>
          : <button type="button" className="cv-wcirc" aria-label={label} onClick={onOpen}><Ic n="ne" s={18} /></button>) : null}
      </div>);
  }

  /* шапка кабинета: логотип, разделы (#окна), «Ещё», справа — свои элементы. nav: [[id, label]], more: [[id|href, label]].
     На телефоне (≤760px) разделы — нижняя панель (KSH-550): phone — id разделов на панели (по умолчанию первые четыре),
     остальные разделы nav уходят в «Ещё» (пункты is-ph-only видны только на телефоне) */
  function Header({ nav, more, cur, right, phone }) {
    const [on, setOn] = React.useState(false);
    const ref = React.useRef(null);
    React.useEffect(() => {
      if (!on) return undefined;
      const out = (e) => { if (ref.current && !ref.current.contains(e.target)) setOn(false); };
      const esc = (e) => { if (e.key === 'Escape') setOn(false); };
      document.addEventListener('mousedown', out); document.addEventListener('keydown', esc);
      return () => { document.removeEventListener('mousedown', out); document.removeEventListener('keydown', esc); };
    }, [on]);
    const href = (id) => (/[./]/.test(id) ? id : '#' + id);
    const onBar = phone || nav.slice(0, 4).map(([id]) => id);
    const spill = nav.filter(([id]) => !onBar.includes(id));
    const extra = more || [];
    return (
      <header className="cv-head">
        <a className="cv-skip" href="#main" onClick={(e) => { const m = document.getElementById('main'); if (m) { e.preventDefault(); m.focus(); m.scrollIntoView(); } }}>Перейти к содержанию</a>
        <span className="cv-logo">ОБИТЕЛЬ</span>
        <nav className="cv-nav" aria-label="Разделы">
          {nav.map(([id, l]) => <a key={id || 'home'} className={onBar.includes(id) ? undefined : 'is-ph-hide'} href={id ? href(id) : window.location.pathname + window.location.search} aria-current={cur === id ? 'page' : undefined}>{l}</a>)}
          {(extra.length > 0 || spill.length > 0) && <div className={'cv-more' + (extra.concat(spill).some(([id]) => id === cur) ? ' is-cur' : '') + (extra.length ? '' : ' is-ph-only')} ref={ref}>
            <button type="button" aria-expanded={on} aria-controls="lw-more-list" onClick={() => setOn(!on)}>Ещё <span aria-hidden="true">▾</span></button>
            {on && <ul id="lw-more-list">
              {spill.map(([id, l]) => <li key={'ph-' + (id || 'home')} className="is-ph-only"><a href={id ? href(id) : window.location.pathname + window.location.search} aria-current={cur === id ? 'page' : undefined} onClick={() => setOn(false)}>{l}</a></li>)}
              {extra.map(([id, l]) => <li key={id}><a href={href(id)} onClick={() => setOn(false)}>{l}</a></li>)}
            </ul>}
          </div>}
        </nav>
        <div className="cv-me">{right}</div>
      </header>);
  }

  /* окно раздела: по центру поверх главной (cv-modal), Esc и клик по затемнению закрывают; size: 'wide' — почти во весь экран */
  function Modal({ title, sub, onClose, size, className, head, children, label }) {
    const box = React.useRef(null);
    React.useEffect(() => {
      const from = document.activeElement;
      const k = (e) => { if (e.key === 'Escape' && !document.querySelector('.lk-lb')) onClose(); };
      document.addEventListener('keydown', k);
      const ov = document.body.style.overflow; document.body.style.overflow = 'hidden';
      if (box.current) box.current.focus();
      return () => { document.removeEventListener('keydown', k); document.body.style.overflow = ov; if (from && from.focus) from.focus(); };
    }, []);
    return (
      <React.Fragment>
        <div className="cv-scrim" onClick={onClose} />
        <div ref={box} tabIndex={-1} className={'cv-modal lw-modal' + (size ? ' is-' + size : '') + (className ? ' ' + className : '')} role="dialog" aria-modal="true" aria-label={label || title}>
          <div className="cv-mhead">
            <div className="lw-mtitle">{sub ? <div className="lab">{sub}</div> : null}<h2 className="h1 lw-mh">{title}</h2></div>
            <div className="lw-mact">{head}<button type="button" className="x" aria-label="Закрыть" onClick={onClose}><Ic n="x" /></button></div>
          </div>
          {children}
        </div>
      </React.Fragment>);
  }

  /* серая полоса сводки (как «Ход ремонта» клиента): живая строка, путь фаз (необязательно), факты-ссылки */
  function PathBar({ path }) {
    return (
      <ol className="cv-prog-path" aria-label="Путь проекта" style={{ gridTemplateColumns: 'repeat(' + path.length + ', minmax(0, 1fr))' }}>
        {path.map((x, i) => (
          <li key={x.idx} className={'is-' + x.state} aria-current={x.state === 'current' ? 'step' : undefined}>
            <span className="b"><i style={x.state === 'current' ? { width: frontPct(x) + '%', animationDelay: (i * 0.2) + 's' } : { animationDelay: (i * 0.2) + 's' }} />{x.state === 'current' ? <b className="cv-front" style={{ left: frontPct(x) + '%' }} /> : null}</span>
            <span className="l">{x.name}<span className="s">{x.state === 'done' ? 'сдано' + (x.paid === 'done' ? ' · оплачено' : '') : x.state === 'current' ? 'идут работы · ' + x.doneN + ' из ' + x.allN : ''}</span></span>
          </li>))}
      </ol>);
  }
  function FactsBand({ live, path, facts, label }) {
    return (
      <section className="cv-prog lw-band" aria-label={label || 'Сводка'}>
        {live ? <div className="cv-prog-live">{live}</div> : null}
        {path && path.length ? <PathBar path={path} /> : null}
        <div className="cv-prog-facts" style={{ gridTemplateColumns: 'repeat(' + facts.length + ', minmax(0, 1fr))' }}>
          {facts.map((f, i) => {
            const body = <React.Fragment><span className="v">{f.v}</span><span className="lab">{f.lab}</span></React.Fragment>;
            return f.onClick ? <button key={i} type="button" className={'cv-fact lw-fact' + (f.late ? ' is-late' : '')} onClick={f.onClick}>{body}</button>
              : <div key={i} className={'cv-fact' + (f.late ? ' is-late' : '')}>{body}</div>;
          })}
        </div>
      </section>);
  }

  /* «Сейчас важно»: карточки в ряд; act — кнопка карточки ({ text, onClick, main }) */
  function NowCards({ rows, label }) {
    if (!rows || !rows.length) return null;
    return (
      <section className="cv-nowrow lw-now" aria-label={label || 'Сейчас важно'}>
        {rows.map((r) => (
          <div key={r.key} className={'cv-now' + (r.late ? ' is-over' : '')}>
            <div className="top"><span className="lab">{r.kindL}</span>{r.right ? <span className={'by' + (r.late ? ' is-over' : '')}><b>{r.right}</b></span> : null}</div>
            <div className="t" title={r.title}>{r.title}</div>
            <div className="bot"><span className="lab" title={r.sub}>{r.sub}</span>
              {r.act ? <button type="button" className={r.act.main ? 'lw-btn-k' : 'cv-arrow-btn'} onClick={r.act.onClick}>{r.act.text}{r.act.main ? null : <Ic n="ne" s={14} />}</button> : null}</div>
          </div>))}
      </section>);
  }

  /* календарь: вкладки-фильтры, две недели с понедельника, повестка со своей прокруткой; у события — объект (obj) */
  const CAL_KL = { work: 'работа', payment: 'оплата', delivery: 'доставка', decide: 'решение', order: 'закупка' };
  const calStatus = (e) => e.status === 'overdue' ? ['Просрочено', 'pill-n cv-over'] : e.status === 'done' ? [e.kind === 'payment' ? 'Оплачено' : e.kind === 'delivery' ? 'Привезли' : 'Готово', 'pill-g']
    : e.status === 'wait' ? ['На проверке', 'pill-g'] : e.status === 'due' ? [e.kind === 'payment' ? 'К оплате' : 'Решить', 'pill-n'] : ['План', 'pill-d'];
  function CalTabs({ kinds, tab, setTab, counts, label, sub }) {
    return (
      <div className={'cv-ctabs' + (sub ? ' is-sub' : '')} role="tablist" aria-label={label || 'Что показать'}>
        {kinds.map(([k, l]) => <button type="button" role="tab" key={k} aria-selected={tab === k} className={tab === k ? 'on' : ''} onClick={() => setTab(k)}>{l}{counts ? <span>{counts[k] || 0}</span> : null}</button>)}
      </div>);
  }
  function CalRow({ e, onPick }) {
    const [l, cls] = calStatus(e);
    return (
      <button type="button" className={'cv-crow k-' + e.kind + (e.status === 'done' ? ' is-done' : '') + (e.status === 'overdue' ? ' is-over' : '')} onClick={() => onPick && onPick(e)} aria-label={e.title + (e.obj ? ', ' + e.obj : '') + ', ' + l.toLowerCase()}>
        <span className="tx"><span className="t">{e.title}</span><span className="lab">{e.obj ? short(e.obj) + ' · ' : ''}{cap(CAL_KL[e.kind] || '')}{e.sub ? ' · ' + e.sub : ''}</span></span>
        <span className={cls}>{l}</span>
      </button>);
  }
  function CalAgenda({ list, t0, onPick }) {
    const groups = [];
    list.forEach((e) => { const g = e.date < t0 && e.status === 'overdue' ? 'over' : e.date; const last = groups[groups.length - 1]; if (last && last.key === g) last.items.push(e); else groups.push({ key: g, items: [e] }); });
    return groups.map((g) => (
      <div key={g.key} className="cv-cgrp">
        <div className={'lab' + (g.key === 'over' ? ' cv-over-t' : '')}>{g.key === 'over' ? 'Просрочено' : dayLabel(g.key, t0)}</div>
        {g.items.map((e) => <CalRow key={e.id} e={e} onPick={onPick} />)}
      </div>));
  }
  const CAL_KINDS = [['all', 'Все'], ['work', 'Работы'], ['delivery', 'Доставки'], ['decide', 'Решения'], ['payment', 'Оплаты']];
  // вкладки объектов календаря: «Все объекты» и по объекту в порядке плиток (tone); у одного объекта вкладок нет
  function calObjs(events) {
    const out = [];
    events.forEach((e) => { if (e.pid && !out.some((o) => o.pid === e.pid)) out.push({ pid: e.pid, obj: e.obj, tone: e.tone || 0 }); });
    return out.sort((a, b) => a.tone - b.tone);
  }
  const countBy = (list, key) => { const n = { all: list.length }; list.forEach((e) => { n[e[key]] = (n[e[key]] || 0) + 1; }); return n; };
  function CalendarW({ c, title, onOpen, onPick, kinds }) {
    const [tab, setTab] = React.useState('all');
    const [obj, setObj] = React.useState('all');
    const [day, setDay] = React.useState(null);
    const objs = calObjs(c.events);
    const inObj = (e) => obj === 'all' || e.pid === obj;
    const fit = (e) => (tab === 'all' || e.kind === tab) && inObj(e);
    const list = day ? c.events.filter((e) => e.date === day && fit(e)) : c.agenda.filter(fit);
    return (
      <section className="cv-w cv-cal lw-cal" aria-labelledby="lw-cal-h">
        <WHead id="lw-cal-h" title={title || 'Календарь'} text={onOpen ? 'Весь календарь' : null} onOpen={onOpen} />
        {objs.length > 1 ? <CalTabs label="Объекты" kinds={[['all', 'Все объекты']].concat(objs.map((o) => [o.pid, short(o.obj)]))} tab={obj} setTab={setObj} counts={countBy(c.agenda, 'pid')} /> : null}
        <CalTabs kinds={kinds || CAL_KINDS} tab={tab} setTab={setTab} counts={countBy(c.agenda.filter(inObj), 'kind')} sub={objs.length > 1} />
        <div className="cv-wk" aria-label="Две недели">
          {['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map((d) => <span key={d} className="wd">{d}</span>)}
          {c.days.map((d) => {
            const evs = c.events.filter((e) => e.date === d && fit(e));
            const over = evs.some((e) => e.status === 'overdue');
            return (
              <button type="button" key={d} className={'dc' + (d === c.today ? ' is-today' : '') + (d < c.today ? ' is-past' : '') + (d === day ? ' is-sel' : '')} onClick={() => setDay(d === day ? null : d)} aria-label={dayLabel(d, c.today) + (evs.length ? ', событий: ' + evs.length : '')}>
                <b>{+d.slice(8)}</b><span className="dots">{evs.length ? <i className={over ? 'is-over' : ''} /> : null}</span>
              </button>);
          })}
        </div>
        {day && <div className="cv-cday"><span className="lab">{dayLabel(day, c.today)}</span><button type="button" className="lab lw-lnk" onClick={() => setDay(null)}>показать ближайшее</button></div>}
        <div className="cv-cal-list cv-scroll" tabIndex={0} aria-label="События">
          {list.length ? <CalAgenda list={list} t0={c.today} onPick={onPick} /> : <div className="empty">{day ? 'В этот день ничего не запланировано.' : 'В ближайшие две недели ничего нет.'}</div>}
        </div>
      </section>);
  }

  /* список строк (как «Согласования» клиента): иконка, название, подпись, справа — срок или действие; foot — кнопки внизу блока */
  function RowsW({ id, title, n, rows, empty, onOpen, text, className, max, foot }) {
    return (
      <section className={'cv-w cv-apr-w lw-rows' + (className ? ' ' + className : '')} aria-labelledby={id}>
        <WHead id={id} title={title} n={n} text={text} label={text || title} onOpen={onOpen} />
        {rows.length === 0
          ? <div className="note-s" style={{ marginTop: 14 }}><Ic n="check" c="#2F5D3A" s={16} /><span>{empty}</span></div>
          : <div className="rows cv-scroll" tabIndex={0} style={max ? { maxHeight: max } : undefined}>{rows.map((r) => (
            <div key={r.key} className={'r lw-r' + (r.late ? ' is-over' : '')}>
              {r.thumb ? <button type="button" className="lw-th" onClick={r.onThumb} aria-label={'Фото: ' + r.title}><img src={r.thumb} alt="" loading="lazy" />{r.thumbN > 1 ? <span>{r.thumbN}</span> : null}</button>
                : <span className="ic"><Ic n={r.ic || 'check'} s={15} /></span>}
              <button type="button" className="tx lw-rt" onClick={r.onClick}><span className="t">{r.title}</span><span className="lab">{r.sub}</span></button>
              {r.right ? <span className={'by' + (r.late ? ' is-over' : '')}>{r.rightLab ? <span className="lab">{r.rightLab}</span> : null}<b>{r.right}</b></span> : null}
              {r.acts ? <span className="lw-racts">{r.acts}</span> : null}
            </div>))}</div>}
        {foot ? <div className="ft">{foot}</div> : null}
      </section>);
  }

  /* чаты: свежие сверху, новое — жирнее со счётчиком */
  function ChatsW({ chats, onOpen, onPick, empty }) {
    const n = chats.filter((c) => c.isNew).length;
    return (
      <section className="cv-w cv-chats-w" aria-labelledby="lw-chats-h">
        <WHead id="lw-chats-h" title="Чаты" n={n} label="Открыть все чаты" onOpen={onOpen} />
        {chats.length === 0 ? <div className="note-s" style={{ marginTop: 12 }}><span>{empty || 'Переписки пока нет.'}</span></div>
          : <div className="rows">{chats.slice(0, 4).map((c) => (
            <button type="button" key={c.pid + c.id} className={'r' + (c.isNew ? ' is-new' : '')} onClick={() => onPick(c)}>
              <span className="tx"><span className="t">{c.title}<span className="lw-obj"> · {short(c.obj)}</span></span><span className="lab">{c.who ? c.who + ': ' : ''}{c.text}</span></span>
              <span className="tm"><span className="lab">{c.when}</span>{c.isNew ? <b className="nd" aria-label="новое">1</b> : null}</span>
            </button>))}</div>}
      </section>);
  }

  /* мини-путь фаз для плитки объекта: сданные — зелёные, текущая — чуть заметная заливка и точка фронта, впереди — светлые */
  function PhaseMini({ path }) {
    return <span className="lw-pmini" aria-hidden="true">{path.map((x) => x.state === 'current'
      ? <i key={x.idx} className="is-current" style={{ background: 'linear-gradient(90deg, var(--front-fill) ' + frontPct(x) + '%, var(--lw-front-rest) ' + frontPct(x) + '%)' }}><b className="cv-front" style={{ left: frontPct(x) + '%' }} /></i>
      : <i key={x.idx} className={'is-' + x.state} />)}</span>;
  }
  const leftL = (n) => n == null ? '' : n < 0 ? 'срок прошёл ' + (-n) + ' ' + plural(-n, 'день', 'дня', 'дней') + ' назад' : n === 0 ? 'сегодня' : 'через ' + n + ' ' + plural(n, 'день', 'дня', 'дней');
  /* плитка объекта: фото последней принятой работы, сигналы поверх, путь фаз, сдача */
  function ObjTile({ o, onOpen }) {
    const flags = [o.review ? o.review + ' на приёмке' : '', o.returned ? o.returned + ' ' + plural(o.returned, 'возврат', 'возврата', 'возвратов') : '', o.late ? 'просрочка' : ''].filter(Boolean);
    return (
      <button type="button" className="lw-tile" onClick={onOpen} aria-label={'Открыть объект ' + o.title}>
        <span className="lw-tph">{o.cover ? <img src={o.cover} alt="" loading="lazy" /> : <span className="ph0"><Ic n="key" s={28} /></span>}
          {flags.length ? <span className="fl">{flags.map((f) => <span key={f} className={/просроч/.test(f) ? 'is-late' : ''}>{f}</span>)}</span> : null}
          <span className="cv-wcirc lw-tcirc" aria-hidden="true"><Ic n="ne" s={18} /></span></span>
        <span className="tx">
          <span className="t">{o.title}</span>
          <span className="lab">{[o.client, o.area ? o.area + ' м²' : ''].filter(Boolean).join(' · ')}</span>
          <PhaseMini path={o.path} />
          <span className="row"><span className="st">Фаза {o.phaseNo} из {o.phaseN} · {o.stage || o.phase}</span><span className="pc">{o.pct} %</span></span>
          <span className="lab">Сдача {dateR(o.dueIso)} · {leftL(o.daysLeft)}</span>
        </span>
      </button>);
  }

  /* график (Гант): шкала месяцев и недель, линия «сегодня», строка объекта — фазы полосами; detail — ещё и этапы.
     Состояния: сдано — серым, идёт — «фронт работ» (чуть заметная заливка и точка), просрочено — охра, впереди — светлая полоса с контуром.
     Справа колонка «Готово»: у объекта — общий ход крупно (% и «N из M работ»), у этапа — свой ход мельче; так общее не сливается с этапами (Роман, 29.10) */
  const MON = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];
  const dd = (a, b) => Math.round((Date.parse(a + 'T00:00:00Z') - Date.parse(b + 'T00:00:00Z')) / 86400000);
  // работа на графике: состояние справа (охра — просрочена или возвращена)
  const WST = { done: 'принята', review: 'на приёмке', current: 'в работе', late: 'просрочена', next: '' };
  /* шкала графика (Роман, 07.11): основа — по числам, сжимается до недель и месяцев. В днях и неделях у дня своя ширина в пикселях,
     график шире окна и листается вбок, подписи слева и «Готово» справа стоят на месте. Месяцы — весь срок в ширину окна, как раньше.
     Работы раскрытого этапа — на той же шкале, что этапы и фазы: раньше у них была своя шкала по дням, и при раскрытии масштаб прыгал */
  const SCALES = [{ id: 'day', l: 'Дни', px: 34 }, { id: 'week', l: 'Недели', px: 11 }, { id: 'month', l: 'Месяцы', px: 0 }];
  const EV_IC = { delivery: 'truck', visit: 'user', waste: 'trash', assembly: 'sofa', clean: 'spark', custom: 'calendar' };
  // даты графика — числом: 09.11 (Роман, 07.11)
  const dm = (d) => (d ? d.slice(8, 10) + '.' + d.slice(5, 7) : '');
  const GWD = ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'];
  function ScaleTabs({ scale, onScale }) {
    return (
      <div className="cv-ctabs lw-seg" role="tablist" aria-label="Шкала">{SCALES.map((x) => (
        <button key={x.id} type="button" role="tab" aria-selected={scale === x.id} className={scale === x.id ? 'on' : ''} onClick={() => onScale(x.id)}>{x.l}</button>))}</div>);
  }
  // ход этапа справа: тонкая полоска и «6 из 7»; сданный — «сдано» без цифр (Роман, 07.11: «правая колонка сливается: 100 % и 9 из 9»)
  function GDone({ pct, n, all, st }) {
    if (all && n >= all) return <span className="lw-gd is-done"><Ic n="check" s={13} /><span>сдано</span></span>;
    return (
      <span className={'lw-gd' + (st ? ' is-' + st : '')}><span className="lw-gdb"><i style={{ width: Math.max(0, Math.min(100, pct || 0)) + '%' }} /></span>
        <span className="lw-gdn"><b>{n}</b> из {all}</span></span>);
  }
  /* График работ (правки Романа 07.11, второй круг): шапка и блок «События на объекте» закреплены при прокрутке; сданные фазы свёрнуты
     и приглушены — акцент на идущей стадии, при открытии график сам прокручен к текущему этапу и к сегодняшнему дню; даты — 09.11;
     просрочка — красным, без коричневого. События: доставки — подгруппой (что везут), сервисные работы, визит клиента, свои события */
  function GanttW({ g, from, to, detail, onRow, onWork, head, compact, scale: scaleIn, onScale, onAddEvent, onRemoveEvent, autoOpen }) {
    const [scaleOwn, setScaleOwn] = React.useState('month');
    const scale = scaleIn || scaleOwn, setScale = onScale || setScaleOwn;
    const f = from || g.from, t = to || g.to;
    const one = g.rows.length === 1;
    // раскрытые этапы, фазы и подгруппы событий; по умолчанию: текущий этап (у одного объекта), несданные фазы, группа событий
    const [openS, setOpenS] = React.useState(() => (detail && autoOpen ? g.rows.flatMap((r) => r.stages.filter((s) => s.state === 'current' || s.state === 'late').map((s) => r.id + ':' + s.id)) : detail && one ? g.rows[0].stages.filter((s) => s.state === 'current' || s.state === 'late').map((s) => g.rows[0].id + ':' + s.id) : []));
    const [openP, setOpenP] = React.useState(() => g.rows.flatMap((r) => r.phases.filter((p) => p.state !== 'done').map((p) => r.id + ':' + p.idx)));
    const [openE, setOpenE] = React.useState([]);
    const [form, setForm] = React.useState(null);
    const [ferr, setFerr] = React.useState('');
    const tg = (set) => (k) => set((a) => (a.includes(k) ? a.filter((x) => x !== k) : a.concat(k)));
    const toggle = tg(setOpenS), toggleP = tg(setOpenP), toggleE = tg(setOpenE);
    const sc = SCALES.find((x) => x.id === scale) || SCALES[2];
    const span = Math.max(1, dd(t, f) + 1);
    const x = (d) => Math.max(0, Math.min(100, dd(d, f) / span * 100));
    const w = (a, b) => Math.max(sc.px ? 100 / span : 0.6, x(b) - x(a) + 100 / span);
    const trackW = sc.px ? span * sc.px : null;
    const scroller = React.useRef(null);
    const today = g.today >= f && g.today <= t ? x(g.today) : null;
    // к сегодняшнему дню по горизонтали (при смене шкалы) и к идущему этапу по вертикали (при открытии)
    React.useEffect(() => {
      const el = scroller.current; if (!el || !trackW || today == null) return;
      el.scrollLeft = Math.max(0, trackW * today / 100 - sc.px * 7);
    }, [scale, trackW]);
    React.useEffect(() => {
      const el = scroller.current; if (!el || compact || !detail) return;
      const act = el.querySelector('.lw-grow.is-stage.is-late, .lw-grow.is-stage.is-current');
      const pin = el.querySelector('.lw-gax'), evb = el.querySelector('.lw-gevb');
      if (act) el.scrollTop = Math.max(0, act.offsetTop - (pin ? pin.offsetHeight : 0) - (evb ? evb.offsetHeight : 0) - 70);
    }, []);
    const months = [];
    const d0 = new Date(f + 'T00:00:00Z'); d0.setUTCDate(1);
    for (let d = new Date(d0); d.toISOString().slice(0, 10) <= t; d.setUTCMonth(d.getUTCMonth() + 1)) {
      const s = d.toISOString().slice(0, 10); const e = new Date(d); e.setUTCMonth(e.getUTCMonth() + 1); e.setUTCDate(0);
      const a = s < f ? f : s, b = e.toISOString().slice(0, 10) > t ? t : e.toISOString().slice(0, 10);
      months.push({ key: s, l: MON[d.getUTCMonth()] + (d.getUTCMonth() === 0 || !months.length ? (sc.px ? ' ' + d.getUTCFullYear() : ' ’' + String(d.getUTCFullYear()).slice(2)) : ''), left: x(a), width: w(a, b) });
    }
    const days = sc.id === 'day' ? Array.from({ length: span }, (_, k) => isoAdd(f, k)) : [];
    const wd = (d) => new Date(d + 'T00:00:00Z').getUTCDay();
    const mondays = sc.id !== 'month' ? Array.from({ length: span }, (_, k) => isoAdd(f, k)).filter((d) => wd(d) === 1) : [];
    const weekends = sc.id === 'day' ? days.filter((d) => wd(d) === 0 || wd(d) === 6) : [];
    const lines = sc.id === 'month' ? months.map((m) => m.key < f ? f : m.key) : mondays;
    const mid = sc.id === 'day' ? 50 / span : 0;
    const grid = (
      <React.Fragment>
        {weekends.map((d) => <i key={'we' + d} className="lw-gwe" style={{ left: x(d) + '%', width: 100 / span + '%' }} />)}
        {lines.map((d) => <i key={d} className={'lw-gv' + (d.slice(8) === '01' || sc.id === 'month' ? ' is-m' : '')} style={{ left: x(d) + '%' }} />)}
        {today != null ? <i className="lw-gnow" style={{ left: (today + mid) + '%' }} /> : null}
      </React.Fragment>);
    const rng = (a, b) => dm(a) + (b && b !== a ? ' — ' + dm(b) : '');
    const phName = (n) => String(n || '').replace(/^Фаза \d+ · /, '');
    const fits = (b) => { const a = b.start < f ? f : b.start, e = b.end > t ? t : b.end; const pxW = trackW ? (x(e) - x(a)) * trackW / 100 : null;
      return pxW != null ? pxW > (phName(b.name).length + 7) * 6.5 : x(e) - x(a) > Math.max(8, (phName(b.name).length + (b.state === 'current' ? 7 : 0)) * 0.75); };
    const bar = (b, cls, lab) => {
      if (b.end < f || b.start > t) return null;
      const fp = b.state === 'current' ? Math.max(3, Math.min(100, b.pct || 0)) : null;
      return (
        <span key={b.id || b.idx} className={'lw-gb is-' + b.state + (cls ? ' ' + cls : '')} style={{ left: x(b.start < f ? f : b.start) + '%', width: w(b.start < f ? f : b.start, b.end > t ? t : b.end) + '%' }} title={phName(b.name) + ': ' + rng(b.start, b.end) + (fp != null ? ' · ' + (b.pct || 0) + ' %' : '')}>
          {fp != null ? <span className="lw-gf cv-glint" style={{ width: fp + '%' }} /> : null}
          {fp != null ? <b className="cv-front" style={{ left: fp + '%' }} /> : null}
          {lab ? <em>{phName(b.name)}{fp != null && b.pct != null ? ' · ' + (b.pct || 0) + ' %' : ''}</em> : null}</span>);
    };
    const keyAct = (fn) => (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fn(); } };
    const chev = (on) => <span className={'lw-gch' + (on ? ' is-on' : '')}><Ic n="right" s={12} /></span>;
    // значки событий на шкале: занятые отрезки в пикселях, следующий — ниже, если места нет
    const marks = (ev, r, withLab, maxLanes, flat) => {
      const pxPer = trackW ? trackW / 100 : 14, lanes = [];
      const pos = ev.map((e) => { const a = x(e.date) * pxPer - 12, b = a + (withLab && sc.id === 'day' ? 40 + (e.group === 'custom' ? e.title : e.kindL).length * 6.8 : 28);
        let k = lanes.findIndex((end) => end < a); if (k < 0) { k = lanes.length < (maxLanes || 9) ? lanes.length : lanes.length - 1; if (k === lanes.length) lanes.push(b); else lanes[k] = b; } else lanes[k] = b; return k; });
      return { n: lanes.length, el: ev.map((e, i) => (
        <span key={e.id} className={'lw-gev is-' + e.kind + ' is-' + e.status + (e.pending ? ' is-pend' : '') + (e.taskId && onWork ? ' is-click' : '')} style={{ left: (x(e.date) + mid) + '%', top: flat ? '50%' : (pos[i] * 30 + 22) + 'px' }}
          title={e.kindL + ' · ' + rng(e.date, e.end) + ': ' + e.title + (e.sub ? ' · ' + e.sub : '') + (e.pending ? ' · ждёт согласования клиента' : '')}
          onClick={e.taskId && onWork ? (ev2) => { ev2.stopPropagation(); onWork({ id: e.taskId }, r); } : undefined}>
          <Ic n={EV_IC[e.kind] || 'calendar'} s={13} />{withLab && sc.id === 'day' ? <em>{e.group === 'custom' ? e.title : e.kindL}</em> : null}</span>)) };
    };
    // одна строка события: название, что и к какому этапу, дата; на шкале — значок или полоса срока
    const evLine = (e, r, cls) => (
      <div key={e.id} className={'lw-grow is-evi is-' + e.status + (cls ? ' ' + cls : '') + (e.taskId && onWork ? ' is-click' : '')} onClick={e.taskId && onWork ? () => onWork({ id: e.taskId }, r) : undefined}
        role={e.taskId && onWork ? 'button' : undefined} tabIndex={e.taskId && onWork ? 0 : undefined} onKeyDown={e.taskId && onWork ? keyAct(() => onWork({ id: e.taskId }, r)) : undefined}>
        <span className="lw-gl"><span className="t"><span className="lw-gevi"><Ic n={EV_IC[e.kind] || 'calendar'} s={13} /></span>{e.group === 'service' || e.group === 'custom' ? e.title : e.group === 'delivery' ? e.title : e.kindL}</span>
          <span className="lab">{e.pending ? <span className="lw-gpend">ждёт согласования клиента · </span> : null}{e.group === 'service' ? e.kindL + ' · ' : ''}{e.sub}</span></span>
        <span className="lw-gt">{grid}{e.end > e.date ? <span className={'lw-gw-b is-ev is-' + e.status} style={{ left: x(e.date) + '%', width: w(e.date, e.end) + '%' }} /> : null}
          <span className={'lw-gev is-' + e.kind + ' is-' + e.status + (e.pending ? ' is-pend' : '')} style={{ left: (x(e.date) + mid) + '%', top: '50%' }} title={e.kindL + ' · ' + rng(e.date, e.end)}><Ic n={EV_IC[e.kind] || 'calendar'} s={13} /></span></span>
        <span className={'lw-gp is-w is-' + e.status}><span className="lab">{rng(e.date, e.end)}</span>{e.evId && onRemoveEvent ? <button type="button" className="lw-gx" aria-label={'Убрать событие «' + e.title + '»'} onClick={(ev) => { ev.stopPropagation(); onRemoveEvent(r, e.evId); }}><Ic n="x" s={12} /></button> : null}</span>
      </div>);
    // блок «События на объекте»: свёрнутый — одна строка со значками; раскрытый — доставки подгруппой, затем каждое событие строкой.
    // Блок закреплён под шапкой: при прокрутке этапов события остаются на месте
    const evBlock = (r) => {
      const all = (r.events || []).filter((e) => !(e.end < f || e.date > t));
      const k = r.id + ':ev', on = openE.includes(k), kd = r.id + ':evd', onD = openE.includes(kd);
      if (!all.length && !onAddEvent) return null;
      const dl = all.filter((e) => e.group === 'delivery'), rest = all.filter((e) => e.group !== 'delivery');
      const mk = marks(all, r, !on, 4);
      const dmk = marks(dl, r, false, 1, true);
      const fr = form && form.pid === r.id ? form : null;
      const save = () => { try { onAddEvent(r, fr); setForm(null); setFerr(''); } catch (e) { setFerr(e.message); } };
      return (
        <div className={'lw-gevb' + (on ? ' is-open' : '') + (on && rest.length + (dl.length ? 1 : 0) + (onD ? dl.length : 0) > 5 ? ' is-long' : '')}>
          <div className="lw-grow is-evh is-click" onClick={() => toggleE(k)} role="button" tabIndex={0} aria-expanded={on} onKeyDown={keyAct(() => toggleE(k))} style={on ? undefined : { minHeight: 26 + mk.n * 30 }}>
            <span className="lw-gl"><span className="t">{chev(on)}События на объекте</span><span className="lab">{all.length ? [dl.length ? 'доставки · ' + dl.length : '', rest.length ? 'ещё ' + rest.length : ''].filter(Boolean).join(' · ') : 'пока нет'}</span></span>
            <span className="lw-gt">{grid}{on ? null : mk.el}</span>
            <span className="lw-gp" />
          </div>
          {on && dl.length > 0 && <React.Fragment>
            <div className={'lw-grow is-evg is-click' + (onD ? ' is-open' : '')} onClick={() => toggleE(kd)} role="button" tabIndex={0} aria-expanded={onD} onKeyDown={keyAct(() => toggleE(kd))}>
              <span className="lw-gl"><span className="t">{chev(onD)}<span className="lw-gevi"><Ic n="truck" s={13} /></span>Доставки</span><span className="lab">{dl.length} {plural(dl.length, 'доставка', 'доставки', 'доставок')} · ближайшая {dm((dl.find((e) => e.status !== 'done') || dl[dl.length - 1]).date)}</span></span>
              <span className="lw-gt">{grid}{onD ? null : dmk.el}</span>
              <span className="lw-gp" />
            </div>
            {onD && <div className="lw-gsub">{dl.map((e) => evLine(e, r))}</div>}
          </React.Fragment>}
          {on && rest.map((e) => evLine(e, r))}
          {on && onAddEvent && (fr ? (
            <div className="lw-gevf">
              <input id={'lw-gevf-t-' + r.id} type="text" placeholder="Что за событие: замер мебельщика, отключение воды…" value={fr.title} onChange={(e) => setForm({ ...fr, title: e.target.value })} />
              <input id={'lw-gevf-d-' + r.id} type="date" value={fr.date} onChange={(e) => setForm({ ...fr, date: e.target.value, end: fr.end && fr.end >= e.target.value ? fr.end : e.target.value })} aria-label="Дата" />
              <input id={'lw-gevf-n-' + r.id} type="text" placeholder="Заметка (необязательно)" value={fr.note} onChange={(e) => setForm({ ...fr, note: e.target.value })} />
              <label className="lw-gevf-c"><input type="checkbox" checked={!!fr.client} onChange={(e) => setForm({ ...fr, client: e.target.checked })} />Показать клиенту</label>
              <button type="button" className="lw-btn-l" onClick={() => { setForm(null); setFerr(''); }}>Отмена</button>
              <button type="button" className="lw-btn-k" disabled={!fr.title.trim() || !fr.date} onClick={save}>Добавить</button>
              {ferr ? <span className="lw-gevf-e">{ferr}</span> : null}
            </div>)
            : <div className="lw-grow is-evadd"><span className="lw-gl"><button type="button" className="lw-gadd" onClick={() => setForm({ pid: r.id, title: '', date: g.today, end: g.today, note: '', client: false })}><Ic n="calendar" s={13} />Своё событие</button></span><span className="lw-gt" /><span className="lw-gp" /></div>)}
        </div>);
    };
    const works = (s, r) => s.items.map((wk) => {
      const e = wk.end > wk.start ? wk.end : wk.start;
      return (
        <div key={wk.id} className={'lw-grow is-work is-' + wk.state + (wk.adj ? ' is-adj' : '') + (wk.mine ? ' is-mine' : '') + (onWork && !wk.adj ? ' is-click' : '')} onClick={onWork && !wk.adj ? () => onWork(wk, r) : undefined} role={onWork && !wk.adj ? 'button' : undefined} tabIndex={onWork && !wk.adj ? 0 : undefined}
          onKeyDown={onWork && !wk.adj ? keyAct(() => onWork(wk, r)) : undefined}>
          <span className="lw-gl"><span className="t">{wk.name}</span>
            <span className="lab">{wk.part ? <span className="lw-gpart">{wk.part.no} из {wk.part.of} · {wk.part.name}</span> : null}{wk.who}</span></span>
          <span className="lw-gt">{grid}<span className={'lw-gw-b is-' + wk.state} style={{ left: x(wk.start) + '%', width: w(wk.start, e) + '%' }} title={wk.name + ': ' + rng(wk.start, e)} /></span>
          <span className={'lw-gp is-w is-' + wk.state}><span className="lab">{wk.ret ? 'на доработке' : WST[wk.state] || 'с ' + dm(wk.start)}</span></span>
        </div>);
    });
    // этапы по фазам: сданная фаза свёрнута и приглушена, идущая раскрыта; раскрытый этап и его работы — одним блоком
    const stageRows = (r) => {
      const byPh = [];
      r.stages.forEach((s) => { let gph = byPh.find((x2) => x2.idx === s.phaseIdx); if (!gph) { gph = { idx: s.phaseIdx, ph: r.phases.find((p) => p.idx === s.phaseIdx), list: [] }; byPh.push(gph); } gph.list.push(s); });
      return byPh.map((gph) => {
        const kp = r.id + ':' + gph.idx, onp = openP.includes(kp), st = gph.ph ? gph.ph.state : 'next';
        return (
          <div key={gph.idx} className={'lw-gph-g is-' + st + (onp ? ' is-open' : '')}>
            {gph.ph ? <div className={'lw-grow is-phh is-click is-' + st} onClick={() => toggleP(kp)} role="button" tabIndex={0} aria-expanded={onp} onKeyDown={keyAct(() => toggleP(kp))}>
              <span className="lw-gl"><span className="t">{chev(onp)}{gph.ph.name}</span><span className="lab">{rng(gph.ph.start, gph.ph.end)} · {gph.list.length} {plural(gph.list.length, 'этап', 'этапа', 'этапов')}{gph.ph.threat ? <span className="lw-gthr"> · {gph.ph.threat} под угрозой</span> : null}</span></span>
              <span className="lw-gt">{grid}{onp ? <span className="lw-gphs" style={{ left: x(gph.ph.start < f ? f : gph.ph.start) + '%', width: w(gph.ph.start < f ? f : gph.ph.start, gph.ph.end > t ? t : gph.ph.end) + '%' }} /> : bar(Object.assign({}, gph.ph, { id: 'ph' + gph.idx }))}</span>
              <GDone pct={gph.ph.pct} n={gph.list.reduce((a, s) => a + (s.doneN || 0), 0)} all={gph.list.reduce((a, s) => a + s.works, 0)} st={st} /></div> : null}
            {(onp || !gph.ph) && gph.list.map((s) => {
              const k = r.id + ':' + s.id, can = !!(s.items && s.items.length), on = can && openS.includes(k);
              return (
                <div key={s.id} className={'lw-gst' + (on ? ' is-open' : '')}>
                  <div className={'lw-grow is-stage is-' + s.state + (can ? ' is-click' : '') + (on ? ' is-open' : '')} onClick={can ? () => toggle(k) : undefined} role={can ? 'button' : undefined} tabIndex={can ? 0 : undefined}
                    aria-expanded={can ? on : undefined} onKeyDown={can ? keyAct(() => toggle(k)) : undefined}>
                    <span className="lw-gl"><span className="t">{can ? chev(on) : null}{s.name}</span>
                      <span className="lab">{rng(s.start, s.end)}{s.together > 1 ? ' · до ' + s.together + ' одновременно' : ''}{on ? ' · ' + s.items.length + ' ' + plural(s.items.length, 'работа', 'работы', 'работ') : ''}</span>
                      {s.spec && s.spec.threat && s.state !== 'done' ? <span className="lab lw-gthr">под угрозой · {s.spec.why}</span> : null}</span>
                    <span className="lw-gt">{grid}{bar(s)}{s.state !== 'done' && s.spec ? s.spec.marks.filter((m) => m.date >= f && m.date <= t).map((m) => (
                      <span key={m.kind + m.date} className={'lw-gspm is-' + m.kind + (m.late ? ' is-late' : '')} style={{ left: x(m.date) + '%' }}
                        title={(m.kind === 'decide' ? 'Решить до ' : 'Заказать до ') + dm(m.date) + ': ' + m.names.join(', ')} aria-label={(m.kind === 'decide' ? 'Решить до ' : 'Заказать до ') + dm(m.date)} />)) : null}</span>
                    <GDone pct={s.pct} n={s.doneN != null ? s.doneN : Math.round(s.pct * s.works / 100)} all={s.works} st={s.state} />
                  </div>
                  {on && <div className="lw-gworks" role="group" aria-label={'Работы этапа «' + s.name + '»'}>{works(s, r)}</div>}
                </div>);
            })}
          </div>);
      });
    };
    const axis = (
      <div className={'lw-gax is-' + sc.id}><span className="lw-gl">{head || 'Объект'}</span>
        <span className="lw-gt">
          {months.map((m) => <span key={m.key} className="lw-gm" style={{ left: m.left + '%', width: m.width + '%' }}>{(trackW ? m.width * trackW / 100 > 40 : m.width > 4.5) ? m.l : ''}</span>)}
          {sc.id === 'day' ? days.map((d) => <span key={d} className={'lw-gdd' + (wd(d) === 0 || wd(d) === 6 ? ' is-we' : '') + (d === g.today ? ' is-today' : '')} style={{ left: x(d) + '%', width: 100 / span + '%' }}><b>{+d.slice(8)}</b>{GWD[wd(d)]}</span>) : null}
          {sc.id === 'week' ? mondays.map((d) => <span key={d} className={'lw-gwk' + (g.today >= d && g.today < isoAdd(d, 7) ? ' is-now' : '')} style={{ left: x(d) + '%' }}>{dm(d)}</span>) : null}
          {today != null && sc.id === 'month' ? <span className="lw-gtoday" style={{ left: today + '%' }}><b>Сегодня</b></span> : null}</span>
        <span className="lw-gp lw-gph">Готово</span></div>);
    const body = (
      <div className={'lw-gantt is-sc-' + sc.id + (compact ? ' is-compact' : '') + (detail ? ' is-detail' : '')} style={trackW ? { '--gtw': trackW + 'px' } : undefined}>
        {axis}
        {g.rows.map((r) => (
          <div key={r.id} className="lw-grow-w">
            <div className={'lw-grow is-obj' + (onRow ? ' is-click' : '')} onClick={onRow ? () => onRow(r) : undefined} role={onRow ? 'button' : undefined} tabIndex={onRow ? 0 : undefined} onKeyDown={onRow ? keyAct(() => onRow(r)) : undefined}>
              <span className="lw-gl"><b>{short(r.title)}</b><span className="lab">{r.sub || (r.title !== short(r.title) ? r.title : '')}</span></span>
              <span className="lw-gt">{grid}
                {r.phases.filter((p) => !(p.end < f || p.start > t)).map((p, i, a) => bar(p, 'is-phase' + (i === 0 ? ' is-first' : '') + (i === a.length - 1 ? ' is-last' : ''), !compact && fits(p)))}
                {r.end >= f && r.end <= t ? <span className="lw-gend" style={{ left: x(r.end) + '%' }} title={'Сдача ' + dm(r.end)}><i /></span> : null}</span>
              {r.allN != null ? <span className="lw-gp is-big"><b>{r.pct} %</b><span className="lab">{r.doneN} из {r.allN} {plural(r.allN, 'работы', 'работ', 'работ')}</span></span> : <span className="lw-gp" />}
            </div>
            {!compact && evBlock(r)}
            {detail && stageRows(r)}
          </div>))}
      </div>);
    return compact && !trackW ? body : <div className={'lw-gscroll' + (detail ? ' is-tall' : '')} ref={scroller}>{body}</div>;
  }

  /* работы в несколько этапов (ForemanViews.multi): одна работа сметы частями по разным этапам — цепочка частей с состоянием и сроком;
     часть открывает карточку своей работы (onPart) */
  function MultiW({ groups, onPart }) {
    if (!groups || !groups.length) return null;
    const when = (x) => (x.state === 'done' ? 'сделано' : x.state === 'late' ? 'срок прошёл ' + dateR(x.end, { month: 'short' }) : x.state === 'review' ? 'на приёмке'
      : x.state === 'current' ? 'идёт сейчас' : 'с ' + dateR(x.start, { month: 'short' }));
    return (
      <section className="cv-w lw-multi" aria-labelledby="lw-multi-h">
        <WHead id="lw-multi-h" title="Работы в несколько этапов" sub={groups.length + ' ' + plural(groups.length, 'работа', 'работы', 'работ')} />
        <p className="lw-mnote">Одна работа сметы делается частями на разных этапах — как в смете конструктора: часть на черновых, часть на чистовых.</p>
        {groups.map((g) => (
          <div key={g.id} className="lw-mg">
            <div className="lw-mgh"><span className="t">{g.name}</span><span className="lab">{g.why}</span><span className="lw-mgn">{g.doneN} из {g.of}</span></div>
            <ol className="lw-mparts">{g.parts.map((x) => (
              <li key={x.taskId} className={'is-' + x.state}>
                <button type="button" onClick={onPart ? () => onPart(g, x) : undefined} aria-label={x.no + ' из ' + g.of + ': ' + x.stage + ', ' + x.name + ', ' + when(x)}>
                  <i aria-hidden="true" /><span className="st">{x.stage}</span><span className="wk">{x.name}</span><span className="lab">{when(x)}</span>
                </button>
              </li>))}</ol>
          </div>))}
      </section>);
  }

  /* команда: человек — строка; объекты буквами, загрузка двух недель, рабочий календарь (день — буква объекта, два объекта в день — охрой),
     работы в руках, выработка недели и месяца. full — ещё столбцы «впереди по смете» и «возвраты»; onPerson — открыть карточку */
  function DayCells({ days, objs }) {
    return (
      <span className="lw-days" role="img" aria-label="Рабочий календарь на две недели">
        {days.map((d) => {
          const n = d.away ? 0 : d.items.length;
          return <i key={d.date} className={(d.work ? '' : 'is-off ') + (d.past ? 'is-past ' : '') + (d.today ? 'is-today ' : '') + (d.away ? 'is-away' : n > 1 ? 'is-clash' : n ? 'is-busy' : 'is-free')}
            title={dateR(d.date) + ': ' + (d.away || (n ? d.items.map((x) => short(x.obj) + ' — ' + x.task).join('; ') : 'свободен'))}>{n > 1 ? n : n ? letter(d.items[0].obj) : ''}</i>;
        })}
      </span>);
  }
  // « · 1 на паузе · 1 ушёл из команды» — к числу людей в заголовке команды
  const teamSt = (x) => (x.away ? ' · ' + x.away + ' ' + plural(x.away, 'отсутствует', 'отсутствуют', 'отсутствуют') + ' сегодня' : '') + (x.paused ? ' · ' + x.paused + ' на паузе' : '') + (x.left ? ' · ' + x.left + ' ' + plural(x.left, 'ушёл', 'ушли', 'ушли') + ' из команды' : '');
  function TeamW({ t, full, onPerson, onOpen, title }) {
    const days = t.days;
    const objs = [];
    t.people.forEach((p) => p.objects.forEach((o) => { if (!objs.some((x) => x.pid === o.pid)) objs.push(o); }));
    const wd = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
    return (
      <section className={'cv-w lw-team' + (full ? ' is-full' : '')} aria-labelledby="lw-team-h">
        {!full && <WHead id="lw-team-h" title={title || 'Команда'} sub={t.totals.people + ' ' + plural(t.totals.people, 'человек', 'человека', 'человек') + teamSt(t.totals)} text={onOpen ? 'Вся команда' : null} onOpen={onOpen} />}
        <div className="lw-tsum">
          <span><b>{t.totals.avgLoad} %</b><span className="lab">Средняя загрузка на две недели</span></span>
          <span><b>{rub(t.totals.outWeek)}</b><span className="lab">Выработка за 7 дней</span></span>
          <span><b>{rub(t.totals.outMonth)}</b><span className="lab">Выработка с начала месяца</span></span>
          <span><b>{t.totals.progress} в работе</b><span className="lab">Всего работ в руках — {t.totals.open}</span></span>
          <span className={t.totals.overloaded ? 'is-late' : ''}><b>{t.totals.overloaded ? t.totals.overloaded + ' ' + plural(t.totals.overloaded, 'конфликт', 'конфликта', 'конфликтов') : 'Без накладок'}</b><span className="lab">{t.totals.overloaded ? 'Два объекта в один день' : 'Каждый день — один объект'}</span></span>
        </div>
        <div className="lw-tleg" aria-label="Обозначения">{objs.map((o) => <span key={o.pid}><i>{letter(o.obj)}</i>{short(o.obj)}</span>)}<span><i className="is-clash">2</i>два объекта в день</span><span><i className="is-free" />свободен</span><span><i className="is-away" />отпуск, больничный, отгул</span></div>
        <div className="lw-tbl" role="table" aria-label="Команда">
          <div className="lw-tr lw-thd" role="row">
            <span role="columnheader">Мастер</span><span role="columnheader">Загрузка</span>
            <span role="columnheader" className="lw-dh">{days.map((d, i) => <i key={d.date} className={(d.today ? 'is-today ' : '') + (d.work ? '' : 'is-off')}>{wd[i % 7]}<b>{+d.date.slice(8)}</b></i>)}</span>
            <span role="columnheader">Работы</span><span role="columnheader">Выработка</span>{full && <span role="columnheader">Впереди</span>}
          </div>
          {t.people.map((p) => (
            <div key={p.id} className={'lw-tr' + (onPerson ? ' is-click' : '')} role="row" onClick={onPerson ? () => onPerson(p) : undefined} tabIndex={onPerson ? 0 : undefined} onKeyDown={onPerson ? (e) => { if (e.key === 'Enter') onPerson(p); } : undefined}>
              <span className="lw-who" role="cell"><FaceSt m={p} size={36} /><span className="tx"><span className="t">{p.name}</span>
                {p.st ? <span className={'lab lw-tst is-' + p.st.k}>{stLabel(p.st)}</span> : p.away ? <span className="lab lw-tst is-away">{awayLabel(p.away)}</span> : <span className="lab">{p.trades.slice(0, 2).join(', ')}</span>}</span></span>
              <span className="lw-load" role="cell"><span className="b"><i style={{ width: p.load + '%' }} className={p.load >= 90 ? 'is-full' : ''} /></span><span className="v">{p.load} %</span><span className="lab">{p.st ? (p.st.k === 'left' ? 'работы передать' : 'без новых') : p.away && p.away.now ? p.away.l : p.freeN ? 'свободно ' + p.freeN + ' ' + plural(p.freeN, 'день', 'дня', 'дней') : 'без окон'}</span></span>
              <span role="cell"><DayCells days={p.days} /></span>
              <span className="lw-tasks" role="cell"><b>{p.progress} в работе</b><span className="lab">{[p.review ? p.review + ' на приёмке' : '', p.returned ? p.returned + ' ' + plural(p.returned, 'возврат', 'возврата', 'возвратов') : '', 'всего ' + p.open].filter(Boolean).join(' · ')}</span></span>
              <span className="lw-out" role="cell"><b>{rub(p.outWeek)}</b><span className="lab">за месяц {rub(p.outMonth)}</span></span>
              {full && <span className="lw-out" role="cell"><b>{rub(p.ahead || 0)}</b><span className="lab">{p.next ? 'дальше: ' + p.next.task : 'новых работ нет'}</span></span>}
            </div>))}
        </div>
      </section>);
  }

  /* календарь работ по дням (KSH-525): неделя — 7 столбцов, строки — объекты (by='obj') или мастера (by='people').
     Клетка объекта: работы дня и кто их делает; клетка мастера: буква объекта и работа или отпуск. Завтра — подсвечено.
     Работа, где мастера нет или все отсутствуют, — охрой «нет мастера». onWork(it) — карточка работы, onRow(row) — объект или мастер */
  const WD = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
  // rowSub(r) — подпись под объектом (у руководителя: прораб объекта); itemCls(it) и whoL(it) — свой класс и подпись работы
  // (у мастера: свои работы и смежные, KSH-528)
  function WeekW({ w, by, onWork, onRow, max, rowSub, itemCls, whoL }) {
    const rows = by === 'people' ? w.people : w.objs;
    const lim = max || 4;
    const who = (it) => whoL ? whoL(it) : (it.need ? 'нужен ' + it.need.toLowerCase() : it.off ? it.who.map((x) => x.name.split(' ')[0]).join(', ') + ': ' + it.who[0].away.l : it.who.map((x) => x.name.split(' ')[0]).join(', '));
    const Item = ({ it, mode }) => (
      <button type="button" className={'lw-wki is-' + it.state + (it.off || it.need ? ' is-gap' : '') + (itemCls ? ' ' + itemCls(it) : '')} onClick={onWork ? (e) => { e.stopPropagation(); onWork(it); } : undefined}
        title={short(it.obj) + ' — ' + it.title + ' · ' + who(it)}>
        {mode === 'people' ? <span className="lw-wkl" data-tone={it.tone}>{letter(it.obj)}</span> : null}
        <span className="t">{it.title}</span>{mode === 'obj' && who(it) ? <span className="lab">{who(it)}</span> : null}</button>);
    return (
      <div className={'lw-wk is-' + by} role="table" aria-label={by === 'people' ? 'Неделя по людям' : 'Неделя по объектам'}>
        <div className="lw-wkr lw-wkh" role="row"><span role="columnheader">{by === 'people' ? 'Мастер' : 'Объект'}</span>
          {w.days.map((d, k) => <span key={d.date} role="columnheader" className={(d.work ? '' : 'is-off ') + (d.today ? 'is-today ' : '') + (d.tomorrow ? 'is-tmr' : '')}>{d.tomorrow ? 'Завтра' : d.today ? 'Сегодня' : WD[d.wd != null ? d.wd : k]}<b>{+d.date.slice(8)}</b></span>)}</div>
        {rows.length === 0 ? <div className="empty">На этой неделе работ нет.</div> : rows.map((r) => (
          <div key={r.pid || r.id} className="lw-wkr" role="row">
            <span role="rowheader" className={'lw-wkn' + (onRow ? ' is-click' : '')} onClick={onRow ? () => onRow(r) : undefined}>
              {by === 'people' ? <React.Fragment><span className="t">{r.name}</span><span className="lab">{r.st ? r.st.l : r.busyN ? r.busyN + ' ' + plural(r.busyN, 'день', 'дня', 'дней') + ' с работой' : 'свободен'}</span></React.Fragment>
                : <React.Fragment><span className="lw-wkl" data-tone={r.tone}>{letter(r.obj)}</span>{rowSub ? <span className="lw-wkn-s"><span className="t">{short(r.obj)}</span><span className="lab">{rowSub(r)}</span></span> : <span className="t">{short(r.obj)}</span>}</React.Fragment>}</span>
            {r.days.map((d, k) => {
              const day = w.days[k];
              return (
                <span key={d.date} role="cell" className={'lw-wkc' + (day.work ? '' : ' is-off') + (day.past ? ' is-past' : '') + (day.today ? ' is-today' : '') + (day.tomorrow ? ' is-tmr' : '') + (d.away ? ' is-away' : '')}>
                  {d.away ? <span className="lab lw-wka">{cap(d.away)}</span> : null}
                  {d.items.slice(0, lim).map((it) => <Item key={it.taskId} it={it} mode={by === 'people' ? 'people' : 'obj'} />)}
                  {d.items.length > lim ? <span className="lab">и ещё {d.items.length - lim}</span> : null}
                </span>);
            })}
          </div>))}
      </div>);
  }
  /* «Завтра» по объектам (next из ForemanViews.week): что идёт и кто; без мастера и с отсутствующим — охрой.
     onObj(r), onWork(r, it) — без них строки не нажимаются; rowSub(r) — подпись к объекту (прораб у руководителя) */
  function TomorrowW({ n, onObj, onWork, rowSub }) {
    if (!n.rows.length) return <div className="empty">Работ не запланировано.</div>;
    const whoL = (it) => (it.need ? 'Нужен ' + it.need.toLowerCase() : it.off ? 'Нет мастера: ' + it.who.map((x) => x.name + ', ' + x.away.l).join('; ') : it.who.map((x) => x.name).join(', '));
    return (
      <div className="lw-wkn-l">{n.rows.map((r) => (
        <div key={r.pid} className="lw-pco">
          {onObj ? <button type="button" className="lw-pco-h" onClick={() => onObj(r)}>{short(r.obj)}<Ic n="ne" s={14} /></button> : <div className="lw-pco-h is-static">{short(r.obj)}</div>}
          {rowSub ? <span className="lab">{rowSub(r)}</span> : null}
          {r.items.map((it) => {
            const cls = 'lw-pct lw-btnrow' + (it.off || it.need ? ' is-gap' : '');
            const body = <React.Fragment><span className="t">{it.title}{it.starts ? ' · начало' : ''}</span><span className="lab">{whoL(it)}</span></React.Fragment>;
            return onWork ? <button type="button" key={it.taskId} className={cls} onClick={() => onWork(r, it)}>{body}</button> : <div key={it.taskId} className={cls}>{body}</div>;
          })}
        </div>))}</div>);
  }
  // «Завтра, пт 6 нояб.: 7 работ на 4 объектах · 1 без мастера»
  function nextLine(n) {
    const k = n.rows.reduce((a, r) => a + r.items.length, 0);
    return cap(new Date(n.date + 'T00:00:00Z').toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })) + ': '
      + (k ? k + ' ' + plural(k, 'работа', 'работы', 'работ') + ' на ' + n.rows.length + ' ' + plural(n.rows.length, 'объекте', 'объектах', 'объектах') : 'работ нет')
      + (n.gaps ? ' · ' + n.gaps + ' без мастера' : '');
  }

  /* таск-менеджер (KSH-528): одна строка — одно действие (TaskViews: что, где, к какому сроку, кто делает следующий шаг).
     tabs — [{ id, label, rows, empty }]: «Мне сделать» и вкладки других источников («От меня», KSH-530) без правки этого компонента;
     views — [{ id, label, render }]: другие виды того же блока (у мастера — «График»), первый вид всегда «Списком».
     В списке — фильтр по сроку (Просрочено / Сегодня / Неделя / Позже), внутри — по дням. onOpen(row) — открыть сам объект.
     Вкладка может задать свои корзины вместо сроков: { buckets: [{ id, label }], bucketOf(row) → id } — тогда без деления по дням
     (у «От меня» — по статусу возврата: ждут моей проверки / у мастера, KSH-530) */
  const TM_DAY = (d, t0) => (!d ? 'Без срока' : d < t0 ? 'Срок прошёл' : dayLabel(d, t0));
  /* канбан таск-менеджера (KSH-533, макет одобрен Романом 07.11.2026): колонки = статусы процессов (tab.columns + tab.colOf),
     карточка переходит сама — перетаскивания нет: принять или вернуть можно только в окне работы, сметы или документа.
     tab.boardRows — строки доски (с закрытыми за 3 дня), иначе tab.rows. На телефоне колонки — чипами */
  const KB_IC = {
    due: ['Срок', <React.Fragment><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></React.Fragment>],
    ret: ['Вернули', <React.Fragment><path d="M9 14L4 9l5-5" /><path d="M4 9h10a6 6 0 0 1 0 12h-3" /></React.Fragment>],
    no: ['Отказ', <React.Fragment><circle cx="12" cy="12" r="9" /><path d="M5.6 5.6l12.8 12.8" /></React.Fragment>],
    ok: ['Принято', <path d="M5 12l5 5L20 7" />],
    sent: ['Отправлено', <React.Fragment><path d="M12 19V5" /><path d="M5 12l7-7 7 7" /></React.Fragment>],
    cal: ['Перенос', <React.Fragment><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M3 10h18M8 3v4M16 3v4" /></React.Fragment>],
  };
  const kbDate = (d) => (d ? String(d).slice(8, 10) + '.' + String(d).slice(5, 7) + '.' + String(d).slice(2, 4) : '');
  // дата карточки и её иконка: решено — когда; перенос — на какие дни; отказ по цене — когда; есть срок — до; иначе — когда вернули
  function kbWhen(r, col) {
    if (col && col.past) return { ic: r.status === 'shiftNo' ? 'no' : 'ok', d: kbDate(r.closedAt || r.at) };
    if (String(r.id).indexOf('out:shift:') === 0) return { ic: 'cal', d: String(r.sub || '').replace(/^перенос на /, '') };
    if (r.kind === 'out' && r.status === 'sent' && /цен/i.test(r.kindL || '')) return { ic: 'no', d: kbDate(r.at) };
    if (r.kindL === 'Моя цена') return { ic: 'sent', d: kbDate(r.at) };
    if (r.due && !(col && col.me && r.kind === 'out')) return { ic: 'due', d: kbDate(r.due) };
    return r.at ? { ic: r.kind === 'out' ? 'ret' : 'sent', d: kbDate(r.at) } : { ic: '', d: '' };
  }
  // тип на карточке: колонка уже говорит «вернули», поэтому у возврата — что вернули: работа, точка фотоконтроля, документ или смета
  // путь «Фаза · Этап» над названием (макет одобрен Романом 07.11.2026): точка цвета этапа, «Ф2 · Черновая электрика»; полностью — при наведении
  const pathFull = (p) => [p.phase, p.stage].filter(Boolean).join(' → ');
  function KbPath({ p, cls }) {
    return (
      <span className={cls || 'lw-kbc-p'} title={pathFull(p)}>
        <i style={p.color ? { background: p.color } : undefined} aria-hidden="true" />
        <span className="t">{p.ph ? <b>{p.ph}</b> : null}{p.ph ? ' · ' : ''}{p.stage}</span>
      </span>
    );
  }
  // фотоконтроль с путём: этап уже в пути, в названии — что за точка
  const kbTitle = (r) => (r.path && r.pointId && r.pointName ? 'Фотоконтроль: «' + r.pointName + '»' : r.title);
  // этап уже в пути — не повторяем его в подписи строки
  const subNoStage = (r) => (r.path && r.sub && r.path.stage && r.sub.indexOf(r.path.stage) === 0 ? r.sub.slice(r.path.stage.length).replace(/^\s*·\s*/, '') : r.sub);
  const kbKind = (r) => (r.kind === 'out' && r.kindL === 'Возврат на доработку' ? (r.dealId ? 'Сделка' : r.pointId ? 'Фотоконтроль' : 'Работа') : r.kindL || '');
  function TaskBoard({ tab, onOpen }) {
    const rows = tab.boardRows || tab.rows || [];
    const cols = tab.columns.map((c) => ({ ...c, rows: rows.filter((r) => tab.colOf(r) === c.id) }));
    const first = (cols.find((c) => c.me && c.rows.length) || cols.find((c) => c.rows.length) || cols[0]).id;
    const [sel, setSel] = React.useState(null);
    const on = sel && cols.some((c) => c.id === sel) ? sel : first;
    const open = (e, r) => { if (onOpen) { e.preventDefault(); onOpen(r); } };
    const who = (r) => String(r.to || r.sub || '').replace(/ · мастер$/, '');
    return (
      <div className="lw-kb">
        <div className="lw-kb-chips" role="tablist" aria-label="Колонки">{cols.map((c) => (
          <button key={c.id} type="button" role="tab" aria-selected={on === c.id} className={on === c.id ? 'on' : ''} onClick={() => setSel(c.id)}>{c.label} · {c.rows.length}</button>))}</div>
        <div className="lw-kb-cols" style={{ '--kb-n': cols.length }}>{cols.map((c) => (
          <section key={c.id} className={'lw-kb-col' + (c.me ? ' is-me' : '') + (c.past ? ' is-past' : '') + (on === c.id ? ' is-sel' : '')} aria-label={c.label}>
            <div className="lw-kb-h"><span className="lw-kb-l">{c.label}</span>
              {c.hint ? <button type="button" className="lw-kb-i" title={c.hint} aria-label={c.label + ': ' + c.hint}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M12 11v5" /><path d="M12 7.6v.1" strokeWidth="2.2" /></svg></button> : null}
              <span className="lw-kb-n">{c.rows.length}</span></div>
            <div className="lw-kb-list">
              {!c.rows.length ? <div className="lw-kb-empty">{c.me ? 'Пусто — всё проверено' : 'Пусто'}</div> : c.rows.map((r) => {
                const w = kbWhen(r, c); const ic = KB_IC[w.ic];
                const act = !c.past && r.next && r.next.who === 'me' ? r.next.l : '';
                return (
                  <a key={r.id} className="lw-kbc" href={r.href || '#'} onClick={(e) => open(e, r)}>
                    {r.path ? <KbPath p={r.path} /> : <span className="lw-kbc-k">{kbKind(r)}</span>}
                    <span className="lw-kbc-t">{kbTitle(r)}</span>
                    {r.obj ? <span className="lw-kbc-o">{r.obj}</span> : null}
                    {r.tag || r.late ? <span className="lw-kbc-tags">{r.tag ? <span className="lw-kbc-tag">{r.tag}</span> : null}
                      {r.late ? <span className="lw-kbc-late">Просрочено на {r.lateDays || 1} {plural(r.lateDays || 1, 'день', 'дня', 'дней')}</span> : null}</span> : null}
                    {r.remark ? <span className="lw-kbc-rm">«{r.remark}»</span> : null}
                    <span className="lw-kbc-f"><span className="lw-kbc-w">{who(r)}</span>
                      {w.d ? <span className="lw-kbc-d">{ic ? <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" role="img" aria-label={ic[0]}><title>{ic[0]}</title>{ic[1]}</svg> : null}{w.d}</span> : null}</span>
                    {act ? <span className="lw-kbc-btn">{act}<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M7 17L17 7M9 7h8v8" /></svg></span> : null}
                  </a>);
              })}
            </div>
          </section>))}</div>
      </div>);
  }

  const TM_VIEW = 'lk_tm_view';
  function TaskList({ tabs, views, t0, onOpen, title }) {
    const T = window.TaskViews;
    t0 = t0 || new Date().toISOString().slice(0, 10);
    const list = (tabs || []).filter(Boolean);
    const [tab, setTab] = React.useState(list[0] ? list[0].id : '');
    const hasBoard = list.some((x) => x.columns && x.colOf);
    // вид запоминается (Списком по умолчанию); «Канбан» — только если у вкладок есть колонки
    const [view, setViewS] = React.useState(() => { try { const v = localStorage.getItem(TM_VIEW); return v === 'board' && hasBoard ? v : 'list'; } catch (e) { return 'list'; } });
    const setView = (v) => { setViewS(v); try { localStorage.setItem(TM_VIEW, v === 'board' ? 'board' : 'list'); } catch (e) { /* без хранилища */ } };
    const cur = list.find((x) => x.id === tab) || list[0];
    // счётчик вкладки — только то, что ждёт меня
    const cnt = (x) => (x.rows || []).filter(T && T.needsMe ? T.needsMe : () => true).length;
    const rows = (cur && cur.rows) || [];
    const own = cur && cur.buckets && cur.bucketOf;
    const B = own ? Object.fromEntries(cur.buckets.map((x) => [x.id, rows.filter((r) => cur.bucketOf(r) === x.id)]))
      : T ? T.buckets(rows, t0) : { late: [], today: rows, week: [], later: [] };
    const BL = own ? cur.buckets : (T && T.BUCKETS) || [{ id: 'today', label: 'Сегодня' }];
    const firstB = (BL.find((b) => B[b.id].length) || BL[0]).id;
    const [bk, setBk] = React.useState(null);
    const b = bk && B[bk] ? bk : firstB;
    // по дням: просроченные — по сроку, остальные — по началу (или сроку, если начала нет)
    // в «Просрочено» возврат со сроком впереди — отдельной группой «Вернули на доработку»
    // «Позже» — по месяцам, чтобы длинный хвост не растягивал список по одной строке на день
    const dayOf = (r) => own ? '' : (b === 'late' ? (r.due && r.due < t0 ? r.due : 'ret') : b === 'today' ? t0 : b === 'later' ? String(r.start || r.due || '').slice(0, 7) : r.start || r.due) || '';
    const monthL = (m) => { const x = new Date(m + '-01T00:00:00Z'); const l = cap(x.toLocaleDateString('ru-RU', { month: 'long', timeZone: 'UTC' })); return m.slice(0, 4) === t0.slice(0, 4) ? l : l + ' ' + m.slice(0, 4); };
    const groups = [];
    B[b].forEach((r) => { const d = dayOf(r); const g = groups.find((x) => x.d === d) || (groups.push({ d, rows: [] }), groups[groups.length - 1]); g.rows.push(r); });
    groups.sort((x, y) => (x.d === 'ret') - (y.d === 'ret') || String(x.d || '9999').localeCompare(String(y.d || '9999')));
    const open = (e, r) => { if (onOpen) { e.preventDefault(); onOpen(r); } };
    const dates = (r) => { const o = { month: 'short' }; return r.start && r.due && r.due !== r.start ? dateR(r.start, o) + ' — ' + dateR(r.due, o) : r.due ? 'до ' + dateR(r.due, o) : ''; };
    return (
      <div className="lw-tm">
        <div className="lw-tm-bar">
          {title ? <h2 className="lw-tm-h">{title}</h2> : null}
          {list.length > 1 ? <div className="cv-ctabs" role="tablist" aria-label="Чьи задачи">{list.map((x) => (
            <button key={x.id} type="button" role="tab" aria-selected={x.id === cur.id} className={x.id === cur.id ? 'on' : ''} onClick={() => { setTab(x.id); setBk(null); }}>{x.label}{cnt(x) ? <span>{cnt(x)}</span> : null}</button>))}</div> : null}
          {(views && views.length) || hasBoard ? <div className="cv-ctabs" role="tablist" aria-label="Вид">
            <button type="button" role="tab" aria-selected={view === 'list'} className={'is-ic' + (view === 'list' ? ' on' : '')} onClick={() => setView('list')} title="Списком" aria-label="Списком"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden="true"><path d="M8 6h12M8 12h12M8 18h12" /><path d="M4 6h.01M4 12h.01M4 18h.01" strokeWidth="2.4" /></svg></button>
            {hasBoard ? <button type="button" role="tab" aria-selected={view === 'board'} className={'is-ic' + (view === 'board' ? ' on' : '')} onClick={() => setView('board')} title="Канбан" aria-label="Канбан"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" aria-hidden="true"><rect x="3.5" y="4" width="4.5" height="16" rx="1.2" /><rect x="9.75" y="4" width="4.5" height="11" rx="1.2" /><rect x="16" y="4" width="4.5" height="7" rx="1.2" /></svg></button> : null}
            {(views || []).map((v) => <button key={v.id} type="button" role="tab" aria-selected={view === v.id} className={view === v.id ? 'on' : ''} onClick={() => setView(v.id)}>{v.label}</button>)}</div> : null}
        </div>
        {view === 'board' && cur && cur.columns && cur.colOf ? <TaskBoard key={cur.id} tab={cur} onOpen={onOpen} />
          : view !== 'list' && view !== 'board' ? ((views || []).find((v) => v.id === view) || { render: () => null }).render() : <React.Fragment>
          <div className="cv-ctabs lw-tm-b" role="tablist" aria-label="Срок">{BL.map((x) => (
            <button key={x.id} type="button" role="tab" aria-selected={b === x.id} className={(b === x.id ? 'on' : '') + (x.id === 'late' && B.late.length ? ' is-late' : '')} onClick={() => setBk(x.id)}>{x.label}<span>{B[x.id].length}</span></button>))}</div>
          {!rows.length ? <div className="empty">{(cur && cur.empty) || 'Задач нет.'}</div> : !B[b].length ? <div className="empty">Здесь пусто.</div>
            : groups.map((g) => (
              <div key={g.d || '_'} className="lw-tm-g">
                {b !== 'today' && !own ? <div className="lw-tm-d">{b === 'late' ? (g.d === 'ret' ? 'Вернули на доработку' : g.d ? 'Срок был ' + dateR(g.d) : 'Без срока') : b === 'later' ? (g.d ? monthL(g.d) : 'Без срока') : TM_DAY(g.d, t0)}</div> : null}
                {g.rows.map((r) => (
                  <a key={r.id} className={'lw-tmr is-' + r.kind + (r.late ? ' is-late' : '')} href={r.href || '#'} onClick={(e) => open(e, r)}>
                    <span className="lw-tmr-a">{r.path ? <KbPath p={r.path} cls="lw-tmr-p" /> : null}<span className="t">{r.title}</span>
                      <span className="lab">{[r.obj, subNoStage(r), r.to ? 'кому: ' + r.to : '', dates(r)].filter(Boolean).join(' · ')}</span>
                      {r.remark ? <span className="lab lw-tmr-rm">«{r.remark}»</span> : null}</span>
                    <span className={'lw-tmr-n is-' + ((r.next && r.next.who) || 'me')}>{r.next ? r.next.l : ''}</span>
                    <span className="lw-tmr-s">{r.lateDays ? 'просрочено на ' + r.lateDays + ' дн' : r.statusL}</span>
                  </a>))}
              </div>))}
        </React.Fragment>}
      </div>);
  }

  /* доступ в новых кабинетах — те же правила, что у прежних страниц (lk-bootstrap): аккаунт не активен или другой роли — кабинет закрыт;
     выключенный раздел (acc.zones[zone] === false) — его окно не открывается. Без аккаунта (демо) — всё открыто, как раньше */
  const DENY_ST = { revoked: 'Доступ закрыт — вы больше не работаете с кабинетом.', blocked: 'Учётная запись заблокирована.', invited: 'Приглашение ещё не активировано.', paused: 'Учётная запись на паузе.' };
  function access(acc, roles) {
    const A = window.AccessStore;
    const deny = !acc ? '' : roles && acc.role && !roles.includes(acc.role) ? 'Эта ссылка открывает кабинет другой роли.'
      : acc.status && acc.status !== 'active' ? DENY_ST[acc.status] || 'Учётная запись не активна.' : '';
    return { deny, zone: (z) => !acc || (!deny && (A && A.zoneAllowed ? A.zoneAllowed(acc, z) : true)) };
  }
  /* title — «Главная закрыта», когда выключена только главная: окна открытых разделов открываются поверх */
  function Denied({ why, title }) {
    return (
      <div className="cv-page lw-page"><main id="main" className="cv-main"><section className="cv-hero"><div>
        <h1 className="cv-hello">{title || 'Нет доступа к кабинету'}</h1><div className="lab">{why} Обратитесь к руководителю.</div></div></section></main></div>);
  }
  function Closed({ onClose, what }) {
    return <Modal title="Раздел закрыт" onClose={onClose}><div className="lab" style={{ marginTop: 16 }}>{what || 'Этот раздел'} не входит в выданный вам доступ. Обратитесь к руководителю.</div></Modal>;
  }

  window.LKW = { access, Denied, Closed, TaskList, TaskBoard, WeekW, TomorrowW, nextLine, Ic, Face, FaceSt, stLabel, awayLabel, teamSt, WHead, Header, Modal, FactsBand, PathBar, NowCards, CalendarW, CalAgenda, CalTabs, calObjs, countBy, RowsW, ChatsW, ObjTile, PhaseMini, GanttW, ScaleTabs, MultiW, TeamW, DayCells,
    rub, plural, cap, isoAdd, dateR, dayLabel, short, letter, leftL };
})();
