/* client-v2-app.jsx — новый кабинет клиента («Главная — комбо», холст «ЛК клиента — дизайн», DESIGN.md v1.3).
   Спека: docs/superpowers/specs/2026-10-23-client-lk-v2-design.md, раздел 3. Данные — только views ProjectStore:
   clientHome (главная), clientStages + clientGates (окно «Этапы»), clientDecisions (окно «Согласования»), projectEstimate + общий компонент сметы estimate-view.jsx (окно «Смета»), clientChat (окно «Чат»), clientMoney (окно «Оплаты»), clientMaterials (окно «Материалы»), clientMedia (окно «Фото и видео»), clientDocs (окно «Документы»); дизайн-проект — отдельная страница client-v2-design.html (client-v2-design.jsx). С 2026-10-26 — кабинет клиента по умолчанию
   (client-cabinet.js): старые страницы client-*.html открывают свой экран здесь. Профиль и права — старая client-profile.html
   (аватар в шапке после запуска). До запуска проекта (спека 2026-10-26-client-v2-prestart) главная, шаги и меню берутся из сделки
   (ClientDealViews) и рисуются в client-v2-prestart.jsx (window.CV.Pre) — в этом же кабинете, без ухода в старый. */
(function () {
  'use strict';
  const CC = window.ClientCabinet; // на демо-страницах переключателя нет
  const Lightbox = window.LKLightbox; // общий просмотр фото и видео — lk-lightbox.jsx
  /* доступ (ClientCabinet.resolve): кто смотрит, какая сделка и проект. Сотрудники — менеджер, руководитель, «ЛК от имени»
     (?superView=1&name=, Конструктор → Доступ), собственная ссылка руководителя — всегда только читают: баннер в шапке, без отметок
     активности; действия от имени клиента (решения, оплаты, сообщения, отметки «прочитано», загрузки) отключены: хранилища этой
     вкладки — только для чтения (ClientCabinet.readOnly), окна показывают причину на месте. Личная ссылка клиента — с действиями */
  const ACCESS = CC && CC.resolve ? CC.resolve(window.location) : { why: null, hint: null, mode: 'demo', dealId: null, project: null, account: null, readOnly: false };
  const SV = ACCESS.readOnly ? ((CC.superView && CC.superView(window.location)) || { name: '' }) : null;
  const RO_SPEC = {
    ProjectStore: { write: ['decideGate', 'decideReplacement', 'decideMaterial', 'decideMaterialsBatch', 'decideApproval', 'setObjectAccess', 'requestVisit', 'bookVisit', 'cancelVisit', 'addPunchItem', 'acceptPunchItem', 'returnPunchItem', 'deferPunchItem', 'openWarrantyCase', 'chatSend', 'chatEnsureClientThread', 'chatEnsureDesignThread', 'pay', 'confirmDeliverySlot', 'rescheduleDeliverySlot', 'setMarketingConsent'], silent: ['markClientSeen'] },
    DealContracts: { write: ['clientAck'] },
    DpPipeline: { write: ['approve', 'requestChanges', 'specTrack', 'addComment'] },
    Files: { async: ['upload', 'uploadAll'] },
    /* до запуска проекта: чат сделки, решения по смете, срок предложения, анкеты и бриф (окна — client-v2-prestart.jsx и далее) */
    DealChat: { write: ['send', 'ensureDefaults', 'create'], silent: ['markRead'] },
    ClientEstimate: { write: ['decide', 'approve', 'rework'] },
    EstimateOffer: { write: ['request'] },
    FormInstances: { write: ['saveFieldResponses', 'submitDpBrief'] },
    ClientIntakeSync: { write: ['saveIntake'] },
  };
  if (ACCESS.readOnly && CC.readOnly) Object.keys(RO_SPEC).forEach((k) => { if (window[k] && !window[k].__readOnly) window[k] = CC.readOnly(window[k], RO_SPEC[k]); });
  const P = window.ProjectStore;
  const A = window.AccessStore;
  // ссылка клиента (?t=, ?user=, ?project=, ?deal=, ?superView=) — без постраничных параметров старого кабинета (?tab=feed)
  const SEARCH = CC && CC.v2Search ? CC.v2Search(window.location.search) : window.location.search || '';
  // кабинет и страница «Дизайн-проект» (спека волны 2, раздел 4) — пара страниц; у демо своя пара, чтобы не уйти на настоящий кабинет
  const DEMO = /-demo(\.html)?$/.test(window.location.pathname);
  const cabHref = (hash) => (DEMO ? 'client-v2-demo.html' : 'client-v2.html') + SEARCH + (hash ? '#' + hash : '');
  const designHref = (tab) => (DEMO ? 'client-v2-design-demo.html' : 'client-v2-design.html') + SEARCH + (tab ? '#' + tab : '');
  const rub = (n) => (Math.round(n) || 0).toLocaleString('ru-RU') + ' ₽';
  const cap = (t) => (t ? t.charAt(0).toUpperCase() + t.slice(1) : t);
  const lc = (t) => (t ? t.charAt(0).toLowerCase() + t.slice(1) : t);
  const days = (n) => n + ' ' + plural(n, 'день', 'дня', 'дней');
  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 TURN = { client: 'ваше решение', foreman: 'проверка прораба', designer: 'проверка дизайнера', manager: 'проверка менеджера', super: 'решение руководителя' };

  const ICONS = {
    arrow: <path d="M5 12h14M13 6l6 6-6 6" />, pen: <path d="M4 20l4-1 11-11-3-3L5 16zM14 6l3 3" />, ne: <path d="M7 17L17 7M9 7h8v8" />, down: <path d="M12 4v12M6 11l6 6 6-6M5 20h14" />, 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" />,
    chat: <path d="M4 5h16v11H9l-5 4z" />, clip: <path d="M16 8l-6.5 6.5a2 2 0 0 0 2.8 2.8L19 10.6a4 4 0 0 0-5.7-5.7L6.6 11.6a6 6 0 0 0 8.5 8.5L20 15" />, check: <path d="M5 12l5 5L20 7" />, tool: <path d="M14 4l6 6-3 3-6-6zM11 10l-7 7 3 3 7-7" />, alert: <path d="M12 4l9 16H3zM12 10v4M12 17v.5" />,
    lock: <React.Fragment><rect x="5" y="10" width="14" height="10" rx="2" /><path d="M8 10V7a4 4 0 0 1 8 0v3" /></React.Fragment>,
    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>,
    play: <path d="M8 5.5v13l10.5-6.5z" />,
    clock: <React.Fragment><circle cx="12" cy="12" r="8.5" /><path d="M12 7.5V12l3 2" /></React.Fragment>,
  };
  function Ic({ n, c = '#252528', 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>;
  }

  function useProjectData() {
    const [, tick] = React.useState(0);
    React.useEffect(() => (P ? P.subscribe(() => tick((x) => x + 1)) : undefined), []);
    const p = P ? P.current('client') : null;
    // у сотрудника по ?deal= до запуска чужой «первый активный» проект не подставляем
    return ACCESS.dealId && p && p.dealId && p.dealId !== ACCESS.dealId ? null : p;
  }
  function useHashModal() {
    const read = () => (window.location.hash || '').replace('#', '');
    const [m, setM] = React.useState(read());
    React.useEffect(() => { const on = () => setM(read()); window.addEventListener('hashchange', on); return () => window.removeEventListener('hashchange', on); }, []);
    const open = (id) => { window.location.hash = id; };
    const close = () => { history.replaceState(null, '', window.location.pathname + window.location.search); setM(''); };
    return [m, open, close];
  }

  /* центр уведомлений (спека волны 2, раздел 5, п. 1): колокольчик со счётчиком нового с последнего входа. Открыл список —
     всё отмечено прочитанным (отметка в проекте), а новое подсвечено, пока список открыт. Клик ведёт в нужное окно */
  function NotifBell({ project, open }) {
    const [on, setOn] = React.useState(false);
    const [prev, setPrev] = React.useState('');
    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 n = P.views.clientNotifications(project);
    if (!n) return null;
    const toggle = () => { if (!on) { setPrev(n.seenAt || ''); if (n.unread) P.markClientSeen(project.id); } setOn(!on); };
    const go = (x) => { setOn(false); if (x.target === 'design') window.location.href = designHref(); else open(x.target); };
    return (
      <div className="cv-bellw" ref={ref}>
        <button type="button" className="cv-bell" aria-label={'Уведомления' + (n.unread ? ': новых ' + n.unread : '')} aria-expanded={on} onClick={toggle}>
          <Ic n="bell" />{n.unread > 0 && <i>{n.unread > 99 ? '99+' : n.unread}</i>}
        </button>
        {on && (
          <div className="cv-notif card" role="dialog" aria-label="Уведомления">
            <div className="hd"><span className="lab">Уведомления</span>{n.items.some((x) => x.ts > prev) && <span className="pill-n">Новое</span>}</div>
            {n.items.length ? n.items.map((x) => (
              <button key={x.id} type="button" className={'cv-nrow' + (String(x.ts) > prev ? ' is-new' : '')} onClick={() => go(x)}>
                <span className="t">{x.text}</span><span className="lab">{x.day} · {x.time}</span>
              </button>)) : <div className="empty">Пока ничего нового.</div>}
          </div>)}
      </div>);
  }

  /* «Новое на объекте» (доработки главной, раздел 4): по дням, внутри дня — горизонтальная лента: вертикальные видео 9:16
     и принятые работы с фото. Видео открывается в полноэкранном вертикальном плеере, как Reels */
  const durL = (s) => (s > 0 ? Math.floor(s / 60) + ':' + String(Math.round(s % 60)).padStart(2, '0') : '');
  function VideoTile({ v, onOpen }) {
    const [dur, setDur] = React.useState(0);
    return (
      <button type="button" className={'cv-vt' + (v.orient === 'h' ? ' is-h' : '')} onClick={onOpen} aria-label={'Видео: ' + v.title}>
        {/* без обложки — первый кадр через #t (iOS Safari иначе показывает пустую плитку) */}
        <video src={v.poster ? v.url : v.url + '#t=0.1'} poster={v.poster || undefined} muted playsInline preload="metadata" onLoadedMetadata={(e) => setDur(e.target.duration || 0)} />
        <span className="play" aria-hidden="true"><Ic n="play" s={18} c="#fff" /></span>
        {dur > 0 && <span className="dur">{durL(dur)}</span>}
        <span className="cap"><span>{v.title}</span><span className="by">снял {v.by}</span></span>
      </button>);
  }
  function FeedBlock({ f }) {
    const [at, setAt] = React.useState(-1);
    if (!f) return null;
    const videos = f.days.flatMap((d) => d.items.filter((x) => x.kind === 'video'));
    return (
      <section className="cv-feed" aria-labelledby="cv-feed-h">
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}><h2 className="h2" id="cv-feed-h">Новое на объекте</h2><a href="#media" className="lab">все фото по этапам <span aria-hidden="true">→</span></a></div>
        <div className="lab" style={{ marginTop: 6 }}>Фото и видео появляются после приёмки работы прорабом</div>
        {f.days.length === 0 && <div className="empty" style={{ marginTop: 18 }}>Принятых работ с фото пока нет.</div>}
        {f.days.length > 0 && <div className="cv-frow">{f.days.map((d) => (
          <div key={d.date} className="cv-fday">
            <h3 className="lab cv-fday-h">{d.label}</h3>
            <div className="cv-fitems">{d.items.map((x, i) => x.kind === 'video'
              ? <VideoTile key={'v' + i} v={x} onOpen={() => setAt(videos.indexOf(x))} />
              : <a key={x.taskId} href="#media" className="cv-fw">
                <div className="ph"><img src={x.cover} alt="" loading="lazy" /><span className="tag pill-g">{x.photos} фото</span><div className="cap"><span>{x.title}</span></div></div>
                <div className="lab" style={{ marginTop: 8 }}>{x.stage} · принято</div>
              </a>)}</div>
          </div>))}</div>}
        {at >= 0 && videos[at] && <ReelsPlayer list={videos} at={at} setAt={setAt} />}
      </section>);
  }
  function ReelsPlayer({ list, at, setAt }) {
    const v = list[at];
    const go = (d) => setAt((i) => Math.max(0, Math.min(list.length - 1, i + d)));
    const close = () => setAt(-1);
    const touch = React.useRef(null);
    const root = React.useRef(null), xBtn = React.useRef(null);
    // модальный плеер: фокус на «Закрыть», Tab ходит по кругу внутри, фон не прокручивается; при закрытии фокус — туда, откуда открыли.
    // Стрелки на самом видео — его перемотка и громкость, переключение видео — стрелками вне видео, кнопками и свайпом
    React.useEffect(() => {
      const from = document.activeElement, ov = document.body.style.overflow;
      document.body.style.overflow = 'hidden';
      if (xBtn.current) xBtn.current.focus();
      const k = (e) => {
        if (e.key === 'Escape') { close(); return; }
        if (e.key === 'Tab') {
          const f = Array.from(root.current ? root.current.querySelectorAll('button, video') : []);
          if (!f.length) return;
          const i = f.indexOf(document.activeElement), n = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i === f.length - 1 ? 0 : i + 1);
          e.preventDefault(); f[n].focus(); return;
        }
        if (e.target && e.target.tagName === 'VIDEO') return;
        if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); go(1); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); go(-1); }
      };
      document.addEventListener('keydown', k);
      return () => { document.removeEventListener('keydown', k); document.body.style.overflow = ov; if (from && from.focus) from.focus(); };
    }, []);
    const onTouchStart = (e) => { const t = e.touches[0]; touch.current = { x: t.clientX, y: t.clientY }; };
    const onTouchEnd = (e) => {
      if (!touch.current) return; const t = e.changedTouches[0]; const dx = t.clientX - touch.current.x, dy = t.clientY - touch.current.y; touch.current = null;
      const m = Math.abs(dx) > Math.abs(dy) ? dx : dy; if (Math.abs(m) > 50) go(m < 0 ? 1 : -1);
    };
    return (
      <div ref={root} className="cv-reels" role="dialog" aria-modal="true" aria-label="Видео с объекта" onTouchStart={onTouchStart} onTouchEnd={onTouchEnd} onClick={close}>
        <div className={'stage' + (v.orient === 'h' ? ' is-h' : '')} onClick={(e) => e.stopPropagation()}>
          <video key={v.url} src={v.url} poster={v.poster || undefined} autoPlay playsInline loop controls />
          <div className="meta"><span className="n">{at + 1} из {list.length}</span><span>{[v.stage, v.title, 'снял ' + v.by].filter(Boolean).join(' · ')}</span></div>
        </div>
        <button ref={xBtn} type="button" className="x" aria-label="Закрыть" onClick={close}><Ic n="x" c="#fff" /></button>
        {at > 0 && <button type="button" className="nav prev" aria-label="Предыдущее видео" onClick={(e) => { e.stopPropagation(); go(-1); }}><Ic n="left" c="#fff" /></button>}
        {at < list.length - 1 && <button type="button" className="nav next" aria-label="Следующее видео" onClick={(e) => { e.stopPropagation(); go(1); }}><Ic n="right" c="#fff" /></button>}
      </div>);
  }

  /* команда объекта (доработки главной, раздел 5): менеджер и прораб — крупно рядом, дизайнер и мастера своей бригады — ниже.
     Фото обязательно; до загрузки — инициалы. «Написать» — чат, где есть эта роль, иначе общий; новый чат не создаём */
  const goChat = (open, m) => open(m && m.chatId ? 'chat=' + m.chatId : 'chat');
  const yearsL = (n) => n == null ? '' : n === 0 ? 'меньше года в профессии' : 'опыт ' + n + ' ' + plural(n, 'год', 'года', 'лет');
  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>;
  }
  function TeamBlock({ t, open }) {
    if (!t) return null;
    const big = [t.manager, t.foreman].filter(Boolean);
    const small = [t.designer].concat(t.executors).filter(Boolean);
    if (!big.length && !small.length) return null;
    return (
      <section className="cv-team" aria-labelledby="cv-team-h">
        <h2 className="h2" id="cv-team-h">Команда</h2>
        <div className="lab" style={{ marginTop: 6 }}>Люди, которые ведут ваш ремонт</div>
        {big.length > 0 && <div className="cv-team-big">{big.map((m) => (
          <div key={m.chatRole} className="cv-tm">
            <Face m={m} size={96} />
            <div className="tx"><div className="n">{m.name}</div><div className="lab">{m.role}{m.years != null ? ' · ' + yearsL(m.years) : ''}</div>
              <button type="button" className="btn-l" style={{ marginTop: 14 }} onClick={() => goChat(open, m)}>Написать</button></div>
          </div>))}</div>}
        {small.length > 0 && <div className="cv-team-sm">{small.map((m) => (
          <div key={m.chatRole + (m.id || m.name)} className="cv-ts">
            <Face m={m} size={48} />
            <div className="tx"><div className="n">{m.name}</div><div className="lab">{m.role}{m.years != null ? ' · ' + yearsL(m.years) : ''}</div>
              {m.onSite ? <div className="on"><span className="dot" />сейчас на объекте</div> : null}</div>
          </div>))}</div>}
      </section>);
  }
  /* визитка проектного менеджера в шапке (доработки главной, раздел 6): нет менеджера — нет визитки */
  function MgrCard({ project, open }) {
    const t = project ? P.views.clientTeam(project) : null;
    const m = t && t.manager;
    if (!m) return null;
    return (
      <div className="cv-mgr">
        <Face m={m} size={36} />
        <span className="tx"><span className="n">{m.name}</span><span className="lab">Проектный менеджер</span></span>
        <button type="button" className="lnk" onClick={() => goChat(open, m)}>Написать</button>
      </div>);
  }

  /* меню (доработки главной, раздел 10): пять ссылок и «Ещё»; все пункты — ссылки на #раздел, у текущего aria-current.
     «Ещё» закрывается по Esc и клику вне */
  // KSH-469: меню короче — календарь, визит, фото и разделы ниже живут на главной виджетами; в «Ещё» остаются, чтобы окно можно было открыть и отсюда
  const TOP = [['stages', 'Реализация'], ['estimate', 'Смета'], ['payments', 'Оплаты']];
  const MORE = [['media', 'Фото и видео'], ['materials', 'Материалы'], ['deliveries', 'Доставки'], ['design', 'Дизайн'], ['report', 'Отчёт за неделю'], ['tools.html?role=client', 'Инструменты']];
  // виджеты внизу главной убраны (вариант A шапки, 2026-10-24): приёмка — пока идёт и акт не подписан, гарантия — после подписания.
  // Когда нужен ответ клиента, эти окна по-прежнему открываются из «Согласований» и «Сейчас важно»
  const moreFor = (project) => { const pl = project ? P.views.punchList(project) : null; if (!pl) return MORE;
    return MORE.concat(pl.active && !pl.signed ? [['handover', 'Приёмка объекта']] : [], pl.signed ? [['warranty', 'Гарантия']] : []); };
  // menu — меню до запуска; project до запуска — «субъект» сделки (ClientDealViews.subject, prestart): визитка менеджера есть, колокольчика нет
  function Header({ name, open, design, active, project, menu }) {
    const ini = (name || 'Клиент').split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase();
    const [modal] = useHashModal();
    // menu заменяет TOP и «Ещё»
    const top = menu || TOP;
    const more_ = menu ? [] : moreFor(project);
    // нижняя панель на телефоне (KSH-550): «Главная» и три первых раздела, остальные разделы меню — в «Ещё» (только на телефоне)
    const spill = top.slice(3);
    const cur = active === 'design' ? 'design' : top.concat(more_).some(([id]) => id === modal) ? modal : '';
    // на странице дизайн-проекта разделы кабинета — переход на главную с окном
    const go = (id) => (active ? cabHref(id) : '#' + id);
    const [more, setMore] = React.useState(false);
    const moreRef = React.useRef(null);
    React.useEffect(() => {
      if (!more) return undefined;
      const out = (e) => { if (moreRef.current && !moreRef.current.contains(e.target)) setMore(false); };
      const esc = (e) => { if (e.key === 'Escape') { setMore(false); const b = moreRef.current && moreRef.current.querySelector('button'); if (b) b.focus(); } };
      document.addEventListener('mousedown', out); document.addEventListener('keydown', esc);
      return () => { document.removeEventListener('mousedown', out); document.removeEventListener('keydown', esc); };
    }, [more]);
    return (
      <React.Fragment>
      {SV && <div className="cv-sv" role="status"><Ic n="lock" s={15} c="currentColor" /><span>Просмотр от имени клиента{SV.name ? <React.Fragment> · <b>{SV.name}</b></React.Fragment> : null} · только чтение: действия отключены</span></div>}
      <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="Разделы">
          <a href={cabHref()} aria-current={cur === '' ? 'page' : undefined}>Главная</a>
          {top.map(([id, l], i) => <a key={id} className={i < 3 ? undefined : 'is-ph-hide'} href={id === 'design' ? designHref() : go(id)} aria-current={cur === id ? 'page' : undefined}>{l}</a>)}
          {(!menu || spill.length > 0) && <div className={'cv-more' + (more_.concat(spill).some(([id]) => id === cur) ? ' is-cur' : '') + (menu ? ' is-ph-only' : '')} ref={moreRef}>
            <button type="button" aria-expanded={more} aria-controls="cv-more-list" onClick={() => setMore(!more)}>Ещё <span aria-hidden="true">▾</span></button>
            {more && <ul id="cv-more-list">{spill.concat(more_.filter(([id]) => id !== 'design' || design)).map(([id, l]) => (
              <li key={id} className={spill.some(([x]) => x === id) ? 'is-ph-only' : undefined}><a href={id === 'design' ? designHref() : go(id)} aria-current={cur === id ? 'page' : undefined} onClick={() => setMore(false)}>{l}</a></li>))}</ul>}
          </div>}
        </nav>
        <div className="cv-me">{window.__CLIENT_DEMO_AUTOBUILD && <span className="pill-g" title="Демо-объект: живёт только в этой вкладке, ничего не сохраняется">Демо · не сохраняется</span>}<MgrCard project={project} open={open} />{project && !project.prestart && <NotifBell project={project} open={open} />}{DEMO || menu ? <span className="cv-ava">{ini}</span> : <a className="cv-ava" href={'client-profile.html' + SEARCH} title="Мой профиль" aria-label="Мой профиль">{ini}</a>}</div>
      </header>
      </React.Fragment>);
  }

  function greeting() {
    const h = new Date().getHours();
    return h < 5 ? 'Доброй ночи' : h < 12 ? 'Доброе утро' : h < 18 ? 'Добрый день' : 'Добрый вечер';
  }
  const todayLine = () => { const s = new Date().toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' }); return s.charAt(0).toUpperCase() + s.slice(1); };

  // виджет «Согласования» на главной: до 5 решений (со сроком — первыми), «ещё N», всё — в окне #approvals
  const KIND_IC = { photo: 'camera', act: 'doc', change: 'doc', replacement: 'box', material: 'box', delivery: 'box', access: 'lock', visit: 'calendar', punch: 'tool', order: 'box', invoice: 'wallet', doc: 'doc', design: 'pen' };
  // куда ведёт строка согласования (спека 2026-10-24-client-lk-v2-home-refine, раздел 9): окно по #hash или страница дизайн-проекта
  const goApr = (r, open) => { if (r.href === 'design-schedule') window.location.href = designHref('schedule'); else if (r.href === 'design') window.location.href = designHref(); else open((r.href || '#approvals').replace('#', '')); };
  // срок строки: просроченный счёт — красным; решение клиента с прошедшим сроком — спокойно «Решите сегодня» (KSH-549:
  // красный только у денег); осталось ≤ 3 дней — жёлтым, иначе дата
  const overPay = (r) => r.overdueDays > 0 && r.kind === 'invoice';
  function DueTag({ r }) {
    if (overPay(r)) return <span className="by is-over"><span className="lab">Просрочено</span><b>на {r.overdueDays} {plural(r.overdueDays, 'день', 'дня', 'дней')}</b></span>;
    if (r.overdueDays > 0) return <span className="by is-soon"><span className="lab">Решите</span><b>сегодня</b></span>;
    if (r.soonDays != null) return <span className="by is-soon"><span className="lab">{r.kind === 'invoice' ? 'Оплатить до' : 'Решить до'}</span><b>{r.soonDays === 0 ? 'сегодня' : 'через ' + r.soonDays + ' ' + plural(r.soonDays, 'день', 'дня', 'дней')}</b></span>;
    return r.decideBy ? <span className="by"><span className="lab">{r.kind === 'invoice' ? 'Оплатить до' : 'Решить до'}</span><b>{r.decideBy}</b></span> : null;
  }
  // короткая подпись строки для экранного диктора: что, вид и срок — без иконки и дублей
  const aprLabel = (r) => r.title + ', ' + r.kindL + (r.sum ? ', ' + rub(r.sum) : '')
    + (overPay(r) ? ', просрочено на ' + r.overdueDays + ' ' + plural(r.overdueDays, 'день', 'дня', 'дней') : r.overdueDays > 0 ? ', решите сегодня' : r.soonDays === 0 ? ', срок сегодня' : '');
  /* KSH-549: каждое решение на главной — в одном месте. Что уже стоит карточкой «Сейчас важно» (skip), здесь не повторяем;
     показываем до трёх самых срочных, остальное — ссылкой «Ещё N» в окно «Согласования» */
  const aprKey = (r) => r.kind + ':' + (r.refId || r.title);
  const APR_TOP = 3;
  function AprWidget({ a, open, skip }) {
    const seen = new Set((skip || []).map(aprKey));
    const rest = (a.all || a.rows).filter((r) => !seen.has(aprKey(r)));
    const top = rest.slice(0, APR_TOP);
    const more = rest.length - top.length;
    return (
      <section className="cv-w cv-apr-w" aria-labelledby="cv-apr-h">
        <WHead id="cv-apr-h" title="Согласования" n={a.n} text="Все согласования" onOpen={() => open('approvals')} />
        {a.n === 0
          ? <div className="note-s" style={{ marginTop: 14 }}><Ic n="check" c="#2F5D3A" s={16} /><span>От вас сейчас ничего не нужно.</span></div>
          : !top.length ? <div className="note-s" style={{ marginTop: 14 }}><span>Всё, что ждёт вас, — в карточках выше.</span></div>
          : <div className="rows" aria-label="Самые срочные согласования">{top.map((r, i) => (
            <button type="button" key={i} className={'r' + (overPay(r) ? ' is-over' : '')} onClick={() => goApr(r, open)} aria-label={aprLabel(r)}>
              <span className="ic"><Ic n={KIND_IC[r.kind] || 'check'} s={15} /></span>
              <span className="tx"><span className="t">{r.title}</span><span className="lab">{cap(r.kindL)}{r.sum ? ' · ' + rub(r.sum) : ''}</span></span>
              <DueTag r={r} />
            </button>))}
            {more > 0 && <button type="button" className="cv-arrow-btn cv-apr-more" onClick={() => open('approvals')}>Ещё {more} {plural(more, 'решение', 'решения', 'решений')}<Ic n="ne" s={14} c="currentColor" /></button>}</div>}
      </section>);
  }

  /* карточка блока главной по референсу Figma 803:2172 (KSH-469): номер-счётчик в рамке, заголовок, тёмный круг ↗ — откроется окно;
     внизу — чёрная кнопка с ↗ (переход в окно или главное действие блока) */
  // text — подпись у стрелки («Все согласования», «Весь календарь»); без неё — только стрелка
  function WHead({ id, title, n, label, onOpen, text }) {
    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}</h2>
        {text ? <button type="button" className="cv-arrow-btn" onClick={onOpen}>{text}<Ic n="ne" s={14} c="currentColor" /></button>
          : <button type="button" className="cv-wcirc" aria-label={label} onClick={onOpen}><Ic n="ne" s={18} c="currentColor" /></button>}
      </div>);
  }


  /* блок «Чаты» под «Согласованиями» (доработки главной, раздел 3): до 3 свежих чатов, клик — окно чата сразу на этом чате.
     Клиент чатов не создаёт: пусто — подсказка и общий чат проекта */
  function ChatsCard({ chats, open }) {
    return (
      <section className="cv-w cv-chats-w" aria-labelledby="cv-chats-h">
        <WHead id="cv-chats-h" title="Чаты" n={chats.reduce((x, c) => x + (c.isNew ? c.unread || 1 : 0), 0)} label="Открыть все чаты" onOpen={() => open('chat')} />
        {chats.length === 0
          ? <div className="note-s" style={{ marginTop: 12 }}><span>Вопросы по ремонту — менеджеру, он ответит здесь.</span></div>
          : <div className="rows">{chats.map((c) => (
            <button type="button" key={c.id} className={'r' + (c.isNew ? ' is-new' : '')} onClick={() => open('chat=' + c.id)}>
              <span className="tx"><span className="t">{c.title}</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={'новых: ' + (c.unread || 1)}>{c.unread || 1}</b> : null}</span>
            </button>))}</div>}
      </section>);
  }

  // общий календарь проекта (спека волны 2, раздел 2): вкладки-фильтры, полоса двух недель на главной, сетка месяца в окне #calendar
  const CAL_KINDS = [['all', 'Все'], ['work', 'Работы'], ['payment', 'Оплаты'], ['delivery', 'Доставки'], ['decide', 'Согласования']];
  // доработки главной, раздел 2: без иконок и цветных меток — вид события тонкой чертой слева и серой подписью, красное — только просрочка
  const CAL_KL = { work: 'работа', payment: 'оплата', delivery: 'доставка', decide: 'решение' };
  // просрочка красным — только у оплат; решение с прошедшим сроком — «Решить», как обычное (KSH-549)
  const calOver = (e) => e.status === 'overdue' && e.kind === 'payment';
  const calStatus = (e) => calOver(e) ? ['Просрочено', 'pill-n cv-over'] : e.status === 'overdue' ? ['Решить', 'pill-n'] : 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'];
  const isoAdd = (d, n) => { const x = new Date(d + 'T00:00:00Z'); x.setUTCDate(x.getUTCDate() + n); return x.toISOString().slice(0, 10); };
  const dayLabel = (d, t0) => {
    if (d === t0) return 'Сегодня';
    if (d === isoAdd(t0, 1)) return 'Завтра';
    return cap(new Date(d + 'T00:00:00Z').toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'long', timeZone: 'UTC' }));
  };
  const goHref = (href) => { window.location.hash = href.replace('#', ''); };
  function CalTabs({ tab, setTab, counts }) {
    return (
      <div className="cv-ctabs" role="tablist" aria-label="Что показать">
        {CAL_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 }) {
    const [l, cls] = calStatus(e);
    return (
      <button type="button" className={'cv-crow k-' + e.kind + (e.status === 'done' ? ' is-done' : '') + (calOver(e) ? ' is-over' : '')} onClick={() => goHref(e.href)} aria-label={e.title + ', ' + (CAL_KL[e.kind] || 'событие') + ', ' + l.toLowerCase()}>
        <span className="tx"><span className="t">{e.title}</span><span className="lab">{cap(CAL_KL[e.kind] || '')}{e.sub ? ' · ' + e.sub : ''}</span></span>
        <span className={cls}>{l}</span>
      </button>);
  }
  function CalAgenda({ list, t0, limit }) {
    const shown = limit ? list.slice(0, limit) : list;
    const groups = [];
    // прошедшие решения клиента — в группе «Сегодня»: их ждут сейчас, без красного заголовка «Просрочено» (KSH-549)
    shown.forEach((e) => { const g = e.date < t0 && calOver(e) ? 'over' : e.date < t0 && e.status === 'overdue' ? t0 : 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} />)}
      </div>));
  }
  // главная: «Календарь проекта» вместо «Ближайших двух недель» — по умолчанию все процессы, вкладки фильтруют
  function CalendarBlock({ c, open }) {
    const [tab, setTab] = React.useState('all');
    const [day, setDay] = React.useState(null);
    const fit = (e) => tab === 'all' || e.kind === tab;
    const list = day ? c.events.filter((e) => e.date === day && fit(e)) : c.agenda.filter(fit);
    const noun = { all: 'событий', work: 'работ', payment: 'оплат', delivery: 'доставок', decide: 'согласований' }[tab];
    return (
      <section className="cv-w cv-cal" aria-labelledby="cv-cal-h">
        <WHead id="cv-cal-h" title="Календарь проекта" text="Весь календарь" onOpen={() => open('calendar')} />
        <CalTabs tab={tab} setTab={(k) => { setTab(k); }} counts={c.counts} />
        <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(calOver);
            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) + (over ? ', есть просрочка' : 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" style={{ background: 'none', border: 0, cursor: 'pointer' }} onClick={() => setDay(null)}>показать ближайшее</button></div>}
        {/* повестка — со своей прокруткой: высота блока равна соседней колонке (KSH-469) */}
        <div className="cv-cal-list cv-scroll" tabIndex={0} aria-label="События">
          {list.length
            ? <CalAgenda list={list} t0={c.today} />
            : <div className="empty">{day ? 'В этот день ничего не запланировано.' : 'В ближайшие две недели ' + noun + ' нет.'}</div>}
        </div>
      </section>);
  }
  // окно #calendar: месяц с понедельника, те же вкладки, события выбранного дня справа
  function CalendarModal({ project, onClose }) {
    React.useEffect(() => { const k = (e) => e.key === 'Escape' && onClose(); document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }, []);
    const ev = P.views.projectCalendar(project, 'client');
    const t0 = new Date().toISOString().slice(0, 10);
    const [tab, setTab] = React.useState('all');
    const [sel, setSel] = React.useState(null); // по умолчанию справа — все события (Роман, 29.10); клик по дню — только этот день
    const [ym, setYm] = React.useState(t0.slice(0, 7));
    const fit = (e) => tab === 'all' || e.kind === tab;
    const first = ym + '-01';
    const dow = (new Date(first + 'T00:00:00Z').getUTCDay() + 6) % 7;
    const start = isoAdd(first, -dow);
    const nextM = isoAdd(first, 32).slice(0, 7) + '-01';
    const weeks = Math.ceil((dow + (new Date(nextM + 'T00:00:00Z') - new Date(first + 'T00:00:00Z')) / 86400000) / 7);
    const cells = Array.from({ length: weeks * 7 }, (_, i) => isoAdd(start, i));
    const shift = (n) => setYm(isoAdd(first, n > 0 ? 32 : -1).slice(0, 7));
    const title = cap(new Date(first + 'T00:00:00Z').toLocaleDateString('ru-RU', { month: 'long', year: 'numeric', timeZone: 'UTC' }));
    const counts = { all: ev.length }; CAL_KINDS.slice(1).forEach(([k]) => { counts[k] = ev.filter((e) => e.kind === k).length; });
    const dayList = ev.filter((e) => e.date === sel && fit(e));
    // все события: просроченные сверху, потом от сегодня вперёд, прошедшие — в конце, тише
    const allList = ev.filter(fit).slice().sort((a, b) => ((a.status === 'overdue' ? 0 : a.date >= t0 ? 1 : 2) - (b.status === 'overdue' ? 0 : b.date >= t0 ? 1 : 2)) || (a.date >= t0 ? a.date.localeCompare(b.date) : b.date.localeCompare(a.date)));
    const overdue = ev.filter((e) => e.status === 'overdue' && fit(e));
    return (
      <React.Fragment>
        <div className="cv-scrim" onClick={onClose} />
        <div className="cv-modal cv-calmod" role="dialog" aria-modal="true" aria-label="Календарь проекта">
          <div className="cv-mhead">
            <div><div className="lab">Работы, оплаты, доставки и сроки решений — общий график для вас и команды</div><h1 className="h1" style={{ marginTop: 6 }}>Календарь проекта</h1></div>
            <button type="button" className="x" aria-label="Закрыть" onClick={onClose}><Ic n="x" /></button>
          </div>
          <CalTabs tab={tab} setTab={setTab} counts={counts} />
          <div className="cv-calm">
            <div>
              <div className="cv-mnav">
                <button type="button" className="x" aria-label="Предыдущий месяц" onClick={() => shift(-1)}><Ic n="left" /></button>
                <b>{title}</b>
                <button type="button" className="x" aria-label="Следующий месяц" onClick={() => shift(1)}><Ic n="right" /></button>
                {ym !== t0.slice(0, 7) && <button type="button" className="pill-d" style={{ cursor: 'pointer' }} onClick={() => { setYm(t0.slice(0, 7)); setSel(null); }}>Сегодня</button>}
              </div>
              <div className="cv-month">
                {['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map((d) => <span key={d} className="wd">{d}</span>)}
                {cells.map((d) => {
                  const list = ev.filter((e) => e.date === d && fit(e));
                  const over = list.some((e) => e.status === 'overdue');
                  return (
                    <button type="button" key={d} className={'mc' + (d.slice(0, 7) !== ym ? ' is-out' : '') + (d === t0 ? ' is-today' : '') + (d === sel ? ' is-sel' : '')} onClick={() => setSel(d === sel ? null : d)} aria-pressed={d === sel}
                      aria-label={dayLabel(d, t0) + (list.length ? ', событий: ' + list.length : '') + (over ? ', есть просрочка' : '')}>
                      <b>{+d.slice(8)}</b>
                      {list.slice(0, 3).map((e) => <span key={e.id} title={e.title} className={'ev k-' + e.kind + (e.status === 'done' ? ' is-done' : '') + (calOver(e) ? ' is-over' : '')}>{e.title}</span>)}
                      {list.length > 3 && <span className="more">+{list.length - 3}</span>}
                    </button>);
                })}
              </div>
            </div>
            <aside className="cv-w cv-calside">
              {sel ? (
                <React.Fragment>
                  <div className="cv-calside-h"><b>{cap(dayLabel(sel, t0))}</b><button type="button" className="cv-arrow-btn" onClick={() => setSel(null)}>Все события</button></div>
                  <div className="cv-calside-l cv-scroll" tabIndex={0} aria-label={'События: ' + dayLabel(sel, t0)}>{dayList.length ? dayList.map((e) => <CalRow key={e.id} e={e} />) : <div className="empty">В этот день ничего не запланировано.</div>}
                    {overdue.length > 0 && <div style={{ marginTop: 24 }}><div className="lab cv-over-t">Просрочено · {overdue.length}</div>{overdue.map((e) => <CalRow key={e.id} e={e} />)}</div>}</div>
                </React.Fragment>) : (
                <React.Fragment>
                  <div className="cv-calside-h"><b>Все события</b><span className="lab">{allList.length}</span></div>
                  <div className="cv-calside-l cv-scroll" tabIndex={0} aria-label="Все события проекта"><CalAgenda list={allList} t0={t0} /></div>
                </React.Fragment>)}
            </aside>
          </div>
        </div>
      </React.Fragment>);
  }

  /* полоса хода работ (вариант A шапки, 2026-10-24): кто сейчас работает, путь фаз и четыре факта — одним блоком вместо лида, пути фаз и плиток.
     Движение: при загрузке фазы заполняются слева направо, текущая — штриховка «идут работы», у живой строки пульс; при «уменьшить движение» всё стоит */
  const agoR = (n) => (n === 0 ? 'сегодня' : n === 1 ? 'вчера' : n + ' ' + plural(n, 'день', 'дня', 'дней') + ' назад');
  /* честные сдвиги (KSH-529): задержка из-за клиента — каждой строкой и сразу; перенос командой за запас — одной строкой
     по первой сдвинутой фазе, с причиной прораба (следующие фазы сдвигаются следом — их новые даты уже в пути фаз) */
  function ShiftNotes({ path }) {
    const open = path.filter((x) => x.state !== 'done');
    const waits = open.filter((x) => x.wait);
    const team = open.find((x) => x.shiftDays > 0 && x.reason && !x.wait);
    if (!waits.length && !team) return null;
    return (
      <div className="cv-shift" role="status">
        {waits.map((x) => <a key={'w' + x.idx} className="cv-shift-r is-you" href={x.wait.href}><b>{x.wait.text}</b><span className="lab">{x.wait.kind === 'payment' ? 'Оплатить' : 'Выбрать'} <span aria-hidden="true">→</span></span></a>)}
        {team && <div className="cv-shift-r"><b>Сдача фазы «{team.name}» переносится на {team.shownEndR}, было {team.endR}</b><span className="lab">{team.reason}</span></div>}
      </div>);
  }
  function ProgressBand({ h, path, live }) {
    const paidN = path.filter((x) => x.paid === 'done').length, payN = path.filter((x) => x.paid).length;
    const now = live.now, cur = path.find((x) => x.state === 'current'), lastP = path[path.length - 1];
    const media = h.weekPhotos + ' фото' + (h.weekVideos ? ', ' + h.weekVideos + ' видео' : '');
    return (
      <section className="cv-prog" aria-label="Ход ремонта">
        <div className="cv-prog-live">
          {now
            ? <span className="now"><i className="cv-pulse" aria-hidden="true" />{now.who ? <Face m={now.who} size={28} /> : null}<span>Сейчас в работе: {lc(now.title)}{now.who ? ', ' + lc(now.who.role) + ' ' + now.who.name : ''}{now.more ? ' и ещё ' + now.more + ' ' + plural(now.more, 'работа', 'работы', 'работ') : ''}</span></span>
            : <span className="now">{h.handedOver ? 'Объект сдан' : 'Сейчас работ на объекте нет'}</span>}
          {live.last && <span className="last">Последняя приёмка — {agoR(live.last.daysAgo)}, «{live.last.title}»</span>}
        </div>
        {path.length > 0 && <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>{x.state !== 'done' && x.shownEndR ? <span className={'s d' + (x.shiftDays ? ' is-shift' : '')}>до {x.shownEndR}</span> : null}</span>
            </li>))}
        </ol>}
        <ShiftNotes path={path} />
        {cur && <div className="cv-prog-cur" aria-hidden="true">Фаза {path.indexOf(cur) + 1} из {path.length} · {cur.name}<span className="s">Идут работы · {cur.doneN} из {cur.allN}</span></div>}
        <div className="cv-prog-facts">
          <a className="cv-fact" href="#stages"><span className="v">{h.pct} %</span><span className="lab">Готово · принято {h.doneN} из {h.allN} {plural(h.allN, 'работы', 'работ', 'работ')}</span></a>
          <a className="cv-fact" href="#stages"><span className="v">{dateY(h.dueIso) || '—'}</span><span className="lab">{h.handedOver ? 'Объект сдан' : h.lateDays ? <span className="cv-late">Срок прошёл {h.lateDays} {plural(h.lateDays, 'день', 'дня', 'дней')} назад</span> : h.dueIso ? 'Сдача через ' + h.daysLeft + ' ' + plural(h.daysLeft, 'день', 'дня', 'дней') + (lastP && lastP.shiftDays ? ' · было ' + lastP.endR : '') : 'Сдача'}{h.dueIso && h.onSchedule && !h.handedOver ? <span className="ok"><i className="dot" />по графику</span> : null}</span></a>
          <a className="cv-fact" href="#payments"><span className="v">{paidN} из {payN} {plural(payN, 'фазы', 'фаз', 'фаз')}</span><span className="lab">Оплачено {rub(h.paid)} из {rub(h.total)}</span></a>
          <a className="cv-fact" href="#media"><span className="v">{media}</span><span className="lab">За неделю{h.weekDone ? ' · принято ' + h.weekDone + ' ' + plural(h.weekDone, 'работа', 'работы', 'работ') : ''}</span></a>
        </div>
      </section>);
  }
  /* «Сейчас важно»: счёт, который держит следующий этап, и самая долгая просрочка (views.clientWidgets → now), до двух карточек в ряд.
     UI/UX-ревью 29.10 (KSH-469): короткий текст — вид, главное (сумма или что сделать), подпись; главная кнопка — только у оплаты */
  const NOW_KIND = { invoice: 'Счёт к оплате', order: 'Заказать самим', photo: 'Фотоконтроль', act: 'Акт', change: 'Допсоглашение', visit: 'Визит', delivery: 'Доставка', doc: 'Документ на подпись', design: 'Дизайн-проект', access: 'Объект и доступ', punch: 'Приёмка' };
  // «Вы заказываете: Светильники» → «Светильники»: вид уже написан сверху карточки
  const nowTitle = (r) => { const i = r.title.indexOf(': '); return i > 0 && i <= 20 ? r.title.slice(i + 2) : r.title; };
  function NowStrip({ rows, open }) {
    if (!rows || !rows.length) return null;
    return (
      <section className="cv-nowrow" aria-label="Сейчас важно">
        {rows.map((r) => { const pay = r.kind === 'invoice';
          const sub = pay ? (r.gate ? cap(r.gate.replace(/^оплата откроет/, 'откроет')) : '') : r.sum ? rub(r.sum) : '';
          return (
            <div key={r.kind + (r.refId || r.title)} className={'cv-now' + (overPay(r) ? ' is-over' : '') + (pay ? ' is-pay' : '')}>
              <div className="top"><span className="lab">{NOW_KIND[r.kind] || cap(r.kindL)}</span><DueTag r={r} /></div>
              <div className="t">{pay ? rub(r.sum) : nowTitle(r)}</div>
              <div className="bot"><span className="lab">{sub}</span>
                {pay ? <button type="button" className="btn-pay" onClick={() => goApr(r, open)}>Оплатить</button> : <button type="button" className="cv-arrow-btn" onClick={() => goApr(r, open)}>Открыть<Ic n="ne" s={14} c="currentColor" /></button>}</div>
            </div>); })}
      </section>);
  }
  /* «Съёмка для портфолио» (KSH-459): маркетолог запросил согласие — клиент выбирает уровень прямо на главной (setMarketingConsent);
     после ответа карточка уходит, изменить выбор можно позже через менеджера */
  function ConsentCard({ project }) {
    const [err, setErr] = React.useState('');
    const r = project && project.consentRequest;
    if (!r || r.answeredAt || !P.setMarketingConsent) return null;
    const actor = { role: 'client', name: (project.client && project.client.name) || 'Клиент' };
    const pick = (level) => { try { P.setMarketingConsent(project.id, level, actor); setErr(''); } catch (e) { setErr(e.message); } };
    const OPTS = [['public', 'Можно с отметкой', 'адрес и название объекта'], ['anonymous', 'Анонимно', 'без адреса, лиц и номеров домов'], ['none', 'Нельзя', 'только для внутреннего архива']];
    return (
      <section className="cv-w cv-consent-w" aria-labelledby="cv-consent-h">
        <div className="hd"><h2 className="cv-wt" id="cv-consent-h">Съёмка для портфолио</h2></div>
        <p className="lab" style={{ margin: '8px 0 0' }}>Мы хотели бы снять ваш ремонт для соцсетей. Выберите, что можно публиковать — выбор можно изменить позже.</p>
        <div className="cv-consent-opts">{OPTS.map(([k, t, s]) => (
          <button key={k} type="button" className="cv-consent-o" onClick={() => pick(k)}><span className="t">{t}</span><span className="lab">{s}</span></button>))}</div>
        {err && <div className="lab" role="alert" style={{ marginTop: 8, color: 'var(--late, #94632A)' }}>{err}</div>}
      </section>);
  }
  /* «Визит на объект» на главной (KSH-469): выбрать дату прямо здесь — те же действия стора, что в окне #visit.
     Нет визита — удобная дата уходит прорабу в запросе; прораб предложил время — варианты кнопками, клик бронирует */
  function VisitCard({ project, open }) {
    const v = P.views.clientVisit(project);
    const t0 = new Date().toISOString().slice(0, 10);
    const [day, setDay] = React.useState('');
    const [err, setErr] = React.useState('');
    const [pick, setPick] = React.useState(null); // время выбирают, затем подтверждают — bookVisit только по кнопке
    if (!v || (!v.active && !v.canRequest && !v.past.length)) return null;
    const a = v.active;
    const actor = { role: 'client', name: (project.client && project.client.name) || 'Клиент' };
    const run = (fn) => { try { fn(); setErr(''); } catch (e) { setErr(e.message); } };
    const dayR = (d) => new Date(d + 'T00:00:00Z').toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'long', timeZone: 'UTC' });
    return (
      <section className="cv-w cv-visit-w" aria-labelledby="cv-visit-h">
        <WHead id="cv-visit-h" title={a && a.state === 'booked' ? 'Визит назначен' : 'Визит на объект'} label="Открыть визит на объект" onOpen={() => open('visit')} />
        {!a && v.canRequest && <div className="cv-visit-b">
          <label className="tx"><span className="lab">Когда вам удобно приехать</span><input type="date" className="cv-vdate" min={t0} value={day} onChange={(e) => setDay(e.target.value)} disabled={!!SV} /></label>
          <button type="button" className="cv-vbtn" disabled={!day || !!SV} onClick={() => run(() => P.requestVisit(project.id, 'Удобно: ' + dayR(day), actor))}>Попросить</button>
        </div>}
        {!a && !v.canRequest && <div className="cv-visit-b"><span className="lab">Были на объекте: {v.past.length}</span></div>}
        {a && a.state === 'requested' && <div className="cv-visit-b"><div className="tx"><span className="t">Запрос отправлен</span><span className="lab">{a.reqNote || 'Прораб предложит время в течение рабочего дня'}</span></div></div>}
        {a && a.state === 'offered' && <div className="cv-visit-b is-pick">
          <span className="lab">Прораб предложил время. Выберите и подтвердите</span>
          <div className="cv-vpick">
            <div className="cv-vchips" role="radiogroup" aria-label="Время визита">{a.slots.map((x, i) => (
              <button type="button" role="radio" aria-checked={pick === i} key={i} className={pick === i ? 'on' : ''} disabled={x.past || !!SV} onClick={() => setPick(i)}>
                <b>{x.dateR}</b><span>{x.past ? 'прошёл' : x.from + '–' + x.to}</span></button>))}</div>
            <button type="button" className="cv-vbtn" disabled={pick == null || !!SV} onClick={() => run(() => P.bookVisit(project.id, a.id, pick, '', actor))}>Подтвердить</button>
          </div>
        </div>}
        {a && a.state === 'booked' && <div className="cv-visit-b"><div className="tx"><span className="t">{a.chosenR}</span><span className="lab">Прораб встретит вас на объекте</span></div></div>}
        {err && <div className="lab" role="alert" style={{ color: 'var(--over)', marginTop: 8 }}>{err}</div>}
      </section>);
  }
  const dateY = (iso) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString('ru-RU', Object.assign({ day: 'numeric', month: 'long', timeZone: 'UTC' }, d.getUTCFullYear() !== new Date().getFullYear() ? { year: 'numeric' } : {})).replace(' г.', ''); };

  function Home({ h, w, open, project }) {
    const first = (h.clientName || '').split(' ')[0];
    const path = P.views.clientPhasePath(project);
    return (
      <div className="cv-page">
        <Header name={h.clientName} open={open} design={h.requiresDesign} project={project} />
        <main id="main" tabIndex={-1} className="cv-main">
        <section className="cv-hero">
          <div><div className="lab">{todayLine()}{h.address ? ' · ' + h.address : ''}</div><h1 className="cv-hello">{greeting()}{first ? ', ' + first : ''}</h1></div>
          <div className="cv-hero-act">
            {w.approvals.n
              ? <React.Fragment><button type="button" onClick={() => open('approvals')} className="btn-k">Нужно ваше решение · {w.approvals.n}</button><button type="button" onClick={() => open('approvals')} aria-label="Открыть согласования" className="ic ic-k"><Ic n="arrow" c="#fff" /></button></React.Fragment>
              : <span className="pill-g"><span className="dot" />От вас сейчас ничего не нужно</span>}
          </div>
        </section>

        <ProgressBand h={h} path={path} live={P.views.clientLive(project)} />

        <NowStrip rows={w.now} open={open} />

        <section className="cv-two">
          <CalendarBlock c={w.calendar} open={open} />
          <div className="cv-side"><AprWidget a={w.approvals} open={open} skip={w.now} /><VisitCard project={project} open={open} /><ConsentCard project={project} /><ChatsCard chats={w.chats} open={open} /></div>
        </section>

        <FeedBlock f={P.views.clientFeed(project)} />

        <TeamBlock t={P.views.clientTeam(project)} open={open} />

        </main>
      </div>);
  }

  function StagePill({ s }) {
    // прошедший этап — тихая подпись, а не плашка: внимание на текущем (KSH-469, референс Figma 749:3260)
    if (s.state === 'done') return <span className="cv-st-done">Закрыт</span>;
    if (s.state === 'current') return <span className="pill-k">Идёт · {s.pct}%</span>;
    if (s.state === 'locked') return <span className="pill-d"><Ic n="lock" s={12} c="#6E6E73" />{s.lockWaiting === 'confirm' ? 'На проверке' : 'Ждёт оплаты'}</span>;
    return <span className="pill-d">Впереди</span>;
  }

  /* акт и счёт фазы — печатная форма в новом окне: «Скачать» = сохранить как PDF из печати (файла в системе может ещё не быть) */
  function printDoc(project, kind, d) {
    const w = window.open('', '_blank'); if (!w) return;
    const esc = (x) => String(x == null ? '' : x).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
    const head = kind === 'act' ? 'Акт выполненных работ' : 'Счёт на оплату';
    w.document.write('<!doctype html><meta charset="utf-8"><title>' + esc(head + ' · ' + d.title) + '</title><style>body{font:15px/1.5 Inter,system-ui,sans-serif;color:#34302C;margin:48px;max-width:720px}h1{font-weight:300;font-size:32px;margin:0 0 4px}.m{color:#6E6E73;font-size:13px}table{width:100%;border-collapse:collapse;margin-top:28px}td{padding:10px 0;border-top:1px solid #ECECEE}td:last-child{text-align:right}.s{font-size:24px;font-weight:300}</style>'
      + '<div class="m">ОБИТЕЛЬ · ' + esc(project.title || '') + '</div><h1>' + esc(head) + '</h1><div class="m">' + esc(d.title) + (d.date || d.due ? ' · ' + esc(d.date || 'срок ' + d.due) : '') + '</div>'
      + '<table><tr><td>' + esc(d.title) + '</td><td class="s">' + esc(rub(d.amount || 0)) + '</td></tr></table>'
      + (kind === 'bill' ? '<p class="m">Оплатите по реквизитам договора и нажмите «Я оплатил» в кабинете. Этап откроется, когда бухгалтерия подтвердит поступление.</p>' : '<p class="m">Акт подписывается в кабинете, в разделе «Согласования».</p>'));
    w.document.close(); setTimeout(() => { try { w.print(); } catch (e) { /* печать недоступна — документ остаётся открытым */ } }, 300);
  }
  // документы фазы в карточке цепочки справа: акт (появляется при закрытии фазы), выставленные счета и фотоотчёт принятых работ
  function PhaseDocs({ project, idx, photos, onPhotos }) {
    const d = P.views.clientPhaseDocs(project, idx);
    if (!d.act && !d.bills.length && !photos.length) return null;
    const ST = { signed: 'подписан', pending: 'на подписи', rejected: 'правки', due: 'к оплате', check: 'на проверке', done: 'оплачен' };
    const row = (kind, x) => (
      <span key={kind + x.id} className={'cv-pdoc is-' + x.state}>
        <Ic n={kind === 'act' ? 'doc' : 'wallet'} s={13} c="currentColor" />
        <span className="t">{kind === 'act' ? 'Акт' : 'Счёт'}{kind === 'bill' && d.act && d.act.amount === x.amount ? '' : ' · ' + rub(x.amount || 0)}</span>{/* подписан / оплачен — тихо, без подписи: статус фазы уже «Закрыта»; подпись только когда нужно действие или ждём */}
        {x.state !== 'signed' && x.state !== 'done' && <span className="lab">{ST[x.state]}{kind === 'bill' && x.state === 'due' && x.due ? ' до ' + x.due : ''}</span>}
        {x.href ? <a className="cv-pdoc-a" href={x.href} target="_blank" rel="noopener" download aria-label={'Скачать: ' + x.title}><Ic n="down" s={13} c="currentColor" /></a>
          : <button type="button" className="cv-pdoc-a" onClick={() => printDoc(project, kind, x)} aria-label={'Скачать: ' + x.title} title="Скачать"><Ic n="down" s={13} c="currentColor" /></button>}
        {/* онлайн: счёт — в «Оплатах», акт на подписи — в «Согласованиях»; подписанный акт — только скачать */}
        {(kind === 'bill' || x.state !== 'signed') && <a className="cv-pdoc-a" href={kind === 'act' ? '#approvals' : '#payments'} aria-label={'Открыть онлайн: ' + x.title} title="Открыть онлайн"><Ic n="ne" s={13} c="currentColor" /></a>}
      </span>);
    return (
      <div className="cv-pdocs">{d.act && row('act', d.act)}{d.bills.map((b) => row('bill', b))}
        {photos.length > 0 && <button type="button" className="cv-pdoc is-ph" onClick={() => onPhotos(photos, 0)} aria-label={'Фотоотчёт фазы: ' + photos.length + ' фото'}>
          <span className="cv-pdoc-th">{photos.slice(0, 3).map((ph, k) => <img key={k} src={ph.url} alt="" />)}</span><span className="t">Фотоотчёт · {photos.length}</span><Ic n="ne" s={13} c="currentColor" /></button>}
      </div>);
  }

  /* «Сдача» — финальный блок во всю ширину (Роман, 29.10: «внушительнее и дизайнерски»): кадр готовой квартиры,
     крупная дата сдачи, этапы фазы на светлой подложке и её карточка с актом, счётом и условиями — как у остальных фаз */
  /* «Сдача» — финальный блок во всю ширину (Роман, 29.10): кадр готовой квартиры — подложка всего блока, ~70 % картинки свободно,
     контент — колонкой слева: дата сдачи, этапы фазы и её карточка с актом, счётом и условиями, как у остальных фаз */
  function Finale({ g, card, done }) {
    const last = g.rows[g.rows.length - 1];
    const end = last ? String(last.dates || '').split(' — ').pop() : '';
    const works = g.rows.reduce((a, r) => a + (r.tasksN || 0), 0);
    return (
      <section className={'cv-fin' + (done ? ' is-done' : '')} aria-label={'Финальная фаза: ' + g.name}>
        <img className="cv-fin-bg" src="demo/finale-keys.jpg" alt="" aria-hidden="true" />
        <div className="cv-fin-col">
          <div className="k">✦ Финиш ремонта</div>
          <h2>{done ? 'Объект сдан' : 'Сдача объекта'}</h2>
          {end && <div className="d"><span>{done ? 'сдали' : 'плановая дата'}</span><b>{end}</b></div>}
          <p>Финальная приёмка, акт, гарантия и паспорт квартиры. Вы получаете ключи от готового дома.</p>
          <div className="m"><span>{works} {plural(works, 'работа', 'работы', 'работ')}</span><span>гарантия 12 месяцев</span></div>
          <ul className="cv-fin-st">
            {g.rows.map((s) => (
              <li key={s.n} className={'is-' + s.state}><span className="n">{s.state === 'done' ? <Ic n="check" s={11} c="currentColor" /> : s.state === 'locked' ? <Ic n="lock" s={11} c="currentColor" /> : s.n}</span>
                <span className="tx"><span className="t">{s.t}</span><span className="l">{s.dates} · {s.tasksN} {plural(s.tasksN, 'работа', 'работы', 'работ')}{s.state === 'locked' ? ' · ' + (cap(s.lockReason) || 'после оплаты') : ''}</span></span></li>))}
          </ul>
          {card}
        </div>
      </section>);
  }

  function StagesModal({ project, onClose, room0 }) {
    React.useEffect(() => { const k = (e) => e.key === 'Escape' && onClose(); document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }, []);
    const V = P.views;
    const rows = V.clientStages(project);
    const real = project.stages.filter((s) => !s.virtual);
    const groups = V.clientGateGroups(project);
    const done = rows.filter((r) => r.state === 'done').length;
    const phases = [];
    rows.forEach((r, i) => {
      const pi = real[i] ? real[i].phaseIdx : 0;
      let g = phases.find((x) => x.idx === pi);
      if (!g) { g = { idx: pi, name: (project.phases[pi] || {}).name || 'Фаза ' + pi, rows: [] }; phases.push(g); }
      g.rows.push(r);
    });
    /* статус — один раз (KSH-469): закрытая фаза — плашкой у фазы, без «Закрыт» у каждого этапа; у остальных фаз — сколько этапов закрыто, статус — у этапа */
    // раскрытые этапы: по умолчанию — текущий; остальные раскрываются кликом по строке
    const [opened, setOpened] = React.useState(() => new Set(rows.filter((r) => r.state === 'current').map((r) => r.n)));
    const toggle = (n) => setOpened((o) => { const x = new Set(o); if (x.has(n)) x.delete(n); else x.add(n); return x; });
    const phaseDone = (g) => g.rows.every((r) => r.state === 'done');
    // обещанные даты фазы (KSH-529): сдача со сдвигом — новой датой, прежняя рядом
    const pdates = V.clientPhaseDates ? V.clientPhaseDates(project) : [];
    const phaseDates = (g) => { const d = pdates.find((x) => x.idx === g.idx); if (!d || !d.end) return '';
      return d.startR + ' — ' + d.shownEndR + (d.shiftDays ? ' · было ' + d.endR : ''); };
    const phaseNote = (g) => { const d = g.rows.filter((r) => r.state === 'done').length; return [d ? d + ' из ' + g.rows.length + ' закрыто' : '', phaseDates(g)].filter(Boolean).join(' · '); };
    const lastIdx = phases.length ? phases[phases.length - 1].idx : -1;
    // условие — в карточку фазы этапа, который оно откроет; этап не нашёлся — в первую незакрытую фазу
    const openPh = (phases.find((g) => !phaseDone(g)) || phases[phases.length - 1] || {}).idx;
    const phaseOfGroup = (grp) => { const st = real.find((x) => x.name === grp.stage); return st && phases.some((g) => g.idx === st.phaseIdx) ? st.phaseIdx : openPh; };
    const [view, setView] = React.useState(null);
    const shot = (list, at) => setView({ items: list, at });
    // готовность по комнатам (room-readiness-view.jsx, KSH-504): плитки-фильтры; проект без комнат — окно как раньше
    const RR = window.RoomReadiness, rrm = RR ? RR.model(project) : null;
    const [room, setRoom] = React.useState(room0 || null);
    const rsel = rrm && room && rrm.tiles.some((t) => t.id === room) ? room : null;
    const rmatch = rsel ? RR.match(project, rsel) : null;
    const stVis = (s) => !rmatch || !!rmatch[s.n];
    const title = <h1 className="h1" style={{ marginTop: 10 }}>Реализация</h1>;
    // вид «График» (Роман, 07.11; KSH-529): та же модель графика, что у прораба, — фазы, обещанные даты и события, которые касаются клиента
    const LW = window.LKW, gt = LW && LW.GanttW && window.ForemanViews && window.ForemanViews.clientGantt ? window.ForemanViews.clientGantt(project) : null;
    const [mode, setMode] = React.useState('list');
    const [scale, setScale] = React.useState('month');
    const graph = mode === 'graph' && gt && gt.rows.length;
    return (
      <React.Fragment>
        <div className="cv-scrim" onClick={onClose} />
        <div className="cv-modal cv-stmod" role="dialog" aria-modal="true" aria-label="Реализация">
          <div className="cv-mhead">
            <div><div className="lab">{rows.length} {plural(rows.length, 'этап', 'этапа', 'этапов')} · {done} закрыто</div>{rrm ? <div className="cv-rr-ttl">{title}<RR.Overall model={rrm} /></div> : title}</div>
            <button type="button" className="x" aria-label="Закрыть" onClick={onClose}><Ic n="x" /></button>
          </div>
          {/* одна прокрутка: фаза слева и её карточка справа — в одной строке (Роман, 29.10: «свяжем левую и правую части»).
              Сдача — отдельный финальный блок во всю ширину */}
          {gt && gt.rows.length ? <div className="cv-stmode">
            <div className="cv-ctabs" role="tablist" aria-label="Вид">{[['list', 'Этапы'], ['graph', 'График']].map(([k, l]) => (
              <button key={k} type="button" role="tab" aria-selected={mode === k} className={mode === k ? 'on' : ''} onClick={() => setMode(k)}>{l}</button>))}</div>
            {graph ? <LW.ScaleTabs scale={scale} onScale={setScale} /> : null}</div> : null}
          {graph ? <div className="cv-stgraph">
            <p className="lab">Фазы ремонта и даты, которые мы обещаем, а ещё доставки и визиты, которые касаются вас. Пунктиром — то, что ждёт вашего согласования.</p>
            <LW.GanttW g={gt} detail head="Фаза" scale={scale} onScale={setScale} />
          </div> : <React.Fragment>
          {rrm && <RR.Tiles model={rrm} sel={rsel} onSelect={setRoom} />}
          <div className="cv-rl">
            <HandoverPack project={project} />
            <div className="cv-rl-hd"><div className="cv-gates-h cv-rl-l"><h2 className="h2">Этапы ремонта</h2><div className="lab">Нажмите на этап, чтобы увидеть работы и даты окончания.</div></div><div className="cv-gates-h"><h2 className="h2">Что открывает следующие этапы</h2>
              <div className="lab">Фаза начинается после оплаты предыдущей и приёмки работ с фотоконтролем. Здесь же акты, счета и фотоотчёты.</div></div></div>
            {phases.map((g) => { const gr = groups.filter((grp) => phaseOfGroup(grp) === g.idx), cur = !phaseDone(g) && g.rows.some((r) => r.state === 'current' || r.doneN > 0);
                const photos = g.rows.flatMap((r) => r.items.flatMap((it) => it.photos || []));
                const docs = <PhaseDocs project={project} idx={g.idx} photos={photos} onPhotos={shot} />;
              const left = (
                <div className={'cv-phase' + (phaseDone(g) ? ' is-done' : '')}>
                  <div className="cv-phase-h"><h2 className="cv-pht"><span className="no">/0{g.idx}</span> {g.name.replace(/^Фаза \d+ · /, '')}</h2>{phaseDone(g) ? <span className="pill-g"><span className="dot" />Закрыта</span> : <span className="lab">{phaseNote(g)}</span>}</div>
                  {rmatch && !g.rows.some(stVis) && <div className="lab cv-rr-none">В этой комнате работ на этой фазе нет.</div>}
                  {g.rows.filter(stVis).map((s) => { const can = s.items.length > 0, on = can && (rmatch ? true : opened.has(s.n));
                    const head = (
                      <React.Fragment>
                        <span className="n">{s.state === 'done' ? <Ic n="check" c="#fff" s={12} /> : s.state === 'locked' ? <Ic n="lock" c="#6E6E73" s={13} /> : s.n}</span>
                        <span className="tx">
                          <span className="t">{s.t}</span>
                          <span className="lab">{s.dates} · {s.tasksN} {plural(s.tasksN, 'работа', 'работы', 'работ')}{s.doneN ? ' · принято ' + s.doneN : ''}</span>
                          {s.state === 'locked' && <span className="lab">{s.lockWaiting === 'confirm' ? 'Оплата на проверке у бухгалтерии' : cap(s.lockReason) || 'После оплаты'}</span>}
                        </span>
                        {phaseDone(g) ? <span /> : <StagePill s={s} />}
                        {can ? <span className="cv-chev" aria-hidden="true"><Ic n="right" s={16} c="currentColor" /></span> : <span />}
                      </React.Fragment>);
                    return (
                    <div key={s.n} className={'cv-stage is-' + s.state + (on ? ' is-open' : '')}>
                      {can ? <button type="button" className="cv-stage-h" aria-expanded={on} onClick={() => toggle(s.n)}>{head}</button> : <div className="cv-stage-h">{head}</div>}
                      {/* ход этапа: полоса заполняется при открытии, у идущего этапа — бегущая штриховка «идут работы» (KSH-469) */}
                      {(s.state === 'current' || (s.doneN > 0 && s.state !== 'done')) && <div className="cv-sprog" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={s.pct} aria-label={'Готово ' + s.pct + ' %'}>
                        <span className="bar"><i style={{ width: Math.max(s.pct, 3) + '%' }} />{s.state === 'current' && <b className="cv-spulse" style={{ left: Math.max(s.pct, 3) + '%' }} />}</span><span className="v">{s.doneN} из {s.tasksN}</span></div>}
                      {/* состав работ: только плановая дата окончания — даты могут сдвигаться, слова про старт не пишем (KSH-469) */}
                      {on && <div className="cv-items" role="table" aria-label={'Работы этапа «' + s.t + '»'}>
                        {s.items.map((it, j) => rmatch && !rmatch[s.n].has(j) ? null : (
                          <div key={j} role="row" className={'cv-item' + (it.done ? ' is-done' : '') + (it.cur ? ' is-cur' : '')}>
                            <span role="cell">{it.done ? <Ic n="check" s={12} c="#6E6E73" /> : it.cur ? <i className="cv-cur" aria-label="в работе" /> : null}{it.t}{it.st === 'review' ? <span className="lab"> · на приёмке</span> : it.st === 'returned' ? <span className="lab"> · на доработке</span> : null}</span>
                            <span role="cell" className="num">{it.done ? '' : it.end || ''}</span>
                          </div>))}
                      </div>}
                    </div>); })}
                </div>);
              const card = (
                <div className={'cv-w cv-gate cv-pcard' + (phaseDone(g) ? ' is-done' : cur ? ' is-cur' : ' is-next')}>
                  <div className="cv-pcard-h"><span className="t"><span className="no">/0{g.idx}</span> {g.name.replace(/^Фаза \d+ · /, '')}</span><span className="lab">{phaseDone(g) ? '✓ закрыта' : cur ? 'идёт' : 'впереди'}</span></div>
                  {docs}
                  {/* ближайший этап — с условиями, остальные — одной строкой (Роман, 29.10: список не растягивать) */}
                  {gr.slice(0, 1).map((grp) => (
                    <div key={grp.stage} className="cv-pgrp">
                      <div className="lab">{grp.started ? 'Нужно, чтобы продолжить' : 'Откроет этап'} «{grp.stage}»</div>
                      <div className="cv-conds">
                        {grp.items.map((it) => (
                          <div key={it.kind + it.id} className={'cv-cond ' + it.turn}>
                            <span className="ic"><Ic n={it.kind === 'payment' ? 'wallet' : 'camera'} s={15} c={it.turn === 'client' ? '#252528' : '#6E6E73'} /></span>
                            <div style={{ minWidth: 0, flex: 1 }}>
                              <div className="nm">{it.kind === 'payment' ? 'Оплата' : 'Фотоконтроль'} · {it.title.replace(/^Этап: /, '')}</div>
                              <div className="lab" style={{ marginTop: 2 }}>{it.turnL}{it.kind === 'payment' ? ' · ' + rub(it.amount) : ''}</div>
                            </div>
                            {it.turn === 'client' && (it.kind === 'payment'
                              ? <a href="#payments" className="btn-pay cv-cta">Оплатить</a>
                              : <a href="#approvals" className="btn-g cv-cta">Проверить</a>)}
                          </div>))}
                      </div>
                    </div>))}
                  {gr.length > 1 && <div className="lab cv-pnote">Дальше {gr.length - 1} {plural(gr.length - 1, 'этап', 'этапа', 'этапов')}: {gr.slice(1, 3).map((x) => '«' + x.stage + '»').join(', ')}{gr.length > 3 ? ' и ещё ' + (gr.length - 3) : ''}. Откроются по очереди после фотоконтроля.</div>}
                  {!gr.length && !phaseDone(g) && !cur && <div className="lab cv-pnote">Откроется после приёмки и оплаты предыдущей фазы.</div>}
                </div>);
              if (g.idx === lastIdx) return <Finale key={g.idx} g={rmatch ? { ...g, rows: g.rows.filter(stVis) } : g} card={card} done={phaseDone(g)} />;
              return <div key={g.idx} className={'cv-rl-row' + (phaseDone(g) ? ' is-done' : cur ? ' is-cur' : '')}>{left}<div className="cv-rl-side">{card}</div></div>; })}
          </div>
          </React.Fragment>}
        </div>
        {view && <Lightbox items={view.items} at={view.at} onClose={() => setView(null)} />}
      </React.Fragment>);
  }

  // окно «Согласования»: решения клиента теми же действиями ProjectStore, что и в старом кабинете
  const KIND_L = { act: 'акт', change: 'допсоглашение', replacement: 'замена материала', material: 'материал' };
  const ASK = { gate: 'Что исправить в работе?', approval: 'Что нужно исправить?', replacement: 'Почему замена не подходит?', material: 'Что не подходит в материале?' };
  function decideAs(project, row, ok, comment) {
    const actor = { role: 'client', name: (project.client && project.client.name) || 'Клиент' };
    if (row.how === 'gate') return P.decideGate(project.id, row.taskId, row.pointId, ok ? 'approve' : 'return', comment, actor);
    if (row.how === 'replacement') return P.decideReplacement(project.id, row.id, ok ? 'approve' : 'reject', comment, actor);
    if (row.how === 'material') return P.decideMaterial(project.id, row.id, ok ? 'approved' : 'rejected', actor, comment);
    return P.decideApproval(project.id, row.id, ok ? 'approved' : 'rejected', comment, actor);
  }
  function DecideBar({ project, row }) {
    const [fix, setFix] = React.useState(false);
    const [txt, setTxt] = React.useState('');
    const [err, setErr] = React.useState('');
    const run = (ok) => {
      if (!ok && !txt.trim()) { setErr('Напишите, что исправить'); return; }
      try { decideAs(project, row, ok, ok ? '' : txt.trim()); setErr(''); } catch (e) { setErr(e.message); }
    };
    return (
      <div style={{ marginTop: 14 }}>
        {fix && <textarea className="cv-ta" rows={3} placeholder={ASK[row.how]} value={txt} onChange={(e) => setTxt(e.target.value)} autoFocus />}
        {err && <div className="att" style={{ marginTop: 8 }}><Ic n="alert" s={16} /><span>{err}</span></div>}
        <div className="cv-dbtn" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 10, flexWrap: 'wrap' }}>
          {fix
            ? <React.Fragment><button type="button" className="btn-l" onClick={() => { setFix(false); setErr(''); }}>Отмена</button><button type="button" className="btn-k" style={{ height: 40, fontSize: 13 }} onClick={() => run(false)}>Отправить правки</button></React.Fragment>
            : <React.Fragment><button type="button" className="btn-l" onClick={() => setFix(true)}>Нужны правки</button><button type="button" className="btn-g" style={{ height: 40, fontSize: 13 }} onClick={() => run(true)}>Согласовать</button></React.Fragment>}
        </div>
      </div>);
  }
  // окно «Согласования»: фотоконтроль — во всю ширину, фото крупно лентой и в просмотре со стрелками; акты, допсоглашения, замены и материалы — карточками по 3; решённые — списком ниже
  // карточка решения/материала: маленькое фото, жирное название сверху, тип строкой ниже, сумма, сроки — парами «подпись — значение»
  function Meta({ rows }) {
    const list = rows.filter((r) => r && r[1]);
    return list.length ? <dl className="cv-meta">{list.map((r) => <div key={r[0]}><dt>{r[0]}</dt><dd className={r[2] ? 'hot' : ''}>{r[1]}</dd></div>)}</dl> : null;
  }
  function CardHead({ cover, title, sub }) {
    return (
      <div className="cv-chead">
        {cover !== undefined && <span className="th">{cover ? <img src={cover} alt="" /> : <Ic n="camera" c="#6E6E73" s={18} />}</span>}
        <div style={{ minWidth: 0 }}><div className="t">{title}</div>{sub && <div className="lab" style={{ marginTop: 3 }}>{sub}</div>}</div>
      </div>);
  }
  function DecisionCard({ project, a }) {
    const type = cap(String(a.kindLabel || '').split(' · ')[0]);
    const rest = String(a.kindLabel || '').split(' · ').slice(1).join(' · ');
    return (
      <div className={'card cv-dec' + (overPay(a) ? ' is-over' : '')}>
        <CardHead cover={a.kind === 'material' || a.kind === 'replacement' ? (a.cover || null) : undefined} title={a.t} sub={[type, rest].filter(Boolean).join(' · ')} />
        {a.sum ? <div className="num cv-sum">{a.kind === 'change' && a.sum > 0 ? '+' : ''}{rub(a.sum)}</div> : null}
        {a.decideBy ? <div style={{ marginTop: 10 }}><DueTag r={a} /></div> : null}
        <Meta rows={[['Поставка', a.lead ? days(a.lead) : ''], ['Пришло', a.when]]} />
        <DecideBar project={project} row={a} />
      </div>);
  }
  function ApprovalsModal({ project, onClose }) {
    React.useEffect(() => { const k = (e) => e.key === 'Escape' && onClose(); document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }, []);
    const [view, setView] = React.useState(null);
    const d = P.views.clientDecisions(project);
    const dl = P.views.clientDeliveries(project);
    const acc = P.views.objectAccess(project);
    const vis = P.views.clientVisit(project);
    const visAsk = vis.active && vis.active.state === 'offered';
    const punchFix = (project.punch || []).filter((x) => x.state === 'fixed');
    // счёт — как у кнопки «Нужно ваше решение» на главной; оплаты, заказы «покупаете вы», документы и дизайн — своей секцией
    const W = P.views.clientWidgets(project).approvals;
    const extra = W.all.filter((r) => ['invoice', 'order', 'doc', 'design'].includes(r.kind));
    // срок и просрочка карточки — та же строка, что в списке «Согласований» на главной (раздел 9)
    const due = (kind, id) => W.all.find((x) => x.kind === kind && x.refId === id) || null;
    const photos = d.photo.map((r) => ({ r, due: due('photo', r.taskId) })).sort((a, b) => ((b.due && b.due.overdueDays) || 0) - ((a.due && a.due.overdueDays) || 0));
    const n = W.n;
    const val = (v) => (v === true ? 'да' : v === false ? 'нет' : Array.isArray(v) ? v.length + ' фото' : (v || '—'));
    return (
      <React.Fragment>
        <div className="cv-scrim" onClick={onClose} />
        <div className="cv-modal" role="dialog" aria-modal="true" aria-label="Согласования">
          <div className="cv-mhead">
            <div><div className="lab">{n ? n + ' ' + plural(n, 'ждёт', 'ждут', 'ждут') + ' вашего решения' : 'от вас сейчас ничего не нужно'}</div><h1 className="h1" style={{ marginTop: 10 }}>Согласования</h1></div>
            <button type="button" className="x" aria-label="Закрыть" onClick={onClose}><Ic n="x" /></button>
          </div>
          <p className="cv-lede" style={{ marginTop: 16, fontSize: 16 }}>Согласуйте — или верните с комментарием, что поправить. Следующие работы начинаются только после вашего решения по фотоконтролю.</p>
          <div className="cv-apr">
            <section>
              <h2 className="h2" style={{ fontSize: 20 }}>Фотоконтроль</h2>
              {d.photo.length === 0 && <div className="lab" style={{ marginTop: 10 }}>Фотоотчётов на вашем решении нет.</div>}
              {photos.map(({ r, due: pd }) => {
                const pics = r.photos.map((ph) => ({ url: ph.url, kind: 'image', name: ph.name || '', work: r.title + (r.pointName ? ' · ' + r.pointName : ''), date: r.when ? 'сдано ' + r.when : '' }));
                return (
                  <div key={r.key} className="card cv-gate" style={{ marginTop: 14 }}>
                    <div className="t" style={{ fontSize: 17, fontWeight: 600 }}>{r.title}{r.pointName ? ' · ' + r.pointName : ''}</div>
                    <div className="lab" style={{ marginTop: 3 }}>Фотоконтроль{r.when ? ' · сдано ' + r.when : ''}{pics.length ? ' · ' + pics.length + ' фото' : ''}</div>
                    {pd && pd.decideBy ? <div style={{ marginTop: 10 }}><DueTag r={pd} /></div> : null}
                    {pics.length > 0 && <div className="cv-strip">{pics.map((ph, i) => <button type="button" key={ph.url + i} onClick={() => setView({ items: pics, at: i })} aria-label={'Открыть фото ' + (i + 1) + ' из ' + pics.length}><img src={ph.url} alt={ph.name} /></button>)}</div>}
                    {r.checks.length > 0 && <div className="cv-items">{r.checks.map((c, i) => <div key={i} className="cv-item"><span>{c.label}</span><span className="lab">{cap(val(c.value))}</span></div>)}</div>}
                    <DecideBar project={project} row={r} />
                  </div>);
              })}
            </section>
            {punchFix.length > 0 && <section>
              <h2 className="h2" style={{ fontSize: 20 }}>Приёмка объекта</h2>
              <div className="card cv-dec" style={{ marginTop: 14 }}>
                <CardHead title={punchFix.length + ' ' + plural(punchFix.length, 'замечание исправлено', 'замечания исправлены', 'замечаний исправлено')} sub="Проверьте на фото или на месте и примите — или верните с комментарием" />
                <div className="cv-dbtn" style={{ display: 'flex', marginTop: 14 }}><a className="btn-k" style={{ height: 40, fontSize: 13 }} href="#handover">Проверить</a></div>
              </div>
            </section>}
            {visAsk && <section>
              <h2 className="h2" style={{ fontSize: 20 }}>Визит на объект</h2>
              <div className="card cv-dec" style={{ marginTop: 14 }}>
                <CardHead title="Выберите время визита" sub={vis.active.slots.length + ' ' + plural(vis.active.slots.length, 'вариант', 'варианта', 'вариантов') + ' от прораба · ближайший ' + vis.active.slots.map((x) => x.dateR)[0]} />
                <div className="cv-dbtn" style={{ display: 'flex', marginTop: 14 }}><a className="btn-k" style={{ height: 40, fontSize: 13 }} href="#visit">Выбрать время</a></div>
              </div>
            </section>}
            {acc.needNow && <section>
              <h2 className="h2" style={{ fontSize: 20 }}>Объект и доступ</h2>
              <div className="card cv-dec" style={{ marginTop: 14 }}>
                <CardHead title="Ключи, часы работ, пропуска" sub={acc.overdue ? 'Работы на объекте уже начались — заполните сегодня' : 'Нужно до старта работ на объекте · ' + acc.startR} />
                <div className="lab" style={{ marginTop: 8 }}>Две минуты: где взять ключи, когда можно шуметь, нужен ли пропуск. Бригада приедет без звонков и ожидания у подъезда.</div>
                <div className="cv-dbtn" style={{ display: 'flex', marginTop: 14 }}><a className="btn-k" style={{ height: 40, fontSize: 13 }} href="#access">Заполнить</a></div>
              </div>
            </section>}
            {dl.waiting > 0 && <section>
              <h2 className="h2" style={{ fontSize: 20 }}>Доставки</h2>
              <div className="lab" style={{ marginTop: 6 }}>Подтвердите окно приезда и данные пропуска — или предложите другое время.</div>
              <div className="cv-cards" style={{ marginTop: 14 }}>{dl.rows.filter((r) => r.needAnswer).map((r) => { const dd = due('delivery', r.id); return (
                <div key={r.id} className="card cv-dec">
                  <CardHead title={'Доставка к «' + r.stage + '»'} sub={'Доставка · ' + (r.accessL || '')} />
                  {dd ? <div style={{ marginTop: 10 }}><DueTag r={dd} /></div> : null}
                  <Meta rows={[['Окно', dlWin(r), true], ['Машина', r.vehicle && [r.vehicle.make, r.vehicle.plate].filter(Boolean).join(', ')], ['Что везут', r.cargo]]} />
                  <div className="cv-dbtn" style={{ display: 'flex', marginTop: 14 }}><a className="btn-k" style={{ height: 40, fontSize: 13 }} href="#deliveries">Подтвердить</a></div>
                </div>); })}</div>
            </section>}
            {extra.length > 0 && <section>
              <h2 className="h2" style={{ fontSize: 20 }}>Оплаты, покупки и подписи</h2>
              <div className="cv-cards" style={{ marginTop: 14 }}>{extra.map((r) => (
                <div key={r.kind + r.refId} className={'card cv-dec' + (overPay(r) ? ' is-over' : '')}>
                  <CardHead title={r.title} sub={cap(r.kindL) + (r.sum ? ' · ' + rub(r.sum) : '')} />
                  <div style={{ marginTop: 10 }}><DueTag r={r} /></div>
                  <div className="cv-dbtn" style={{ display: 'flex', marginTop: 14 }}>
                    <button type="button" className={r.kind === 'invoice' ? 'btn-pay' : 'btn-k'} style={{ height: 40, fontSize: 13 }} onClick={() => goApr(r, (k) => { window.location.hash = k; })}>
                      {r.kind === 'invoice' ? 'Оплатить' : r.kind === 'order' ? 'Отметить покупку' : r.kind === 'doc' ? 'Открыть документ' : 'Открыть дизайн-проект'}</button>
                  </div>
                </div>))}</div>
            </section>}
            <section>
              <h2 className="h2" style={{ fontSize: 20 }}>Документы и материалы</h2>
              {d.pending.length === 0 && <div className="lab" style={{ marginTop: 10 }}>Документов и материалов на вашем решении нет.</div>}
              {d.pending.some((a) => a.decideBy) && <div className="lab" style={{ marginTop: 6 }}>Срок «решить до» учитывает поставку, чтобы этап начался вовремя.</div>}
              <div className="cv-cards" style={{ marginTop: 14 }}>{d.pending.map((a) => <DecisionCard key={a.kind + a.id} project={project} a={a} />)}</div>
            </section>
          </div>
          {!n && <div className="note-s" style={{ marginTop: 24 }}><Ic n="check" c="#2F5D3A" s={16} /><span>Ничего не ждёт вашего решения.</span></div>}
          {d.resolved.length > 0 && <section style={{ marginTop: 32 }}>
            <h2 className="h2" style={{ fontSize: 20 }}>Решённые</h2>
            <div style={{ marginTop: 10 }}>{d.resolved.map((a) => (
              <div key={a.kind + a.id} className="cv-tot" style={{ alignItems: 'baseline' }}>
                <span>{a.t}<span className="lab"> · {cap(a.kindLabel)}{a.decidedAt ? ' · ' + a.decidedAt : ''}{a.status === 'rejected' && a.comment ? ' · ' + a.comment : ''}</span></span>
                <span className={a.status === 'approved' ? 'pill-g' : 'pill-d'} style={{ whiteSpace: 'nowrap' }}>{a.status === 'approved' ? 'Согласовано' : 'Правки'}</span>
              </div>))}</div>
          </section>}
          <div className="lab" style={{ marginTop: 20 }}>{acc.filled ? <React.Fragment>Карточка <a href="#access" style={{ textDecoration: 'underline' }}>«Объект и доступ»</a> заполнена. </React.Fragment> : null}Договоры на ознакомление — в <a href="#docs" style={{ textDecoration: 'underline' }}>«Документах»</a>{project.requiresDesign ? <React.Fragment>, этапы дизайн-проекта — в <a href={designHref('stages')} style={{ textDecoration: 'underline' }}>«Дизайне»</a></React.Fragment> : null}.</div>
        </div>
        {view && <Lightbox items={view.items} at={view.at} onClose={() => setView(null)} />}
      </React.Fragment>);
  }

  // окно «Объект и доступ» (спека волны 2, раздел 5): ключи, УК, часы работ, пропуска, лифт — один раз до старта работ на объекте;
  // после заполнения — карточка для чтения с кнопкой «Изменить». Видят менеджер и прораб, в чат не дублируется
  const ACC0 = { keys: { where: '', holder: '' }, mc: { name: '', phone: '' }, hours: { weekdays: '9:00–19:00', weekend: '' }, pass: { crew: false, vehicles: false, note: '' }, elevator: { protect: false, note: '' }, note: '' };
  const accInit = (a) => { const o = {}; Object.keys(ACC0).forEach((k) => { o[k] = typeof ACC0[k] === 'object' ? Object.assign({}, ACC0[k], a && a[k]) : ((a && a[k]) || ''); }); return o; };
  function AccessModal({ project, onClose }) {
    React.useEffect(() => { const k = (e) => e.key === 'Escape' && onClose(); document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }, []);
    const v = P.views.objectAccess(project);
    const [edit, setEdit] = React.useState(!v.filled);
    const [f, setF] = React.useState(() => accInit(v.data));
    const [err, setErr] = React.useState('');
    const actor = { role: 'client', name: (project.client && project.client.name) || 'Клиент' };
    const set = (k, sub) => (e) => { const val = e.target.type === 'checkbox' ? e.target.checked : e.target.value; setF(Object.assign({}, f, { [k]: sub ? Object.assign({}, f[k], { [sub]: val }) : val })); };
    const save = () => { try { P.setObjectAccess(project.id, f, actor); setErr(''); setEdit(false); } catch (e) { setErr(e.message); } };
    const a = v.data;
    const passL = a ? [a.pass.crew ? 'бригаде' : '', a.pass.vehicles ? 'машинам' : ''].filter(Boolean).join(' и ') : '';
    return (
      <React.Fragment>
        <div className="cv-scrim" onClick={onClose} />
        <div className="cv-modal" role="dialog" aria-modal="true" aria-label="Объект и доступ" style={{ maxWidth: 760 }}>
          <div className="cv-mhead">
            <div><div className="lab">{v.filled ? 'заполнено ' + v.filledAtR : v.start ? 'нужно до ' + v.startR : 'до старта работ'}</div><h1 className="h1" style={{ marginTop: 10 }}>Объект и доступ</h1></div>
            <button type="button" className="x" aria-label="Закрыть" onClick={onClose}><Ic n="x" /></button>
          </div>
          <p className="cv-lede" style={{ marginTop: 16, fontSize: 16 }}>Где взять ключи, когда можно работать и нужен ли пропуск. Видят менеджер и прораб — бригада приедет без звонков и ожидания у подъезда.</p>
          {v.overdue && !v.filled && <div className="att" style={{ marginTop: 14 }}><Ic n="alert" s={16} /><span>Работы на объекте уже начались — заполните, пожалуйста, сегодня.</span></div>}
          {!edit && a && <React.Fragment>
            <dl className="cv-meta" style={{ marginTop: 18 }}>
              {[['Ключи', [a.keys.where, a.keys.holder].filter(Boolean).join(' · ')], ['УК / консьерж', [a.mc.name, a.mc.phone].filter(Boolean).join(' · ')],
                ['Часы работ', 'будни ' + a.hours.weekdays + (a.hours.weekend ? ' · выходные ' + a.hours.weekend : ' · в выходные не работаем')],
                ['Пропуск', passL ? 'нужен ' + passL + (a.pass.note ? ' · ' + a.pass.note : '') : (a.pass.note || 'не нужен')],
                ['Лифт', [a.elevator.protect ? 'защищать грузовой лифт' : '', a.elevator.note].filter(Boolean).join(' · ')], ['Комментарий', a.note]]
                .filter((x) => x[1]).map((x) => <div key={x[0]}><dt>{x[0]}</dt><dd>{x[1]}</dd></div>)}
            </dl>
            <div className="lab" style={{ marginTop: 12 }}>{a.role === 'client' ? 'Заполнили вы' : 'Заполнил менеджер с ваших слов'} · {v.filledAtR}</div>
            <div className="cv-dbtn" style={{ display: 'flex', marginTop: 18 }}><button type="button" className="btn-l" onClick={() => { setF(accInit(a)); setEdit(true); }}>Изменить</button></div>
          </React.Fragment>}
          {edit && <div className="cv-dlform cv-accform">
            <div className="cv-dlrow">
              <label><span className="lab">Где ключи *</span><input value={f.keys.where} onChange={set('keys', 'where')} placeholder="У консьержа, 1 подъезд" /></label>
              <label><span className="lab">У кого или как передать</span><input value={f.keys.holder} onChange={set('keys', 'holder')} placeholder="Анна, +7 …" /></label>
            </div>
            <div className="cv-dlrow">
              <label><span className="lab">УК или консьерж</span><input value={f.mc.name} onChange={set('mc', 'name')} placeholder="ООО «Дом-сервис»" /></label>
              <label><span className="lab">Телефон</span><input value={f.mc.phone} onChange={set('mc', 'phone')} placeholder="+7 495 …" /></label>
            </div>
            <div className="cv-dlrow">
              <label><span className="lab">Часы работ в будни *</span><input value={f.hours.weekdays} onChange={set('hours', 'weekdays')} placeholder="9:00–19:00, тихий час 13–15" /></label>
              <label><span className="lab">В выходные</span><input value={f.hours.weekend} onChange={set('hours', 'weekend')} placeholder="Пусто — не работаем" /></label>
            </div>
            <div className="cv-dlrow">
              <label className="ck"><input type="checkbox" checked={f.pass.crew} onChange={set('pass', 'crew')} /><span>Пропуск бригаде</span></label>
              <label className="ck"><input type="checkbox" checked={f.pass.vehicles} onChange={set('pass', 'vehicles')} /><span>Пропуск машинам</span></label>
              <label className="ck"><input type="checkbox" checked={f.elevator.protect} onChange={set('elevator', 'protect')} /><span>Защищать грузовой лифт</span></label>
            </div>
            {(f.pass.crew || f.pass.vehicles) && <label><span className="lab">Как оформить пропуск</span><input value={f.pass.note} onChange={set('pass', 'note')} placeholder="Заявка в УК за сутки, ФИО и паспорт" /></label>}
            {f.elevator.protect && <label><span className="lab">Правила УК по лифту</span><input value={f.elevator.note} onChange={set('elevator', 'note')} placeholder="Фанера на пол и стены, ключ у консьержа" /></label>}
            <label><span className="lab">Что ещё важно знать</span><input value={f.note} onChange={set('note')} placeholder="Домофон, парковка, соседи снизу" /></label>
            {err && <div className="att"><Ic n="alert" s={16} /><span>{err}</span></div>}
            <div className="cv-dbtn">{v.filled && <button type="button" className="btn-l" onClick={() => { setEdit(false); setErr(''); }}>Отмена</button>}<button type="button" className="btn-g" style={{ height: 40, fontSize: 13 }} onClick={save}>Сохранить</button></div>
          </div>}
        </div>
      </React.Fragment>);
  }

  // окно «Визит на объект» (спека волны 2, раздел 5, п. 5): попросить визит → выбрать один из слотов прораба → назначено;
  // отменить можно с причиной. Визит появляется в календаре клиента и команды
  function VisitModal({ project, onClose }) {
    React.useEffect(() => { const k = (e) => e.key === 'Escape' && onClose(); document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }, []);
    const v = P.views.clientVisit(project);
    const a = v.active;
    const [note, setNote] = React.useState('');
    const [pick, setPick] = React.useState(() => (a && a.state === 'offered' ? a.slots.findIndex((x) => !x.past) : -1));
    const [cancel, setCancel] = React.useState(null);
    const [err, setErr] = React.useState('');
    const actor = { role: 'client', name: (project.client && project.client.name) || 'Клиент' };
    const run = (fn) => { try { fn(); setErr(''); setNote(''); setCancel(null); } catch (e) { setErr(e.message); } };
    const head = !a ? 'приедем вместе с прорабом' : a.state === 'requested' ? 'запрос отправлен' : a.state === 'offered' ? 'прораб предложил время' : 'назначен';
    const cancelBox = cancel !== null && (
      <div className="cv-dlform">
        <textarea className="cv-ta" rows={2} placeholder="Почему отменяете — прораб перестроит день" value={cancel} onChange={(e) => setCancel(e.target.value)} autoFocus />
        <div className="cv-dbtn"><button type="button" className="btn-l" onClick={() => { setCancel(null); setErr(''); }}>Не отменять</button><button type="button" className="btn-k" style={{ height: 40, fontSize: 13 }} onClick={() => run(() => P.cancelVisit(project.id, a.id, cancel, actor))}>Отменить визит</button></div>
      </div>);
    return (
      <React.Fragment>
        <div className="cv-scrim" onClick={onClose} />
        <div className="cv-modal" role="dialog" aria-modal="true" aria-label="Визит на объект" style={{ maxWidth: 760 }}>
          <div className="cv-mhead">
            <div><div className="lab">{head}</div><h1 className="h1" style={{ marginTop: 10 }}>{a && a.state === 'booked' ? a.chosenR : 'Визит на объект'}</h1></div>
            <button type="button" className="x" aria-label="Закрыть" onClick={onClose}><Ic n="x" /></button>
          </div>
          {!a && <React.Fragment>
            <p className="cv-lede" style={{ marginTop: 16, fontSize: 16 }}>Хотите посмотреть, как идут работы? Напишите, что важно увидеть, — прораб предложит два-три варианта времени, вы выберете удобный.</p>
            <div className="cv-dlform">
              <textarea className="cv-ta" rows={3} placeholder="Например: посмотреть разводку электрики до штукатурки" value={note} onChange={(e) => setNote(e.target.value)} />
              {err && <div className="att"><Ic n="alert" s={16} /><span>{err}</span></div>}
              <div className="cv-dbtn"><button type="button" className="btn-g" style={{ height: 40, fontSize: 13 }} onClick={() => run(() => P.requestVisit(project.id, note, actor))}>Попросить визит</button></div>
            </div>
          </React.Fragment>}
          {a && a.state === 'requested' && <React.Fragment>
            <p className="cv-lede" style={{ marginTop: 16, fontSize: 16 }}>Прораб предложит время — обычно в течение рабочего дня. Мы пришлём уведомление.</p>
            {a.reqNote && <div className="lab" style={{ marginTop: 10 }}>Ваш комментарий: {a.reqNote}</div>}
            {cancel === null ? <div className="cv-dbtn" style={{ display: 'flex', marginTop: 18 }}><button type="button" className="btn-l" onClick={() => setCancel('')}>Отменить запрос</button></div> : cancelBox}
          </React.Fragment>}
          {a && a.state === 'offered' && <React.Fragment>
            <p className="cv-lede" style={{ marginTop: 16, fontSize: 16 }}>Выберите удобное время — прораб встретит вас на объекте.</p>
            {a.offerNote && <div className="lab" style={{ marginTop: 8 }}>Прораб: «{a.offerNote}»</div>}
            <div className="cv-vslots" role="radiogroup" aria-label="Время визита">{a.slots.map((x, i) => (
              <button type="button" key={i} role="radio" aria-checked={pick === i} disabled={x.past} className={'cv-vslot' + (pick === i ? ' on' : '')} onClick={() => setPick(i)}>
                <span className="d">{x.dateR}</span><span className="lab">{x.past ? 'уже прошёл' : x.from + '–' + x.to}</span>
              </button>))}</div>
            <div className="cv-dlform">
              <label><span className="lab">Комментарий прорабу</span><input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Например: буду с мужем, на машине" /></label>
              {err && <div className="att"><Ic n="alert" s={16} /><span>{err}</span></div>}
              {cancel === null
                ? <div className="cv-dbtn"><button type="button" className="btn-l" onClick={() => setCancel('')}>Не подходит</button><button type="button" className="btn-g" style={{ height: 40, fontSize: 13 }} disabled={pick < 0} onClick={() => run(() => P.bookVisit(project.id, a.id, pick, note, actor))}>Выбрать это время</button></div>
                : null}
            </div>
            {cancelBox}
          </React.Fragment>}
          {a && a.state === 'booked' && <React.Fragment>
            <dl className="cv-meta" style={{ marginTop: 18 }}>
              {[['Когда', a.chosenR], ['Где', project.address || project.title], ['Встретит', 'прораб' + (a.offeredBy && a.offeredBy !== 'Прораб' ? ' · ' + a.offeredBy : '')], ['Ваш комментарий', a.clientNote]]
                .filter((x) => x[1]).map((x) => <div key={x[0]}><dt>{x[0]}</dt><dd>{x[1]}</dd></div>)}
            </dl>
            <div className="lab" style={{ marginTop: 12 }}>На объекте идёт ремонт: возьмите удобную обувь, каску и бахилы выдаст прораб.</div>
            {cancel === null ? <div className="cv-dbtn" style={{ display: 'flex', marginTop: 18 }}><button type="button" className="btn-l" onClick={() => setCancel('')}>Отменить визит</button></div> : cancelBox}
          </React.Fragment>}
          {err && cancel !== null && <div className="att" style={{ marginTop: 10 }}><Ic n="alert" s={16} /><span>{err}</span></div>}
          {v.past.length > 0 && <section style={{ marginTop: 32 }}>
            <h2 className="h2" style={{ fontSize: 20 }}>Прошлые визиты</h2>
            <div style={{ marginTop: 10 }}>{v.past.map((x) => <div key={x.id} className="cv-tot"><span>{x.chosenR}</span><span className="pill-g" style={{ whiteSpace: 'nowrap' }}>Были на объекте</span></div>)}</div>
          </section>}
        </div>
      </React.Fragment>);
  }

  // окно «Приёмка объекта» (спека волны 2, раздел 5, п. 6): замечания по комнатам с фото; исправленные прорабом — принять, вернуть
  // с комментарием или перенести в гарантию; финальный акт подписывается, когда нерешённых замечаний нет
  const ROOMS = ['Кухня', 'Гостиная', 'Спальня', 'Детская', 'Кабинет', 'Ванная', 'Санузел', 'Коридор', 'Балкон', 'Вся квартира'];
  const PN_ST = { open: ['pill-d', 'Открыто'], fixed: ['pill-k', 'Исправлено — проверьте'], accepted: ['pill-g', 'Принято'], warranty: ['pill-g', 'В гарантии'] };
  function PunchCard({ project, it, onView }) {
    const [mode, setMode] = React.useState(null); // 'return' | 'defer'
    const [txt, setTxt] = React.useState('');
    const [err, setErr] = React.useState('');
    const actor = { role: 'client', name: (project.client && project.client.name) || 'Клиент' };
    const run = (fn) => { try { fn(); setErr(''); setMode(null); setTxt(''); } catch (e) { setErr(e.message); } };
    const pics = (list, what) => (list || []).map((ph) => ({ url: ph.url, kind: 'image', name: ph.name, work: it.room + ' · ' + it.text, date: what }));
    const before = pics(it.photos, 'замечание · ' + it.atR), after = it.fix ? pics(it.fix.photos, 'исправлено · ' + it.fixAtR) : [];
    const all = before.concat(after);
    const [cls, lab] = PN_ST[it.state];
    return (
      <div className={'card cv-pn' + (it.state === 'fixed' ? ' is-ask' : '')}>
        <div className="hd"><div style={{ minWidth: 0 }}><div className="t">{it.text}</div><div className="lab" style={{ marginTop: 3 }}>{it.room} · {it.role === 'client' ? 'вы' : it.role === 'foreman' ? 'прораб' : 'менеджер'}, {it.atR}{it.returns ? ' · возвращали ' + it.returns + ' ' + plural(it.returns, 'раз', 'раза', 'раз') : ''}</div></div><span className={cls}>{lab}</span></div>
        {all.length > 0 && <div className="cv-pnph">{all.map((ph, i) => <button type="button" key={ph.url + i} className={i >= before.length ? 'fx' : ''} onClick={() => onView({ items: all, at: i })} aria-label={(i >= before.length ? 'Фото исправления ' : 'Фото замечания ') + (i + 1)}><img src={ph.url} alt="" /></button>)}</div>}
        {it.state === 'open' && it.lastReturn && <div className="lab" style={{ marginTop: 10 }}>Вы вернули: «{it.lastReturn}»</div>}
        {it.fix && it.state !== 'open' && <div className="lab" style={{ marginTop: 10 }}>Прораб: {it.fix.note || 'исправлено'} · {it.fixAtR}</div>}
        {it.state === 'warranty' && <div className="lab" style={{ marginTop: 6 }}>Перенесено в гарантию: {it.warrantyReason}</div>}
        {mode && <textarea className="cv-ta" style={{ marginTop: 12 }} rows={2} autoFocus value={txt} onChange={(e) => setTxt(e.target.value)} placeholder={mode === 'return' ? 'Что не так с исправлением' : 'Почему переносим в гарантию — например, ждём деталь от производителя'} />}
        {err && <div className="att" style={{ marginTop: 8 }}><Ic n="alert" s={16} /><span>{err}</span></div>}
        {(it.state === 'open' || it.state === 'fixed') && <div className="cv-dbtn" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12, flexWrap: 'wrap' }}>
          {mode
            ? <React.Fragment><button type="button" className="btn-l" onClick={() => { setMode(null); setErr(''); }}>Отмена</button><button type="button" className="btn-k" style={{ height: 40, fontSize: 13 }} onClick={() => run(() => (mode === 'return' ? P.returnPunchItem(project.id, it.id, txt, actor) : P.deferPunchItem(project.id, it.id, txt, actor)))}>{mode === 'return' ? 'Вернуть прорабу' : 'Перенести в гарантию'}</button></React.Fragment>
            : <React.Fragment>
              <button type="button" className="btn-l" onClick={() => setMode('defer')}>В гарантию</button>
              {it.state === 'fixed' && <button type="button" className="btn-l" onClick={() => setMode('return')}>Вернуть</button>}
              {it.state === 'fixed' && <button type="button" className="btn-g" style={{ height: 40, fontSize: 13 }} onClick={() => run(() => P.acceptPunchItem(project.id, it.id, actor))}>Принять</button>}
            </React.Fragment>}
        </div>}
      </div>);
  }
  function HandoverModal({ project, onClose }) {
    React.useEffect(() => { const k = (e) => e.key === 'Escape' && onClose(); document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }, []);
    const pl = P.views.punchList(project);
    const [f, setF] = React.useState({ room: ROOMS[0], text: '', photos: [] });
    const [busy, setBusy] = React.useState(false);
    const [err, setErr] = React.useState('');
    const [view, setView] = React.useState(null);
    const [showDone, setShowDone] = React.useState(false);
    const fileRef = React.useRef(null);
    const actor = { role: 'client', name: (project.client && project.client.name) || 'Клиент' };
    const attach = (files) => {
      if (!files || !files.length || !window.Files) return;
      setBusy(true);
      window.Files.uploadAll([...files], { pid: project.id, kind: 'punch' }).then((up) => { setF((x) => Object.assign({}, x, { photos: x.photos.concat(up) })); setBusy(false); }).catch((e) => { setErr(e.message); setBusy(false); });
    };
    const add = () => { try { P.addPunchItem(project.id, f, actor); setF({ room: f.room, text: '', photos: [] }); setErr(''); } catch (e) { setErr(e.message); } };
    const left = pl.counts.open + pl.counts.fixed;
    const by = (st) => pl.items.filter((x) => x.state === st);
    const done = by('accepted').concat(by('warranty'));
    return (
      <React.Fragment>
        <div className="cv-scrim" onClick={onClose} />
        <div className="cv-modal" role="dialog" aria-modal="true" aria-label="Приёмка объекта">
          <div className="cv-mhead">
            <div><div className="lab">{pl.stage} · {left ? left + ' ' + plural(left, 'замечание', 'замечания', 'замечаний') + ' в работе' : pl.items.length ? 'все замечания закрыты' : 'замечаний пока нет'}</div><h1 className="h1" style={{ marginTop: 10 }}>Приёмка объекта</h1></div>
            <button type="button" className="x" aria-label="Закрыть" onClick={onClose}><Ic n="x" /></button>
          </div>
          <p className="cv-lede" style={{ marginTop: 16, fontSize: 16 }}>Пройдите объект вместе с прорабом и запишите всё, что нужно доделать: комната, что не так, фото. Прораб исправит и пришлёт фото, вы примете. Финальный акт подписывается, когда все замечания приняты или перенесены в гарантию.</p>
          <div className="cv-pnsum">
            {[['Открыто', pl.counts.open], ['Ждут вашей проверки', pl.counts.fixed], ['Принято', pl.counts.accepted], ['В гарантии', pl.counts.warranty]].map((x) => <div key={x[0]}><div className="num">{x[1]}</div><div className="lab">{x[0]}</div></div>)}
          </div>
          {pl.signed
            ? <div className="note-s" style={{ marginTop: 18 }}><Ic n="check" c="#2F5D3A" s={16} /><span>Финальный акт подписан, объект сдан. Если что-то проявится позже — <a href="#warranty" style={{ textDecoration: 'underline' }}>оформите гарантийное обращение</a>.</span></div>
            : pl.finalAct && pl.canSign ? <div className="note-s" style={{ marginTop: 18 }}><Ic n="check" c="#2F5D3A" s={16} /><span>Все замечания закрыты — <a href="#approvals" style={{ textDecoration: 'underline' }}>подпишите финальный акт</a>.</span></div>
            : pl.finalAct ? <div className="att" style={{ marginTop: 18 }}><Ic n="alert" s={16} /><span>Финальный акт готов, но подписать его можно после того, как закроются {left} {plural(left, 'замечание', 'замечания', 'замечаний')}.</span></div> : null}
          {!pl.signed && <div className="card cv-pnadd">
            <div className="t" style={{ fontSize: 17, fontWeight: 600 }}>Новое замечание</div>
            <div className="cv-dlform" style={{ marginTop: 10, paddingTop: 0, borderTop: 0 }}>
              <div className="cv-rooms" role="radiogroup" aria-label="Комната">{ROOMS.map((r) => <button type="button" key={r} role="radio" aria-checked={f.room === r} className={f.room === r ? 'on' : ''} onClick={() => setF(Object.assign({}, f, { room: r }))}>{r}</button>)}</div>
              <textarea className="cv-ta" rows={2} value={f.text} onChange={(e) => setF(Object.assign({}, f, { text: e.target.value }))} placeholder="Что не так: например, скол на плитке у мойки" />
              {f.photos.length > 0 && <div className="cv-pnph">{f.photos.map((ph, i) => <button type="button" key={ph.url + i} onClick={() => setF(Object.assign({}, f, { photos: f.photos.filter((_, j) => j !== i) }))} aria-label="Убрать фото"><img src={ph.url} alt="" /></button>)}</div>}
              {err && <div className="att"><Ic n="alert" s={16} /><span>{err}</span></div>}
              <div className="cv-dbtn">
                {window.Files && <React.Fragment><button type="button" className="btn-l" disabled={busy} onClick={() => fileRef.current && fileRef.current.click()}><Ic n="camera" s={16} /> {busy ? 'Загружаем…' : 'Фото'}</button><input ref={fileRef} type="file" accept="image/*" multiple hidden onChange={(e) => { attach(e.target.files); e.target.value = ''; }} /></React.Fragment>}
                <button type="button" className="btn-g" style={{ height: 40, fontSize: 13 }} onClick={add}>Добавить замечание</button>
              </div>
            </div>
          </div>}
          {by('fixed').length > 0 && <section style={{ marginTop: 28 }}><h2 className="h2" style={{ fontSize: 20 }}>Ждут вашей проверки</h2><div className="cv-pnlist">{by('fixed').map((it) => <PunchCard key={it.id} project={project} it={it} onView={setView} />)}</div></section>}
          {by('open').length > 0 && <section style={{ marginTop: 28 }}><h2 className="h2" style={{ fontSize: 20 }}>В работе у прораба</h2><div className="cv-pnlist">{by('open').map((it) => <PunchCard key={it.id} project={project} it={it} onView={setView} />)}</div></section>}
          {done.length > 0 && <section style={{ marginTop: 28 }}>
            <button type="button" className="btn-l" onClick={() => setShowDone(!showDone)}>{showDone ? 'Скрыть закрытые' : 'Закрытые · ' + done.length}</button>
            {showDone && <div className="cv-pnlist">{done.map((it) => <PunchCard key={it.id} project={project} it={it} onView={setView} />)}</div>}
          </section>}
        </div>
        {view && <Lightbox items={view.items} at={view.at} onClose={() => setView(null)} />}
      </React.Fragment>);
  }

  // окно «Смета»: согласованная смета проекта — общий компонент сметы (estimate-view.jsx, тот же у прораба и офиса): фазы → этапы → работы
  // и материалы этапа, оплачено, история изменений; только чтение
  function EstimateModal({ project, onClose }) {
    React.useEffect(() => { const k = (e) => e.key === 'Escape' && onClose(); document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }, []);
    const e = P.views.projectEstimate(project, 'client');
    const PE = window.ProjectEstimate;
    // «По комнатам» (lk/estimate-rooms-view.jsx, KSH-502): переключатель — у проекта с комнатами; вид встаёт в .pe-main, итоги «Оплачено / Осталось» справа остаются
    const [view, setView] = React.useState('all');
    const RV = window.EstimateRoomsView, TG = window.EstimateViewToggle, ER = window.EstimateRooms;
    const rooms = RV && TG && ER ? ER.forProject(project) : null;
    return (
      <React.Fragment>
        <div className="cv-scrim" onClick={onClose} />
        <div className="cv-modal" role="dialog" aria-modal="true" aria-label="Смета">
          <div className="cv-mhead">
            <div><div className="lab">{e.signed ? 'согласована · договор подписан' : 'согласована · договор на подписании'}{e.dealId ? ' · сделка ' + e.dealId : ''}</div><h1 className="h1" style={{ marginTop: 10 }}>Смета</h1></div>
            <button type="button" className="x" aria-label="Закрыть" onClick={onClose}><Ic n="x" /></button>
          </div>
          <div style={{ marginTop: 28 }}>
            {rooms ? <TG value={view} onChange={setView} /> : null}
            {PE ? <PE project={project} lens="client" main={rooms && view === 'rooms' ? <RV data={rooms} rub={rub} /> : undefined} note={'Аванс ' + e.advancePct + '% от работ. Дальше оплата по фазам: счёт приходит вместе с актом. Цена договора меняется только через допсоглашение, которое вы согласуете.'} />
              : <div className="lab">Смета загружается…</div>}
          </div>
        </div>
      </React.Fragment>);
  }

  /* окно «Гарантия» (спека волны 2, раздел 5, п. 8): после подписания финального акта — обращение по этапу (комната, описание, фото);
     у каждого обращения шаги «отправлено → принято → выезд → устранено» и своя переписка с менеджером. Видно, действует ли гарантия этапа */
  const WR_PILL = { new: ['pill-k', 'ждёт ответа менеджера'], accepted: ['pill-d', 'принято'], visit: ['pill-d', 'выезд назначен'], fixed: ['pill-g', 'устранено'], declined: ['pill-d', 'не гарантийный случай'] };
  function WarrantyModal({ project, onClose }) {
    React.useEffect(() => { const k = (e) => e.key === 'Escape' && onClose(); document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }, []);
    const w = P.views.clientWarranty(project);
    const [f, setF] = React.useState({ stageId: '', room: '', text: '', photos: [] });
    const [busy, setBusy] = React.useState(false);
    const [err, setErr] = React.useState('');
    const [talk, setTalk] = React.useState(null);
    const [view, setView] = React.useState(null);
    const fileRef = React.useRef(null);
    const actor = { role: 'client', name: (project.client && project.client.name) || 'Клиент' };
    const st = w.stages.find((x) => x.stageId === f.stageId);
    const attach = (files) => {
      if (!files || !files.length || !window.Files) return;
      setBusy(true);
      window.Files.uploadAll([...files], { pid: project.id, kind: 'warranty' }).then((up) => { setF((x) => Object.assign({}, x, { photos: x.photos.concat(up) })); setBusy(false); }).catch((e) => { setErr(e.message); setBusy(false); });
    };
    const send = () => { try { const id = P.openWarrantyCase(project.id, f, actor); setF({ stageId: '', room: '', text: '', photos: [] }); setErr(''); setTalk(id); } catch (e) { setErr(e.message); } };
    const threads = talk ? P.views.clientChat(project) : [];
    const pics = (list) => list.map((x) => ({ url: x.url, kind: 'image' }));
    return (
      <React.Fragment>
        <div className="cv-scrim" onClick={onClose} />
        <div className="cv-modal" role="dialog" aria-modal="true" aria-label="Гарантия">
          <div className="cv-mhead">
            <div><div className="lab">{w.open ? w.open + ' ' + plural(w.open, 'обращение', 'обращения', 'обращений') + ' в работе' : 'гарантийных обращений в работе нет'}</div><h1 className="h1" style={{ marginTop: 10 }}>Гарантия</h1></div>
            <button type="button" className="x" aria-label="Закрыть" onClick={onClose}><Ic n="x" /></button>
          </div>
          {!w.canOpen
            ? <div className="att" style={{ marginTop: 18 }}><Ic n="alert" s={16} /><span>Гарантийные обращения открываются после подписания финального акта. Пока объект на сдаче — <a href="#handover" style={{ textDecoration: 'underline' }}>запишите замечание в приёмку</a>.</span></div>
            : <React.Fragment>
              <p className="cv-lede" style={{ marginTop: 16, fontSize: 16 }}>Что-то проявилось после сдачи — выберите этап работ, опишите и приложите фото. Менеджер ответит в переписке по обращению, согласует выезд мастера и отметит, когда всё устранено. Сроки гарантии по этапам — в <a href="#docs" style={{ textDecoration: 'underline' }}>«Документах» → «Сдача объекта»</a>.</p>
              <div className="card cv-pnadd">
                <div className="t" style={{ fontSize: 17, fontWeight: 600 }}>Новое обращение</div>
                <div className="cv-dlform" style={{ marginTop: 10, paddingTop: 0, borderTop: 0 }}>
                  <label className="lab">Этап работ
                    <select value={f.stageId} onChange={(e) => setF(Object.assign({}, f, { stageId: e.target.value }))}>
                      <option value="">— выберите этап —</option>
                      {w.stages.map((x) => <option key={x.stageId} value={x.stageId}>{x.stage}{x.active ? ' · гарантия до ' + x.untilR : ' · гарантия закончилась'}</option>)}
                    </select>
                  </label>
                  {st && !st.active && <div className="att"><Ic n="alert" s={16} /><span>Гарантия на этот этап закончилась{st.untilR ? ' ' + st.untilR : ''}. Менеджер всё равно ответит — скорее всего, предложит платный выезд.</span></div>}
                  <div className="cv-rooms" role="radiogroup" aria-label="Комната">{ROOMS.map((r) => <button type="button" key={r} role="radio" aria-checked={f.room === r} className={f.room === r ? 'on' : ''} onClick={() => setF(Object.assign({}, f, { room: f.room === r ? '' : r }))}>{r}</button>)}</div>
                  <textarea className="cv-ta" rows={2} value={f.text} onChange={(e) => setF(Object.assign({}, f, { text: e.target.value }))} placeholder="Что случилось: например, отошла плитка у ванны" />
                  {f.photos.length > 0 && <div className="cv-pnph">{f.photos.map((ph, i) => <button type="button" key={ph.url + i} onClick={() => setF(Object.assign({}, f, { photos: f.photos.filter((_, j) => j !== i) }))} aria-label="Убрать фото"><img src={ph.url} alt="" /></button>)}</div>}
                  {err && <div className="att"><Ic n="alert" s={16} /><span>{err}</span></div>}
                  <div className="cv-dbtn">
                    {window.Files && <React.Fragment><button type="button" className="btn-l" disabled={busy} onClick={() => fileRef.current && fileRef.current.click()}><Ic n="camera" s={16} /> {busy ? 'Загружаем…' : 'Фото'}</button><input ref={fileRef} type="file" accept="image/*" multiple hidden onChange={(e) => { attach(e.target.files); e.target.value = ''; }} /></React.Fragment>}
                    <button type="button" className="btn-g" style={{ height: 40, fontSize: 13 }} onClick={send}>Отправить менеджеру</button>
                  </div>
                </div>
              </div>
            </React.Fragment>}
          {w.cases.length > 0 && <section style={{ marginTop: 28 }}>
            <h2 className="h2" style={{ fontSize: 20 }}>Ваши обращения</h2>
            <div className="cv-pnlist cv-wrlist">{w.cases.map((c) => (
              <div key={c.id} className="card cv-pn">
                <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}><span className="lab">{c.stage}{c.room ? ' · ' + c.room : ''} · {c.atR}</span><span className={WR_PILL[c.state][0]}>{WR_PILL[c.state][1]}</span></div>
                <div style={{ fontSize: 16, marginTop: 10 }}>{c.text}</div>
                {!c.inWarranty && <div className="lab" style={{ marginTop: 4 }}>гарантия на этап к моменту обращения закончилась</div>}
                {c.photos.length > 0 && <div className="cv-pnph" style={{ marginTop: 10 }}>{c.photos.map((ph, i) => <button type="button" key={ph.url + i} onClick={() => setView({ items: pics(c.photos), at: i })} aria-label="Открыть фото"><img src={ph.url} alt="" /></button>)}</div>}
                {c.steps.length > 0 && <ol className="cv-wrsteps">{c.steps.map((x) => <li key={x.s} className={x.done ? 'on' : ''}><span className="dot" /><span>{x.label}</span><span className="lab">{x.s === 'visit' && c.visitR ? c.visitR : x.at}</span></li>)}</ol>}
                {c.fix && <div className="note-s" style={{ marginTop: 12 }}><Ic n="check" c="#2F5D3A" s={16} /><span>Устранено: {c.fix.note}</span></div>}
                {c.fix && c.fix.photos.length > 0 && <div className="cv-pnph" style={{ marginTop: 8 }}>{c.fix.photos.map((ph, i) => <button type="button" key={ph.url + i} className="fx" onClick={() => setView({ items: pics(c.fix.photos), at: i })} aria-label="Открыть фото"><img src={ph.url} alt="" /></button>)}</div>}
                {c.state === 'declined' && <div className="lab" style={{ marginTop: 10 }}>Менеджер: {c.declineReason}</div>}
                <div className="cv-dbtn" style={{ display: 'flex', marginTop: 12 }}><button type="button" className="btn-l" onClick={() => setTalk(talk === c.id ? null : c.id)}><Ic n="chat" s={16} /> {talk === c.id ? 'Скрыть переписку' : 'Переписка с менеджером'}</button></div>
                {talk === c.id && <div className="cv-wrchat"><ChatConv project={project} active={threads.find((t) => t.id === c.threadId) || null} target={() => c.threadId} /></div>}
              </div>))}</div>
          </section>}
        </div>
        {view && <Lightbox items={view.items} at={view.at} onClose={() => setView(null)} />}
      </React.Fragment>);
  }

  // окно «Чат»: те же треды проекта, что видят менеджер и прораб (ProjectStore.chat*); клиент пишет в свои треды
  // переписка одного треда: сообщения по дням и поле ввода; target() отдаёт id треда (создаёт его система при первом сообщении).
  // Общая для окна «Чат» и вкладки «Обсуждение» страницы «Дизайн-проект»
  // sendFn — своя отправка (чат сделки до запуска, DealChat); readOnly — без поля ввода (сотрудники); noAttach — без вложений
  function ChatConv({ project, active, target, onSent, empty, sendFn, readOnly, noAttach, answerBy }) {
    const [draft, setDraft] = React.useState('');
    const [err, setErr] = React.useState('');
    const endRef = React.useRef(null);
    const fileRef = React.useRef(null);
    React.useEffect(() => { if (endRef.current) endRef.current.scrollIntoView({ block: 'end' }); }, [active && active.msgs.length, active && active.id]);
    const name = (project.client && project.client.name) || 'Клиент';
    const send = (media) => {
      const t = draft.trim(); if (!t && !media) return;
      try { const tid = target(); const msg = { from: 'client', name, t, media: media || null }; if (sendFn) sendFn(tid, msg); else P.chatSend(project.id, tid, msg); if (onSent) onSent(tid); setDraft(''); setErr(''); } catch (e) { setErr(e.message); }
    };
    const attach = (f) => {
      if (!f || !window.Files) return;
      window.Files.upload(f, { pid: project.id, kind: 'chat' }).then((r) => send({ kind: r.kind, name: r.name, url: r.url })).catch((e) => setErr(e.message));
    };
    /* честный срок ответа (KSH-497): кто отвечает в этом треде — answerBy(active), срок — ProjectStore.views.replyEta */
    const eta = active && P.views.replyEta ? P.views.replyEta(active.msgs, answerBy ? answerBy(active) : '') : null;
    let lastDay = '';
    return (
      <div className="cv-conv">
        <div className="lab" style={{ paddingBottom: 10, borderBottom: '1px solid var(--hair)' }}>{active ? 'в чате: вы, ' + active.members.join(', ') : empty || 'новый чат с менеджером'}</div>
        <div className="cv-msgs">
          {active && active.msgs.map((m) => {
            const day = m.day !== lastDay ? (lastDay = m.day) : null;
            return (
              <React.Fragment key={m.id}>
                {day && <div className="lab" style={{ textAlign: 'center', margin: '10px 0' }}>{day}</div>}
                <div className={'cv-msg' + (m.mine ? ' mine' : '')}>
                  {!m.mine && <div className="lab" style={{ marginBottom: 3 }}>{m.who}</div>}
                  {m.t && <div style={{ whiteSpace: 'pre-wrap' }}>{m.t}</div>}
                  {m.media && (m.media.kind === 'image' && m.media.url ? <a href={m.media.url} target="_blank" rel="noopener"><img src={m.media.url} alt={m.media.name || ''} /></a> : <a href={m.media.url || '#'} target="_blank" rel="noopener" style={{ textDecoration: 'underline' }}>{m.media.name || 'файл'}</a>)}
                  <div className="t">{m.time}</div>
                </div>
              </React.Fragment>);
          })}
          {eta && <div className={'cv-eta' + (eta.late ? ' is-late' : '')} role="status"><Ic n="clock" s={15} /><span>{eta.text}</span></div>}
          <div ref={endRef} />
        </div>
        {err && <div className="att" style={{ marginTop: 8 }}><Ic n="alert" s={16} /><span>{err}</span></div>}
        {!readOnly && <div className="cv-compose">
          {window.Files && !noAttach && <React.Fragment><button type="button" className="ic ic-s" aria-label="Прикрепить фото или файл" onClick={() => fileRef.current && fileRef.current.click()}><Ic n="clip" s={17} /></button><input ref={fileRef} type="file" hidden onChange={(e) => { attach(e.target.files[0]); e.target.value = ''; }} /></React.Fragment>}
          <textarea className="cv-ta" rows={1} placeholder="Сообщение" value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }} />
          <button type="button" className="ic ic-k" aria-label="Отправить" onClick={() => send()}><Ic n="arrow" c="#fff" /></button>
        </div>}
      </div>);
  }
  function ChatModal({ project, onClose, initialId }) {
    React.useEffect(() => { const k = (e) => e.key === 'Escape' && onClose(); document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }, []);
    const threads = P.views.clientChat(project);
    const [activeId, setActiveId] = React.useState(initialId && threads.some((t) => t.id === initialId) ? initialId : threads[0] ? threads[0].id : null);
    const active = threads.find((t) => t.id === activeId) || threads[0] || null;
    const target = () => (active ? active.id : P.chatEnsureClientThread(project.id));
    /* отвечает первый из команды, кто есть в треде: менеджер, прораб, дизайнер (имена — из clientTeam, подрядчиков там нет) */
    const team = P.views.clientTeam ? P.views.clientTeam(project) : null;
    const answerBy = (t) => { const by = { 'Менеджер': team && team.manager, 'Прораб': team && team.foreman, 'Дизайнер': team && team.designer }; const m = t.members.map((x) => by[x]).find(Boolean); return m ? m.name : ''; };
    return (
      <React.Fragment>
        <div className="cv-scrim" onClick={onClose} />
        <div className="cv-modal cv-chatm" role="dialog" aria-modal="true" aria-label="Чат">
          <div className="cv-mhead">
            <div><div className="lab">отвечаем пн–пт, 9:00–19:00</div><h1 className="h1" style={{ marginTop: 10 }}>Чат</h1></div>
            <button type="button" className="x" aria-label="Закрыть" onClick={onClose}><Ic n="x" /></button>
          </div>
          <div className="cv-chat">
            <div className="cv-threads">
              {threads.length === 0 && <div className="lab" style={{ padding: '8px 4px' }}>Напишите менеджеру — он ответит здесь и подключит прораба или дизайнера.</div>}
              {threads.map((t) => (
                <button type="button" key={t.id} className={'cv-th' + (active && t.id === active.id ? ' on' : '')} onClick={() => setActiveId(t.id)}>
                  <span style={{ fontSize: 14 }}>{t.title}</span>
                  <span className="lab" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.last || 'нет сообщений'}</span>
                </button>))}
            </div>
            <ChatConv project={project} active={active} target={target} onSent={setActiveId} answerBy={answerBy} />
          </div>
        </div>
      </React.Fragment>);
  }

  // окно «Оплаты»: график платежей; «Я оплатил» — тот же ProjectStore.pay, что в старом кабинете, деньги подтверждает бухгалтерия
  const PAY_STATE = { planned: ['pill-d', 'счёт ещё не выставлен'], due: ['pill-k', 'к оплате'], overdue: ['pill-n cv-over', 'просрочено'], check: ['pill-g', 'на проверке у бухгалтерии'], done: ['pill-g', 'оплачено'] };
  /* «Документы» отдельным окном убраны (Роман, 29.10): акты — в карточках фаз «Реализации», договор, смета и допсоглашения — здесь,
     под платежами. Старые ссылки #docs открывают это окно сразу на документах */
  function PaymentsModal({ project, onClose, focus }) {
    React.useEffect(() => { const k = (e) => e.key === 'Escape' && onClose(); document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }, []);
    const [, tick] = React.useState(0);
    React.useEffect(() => { const up = () => tick((x) => x + 1); window.addEventListener('obitel:deal-docs', up); return () => window.removeEventListener('obitel:deal-docs', up); }, []);
    const docsRef = React.useRef(null);
    React.useEffect(() => { if (focus === 'docs' && docsRef.current) docsRef.current.scrollIntoView({ block: 'start' }); }, [focus]);
    const [err, setErr] = React.useState('');
    const m = P.views.clientMoney(project);
    const d = P.views.clientDocs(project);
    const docs = d.contracts.concat(d.acts.filter((r) => r.type !== 'Акт'));
    const paid = (r) => { try { P.pay(project.id, r.id, { role: 'client', name: (project.client && project.client.name) || 'Клиент' }); setErr(''); } catch (e) { setErr(e.message); } };
    return (
      <React.Fragment>
        <div className="cv-scrim" onClick={onClose} />
        <div className="cv-modal" role="dialog" aria-modal="true" aria-label="Оплаты">
          <div className="cv-mhead">
            <div><div className="lab">{m.payGate ? 'следующий этап начинается после оплаты предыдущего' : 'график по договору'}</div><h1 className="h1" style={{ marginTop: 10 }}>Оплаты</h1></div>
            <button type="button" className="x" aria-label="Закрыть" onClick={onClose}><Ic n="x" /></button>
          </div>
          <div className="cv-w cv-paysum">
            <div><div className="lab">Оплачено</div><div className="stat num">{rub(m.paid)}</div><div className="bar"><i style={{ width: (m.total ? m.paid / m.total * 100 : 0) + '%' }} /></div><div className="lab" style={{ marginTop: 8 }}>подтверждено бухгалтерией</div></div>
            <div><div className="lab">На проверке</div><div className="stat num">{rub(m.check)}</div><div className="lab" style={{ marginTop: 18 }}>вы оплатили, ждём поступления</div></div>
            <div><div className="lab">К оплате сейчас</div><div className="stat num">{rub(m.debt)}</div><div className="lab" style={{ marginTop: 18 }}>выставленные счета</div></div>
            <div><div className="lab">Впереди</div><div className="stat num">{rub(m.planned)}</div><div className="lab" style={{ marginTop: 18 }}>счёт приходит вместе с актом</div></div>
          </div>
          {err && <div className="att" style={{ marginTop: 16 }}><Ic n="alert" s={16} /><span>{err}</span></div>}
          {/* KSH-469: та же карточка, что на главной; оплаченное — тише, «Я оплатил» — зелёной кнопкой оплаты */}
          <div className="cv-w cv-paylist">
            {m.rows.map((r) => (
              <div key={r.id} className={'cv-payrow' + (r.overdueDays > 0 ? ' is-over' : '') + (r.state === 'done' ? ' is-done' : '')}>
                <div style={{ minWidth: 0 }}>
                  <div style={{ fontSize: 15 }}>{r.title}</div>
                  <div className="lab" style={{ marginTop: 3 }}>{r.state === 'done' ? 'оплачено ' + r.paidAt + ' · подтверждено ' + r.verifiedAt : r.state === 'check' ? 'вы оплатили ' + r.paidAt + ' · бухгалтерия подтвердит поступление' : 'срок ' + r.due}{r.opens.length && r.state !== 'done' ? ' · откроет: ' + r.opens.slice(0, 2).join(', ') + (r.opens.length > 2 ? ' и ещё ' + (r.opens.length - 2) : '') : ''}</div>
                </div>
                <span className="num" style={{ fontSize: 18, whiteSpace: 'nowrap' }}>{rub(r.amount)}</span>
                {r.overdueDays > 0 ? <span className="by is-over"><span className="lab">Просрочено</span><b>на {r.overdueDays} {plural(r.overdueDays, 'день', 'дня', 'дней')}</b></span>
                  : r.state === 'done' ? <span className="cv-st-done">✓ оплачено</span> : <span className={PAY_STATE[r.state][0]}>{PAY_STATE[r.state][1]}</span>}
                <span style={{ minWidth: 120, textAlign: 'right' }}>{(r.state === 'due' || r.state === 'overdue') && <button type="button" className="btn-pay" style={{ height: 40, fontSize: 13 }} onClick={() => paid(r)}>Я оплатил</button>}</span>
              </div>))}
          </div>
          <div className="note-s" style={{ marginTop: 20 }}><span>Онлайн-оплаты пока нет: оплатите по реквизитам из счёта и нажмите «Я оплатил». Этап откроется, когда бухгалтерия подтвердит поступление.</span></div>
          <section ref={docsRef} style={{ marginTop: 36 }}>
            <div className="cv-sec-h"><h2 className="h2">Договор и документы</h2><span className="lab">акты — в «Реализации», у своей фазы</span></div>
            <div className="cv-w cv-dlist2">{docs.map((r) => <DocRow key={r.id} project={project} r={r} />)}</div>
          </section>
        </div>
      </React.Fragment>);
  }

  // окно «Доставки» (спека волны 2, раздел 3): анкету заполняет менеджер или закупщик, клиент подтверждает окно и пишет данные пропуска / ключа
  const DL_STATE = { sent: ['Ждёт вашего ответа', 'pill-k'], confirmed: ['Подтверждено', 'pill-g'], reschedule: ['Вы предложили другое время', 'pill-d'], plan: ['Дату уточним', 'pill-d'], done: ['Привезли', 'pill-g'] };
  const dlWin = (r) => r.dateR + (r.from ? ', ' + r.from + (r.to ? '–' + r.to : '') : '');
  function DeliveryAnswer({ project, r }) {
    const c = r.client || {};
    const [f, setF] = React.useState({ passOrdered: !!c.passOrdered, passNumber: c.passNumber || '', keyWhere: c.keyWhere || '', meetBy: c.meetBy || '', note: '' });
    const [alt, setAlt] = React.useState(null);
    const [err, setErr] = React.useState('');
    const actor = { role: 'client', name: (project.client && project.client.name) || 'Клиент' };
    const set = (k) => (e) => setF(Object.assign({}, f, { [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }));
    const needPass = r.access === 'pass' || r.access === 'pass_key', needKey = r.access === 'key' || r.access === 'pass_key';
    const run = (fn) => { try { fn(); setErr(''); } catch (e) { setErr(e.message); } };
    if (alt) {
      return (
        <div className="cv-dlform">
          <div className="lab">Когда вам удобно принять доставку</div>
          <div className="cv-dlrow">
            <label><span className="lab">Дата</span><input type="date" value={alt.date} onChange={(e) => setAlt(Object.assign({}, alt, { date: e.target.value }))} /></label>
            <label><span className="lab">С</span><input type="time" value={alt.from} onChange={(e) => setAlt(Object.assign({}, alt, { from: e.target.value }))} /></label>
            <label><span className="lab">До</span><input type="time" value={alt.to} onChange={(e) => setAlt(Object.assign({}, alt, { to: e.target.value }))} /></label>
          </div>
          <textarea className="cv-ta" rows={2} placeholder="Комментарий: почему не подходит время" value={alt.note} onChange={(e) => setAlt(Object.assign({}, alt, { note: e.target.value }))} />
          {err && <div className="att"><Ic n="alert" s={16} /><span>{err}</span></div>}
          <div className="cv-dbtn"><button type="button" className="btn-l" onClick={() => { setAlt(null); setErr(''); }}>Отмена</button><button type="button" className="btn-k" style={{ height: 40, fontSize: 13 }} onClick={() => run(() => P.rescheduleDeliverySlot(project.id, r.id, alt, alt.note, actor))}>Предложить время</button></div>
        </div>);
    }
    return (
      <div className="cv-dlform">
        {needPass && <div className="cv-dlrow">
          <label className="ck"><input type="checkbox" checked={f.passOrdered} onChange={set('passOrdered')} /><span>Пропуск заказан</span></label>
          <label><span className="lab">Номер пропуска</span><input value={f.passNumber} onChange={set('passNumber')} placeholder="Если уже есть" /></label>
        </div>}
        {needKey && <label><span className="lab">Где взять ключ</span><input value={f.keyWhere} onChange={set('keyWhere')} placeholder="Например, у консьержа" /></label>}
        <label><span className="lab">Кто встретит</span><input value={f.meetBy} onChange={set('meetBy')} placeholder="Вы, консьерж или прораб" /></label>
        <label><span className="lab">Комментарий для водителя</span><input value={f.note} onChange={set('note')} placeholder="Въезд, подъезд, грузовой лифт" /></label>
        {err && <div className="att"><Ic n="alert" s={16} /><span>{err}</span></div>}
        <div className="cv-dbtn"><button type="button" className="btn-l" onClick={() => setAlt({ date: r.date, from: r.from, to: r.to, note: '' })}>Другое время</button><button type="button" className="btn-g" style={{ height: 40, fontSize: 13 }} onClick={() => run(() => P.confirmDeliverySlot(project.id, r.id, f, actor))}>Подтвердить</button></div>
      </div>);
  }
  function DeliveryCard({ project, r }) {
    const [l, cls] = DL_STATE[r.state] || DL_STATE.plan;
    const c = r.client || {};
    return (
      <div className={'cv-w cv-dlcard' + (r.needAnswer ? ' is-ask' : '') + (r.state === 'plan' ? ' is-plan' : '')}>
        <div className="hd">
          <div style={{ minWidth: 0 }}><div className="t">{r.state !== 'plan' ? dlWin(r) : r.date < new Date().toISOString().slice(0, 10) ? 'Дату уточняем' : 'Ориентировочно ' + r.dateR}</div><div className="lab" style={{ marginTop: 3 }}>Доставка к этапу «{r.stage}»</div></div>
          {!r.needAnswer && <span className={'cv-dlst ' + (r.state === 'confirmed' ? 'is-ok' : '')}>{r.state === 'confirmed' ? '✓ ' : ''}{l.toLowerCase()}</span>}
        </div>
        <div className={r.needAnswer ? 'cv-dlbody' : ''}>
        {r.state === 'plan'
          ? <div className="lab" style={{ marginTop: 10 }}>Точное окно приезда пришлёт менеджер или закупщик, вы подтвердите его здесь. Везём: {r.items.map((it) => it.name).join(', ')}.</div>
          : <dl className="cv-meta">
            {[['Что везут', r.cargo], ['Объём', r.volume], ['Машина', r.vehicle && [r.vehicle.make, r.vehicle.plate].filter(Boolean).join(', ')], ['Кто везёт', r.carrier && [r.carrier.kind === 'own' ? 'Наша машина' : r.carrier.name, r.carrier.phone].filter(Boolean).join(' · ')], ['Пропуск и ключ', r.accessL]]
              .filter((x) => x[1]).map((x) => <div key={x[0]}><dt>{x[0]}</dt><dd>{x[1]}</dd></div>)}
            {r.state === 'confirmed' && [['Пропуск', c.passNumber ? '№ ' + c.passNumber : c.passOrdered ? 'Заказан' : ''], ['Ключ', c.keyWhere], ['Встретит', c.meetBy], ['Комментарий', c.note]].filter((x) => x[1]).map((x) => <div key={x[0]}><dt>{x[0]}</dt><dd>{x[1]}</dd></div>)}
            {r.state === 'reschedule' && c.proposed && <div><dt>Вы предложили</dt><dd className="hot">{c.proposed.date.split('-').reverse().slice(0, 2).join('.') + (c.proposed.from ? ', ' + c.proposed.from + (c.proposed.to ? '–' + c.proposed.to : '') : '')}</dd></div>}
          </dl>}
        {r.state === 'reschedule' && <div className="lab" style={{ marginTop: 10 }}>Менеджер согласует новое время и пришлёт анкету заново.</div>}
        {r.needAnswer && <DeliveryAnswer project={project} r={r} />}
        </div>
      </div>);
  }
  function DeliveriesModal({ project, onClose }) {
    React.useEffect(() => { const k = (e) => e.key === 'Escape' && onClose(); document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }, []);
    const v = P.views.clientDeliveries(project);
    const ask = v.rows.filter((r) => r.needAnswer), rest = v.rows.filter((r) => !r.needAnswer && r.state !== 'done'), done = v.rows.filter((r) => r.state === 'done');
    return (
      <React.Fragment>
        <div className="cv-scrim" onClick={onClose} />
        <div className="cv-modal" role="dialog" aria-modal="true" aria-label="Доставки">
          <div className="cv-mhead">
            <div><div className="lab">{v.waiting ? v.waiting + ' ' + plural(v.waiting, 'ждёт', 'ждут', 'ждут') + ' вашего ответа' : 'Материалы на объект по графику этапов'}</div><h1 className="h1" style={{ marginTop: 10 }}>Доставки</h1></div>
            <button type="button" className="x" aria-label="Закрыть" onClick={onClose}><Ic n="x" /></button>
          </div>
          {/* две колонки (Роман, 29.10): слева — компактная анкета, справа — будущие доставки, ниже под ними — привезённые */}
          <div className="cv-dlgrid">
            <aside className="cv-dlside">
              <div className="cv-sec-h"><h2 className="h2">Нужен ваш ответ</h2>{ask.length > 0 && <span className="lab">{ask.length}</span>}</div>
              {ask.length ? ask.map((r) => <DeliveryCard key={r.id} project={project} r={r} />)
                : <div className="cv-w cv-dlcard is-quiet"><div className="lab">Сейчас ничего подтверждать не нужно. Когда менеджер пришлёт окно приезда, анкета появится здесь.</div></div>}
              <div className="lab cv-dlhint">Подтвердите окно приезда и напишите, нужен ли пропуск или ключ. Если время не подходит, предложите своё: работы из-за этого не встанут.</div>
            </aside>
            <div className="cv-dlmain">
              <div className="cv-sec-h"><h2 className="h2">Предстоящие</h2>{rest.length > 0 && <span className="lab">{rest.length}</span>}</div>
              {rest.length ? <div className="cv-dlist one">{rest.map((r) => <DeliveryCard key={r.id} project={project} r={r} />)}</div> : <div className="lab" style={{ marginTop: 12 }}>Запланированных доставок нет.</div>}
              {done.length > 0 && <React.Fragment>
                <div className="cv-sec-h" style={{ marginTop: 36 }}><h2 className="h2">Привезли</h2><span className="lab">{done.length}</span></div>
                <div className="cv-w cv-dlist2">{done.map((r) => <div key={r.id} className="cv-tot is-quiet"><span>Доставка к этапу «{r.stage}»{r.cargo ? <span className="lab"> · {r.cargo}</span> : null}</span><span className="lab">✓ {r.dateR}</span></div>)}</div>
              </React.Fragment>}
            </div>
          </div>
        </div>
      </React.Fragment>);
  }

  // окно «Материалы»: чистовые — карточками (решение тем же DecideBar, что в «Согласованиях»), черновые — списком
  // чистовые — группами по комнате (из спецификации дизайн-проекта), без комнаты — по этапу; порядок групп — как пришли строки (по графику)
  function groupRows(rows) {
    const out = [];
    rows.forEach((r) => { let g = out.find((x) => x.name === r.group); if (!g) out.push(g = { name: r.group, rows: [], pending: [] }); g.rows.push(r); if (r.approval === 'pending') g.pending.push(r); });
    return out;
  }
  // «Согласовать все» по комнате (KSH-537): одно подтверждение на группу; правки — по-прежнему у каждой позиции с комментарием
  function BatchApprove({ project, group }) {
    const [ask, setAsk] = React.useState(false);
    const [err, setErr] = React.useState('');
    const sum = group.pending.reduce((a, r) => a + (+r.sum || 0), 0);
    const run = () => {
      try { P.decideMaterialsBatch(project.id, group.pending.map((r) => r.id), { role: 'client', name: (project.client && project.client.name) || 'Клиент' }); setAsk(false); setErr(''); }
      catch (e) { setErr(e.message); }
    };
    if (!ask) return <button type="button" className="btn-g" style={{ height: 36, fontSize: 13 }} onClick={() => setAsk(true)}>Согласовать все {group.pending.length}</button>;
    return (
      <span className="cv-batch" style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
        <span className="lab">{err || 'Согласовать ' + group.pending.length + ' ' + plural(group.pending.length, 'позицию', 'позиции', 'позиций') + ' на ' + rub(sum) + '?'}</span>
        <button type="button" className="btn-l" onClick={() => { setAsk(false); setErr(''); }}>Отмена</button>
        <button type="button" className="btn-g" style={{ height: 36, fontSize: 13 }} onClick={run}>Да, согласовать</button>
      </span>);
  }
  function MaterialsModal({ project, onClose }) {
    React.useEffect(() => { const k = (e) => e.key === 'Escape' && onClose(); document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }, []);
    const [tab, setTab] = React.useState('finish');
    const m = P.views.clientMaterials(project);
    const rows = m.rows.filter((r) => r.kind === tab);
    return (
      <React.Fragment>
        <div className="cv-scrim" onClick={onClose} />
        <div className="cv-modal" role="dialog" aria-modal="true" aria-label="Материалы">
          <div className="cv-mhead">
            <div><div className="lab">{m.onSiteN} из {m.rows.length} на объекте{m.pendingN ? ' · ' + m.pendingN + ' ждут вашего решения' : ''}</div><h1 className="h1" style={{ marginTop: 10 }}>Материалы</h1></div>
            <button type="button" className="x" aria-label="Закрыть" onClick={onClose}><Ic n="x" /></button>
          </div>
          <div style={{ display: 'flex', gap: 8, marginTop: 20 }}>
            <button type="button" className={tab === 'finish' ? 'pill-k' : 'pill-d'} style={{ cursor: 'pointer', border: tab === 'finish' ? 0 : undefined }} onClick={() => setTab('finish')}>Чистовые · {m.finishN}</button>
            <button type="button" className={tab === 'rough' ? 'pill-k' : 'pill-d'} style={{ cursor: 'pointer', border: tab === 'rough' ? 0 : undefined }} onClick={() => setTab('rough')}>Черновые · {m.roughN}</button>
          </div>
          {tab === 'finish'
            ? <React.Fragment>
                {groupRows(rows).map((g) => (
                  <section key={g.name} style={{ marginTop: 28 }}>
                    <div className="cv-sec-h"><h2 className="h2">{g.name}</h2><span className="lab">{g.rows.length} {plural(g.rows.length, 'позиция', 'позиции', 'позиций')}{g.pending.length ? ' · ждут решения ' + g.pending.length : ''}</span></div>
                    {g.pending.length > 1 && <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 10 }}><BatchApprove project={project} group={g} /></div>}
                    <div className="cv-cards" style={{ marginTop: 14 }}>
                      {g.rows.map((r) => (
                        <div key={r.id} className={'cv-w cv-dec cv-mat' + (r.approval === 'approved' ? ' is-quiet' : '')}>
                          <CardHead cover={r.cover} title={r.name} sub={r.room ? r.room + ' · ' + r.stage : r.stage} />
                          <div className="num cv-sum">{rub(r.sum)}</div>
                          <Meta rows={[['Количество', r.qty + ' ' + r.unit], ['Закупка', cap(r.procL) + (r.eta ? ', привезут ' + r.eta : '')], ['Вместо', r.replacedFrom],
                            ['Решить до', r.approval === 'pending' ? r.decideBy : '', true], ['Поставка', r.approval === 'pending' && r.lead ? days(r.lead) : ''],
                            ['Решение', r.approval === 'approved' ? 'Согласован' : r.approval === 'rejected' ? 'Отправлены правки' : ''], ['Комментарий', r.approval === 'rejected' ? r.comment : '']]} />
                          {r.approval === 'pending' && <DecideBar project={project} row={{ ...r, how: 'material' }} />}
                        </div>))}
                    </div>
                  </section>))}
                {rows.length === 0 && <div className="empty" style={{ marginTop: 24 }}>Чистовых материалов в смете нет.</div>}
              </React.Fragment>
            : <div className="cv-w cv-dlist2" style={{ marginTop: 24 }}>
                {rows.map((r) => (
                  <div key={r.id} className={'cv-tot' + (r.proc === 'on_site' || r.proc === 'installed' ? ' is-quiet' : '')} style={{ alignItems: 'baseline' }}>
                    <span>{r.name}<span className="lab"> · {r.qty} {r.unit} · {r.stage}</span></span>
                    <span style={{ display: 'flex', gap: 12, alignItems: 'center', whiteSpace: 'nowrap' }}><span className="lab">{cap(r.procL)}{r.eta ? ' · ' + r.eta : ''}</span><span className="num">{rub(r.sum)}</span></span>
                  </div>))}
                {rows.length === 0 && <div className="empty">Черновых материалов в смете нет.</div>}
              </div>}
        </div>
      </React.Fragment>);
  }

  // окно «Фото и видео»: только принятые работы, по этапам; клик — просмотр со стрелками по всему этапу
  function MediaModal({ project, onClose }) {
    React.useEffect(() => { const k = (e) => e.key === 'Escape' && onClose(); document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }, []);
    const [view, setView] = React.useState(null);
    const m = P.views.clientMedia(project);
    return (
      <React.Fragment>
        <div className="cv-scrim" onClick={onClose} />
        <div className="cv-modal" role="dialog" aria-modal="true" aria-label="Фото и видео">
          <div className="cv-mhead">
            <div><div className="lab">{m.n} {plural(m.n, 'файл', 'файла', 'файлов')}{m.videos ? ' · ' + m.videos + ' видео' : ''} · только принятые работы</div><h1 className="h1" style={{ marginTop: 10 }}>Фото и видео</h1></div>
            <button type="button" className="x" aria-label="Закрыть" onClick={onClose}><Ic n="x" /></button>
          </div>
          {m.groups.map((g) => (
            <section key={g.stage} style={{ marginTop: 28 }}>
              <div className="cv-sec-h"><h2 className="h2">{g.stage}</h2><span className="lab">{g.n} {plural(g.n, 'файл', 'файла', 'файлов')} · принято {g.date}</span></div>
              <div className="cv-gal cv-gal2">
                {g.items.map((it, i) => (
                  <figure key={it.url}>
                    <button type="button" onClick={() => setView({ items: g.items, at: i })} aria-label={'Открыть: ' + it.work}>
                      {it.kind === 'video' ? <video src={it.url + '#t=0.1'} muted preload="metadata" /> : <img src={it.url} alt="" loading="lazy" />}
                      {it.kind === 'video' && <span className="cv-play" aria-hidden="true"><Ic n="play" s={16} c="#fff" /></span>}
                    </button>
                    <figcaption>{it.work}</figcaption>
                  </figure>))}
              </div>
            </section>))}
          {m.groups.length === 0 && <div className="empty" style={{ marginTop: 24 }}>Фото появятся после приёмки первой работы.</div>}
        </div>
        {view && <Lightbox items={view.items} at={view.at} onClose={() => setView(null)} />}
      </React.Fragment>);
  }

  /* пакет сдачи (спека волны 2, раздел 5, п. 7): раздел «Сдача объекта» в «Документах» — финальный акт, гарантийный талон по этапам,
     исполнительные схемы, фото скрытых коммуникаций по системам, паспорта и памятка по уходу. Появляется вместе с приёмкой */
  function HandoverPack({ project }) {
    const [view, setView] = React.useState(null);
    const [allSt, setAllSt] = React.useState(false);
    const hp = P.views.handoverPack(project);
    if (!hp || !hp.available) return null;
    const fa = hp.finalAct;
    const wGroups = [];
    hp.warranty.forEach((w) => { const key = w.by + '|' + (w.until || ''); let g = wGroups.find((x) => x.key === key); if (!g) wGroups.push(g = { key, by: w.by, untilR: w.untilR, rows: [] }); g.rows.push(w); });
    const files = (list, empty) => list.length ? <div className="cv-hpfiles">{list.map((d) => <a key={d.id} className="card" href={d.url} target="_blank" rel="noopener"><Ic n="doc" s={18} /><span>{d.title}</span><span className="lab">{d.atR}</span></a>)}</div> : <div className="lab" style={{ marginTop: 8 }}>{empty}</div>;
    const sub = (t, body) => <div style={{ marginTop: 22 }}><div className="lab" style={{ marginBottom: 10 }}>{t}</div>{body}</div>;
    return (
      <section style={{ marginTop: 28 }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
          <h2 className="h2" style={{ fontSize: 20 }}>Сдача объекта</h2>
          <span className={fa && fa.signedR ? 'pill-g' : 'pill-d'}>{fa && fa.signedR ? 'финальный акт подписан ' + fa.signedR : 'пакет собирается к подписанию финального акта'}</span>
          {fa && fa.signedR && <a className="btn-l" href="#warranty">Гарантийное обращение</a>}
        </div>
        {sub('Гарантийный талон · ' + hp.warrantyMonths + ' мес. на работы нашей бригады', (
          <div className="cv-wty">
            {wGroups.map((g) => (
              <div key={g.key}>
                <div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline' }}><span style={{ fontWeight: 500 }}>{g.by === 'own' ? 'Работы нашей бригады' : 'Работы партнёра'} · {g.rows.length} {plural(g.rows.length, 'этап', 'этапа', 'этапов')}</span><span className="num">{g.untilR ? 'до ' + g.untilR : 'с подписания финального акта'}</span></div>
                <div className="lab" style={{ marginTop: 6, lineHeight: 1.5 }}>{(allSt || g.rows.length <= 6 ? g.rows : g.rows.slice(0, 6)).map((w) => w.stage).join(', ')}{!allSt && g.rows.length > 6 && <React.Fragment>{' '}<button type="button" className="lnk" onClick={() => setAllSt(true)}>и ещё {g.rows.length - 6}</button></React.Fragment>}</div>
              </div>))}
            {!hp.warranty.length && <div className="lab">Этапы появятся после старта работ.</div>}
          </div>))}
        {sub('Исполнительная схема электрики', files(hp.schemes.electric, 'Прораб загрузит схему к сдаче объекта.'))}
        {sub('Исполнительная схема сантехники', files(hp.schemes.plumbing, 'Прораб загрузит схему к сдаче объекта.'))}
        {hp.comms.map((c) => sub('Скрытые коммуникации · ' + c.label + ' · ' + c.photos.length + ' фото', (
          <div className="cv-gal">{c.photos.map((ph, i) => (
            <button type="button" key={ph.url + i} onClick={() => setView({ items: c.photos.map((x) => ({ url: x.url, kind: 'image', work: [x.room, x.label].filter(Boolean).join(' · '), date: x.at })), at: i })} aria-label={'Открыть: ' + (ph.room || c.label)}>
              <img src={ph.url} alt="" loading="lazy" /><span className="cap">{[ph.room, ph.label].filter(Boolean).join(' · ')}</span>
            </button>))}</div>)))}
        {hp.docs.length > 0 && sub('Паспорта и инструкции', files(hp.docs))}
        {hp.care.length > 0 && sub('Памятка по уходу', <div className="cv-care">{hp.care.map((c) => <div key={c.title}><div style={{ fontWeight: 500 }}>{c.title}</div><div className="lab" style={{ marginTop: 4, lineHeight: 1.5 }}>{c.text}</div></div>)}</div>)}
        {view && <Lightbox items={view.items} at={view.at} onClose={() => setView(null)} />}
      </section>);
  }

  // окно «Документы»: карточки по 3 в ряд — договоры и смета, акты и допсоглашения, счета; решения — теми же действиями, что в «Согласованиях» и «Оплатах»
  const DOC_STATE = { pending: ['pill-k', 'ждёт вашего решения'], acked: ['pill-d', 'вы согласны · подпишем при встрече'], signed: ['pill-g', 'подписан'], rejected: ['pill-d', 'отправлены правки'],
    due: ['pill-k', 'к оплате'], overdue: ['pill-n cv-over', 'просрочен'], check: ['pill-g', 'на проверке у бухгалтерии'], done: ['pill-g', 'оплачен'] };
  /* строка документа (KSH-469): иконка, название, тип · дата, сумма, статус одним словом; действия — маленькие ↓ ↗,
     решение (акт, допсоглашение) — тем же DecideBar, что в «Согласованиях», под строкой */
  const DOC_IC = { 'Договор': 'doc', 'Смета': 'doc', 'Акт': 'doc', 'Допсоглашение': 'doc', 'Счёт': 'wallet' };
  function DocRow({ project, r }) {
    const [err, setErr] = React.useState('');
    const st = r.how === 'deal' ? ['is-ask', 'прочитайте и подтвердите'] : r.state === 'pending' && !r.how ? ['', 'на подписании'] : DOC_STATE[r.state];
    const quiet = r.state === 'signed' || r.state === 'done';
    const ack = () => { try { window.DealContracts.clientAck(project.dealId, r.dealKind, (project.client && project.client.name) || 'client'); setErr(''); } catch (e) { setErr(e.message); } };
    const kind = r.type === 'Счёт' ? 'bill' : 'act';
    return (
      <div className={'cv-drow' + (quiet ? ' is-quiet' : '') + (r.how ? ' is-ask' : '')}>
        <div className="cv-drow-m">
          <span className="ic"><Ic n={DOC_IC[r.type] || 'doc'} s={15} c="currentColor" /></span>
          <span className="tx"><span className="t">{r.title}</span><span className="lab">{r.type}{r.date ? ' · ' + r.date : ''}{r.state === 'rejected' && r.comment ? ' · ваш комментарий: ' + r.comment : ''}</span></span>
          <span className="num s">{r.sum != null ? rub(r.sum) : r.type === 'Счёт' ? 'сумма уточняется' : ''}</span>
          <span className={'st ' + (st ? st[0].replace(/pill-\S+/g, '') : '')}>{st ? (quiet ? '✓ ' : '') + st[1] : ''}</span>
          <span className="acts">
            {r.file ? <a className="cv-pdoc-a" href={r.file} target="_blank" rel="noopener" download aria-label={'Скачать: ' + r.title} title="Скачать"><Ic n="down" s={14} c="currentColor" /></a>
              : (r.type === 'Акт' || r.type === 'Счёт') && <button type="button" className="cv-pdoc-a" onClick={() => printDoc(project, kind, { title: r.title, amount: r.sum, date: r.date })} aria-label={'Скачать: ' + r.title} title="Скачать"><Ic n="down" s={14} c="currentColor" /></button>}
            {r.how === 'estimate' && <a className="cv-arrow-btn" href="#estimate">Открыть<Ic n="ne" s={13} c="currentColor" /></a>}
            {r.how === 'pay' && <a className="cv-arrow-btn" href="#payments">К оплате<Ic n="ne" s={13} c="currentColor" /></a>}
            {r.how === 'deal' && !ACCESS.readOnly && <button type="button" className="cv-arrow-btn" onClick={ack}>Ознакомлен, согласен<Ic n="ne" s={13} c="currentColor" /></button>}
          </span>
        </div>
        {err && <div className="att" style={{ marginTop: 8 }}><Ic n="alert" s={16} /><span>{err}</span></div>}
        {r.how === 'approval' && <DecideBar project={project} row={r} />}
      </div>);
  }
  const DocCard = DocRow; // имя для кабинета до запуска (client-v2-prestart.jsx)
  /* недельный отчёт (спека волны 2, раздел 5, п. 3): прошедшая неделя пн–вс из данных проекта — принятые работы с фото,
     что начнётся дальше, что ждёт клиента, доставки и переносы сроков. Стрелки — соседние недели */
  function ReportModal({ project, onClose }) {
    const [w, setW] = React.useState(null);
    const [view, setView] = React.useState(null);
    React.useEffect(() => { const k = (e) => e.key === 'Escape' && !view && onClose(); document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }, [view]);
    const r = P.views.clientWeeklyReport(project, w);
    const i = r.from ? r.weeks.findIndex((x) => x.from === r.from) : -1;
    const sec = (title, body) => <section style={{ marginTop: 32 }}><div className="cv-sec-h"><h2 className="h2">{title}</h2></div><div className="cv-w cv-dlist2">{body}</div></section>;
    const photos = r.from ? r.accepted.flatMap((a) => a.photos.map((url) => ({ id: a.id, url, kind: 'image', work: a.name, date: a.date }))) : [];
    return (
      <React.Fragment>
        <div className="cv-scrim" onClick={onClose} />
        <div className="cv-modal" role="dialog" aria-modal="true" aria-label="Отчёт за неделю">
          <div className="cv-mhead">
            <div><div className="lab">Отчёт за неделю · собирается по понедельникам из данных проекта</div><h1 className="h1" style={{ marginTop: 10 }}>{r.from ? r.label : 'Отчёт за неделю'}</h1></div>
            <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
              {r.from && <button type="button" className="ic ic-l" aria-label="Неделя раньше" disabled={i >= r.weeks.length - 1} onClick={() => setW(r.weeks[i + 1].from)}><Ic n="left" /></button>}
              {r.from && <button type="button" className="ic ic-l" aria-label="Неделя позже" disabled={i <= 0} onClick={() => setW(r.weeks[i - 1].from)}><Ic n="right" /></button>}
              <button type="button" className="x" aria-label="Закрыть" onClick={onClose}><Ic n="x" /></button>
            </div>
          </div>
          {!r.from ? <div className="empty" style={{ marginTop: 28 }}>Первый отчёт придёт в понедельник, {r.firstAtR}: что принято за неделю, что начнётся дальше и что ждёт вас.</div> : (
            <React.Fragment>
              {/* выполненные работы — главное в отчёте (Роман, 29.10): каждая работа строкой со своими фото, по этапам */}
              {r.accepted.length ? (
                <section style={{ marginTop: 32 }}>
                  <div className="cv-sec-h"><h2 className="h2">Выполнено и принято</h2><span className="lab">{r.accepted.length} {plural(r.accepted.length, 'работа', 'работы', 'работ')}{r.photosN ? ' · ' + r.photosN + ' фото' : ''}</span></div>
                  <div className="cv-w cv-dlist2 cv-rpw">
                    {r.accepted.map((a) => { const at = photos.findIndex((ph) => ph.id === a.id);
                      return (
                      <div key={a.id} className="cv-rpw-row">
                        <span className="ok" aria-hidden="true"><Ic n="check" s={12} c="#fff" /></span>
                        <div className="tx"><div className="t">{a.name}</div><div className="lab">Этап «{a.stage}» · принято {a.date}</div></div>
                        <div className="cv-rpw-ph">{a.photos.slice(0, 4).map((url, k) => <button key={k} type="button" onClick={() => setView({ items: photos, at: at + k })} aria-label={'Фото: ' + a.name}><img src={url} alt="" loading="lazy" />{k === 3 && a.photos.length > 4 && <b>+{a.photos.length - 4}</b>}</button>)}
                          {!a.photos.length && <span className="lab">без фото</span>}</div>
                      </div>); })}
                  </div>
                </section>) : sec('Выполнено и принято', <div className="lab">За эту неделю работ не принимали.</div>)}
              {sec('Дальше · ' + r.nextLabel, r.next.length ? (
                <React.Fragment>
                  {r.next.map((t) => <div key={t.id} className="cv-tot"><span>{t.name}<span className="lab"> · {t.stage}</span></span><span className="lab">старт {t.date}</span></div>)}
                  {r.nextN > r.next.length && <div className="lab" style={{ marginTop: 8 }}>и ещё {r.nextN - r.next.length} — в <a href="#calendar" style={{ textDecoration: 'underline' }}>календаре</a></div>}
                </React.Fragment>) : <div className="lab">Новых работ на этой неделе не начинается.</div>)}
              {sec('Ждёт вас', r.payments.length || r.decisions.length ? (
                <React.Fragment>
                  {r.payments.map((x) => <a key={x.id} href="#payments" className="cv-tot"><span>{x.title}<span className="lab"> · {x.status === 'planned' ? 'счёт придёт ' + x.due : 'срок ' + x.due}</span></span><span className="cv-arrow-btn num" style={{ whiteSpace: 'nowrap' }}>{rub(x.amount)}<Ic n="ne" s={13} c="currentColor" /></span></a>)}
                  {r.decisions.map((d, k) => <a key={k} href="#approvals" className="cv-tot"><span>{d.title}<span className="lab"> · {d.sub}</span></span><span className="cv-arrow-btn">Решить<Ic n="ne" s={13} c="currentColor" /></span></a>)}
                </React.Fragment>) : <div className="lab">От вас ничего не нужно.</div>)}
              {r.deliveries.length > 0 && sec('Доставки', r.deliveries.map((d) => <a key={d.id} href="#deliveries" className="cv-tot"><span>К этапу «{d.stage}»<span className="lab">{d.from ? ' · ' + d.from + '–' + d.to : ' · время уточним'}</span></span><span className="lab">{d.date}</span></a>))}
              {r.shifts.length > 0 && sec('Сроки', r.shifts.map((x, k) => <div key={k} className="cv-tot"><span>Этап «{x.stageName}»: {x.from} → {x.to}<span className="lab"> · {x.reason}</span></span></div>))}
            </React.Fragment>)}
        </div>
        {view && <Lightbox items={view.items} at={view.at} onClose={() => setView(null)} />}
      </React.Fragment>);
  }

  // доступ по ссылке — как в старом кабинете (lk-bootstrap): ссылка чужой роли, закрытый аккаунт, неизвестная ссылка ?t=, чужой
  // ?project=. Открытие экрана — в активность (W3), но не при «ЛК от имени», не без проекта и не когда страница уже уходит
  function useAccess(modal, project) {
    const acc = A && A.currentAccount ? A.currentAccount() : null;
    const why = ACCESS.why;
    const track = !!(acc && !why && !ACCESS.readOnly && (project || ACCESS.dealId) && A.trackActivity && CC);
    React.useEffect(() => { if (track && !window.__CLIENT_CABINET_LEAVING) A.trackActivity(acc.profileId, CC.zoneOf(modal)); }, [modal, track]);
    return why;
  }
  function Denied({ why, hint }) {
    return (
      <div className="cv-page">
        <header className="cv-head"><span className="cv-logo">ОБИТЕЛЬ</span></header>
        <section className="cv-hero"><div><h1 className="cv-hello">Нет доступа</h1><p className="cv-lede" style={{ marginTop: 16 }}>{why} {hint || ACCESS.hint || 'Обратитесь к вашему менеджеру.'}</p></div></section>
      </div>);
  }

  function App() {
    const project = useProjectData();
    const [modal, open, close] = useHashModal();
    const why = useAccess(modal, project);
    if (window.__CLIENT_CABINET_LEAVING) return null; // client-cabinet.js уже уводит (включён старый, проекта ещё нет)
    if (why) return <Denied why={why} hint={ACCESS.hint} />;
    if (!P) return <div className="cv-page"><div className="empty">Кабинет не загрузился. Обновите страницу.</div></div>;
    if (!project) {
      // проект ещё не запущен: главная, шаги, меню и окна по сделке — client-v2-prestart.jsx (в этом же кабинете, с той же ссылкой)
      const Pre = window.CV && window.CV.Pre;
      if (ACCESS.dealId && Pre) return <Pre.PreApp dealId={ACCESS.dealId} modal={modal} open={open} close={close} />;
      return (
        <div className="cv-page">
          <header className="cv-head"><span className="cv-logo">ОБИТЕЛЬ</span></header>
          <section className="cv-hero"><div><h1 className="cv-hello">{greeting()}</h1><p className="cv-lede" style={{ marginTop: 16 }}>Проект ещё не запущен.</p></div></section>
        </div>);
    }
    // раньше дизайн-проект был окном #design — старые ссылки ведут на страницу, во вкладку «Этапы»
    // #design-<вкладка> (например, из календаря #design-schedule) — сразу в нужную вкладку
    if (modal === 'design' || modal.indexOf('design-') === 0) { window.location.replace(designHref(modal.slice(7) || 'stages')); return null; }
    const h = P.views.clientHome(project);
    return (
      <React.Fragment>
        <Home h={h} w={P.views.clientWidgets(project)} open={open} project={project} />
        {modal === 'stages' && <StagesModal project={project} onClose={close} />}
        {modal === 'approvals' && <ApprovalsModal project={project} onClose={close} />}
        {modal === 'calendar' && <CalendarModal project={project} onClose={close} />}
        {modal === 'deliveries' && <DeliveriesModal project={project} onClose={close} />}
        {modal === 'estimate' && <EstimateModal project={project} onClose={close} />}
        {(modal === 'chat' || modal.indexOf('chat=') === 0) && <ChatModal key={modal} project={project} onClose={close} initialId={modal.indexOf('chat=') === 0 ? decodeURIComponent(modal.slice(5)) : null} />}
        {(modal === 'payments' || modal === 'docs') && <PaymentsModal project={project} onClose={close} focus={modal} />}
        {modal === 'materials' && <MaterialsModal project={project} onClose={close} />}
        {modal === 'media' && <MediaModal project={project} onClose={close} />}
        {modal === 'report' && <ReportModal project={project} onClose={close} />}
        {modal === 'access' && <AccessModal project={project} onClose={close} />}
        {modal === 'visit' && <VisitModal project={project} onClose={close} />}
        {modal === 'handover' && <HandoverModal project={project} onClose={close} />}
        {modal === 'warranty' && <WarrantyModal project={project} onClose={close} />}
        {/* «Добро пожаловать в команду» (KSH-498): аванс подтверждён бухгалтером — один раз, поверх главной (client-v2-prestart.jsx) */}
        {project.dealId && window.CV.Pre && window.CV.Pre.WelcomeGate ? React.createElement(window.CV.Pre.WelcomeGate, { dealId: project.dealId, hidden: !!modal }) : null}
      </React.Fragment>);
  }

  // общие куски для страницы «Дизайн-проект» (client-v2-design.jsx); там же своя отрисовка вместо App
  /* __CV_PAGE: 'design' — страница дизайн-проекта монтирует себя сама; 'prestart' — главную монтирует client-v2-prestart.jsx
     (грузится после этого файла и дополняет window.CV); без метки (демо) — монтируем здесь */
  window.CV = { StagesModal, P, SV, ACCESS, SEARCH, App, Ic, Header, Lightbox, ChatConv, TeamBlock, useProjectData, useHashModal, useAccess, Denied, DocCard, rub, cap, plural, cabHref, designHref, greeting, todayLine };
  if (!window.__CV_PAGE) ReactDOM.createRoot(document.getElementById('root')).render(<App />);
})();
