/* global React, Icon, Button, Card, Avatar, Dropdown, Field, TextField, plural */
// Артель CRM — раздел «Задачи».
//
// Задачи живут ТОЛЬКО на сервере: витрины в window.CRM_DATA под них нет (мок-данных
// не заводили), поэтому раздел сам ходит в /tasks и сам держит состояния «грузим /
// пусто / не загрузилось». По той же причине в меню у пункта нет счётчика — считать
// его, как соседние, из CRM_DATA не из чего, а бейдж с неправдой хуже отсутствующего.

// Статус рисуем точкой с подписью, как статусы сотрудников в Staff.jsx: список задач
// длинный и однотипный, цветные плашки в каждой строке читались бы как тревога.
//
// У двух ЗАКРЫТЫХ статусов точка заменена маркером ФАКТА — тем же, каким у занятия видно,
// состоялось оно или нет («задача должна иметь маркера произошло, не произошло — на события
// типа», 08:37). Задача со сроком-часом стоит в сетке рядом с занятиями, и канцелярские
// «Выполнена / Отменена» рядом с ними читались как состояние бумаги, а не как то, что
// произошло на самом деле. Схема прежняя: это те же done и cancelled, поменялись подпись
// и значок.
const TASK_STATUS = {
  open:        { label: 'Открыта',      dot: '#1E3A8A', fg: '#234381' },
  in_progress: { label: 'В работе',     dot: '#9A7B33', fg: '#6A5320' },
  done:        { label: 'Произошло',    dot: '#2F6B4F', fg: '#214E39', mark: 'check', hint: 'Задача выполнена' },
  cancelled:   { label: 'Не произошло', dot: '#A6A296', fg: '#7E8595', mark: 'x',     hint: 'Задача отменена' },
};
// Приоритет: заметен только высокий. «Обычный» стоит у большинства задач — если его
// красить, выделение перестаёт что-либо значить.
const TASK_PRIORITY = {
  high:   { label: 'Высокий', bg: 'var(--coral-bg)', fg: 'var(--coral-ink)', bd: '#E6C9C4' },
  normal: { label: 'Обычный', bg: 'var(--bg-soft)',  fg: 'var(--text-secondary)', bd: 'var(--border)' },
  low:    { label: 'Низкий',  bg: 'transparent',     fg: 'var(--text-dim)', bd: 'var(--border)' },
};
const PRIORITY_ORDER = ['low', 'normal', 'high'];
// Подпись сущности, к которой прицеплена задача. Ссылок отсюда пока нет — только пометка,
// чтобы задача «позвонить» не выглядела оторванной от клиента, из карточки которого её завели.
const ENTITY_LABEL = { client: 'Клиент', lead: 'Заявка', lesson: 'Занятие' };

// Принадлежность задачи (tasks.kind): рабочая видна в режиме «Рабочий», личная — в «Личное»,
// так же, как это давно устроено у занятий (lessons.kind). Рабочая — значение по умолчанию,
// поэтому старые задачи и ответы сервера без поля читаются как рабочие, а не «непонятно чьи».
//
// В ЭТОЙ ЛЕНТЕ ЛИЧНЫХ ЗАДАЧ НЕТ. Владелец на созвоне 05.09.2026 (38:06) смотрел на строки
// списка и сказал про них: «это типа личные задачи Гончаренко, или что это такое? […] всё
// это убираем». Прежнее обоснование, стоявшее здесь («заводить нельзя, но показывать
// приходится, раз сервер их отдаёт»), он этим и отменил: раздел в большом CRM — рабочее
// место администратора и менеджера, и чужое личное ему тут не нужно ни в каком виде, даже
// с меткой. Раньше же прозвучало и общее правило: «не рабочие задачи ставить? Но это кринж.
// Нет, только рабочие» (31:56).
//
// Личные отсекает САМ СЕРВЕР: раздел просит GET /tasks с kind=work на всех вкладках, и чужое
// личное в браузер администратора не приезжает вовсе — фильтр только на экране оставлял бы его
// в ответе, откуда оно читается любым отладчиком. Отсев при разборе ответа остаётся страховкой
// (см. загрузку списка), а принадлежность ниже — его правилом.
const принадлежность = (t) => (t && t.kind === 'personal' ? 'personal' : 'work');

// Роль сотрудника человеческим словом — «желательно, чтобы в исполнителях было подписано,
// кто такой Гончаренко — преподаватель, менеджер и так далее. Прикинь, у меня тридцать
// сотрудников, я каждого по фамилии должен помнить» (созвон 05.09.2026, 31:34).
//
// Подпись берём из справочника STAFF_ROLES: роли переименовываются в Настройках, и жёстко
// зашитое «Преподаватель» разошлось бы с тем, как их назвали там. Запасной список нужен
// потому, что справочник приезжает с /staff-roles под правом settings.read — у менеджера
// его может не быть, и в CRM_DATA останется значение по умолчанию из data.jsx.
// Незнакомую роль подписываем ПУСТЫМ: ключ вроде «curator» — это не человеческое слово, а
// внутреннее имя, и в строке рядом с фамилией оно выглядит опечаткой.
const ROLE_FALLBACK = { teacher: 'Преподаватель', manager: 'Менеджер', admin: 'Администратор' };
const рольПодпись = (role) => {
  if (!role) return '';
  const спр = (window.CRM_DATA.STAFF_ROLES || []).find((r) => r.value === role);
  return спр ? спр.label : (ROLE_FALLBACK[role] || '');
};
// Опции выпадающего списка исполнителей: фамилия, СПРАВА от неё — роль («короче, справа
// приписки», 31:34). Разделитель — точка-посередине, как в остальных подписях раздела.
// Роль вписана в саму подпись, а не вынесена вторым столбцом: Dropdown из primitives.jsx
// рисует строку целиком и своей вёрстки строки не принимает, а ради приписки заводить
// второй выпадающий список — плодить компоненты на ровном месте.
const исполнительОпции = (люди) => люди.map((s) => {
  const роль = рольПодпись(s.role);
  return { value: s.id, label: s.name + (роль ? ' · ' + роль : '') };
});
// Роли, которые реально есть в переданном штате, в порядке справочника. Собираем ИЗ ЛЮДЕЙ,
// а не из справочника целиком: «автоматическая фильтрация по роли» (31:34) должна показывать
// то, что есть, — чип роли, на которой ни одного человека, отбирать нечего и он только
// удлиняет ряд. Роль без человеческой подписи в чипы не берём: кнопка с ключом нечитаема.
// count — число ЛЮДЕЙ с этой ролью; оно уместно только там, где чип сужает список людей,
// то есть в форме задачи (см. `числаРолей` в ВыборИсполнителя).
function доступныеРоли(люди) {
  const порядок = (window.CRM_DATA.STAFF_ROLES || []).map((r) => r.value);
  const свои = [];
  люди.forEach((s) => { if (s.role && !свои.includes(s.role)) свои.push(s.role); });
  свои.sort((a, b) => {
    const ia = порядок.indexOf(a), ib = порядок.indexOf(b);
    return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
  });
  return свои
    .map((v) => ({ value: v, label: рольПодпись(v), count: люди.filter((s) => s.role === v).length }))
    .filter((r) => r.label);
}

// Поставил ли задачу администратор. Роли автора в ответе нет — в задаче приходят только
// authorUserId и authorName, — поэтому узнаём её по штату: имя автора сервер собирает как
// COALESCE(staff.full_name, users.login, users.email), и у автора с карточкой сотрудника это
// ровно staff.full_name, по которому строка в STAFF и находится.
// Автора, которого в штате НЕТ, считаем административной учётной записью: под правом «только
// своё» аккаунт без карточки сотрудника задачу вообще не заводит (сервер отвечает
// no_staff_card), значит без карточки её мог создать лишь тот, кто ставит задачи всем.
function отАдминистратора(t) {
  if (!t || !t.authorName) return false;   // автор неизвестен — судить не по чему
  const штат = window.CRM_DATA.STAFF || [];
  if (!штат.length) return false;          // штата нет (не отдали /staff) — тем более
  const автор = штат.find((s) => s.name === t.authorName);
  return автор ? автор.role === 'admin' : true;
}

// Подпись автора. Свою учётную запись показываем как «Вы»: в CRM большинство задач заводит
// тот же человек, который на них смотрит, и собственное имя в каждой строке ему ничего не
// сообщает — а у административной учётки без карточки сотрудника это и вовсе логин.
const имяАвтора = (t, meUserId) => (meUserId != null && t.authorUserId === meUserId ? 'Вы' : (t.authorName || '—'));

// Цвет кодирует ТОЛЬКО происхождение задачи: своего цвета задача не выбирает («у задач цвета
// не нужны… они просто будут делиться на синенькие и красненькие рабочие», 15:13). Поручение
// администратора — самое заметное: «ярко-оранжевого цвета… который обычно на [жилетах] у
// рабочих, чтобы сразу в глаза стрелялось» (14:26). Ярко-оранжевого в палитре темы нет, её
// оранжевый край — gold, его и берём; коралловый не годится — в этой же таблице он означает
// просрочку, и каждое поручение выглядело бы горящим.
// Тона личной задачи здесь больше нет: личные в ленту не попадают (см. `принадлежность`).
const TASK_TONE = {
  admin: { line: 'var(--gold)',  title: 'Поручение от администратора' },
  work:  { line: 'var(--brand)', title: 'Рабочая задача' },
};

const TASK_MONTHS = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];

// Срок приходит как TIMESTAMPTZ. И «просрочено», и подпись считаем в МОСКВЕ, а не в зоне
// браузера: у сотрудника с другим часовым поясом срок «сегодня до 23:59» иначе показывался
// бы завтрашним днём, а вечерние задачи — уже просроченными.
const мскISO = (d) => new Intl.DateTimeFormat('ru-RU', { timeZone: 'Europe/Moscow', year: 'numeric', month: '2-digit', day: '2-digit' })
  .format(d).split('.').reverse().join('-');

// «5 сен», «сегодня», «завтра», «5 сен 2027» (год — только если не текущий). Со сроком
// «на день и час» дописываем и час: «сегодня 14:00». Час берём НЕ из времени в due_at, а по
// флагу сЧасом (dueHasTime общего контракта): у срока «на день» в due_at лежит служебный
// конец суток, обычно 23:59, и показанный час читался бы как назначенное время, которого
// никто не называл. Без флага задачи «на день» и «на день и час» в списке неразличимы —
// именно так этот столбец и выглядел раньше.
function срокПодпись(iso, сЧасом) {
  const d = new Date(iso);
  if (isNaN(d)) return '—';
  const час = сЧасом ? ' ' + часСрока(iso) : '';
  const сегодня = мскISO(new Date());
  const день = мскISO(d);
  if (день === сегодня) return 'сегодня' + час;
  if (день === мскISO(new Date(Date.now() + 86400000))) return 'завтра' + час;
  if (день === мскISO(new Date(Date.now() - 86400000))) return 'вчера' + час;
  const [y, m, dd] = день.split('-');
  return `${+dd} ${TASK_MONTHS[+m - 1]}` + (y === сегодня.slice(0, 4) ? '' : ' ' + y) + час;
}
// Обратное превращение срока — для поля формы: с сервера приходит момент времени, а в
// <input type="date"> нужен календарный день. Разворачиваем В МОСКВЕ, тем же мскISO, что и
// подписи: конец дня «23:59 MSK» в поясе восточнее пришёлся бы уже на следующие сутки, и
// форма показала бы срок на день позже настоящего.
const деньСрока = (iso) => { const d = new Date(iso); return isNaN(d) ? '' : мскISO(d); };
// Час срока для поля времени — тоже по Москве, по той же причине, что и день.
// Пустая строка означает «часа нет»: у задачи «на день» время не показывают.
const часСрока = (iso) => {
  const d = new Date(iso);
  if (isNaN(d)) return '';
  const ч = new Intl.DateTimeFormat('ru-RU', { timeZone: 'Europe/Moscow', hour: '2-digit', minute: '2-digit', hour12: false }).format(d);
  return ч.replace('24:', '00:');
};

// Подпись срока целиком, с началом периода, если оно есть: «— Я могу выбрать только дедлайн?
// — От-до. Мне нужно задачи ставить. Ну и менеджеру тоже. От-до, диапазон» (созвон 05.09,
// 32:22). Начало (колонка due_from) стоит не у всякой задачи — обычный срок на число живёт
// без него, — и тогда столбец выглядит ровно как раньше. День в день период тоже не пишем
// двумя датами: «5 сен – 5 сен» это не диапазон, а шум; у такого периода с часом пишем
// промежуток часов — «завтра 11:00–12:00».
// Многодневный период — по макету PodpisSroka (созвон 15.09): месяц пишем ВСЕГДА — «10–13»
// Павел прочёл как часы (01:30), — а при переходе через месяц у обоих концов: «10–13 сен»,
// «30 сен – 2 окт». Слова «сегодня/завтра» в период не ставим: «вчера – 2 окт» не читается
// как отрезок. Год — только если он не текущий.
function периодПодпись(t) {
  const до = срокПодпись(t.dueAt, t.dueHasTime);
  if (!t.dueFrom || !t.dueAt) return до;
  const начало = деньСрока(t.dueFrom);
  const конец = деньСрока(t.dueAt);
  if (!начало) return до;
  if (начало === конец) {
    const с = часСрока(t.dueFrom);
    return t.dueHasTime && с && с !== '00:00' ? до.replace(/ (\d\d:\d\d)$/, ' ' + с + '–$1') : до;
  }
  const год = мскISO(new Date()).slice(0, 4);
  const [y1, m1, d1] = начало.split('-');
  const [y2, m2, d2] = конец.split('-');
  const час = t.dueHasTime ? ' ' + часСрока(t.dueAt) : '';
  if (y1 === y2 && m1 === m2) return `${+d1}–${+d2} ${TASK_MONTHS[+m2 - 1]}` + (y2 === год ? '' : ' ' + y2) + час;
  const сГодом = y1 !== y2 || y2 !== год;
  const день = (y, m, d) => `${+d} ${TASK_MONTHS[+m - 1]}` + (сГодом ? ' ' + y : '');
  return день(y1, m1, d1) + ' – ' + день(y2, m2, d2) + час;
}

const завершена = (t) => t.status === 'done' || t.status === 'cancelled';
// Подсказка у погашенного «Вернуть в работу». Сам отказ сервера (403 period_locked) показываем
// его текстом — в нём дата границы периода.
const ПЕРИОД_ЗАКРЫТ = 'Период закрыт — вернуть в работу может администратор';
/* Просрочена = срок уже прошёл, а задача ещё в работе. Правило общее с кабинетом
   (teacher-app.jsx, срокПрошёл) и переписано 20.09 по решению владельца: задача с НАЗВАННЫМ
   часом (dueHasTime) горит с самого момента срока — «на сегодня 18:00» в 20:45 обязана быть
   просроченной. У задачи «на день» часа нет вовсе: в due_at лежит служебный конец суток, и
   «истёк в 00:00» было бы неправдой — такая просрочена со следующего дня, сравнением ДНЕЙ по
   Москве. Момент сравниваем моментом: на «раньше или позже» часовой пояс не влияет. */
const просрочена = (t) => {
  if (!t.dueAt || завершена(t)) return false;
  return t.dueHasTime === true ? Date.parse(t.dueAt) <= Date.now() : мскISO(new Date(t.dueAt)) < мскISO(new Date());
};
/* Минутный пересчёт ленты. Просрочку считают прямо при отрисовке, от текущего времени, —
   значит довольно перерисовать список, чтобы задача, чей час настал, покраснела сама, без
   перезагрузки. Шаг подгоняем к границе минуты: иначе строка краснела бы на случайной
   секунде после срока. В кабинете тем же тиком едет красная линия текущего времени. */
function useМинутаЗадач() {
  const [, тикнуть] = React.useState(0);
  React.useEffect(() => {
    const доМинуты = () => 60000 - (Date.now() % 60000);
    let таймер = 0;
    const шаг = () => { тикнуть((n) => n + 1); таймер = setTimeout(шаг, доМинуты()); };
    таймер = setTimeout(шаг, доМинуты());
    return () => clearTimeout(таймер);
  }, []);
}

// Текущая календарная неделя (пн–вс) по Москве, границами-днями «ГГГГ-ММ-ДД». День недели
// берём у МОСКОВСКОЙ даты, а не у браузерной: в ночь на понедельник в поясе западнее Москвы
// «эта неделя» иначе оказалась бы прошлой. Приём тот же, что у мскISO, — день собирается в
// UTC-полночь, и getUTCDay отвечает уже про московский день.
function неделяМСК() {
  const сегодня = new Date(мскISO(new Date()) + 'T00:00:00Z');
  const снедели = (сегодня.getUTCDay() + 6) % 7;        // 0 — понедельник
  const пн = new Date(сегодня.getTime() - снедели * 86400000);
  const вс = new Date(пн.getTime() + 6 * 86400000);
  return [пн.toISOString().slice(0, 10), вс.toISOString().slice(0, 10)];
}
// Срезы для фильтра по сроку: «но фильтр по сроку тоже нужен» (созвон 05.09, 38:06). Именно
// СРЕЗЫ, а не два поля даты, — фильтром пользуется менеджер, ему нужно «что горит», а не
// произвольный отрезок календаря.
const ФИЛЬТР_СРОКА = [
  { id: 'any',     label: 'Любой' },
  { id: 'overdue', label: 'Просроченные' },
  { id: 'today',   label: 'Сегодня' },
  { id: 'week',    label: 'Эта неделя' },
  { id: 'none',    label: 'Без срока' },
];
// У задачи с периодом смотрим на КОНЕЦ — на срок, а не на начало: задача «с 23-го по 31-е»
// попадает в неделю 31-го, в ту, где она горит, а не размазывается по всем неделям периода.
// Ровно так же считает и недельный фильтр сервера (tasks-service.ts, listTasks).
function поСроку(t, режим) {
  if (режим === 'any') return true;
  if (режим === 'none') return !t.dueAt;
  if (!t.dueAt) return false;
  if (режим === 'overdue') return просрочена(t);
  const день = мскISO(new Date(t.dueAt));
  if (режим === 'today') return день === мскISO(new Date());
  if (режим === 'week') { const [пн, вс] = неделяМСК(); return день >= пн && день <= вс; }
  return true;
}

const сотрудникПоId = (id) => (id == null ? null : (window.CRM_DATA.STAFF || []).find((s) => s.id === id) || null);
// Роль исполнителя задачи — для приписки в списке и для отбора по роли. Уволенного тоже
// находим: STAFF отдаётся целиком, а у старой задачи исполнитель мог успеть уволиться.
const рольИсполнителя = (t) => { const s = сотрудникПоId(t.assigneeStaffId); return s ? s.role : null; };

// Кружок-отметка «выполнено». Клик по уже закрытой возвращает её в работу — без этого
// промах по строке нечем откатить (удалять задачу ради опечатки — слишком). Он же —
// единственный путь назад: правку закрытая задача не открывает.
// Выполненную в закрытом расчётном периоде вернуть в работу может только администратор
// (решение владельца 17.09): сервер помечает такую задачу reopenLocked, и кружок гаснет
// заранее, а не отвечает отказом после нажатия. Клик по погашенному всё равно гасим у себя —
// иначе он всплыл бы в строку и открыл правку.
function DoneToggle({ task, busy, onToggle }) {
  const [hover, setHover] = React.useState(false);
  // Заливка — только у выполненной: отменённая не сделана, и зелёная галочка на ней врала бы.
  // А подпись смотрит на закрытость вообще — жест у обеих один и тот же.
  const done = task.status === 'done';
  const заперта = done && !!task.reopenLocked;
  const title = заперта ? ПЕРИОД_ЗАКРЫТ : завершена(task) ? 'Вернуть в работу' : 'Отметить выполненной';
  const нельзя = busy || заперта;
  return (
    <button onClick={(e) => { e.stopPropagation(); if (!нельзя) onToggle(task); }} title={title} aria-label={title} disabled={нельзя}
      onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
      style={{
        width: 24, height: 24, flex: 'none', borderRadius: '50%', cursor: нельзя ? 'default' : 'pointer',
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: 0,
        border: '1.5px solid ' + (done ? 'var(--green)' : hover ? 'var(--brand)' : 'var(--border-strong)'),
        background: done ? 'var(--green)' : hover ? 'var(--brand-soft)' : 'var(--bg-card)',
        color: done ? '#fff' : 'var(--brand)', opacity: busy ? 0.5 : заперта ? 0.45 : 1,
        transition: 'background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease)',
      }}>
      {busy ? <span className="om-spin"><Icon name="loader" size={13} /></span>
        : (done || (hover && !заперта)) ? <Icon name="check" size={14} strokeWidth={3} /> : null}
    </button>
  );
}

/* Тумблера принадлежности здесь БОЛЬШЕ НЕТ — убран 05.09.2026 по прямому указанию.
   Личная задача — это дело человека, а не поручение сверху: «личная задача от лица
   администратора» противоречит сама себе. Раздел «Задачи» в большом CRM — рабочее место
   администратора и менеджера, и всё, что он тут заводит, по определению рабочее.
   Переключатель, который владелец просил на созвоне 02.09 (11:46), остаётся там, где он и
   имеет смысл, — в кабинете преподавателя, где человек отмечает СВОЮ задачу личной.
   Заодно это снимает расхождение: в кабинете под администратором тумблер уже был скрыт
   (`ЗадачаФорма`, `SlotPickDialog` в teacher-app.jsx), а в CRM тот же администратор ставил
   личные задачи без всякой преграды. */

// Выбор исполнителя: ряд чипов-ролей и под ним обычный Dropdown с приписками.
//
// Чипы — это и есть «автоматическая фильтрация по роли» (31:34): в тридцати фамилиях нужного
// человека глазами не найти, а «Преподаватель» отсекает список до тех, кто вообще может это
// делать. Ряд собирается из самого штата, поэтому новая роль из Настроек появляется здесь
// сама, а роль без людей его не удлиняет; при одной роли на весь штат чипов нет вовсе —
// отбирать не из чего.
//
// Компонент один на два места — форму задачи и панель фильтров, — и он НАМЕРЕННО ничего не
// решает сам: роль ему передают снаружи. В форме она только сужает список, в фильтрах ещё и
// режет ленту, и зашить одно поведение внутрь значило бы сломать второе.
//
// Из-за этой же двойственности числа у чипов рисуются НЕ ВЕЗДЕ (`числаРолей`). В форме чип
// сужает список людей, и «Преподаватель 2» там читается верно: столько человек и покажет
// выпадающий список. В ПАНЕЛИ ФИЛЬТРОВ тот же чип стоит рядом со счётчиком задач и срезами
// по сроку, и та же двойка читается как «столько задач останется» — человек жмёт и получает
// двадцать две строки. Цифра рядом с фильтром обязана означать результат фильтра, а честно
// посчитать его заранее тут нечем: клик по чипу не только режет ленту, но и расширяет охват
// с «Мои» на «Все» (см. `выбратьРоль`), то есть подтягивает с сервера строки, которых сейчас
// в памяти нет. Поэтому в фильтрах чисел нет вовсе — сколько отобралось, видно живьём в
// счётчике над таблицей, и он не врёт.
function ВыборИсполнителя({ люди, head, value, onChange, role, onRole, missingLabel, placeholder, числаРолей = true }) {
  const роли = доступныеРоли(люди);
  const видимые = role ? люди.filter((s) => s.role === role) : люди;
  const опции = (head || []).concat(исполнительОпции(видимые));
  // Выбранный остаётся в списке, даже если его отсекла роль или он успел уволиться: без
  // своей строки поле показало бы плейсхолдер там, где исполнитель есть, и правка срока
  // выглядела бы как снятие человека с задачи.
  if (value != null && !опции.some((o) => o.value === value)) {
    const он = люди.find((s) => s.id === value);
    опции.push(он ? исполнительОпции([он])[0] : { value, label: missingLabel || ('Сотрудник #' + value) });
  }
  const чип = (on) => ({
    padding: '5px 11px', borderRadius: 'var(--radius-pill)', cursor: 'pointer',
    border: '1px solid ' + (on ? 'transparent' : 'var(--border)'),
    background: on ? 'var(--brand-soft)' : 'var(--bg-card)',
    color: on ? 'var(--brand-ink)' : 'var(--text-secondary)',
    fontFamily: 'var(--font-sans)', fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap',
    transition: 'background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease)',
  });
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
      {роли.length > 1 && (
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
          <button type="button" onClick={() => onRole(null)} style={чип(!role)}>Все</button>
          {роли.map((r) => (
            <button key={r.value} type="button" onClick={() => onRole(role === r.value ? null : r.value)} style={чип(role === r.value)}>
              {r.label}
              {числаРолей && <span style={{ marginLeft: 5, opacity: 0.6, fontWeight: 500 }}>{r.count}</span>}
            </button>
          ))}
        </div>
      )}
      <Dropdown value={value} onChange={onChange} options={опции} placeholder={placeholder} width="100%" searchable={опции.length > 5} />
    </div>
  );
}

// Включённый фильтр, названный словом, с крестиком «снять». Каждый — отдельно: у менеджера
// их бывает два-три сразу, и «сбросить всё» вместо снятия одного заставляет собирать отбор
// заново.
function ФильтрЧип({ label, onDrop }) {
  return (
    <span style={{
      display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 6px 4px 11px',
      borderRadius: 'var(--radius-pill)', background: 'var(--brand-soft)', color: 'var(--brand-ink)',
      fontSize: 12.5, fontWeight: 600, whiteSpace: 'nowrap',
    }}>
      {label}
      <button onClick={onDrop} title={'Снять: ' + label} aria-label={'Снять фильтр: ' + label}
        style={{ width: 18, height: 18, borderRadius: '50%', border: 'none', background: 'transparent', cursor: 'pointer', color: 'inherit', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: 0, flex: 'none' }}>
        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M8 8l8 8M16 8l-8 8" /></svg>
      </button>
    </span>
  );
}

// Тихая подпись над полем внутри Field: у срока их теперь три (начало, срок, час), и без
// подписей это три одинаковые коробки, в которых человек угадывает, какая из них какая.
const подписьПоля = { display: 'block', fontSize: 11.5, fontWeight: 600, color: 'var(--text-dim)', marginBottom: 4 };
const полеДаты = {
  boxSizing: 'border-box', padding: '10px 12px', border: '1px solid var(--border)',
  borderRadius: 'var(--radius-sm)', fontFamily: 'var(--font-mono)', fontSize: 13.5,
  color: 'var(--text)', outline: 'none', background: 'var(--bg-card)',
};

// Форма задачи — выдвижная панель справа, как в AddLeadModal.jsx. Одна и та же и для
// создания, и для правки, режим задаёт проп task: поля у них совпадают полностью, и вторая
// форма разъехалась бы с этой на первой же доработке — человек получил бы разный набор
// полей в зависимости от того, заводит он задачу или поправляет.
function TaskForm({ staff, task, defaultAssignee, meUserId, onClose, onCreate, onSave }) {
  const editing = !!task;
  const авторАдмин = editing && отАдминистратора(task);
  const [shown, setShown] = React.useState(false);
  // Поля заполняются ОДИН раз, на монтировании: форма пересоздаётся под каждое открытие
  // (см. ключ на вызове), поэтому синхронизировать их с пропом по ходу жизни не нужно.
  const [title, setTitle] = React.useState(editing && task.title ? task.title : '');
  const [body, setBody] = React.useState(editing && task.body ? task.body : '');
  const [assignee, setAssignee] = React.useState(
    editing ? (task.assigneeStaffId != null ? task.assigneeStaffId : null)
      : (defaultAssignee != null ? defaultAssignee : null));
  // Роль в форме — только сито для списка исполнителей, в самой задаче её нет. Начинаем с
  // «Все»: догадываться, кому именно человек собрался поручить, не из чего.
  const [роль, setРоль] = React.useState(null);
  // Начало периода — колонка due_from, отдельная от срока («От-до, диапазон», 32:22). Поля
  // для неё тут не было, и это не только недоделка: у задачи с периодом из CRM нельзя было
  // СНЯТЬ срок — сервер отвечал «начало периода без срока не бывает» и советовал убрать
  // начало, а убирать его было нечем.
  const [from, setFrom] = React.useState(editing && task.dueFrom ? деньСрока(task.dueFrom) : '');
  const [due, setDue] = React.useState(editing && task.dueAt ? деньСрока(task.dueAt) : '');
  // Час у срока — отдельное поле и отдельный флаг dueHasTime на сервере. Пустое поле
  // значит «задача на день», заполненное — «на день и час». Без него раздел CRM не мог
  // ни убрать час, ни увидеть его: он слал только дату, и задача с часом либо становилась
  // неисправимой (сервер отбивал «час без срока не бывает»), либо тихо переезжала на
  // 23:59 — тот самый выдуманный час, ради запрета которого флаг и заводили.
  const [time, setTime] = React.useState(editing && task.dueAt && task.dueHasTime ? часСрока(task.dueAt) : '');
  const [priority, setPriority] = React.useState(editing && task.priority ? task.priority : 'normal');
  // Из CRM задача заводится ТОЛЬКО рабочей — менять принадлежность отсюда нельзя (см.
  // комментарий на месте убранного тумблера). Состояния под неё больше нет: правка kind в
  // патч не кладёт вовсе, и уже заведённую личную задачу CRM в рабочую не переделает — молча
  // менять чужой признак хуже, чем не трогать его.
  const kind = 'work';
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const [touched, setTouched] = React.useState(false);

  React.useEffect(() => { const t = setTimeout(() => setShown(true), 10); return () => clearTimeout(t); }, []);
  const close = () => { setShown(false); setTimeout(onClose, 200); };

  const titleOk = title.trim().length > 0;

  // Срок уходит ПАРОЙ с флагом часа — иначе сервер увидит рассогласование и откажет.
  // Без часа отправляем КОНЕЦ дня по Москве: от полуночи задача «на 5 сентября» была бы
  // просрочена уже в 00:01 пятого. Зону пишем явно — иначе браузер сотрудника в другом
  // поясе отправит другой момент времени.
  const срокВОтправку = () => (due ? due + 'T' + (time ? time : '23:59') + ':00+03:00' : null);
  const часОтправку = () => (due ? !!time : false);
  // Начало периода — НАЧАЛО дня по Москве, зеркально концу у срока: период «с 5-го по 12-е»
  // должен накрывать пятое целиком.
  const началоВОтправку = () => (from ? from + 'T00:00:00+03:00' : null);

  // Оба запрета сервера (проверитьПериод в tasks-service.ts) ловим ЗДЕСЬ, а не отказом на
  // сохранении: обе даты человек видит перед собой, и сказать ему про них до нажатия честнее,
  // чем после. Тексты — сервера, слово в слово, чтобы одна и та же ошибка не звучала в двух
  // местах по-разному.
  // Третий запрет — промежуток со временем не короче 5 минут (решение владельца 15.09). Начало
  // из этой формы — всегда 00:00 дня, поэтому короче он бывает только день в день с часом
  // срока раньше 00:05; сравниваем те же моменты, что сверит сервер. При правке нетронутая
  // граница в патч не уходит (см. патч()), и сервер берёт её из базы — а там начало из кабинета
  // бывает с часом (10:00). Поэтому неизменённую границу сверяем сохранённым моментом, а не
  // полуночью: иначе «14:00 → 14:03» проходило бы здесь и получало отказ сервера после нажатия.
  const началоДляПроверки = () => (editing && task.dueFrom && from === деньСрока(task.dueFrom) ? task.dueFrom : началоВОтправку());
  const срокДляПроверки = () => (editing && task.dueAt && due === деньСрока(task.dueAt)
    && time === (task.dueHasTime ? часСрока(task.dueAt) : '') ? task.dueAt : срокВОтправку());
  // Порядок границ сверяем тоже МОМЕНТАМИ, как сервер, а не одними датами: однодневный период
  // с началом в 14:00 и часом срока 13:00 — это «начало позже срока», а не короткий промежуток.
  const разрывМс = from && due ? new Date(срокДляПроверки()).getTime() - new Date(началоДляПроверки()).getTime() : null;
  const промежутокМал = !!(from && due && time && разрывМс >= 0 && разрывМс < 5 * 60000);
  const срокОшибка = from && !due ? 'Начало периода без срока не бывает — укажите срок или уберите начало'
    : (from && due && (from > due || разрывМс < 0)) ? 'Начало периода не может быть позже срока'
      : промежутокМал ? 'Промежуток задачи — не короче 5 минут'
        : null;

  // В PATCH уходят ТОЛЬКО реально изменённые поля. Отправить форму целиком нельзя: под
  // правом tasks.write со scope 'own' сервер считает лишним ЛЮБОЕ содержательное поле, и
  // неизменившийся приоритет превратил бы правку срока в отказ only_status. Плюс письмо
  // исполнителю уходит по СМЕНЕ assigneeStaffId — неизменным полем его не дёрнешь, но шум
  // в патче мешает потом понять, что человек на самом деле правил.
  // entityType/entityId форма не трогает: привязку к клиенту или занятию ставят из карточки,
  // поля для неё здесь нет, и слать её «на всякий случай» — тот же лишний шум.
  const патч = () => {
    const p = {};
    const t = title.trim();
    if (t !== task.title) p.title = t;
    const b = body.trim() || null;
    if (b !== (task.body || null)) p.body = b;
    if (assignee !== (task.assigneeStaffId != null ? task.assigneeStaffId : null)) p.assigneeStaffId = assignee;
    if (priority !== task.priority) p.priority = priority;
    // kind в патч не уходит НИКОГДА — менять принадлежность из CRM нечем и незачем.
    // Срок сравниваем ПО ДНЮ, а не по строке: в форме день, с сервера — момент с зоной и
    // миллисекундами, и «2026-09-05» не совпало бы с «2026-09-05T20:59:00.000Z» никогда —
    // dueAt уходил бы в каждом патче, даже когда его не трогали.
    // Срок и час — одна величина, поэтому и в патч они уходят вместе. Отправить один без
    // другого нельзя: сервер сверяет их с сохранённым в базе вторым полем и откажет.
    const былДень = task.dueAt ? деньСрока(task.dueAt) : '';
    const былЧас = task.dueAt && task.dueHasTime ? часСрока(task.dueAt) : '';
    if (due !== былДень || time !== былЧас) { p.dueAt = срокВОтправку(); p.dueHasTime = часОтправку(); }
    // Начало периода — тоже по дню и тоже своим полем: сервер правит границы поодиночке и
    // непришедшую берёт из базы. Снятие срока гасит начало не здесь, а на самом поле (см.
    // onChange у даты срока), поэтому «убрали срок» приходит сюда уже парой dueAt+dueFrom —
    // без этого сервер отказал бы, что начало периода без срока не бывает.
    const былоНачало = task.dueFrom ? деньСрока(task.dueFrom) : '';
    if (from !== былоНачало) p.dueFrom = началоВОтправку();
    return p;
  };

  const submit = async () => {
    // Второй клик, пока ждём ответа, — не второй запрос: иначе двойное нажатие создаёт две
    // одинаковые задачи и два письма исполнителю.
    if (busy) return;
    if (!titleOk || срокОшибка) { setTouched(true); return; }
    setBusy(true); setErr(null);
    try {
      if (editing) await onSave(task.id, патч());
      else await onCreate({
        title: title.trim(),
        body: body.trim() || null,
        assigneeStaffId: assignee,
        priority,
        kind,
        dueFrom: началоВОтправку(),
        dueAt: срокВОтправку(),
        dueHasTime: часОтправку(),
      });
      close();
    } catch (e) {
      setBusy(false);
      // Текст отказа показываем КАК ЕСТЬ: сервер объясняет причину («задача закрыта»,
      // «назначать другим нельзя») точнее, чем наше общее «не удалось сохранить».
      setErr(e && e.message ? e.message : (editing ? 'Не удалось сохранить задачу' : 'Не удалось создать задачу'));
    }
  };

  // Порталом в <body>, как TaskDetails в Schedule.jsx: раздел живёт внутри .om-fade-in с
  // transform, и без портала fixed-обёртка прилипала бы к области раздела — меню оставалось
  // светлым, а подвал с кнопками уезжал ниже окна вместе с высотой списка.
  return ReactDOM.createPortal(
    <div style={{ position: 'fixed', inset: 0, zIndex: 50 }}>
      <div onClick={close} style={{ position: 'absolute', inset: 0, background: 'rgba(21,28,46,0.45)', backdropFilter: 'blur(2px)', opacity: shown ? 1 : 0, transition: 'opacity var(--dur) var(--ease)' }} />
      <aside style={{
        position: 'absolute', top: 0, right: 0, bottom: 0, width: 420, maxWidth: '100vw',
        background: 'var(--bg-card)', boxShadow: 'var(--shadow-lg)', display: 'flex', flexDirection: 'column',
        transform: shown ? 'translateX(0)' : 'translateX(100%)', transition: 'transform 280ms cubic-bezier(0.22, 1, 0.36, 1)',
      }}>
        <div style={{ padding: '18px 22px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12 }}>
          <span style={{ width: 40, height: 40, borderRadius: 'var(--radius-md)', background: 'var(--brand-soft)', color: 'var(--brand-ink)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flex: 'none' }}>
            <Icon name="check-square" size={20} />
          </span>
          <div style={{ flex: 1 }}>
            <h2 style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.3px', margin: 0, color: 'var(--text)' }}>{editing ? 'Изменить задачу' : 'Новая задача'}</h2>
            <div style={{ fontSize: 13, color: 'var(--text-dim)', marginTop: 1 }}>{editing ? 'Задача №' + task.id : 'Что и к какому сроку нужно сделать'}</div>
          </div>
          <button onClick={close} title="Закрыть" style={{ width: 34, height: 34, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--bg-card)', cursor: 'pointer', color: 'var(--text-secondary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flex: 'none' }}>
            <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round"><path d="M9.2 9.2l5.6 5.6M14.8 9.2l-5.6 5.6" /></svg>
          </button>
        </div>

        <div style={{ flex: 1, overflowY: 'auto', padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16 }}>
          {/* Автор — первым, до полей: от него зависит, что с задачей вообще можно сделать
              (поручение сверху исполнитель только закрывает, свою запись ведёт целиком), и
              по одному заголовку это не отличить. Административное подписано словом, а не
              только цветом: цвет объясняет себя в списке, где рядом есть другие строки, а в
              открытой форме сравнивать не с чем. У НОВОЙ задачи автора ещё нет — его
              проставит сервер по тому, кто нажал, поэтому блока в этом режиме нет. */}
          {editing && (
            <Field label="Автор">
              <div style={{
                display: 'flex', alignItems: 'center', gap: 9, padding: '10px 12px',
                borderRadius: 'var(--radius-sm)', background: авторАдмин ? 'var(--gold-soft)' : 'var(--bg-soft)',
                border: '1px solid ' + (авторАдмин ? '#D8C291' : 'var(--border)'),
              }}>
                <Icon name={авторАдмин ? 'shield' : 'user'} size={16}
                  style={{ color: авторАдмин ? 'var(--gold-ink)' : 'var(--text-dim)', flex: 'none' }} />
                <span style={{ fontSize: 13.5, fontWeight: 600, color: авторАдмин ? 'var(--gold-ink)' : 'var(--text)' }}>
                  {имяАвтора(task, meUserId)}
                </span>
                {авторАдмин && <span style={{ fontSize: 12.5, color: 'var(--gold-ink)' }}>· администратор</span>}
              </div>
            </Field>
          )}

          <Field label="Заголовок" required>
            <TextField value={title} onChange={setTitle} placeholder="Например: позвонить маме Артёма" autoFocus />
          </Field>
          {touched && !titleOk && <div style={{ fontSize: 12, color: 'var(--coral)', marginTop: -8 }}>Укажите, что нужно сделать</div>}

          <Field label="Описание">
            <textarea value={body} onChange={(e) => setBody(e.target.value)} rows={4} placeholder="Детали: о чём договорились, что уточнить"
              style={{
                width: '100%', boxSizing: 'border-box', borderRadius: 'var(--radius-sm)', padding: '11px 13px',
                border: '1px solid var(--border)', outline: 'none', background: 'var(--bg-card)', resize: 'vertical',
                fontFamily: 'var(--font-sans)', fontSize: 14, lineHeight: 1.45, color: 'var(--text)',
              }} />
          </Field>

          {/* Уволенных в списке нет — назначать на них нечего. Но у СТАРОЙ задачи исполнитель
              мог успеть уволиться, и его строку добавляет сам ВыборИсполнителя. */}
          <Field label="Исполнитель">
            <ВыборИсполнителя люди={staff} role={роль} onRole={setРоль}
              head={[{ value: null, label: 'Без исполнителя' }]}
              value={assignee} onChange={setAssignee} placeholder="Без исполнителя"
              missingLabel={editing ? (task.assigneeName || 'Сотрудник #' + assignee) + ' · уволен' : null} />
          </Field>

          <Field label="Срок">
            <div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
              <label style={{ flex: 1, minWidth: 0 }}>
                <span style={подписьПоля}>Начало</span>
                <input type="date" value={from} max={due || undefined} onChange={(e) => setFrom(e.target.value)}
                  style={{ ...полеДаты, width: '100%' }} />
              </label>
              <span style={{ flex: 'none', paddingBottom: 11, color: 'var(--text-dim)', fontSize: 13 }}>–</span>
              <label style={{ flex: 1, minWidth: 0 }}>
                <span style={подписьПоля}>Срок</span>
                {/* Снятый срок гасит и час, и начало периода: без срока сервер не принимает
                    ни того, ни другого, а руками убрать их человеку было бы уже нечем —
                    поля бы просто погасли вместе с датой. */}
                <input type="date" value={due} min={from || undefined}
                  onChange={(e) => { const v = e.target.value; setDue(v); if (!v) { setTime(''); setFrom(''); } }}
                  style={{ ...полеДаты, width: '100%' }} />
              </label>
            </div>
            <div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, marginTop: 10 }}>
              <label style={{ flex: 'none' }}>
                <span style={подписьПоля}>Час срока</span>
                <input type="time" value={time} onChange={(e) => setTime(e.target.value)} disabled={!due}
                  style={{ ...полеДаты, width: 116, color: due ? 'var(--text)' : 'var(--text-faint)', background: due ? 'var(--bg-card)' : 'var(--bg-soft)' }} />
              </label>
              <div style={{ flex: 1, minWidth: 0, paddingBottom: 10, fontSize: 12, lineHeight: 1.35, color: срокОшибка ? 'var(--coral)' : 'var(--text-dim)' }}>
                {срокОшибка ? срокОшибка
                  : time ? 'Час относится к сроку — задача встанет в сетку часов на это время.'
                    : from ? 'Задача идёт весь период и горит по сроку.'
                      : 'Без часа задача считается просроченной со следующего дня.'}
              </div>
            </div>
          </Field>

          <Field label="Приоритет">
            <div style={{ display: 'flex', gap: 8 }}>
              {PRIORITY_ORDER.map((p) => {
                const on = priority === p;
                const m = TASK_PRIORITY[p];
                return (
                  <button key={p} onClick={() => setPriority(p)} style={{
                    flex: 1, padding: '9px 12px', borderRadius: 'var(--radius-sm)', cursor: 'pointer',
                    border: '1px solid ' + (on ? 'var(--brand)' : 'var(--border)'),
                    background: on ? 'var(--brand-soft)' : 'var(--bg-card)',
                    color: on ? 'var(--brand-ink)' : 'var(--text-secondary)',
                    fontFamily: 'var(--font-sans)', fontSize: 13.5, fontWeight: 600,
                    transition: 'background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease)',
                  }}>{m.label}</button>
                );
              })}
            </div>
          </Field>

          {err && <div style={{ color: 'var(--coral-ink)', background: 'var(--coral-bg)', border: '1px solid #E6C9C4', padding: '9px 12px', borderRadius: 'var(--radius-sm)', fontSize: 13 }}>{err}</div>}
        </div>

        <div style={{ padding: '14px 22px', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
          <Button variant="ghost" onClick={close}>Отмена</Button>
          <Button onClick={submit} style={{ opacity: busy ? 0.6 : 1, cursor: busy ? 'default' : 'pointer' }}>
            {busy ? <span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}><span className="om-spin"><Icon name="loader" size={15} /></span>Сохраняем…</span> : editing ? 'Сохранить' : 'Создать задачу'}
          </Button>
        </div>
      </aside>
    </div>,
    document.body
  );
}

function Tasks({ onToast }) {
  useМинутаЗадач();                                   // задача, чей час настал, краснеет сама
  const [scope, setScope] = React.useState('mine');   // mine | all | closed
  const [q, setQ] = React.useState('');
  // Отдельная кнопка «Фильтры» — «мне как менеджеру хочется видеть, например, задачу только
  // Гончаренко. Добавить фильтры. Где-нибудь кнопочка фильтры» (созвон 05.09, 37:15). Вкладки
  // выше — не фильтр, а охват: «Мои» жёстко привязано к своему staffId и другого человека
  // показать не может в принципе.
  // Отбор КЛИЕНТСКИЙ: лента и так грузится целиком, и лишний поход на сервер на каждое
  // движение чипа только моргал бы списком.
  const [фильтрыОткрыты, setФильтрыОткрыты] = React.useState(false);
  const [фРоль, setФРоль] = React.useState(null);      // null — любая
  const [фИсп, setФИсп] = React.useState(null);        // null — любой, 'none' — не назначен, иначе id
  const [фСрок, setФСрок] = React.useState('any');
  // Узкая таблица: на ноутбуке (1280 px) все семь колонок в карточку не входят, и «Срок»,
  // «Приоритет», «Статус» уезжали за правый край — прокрутку при скрытой полосе не видно.
  // Меряем саму коробку таблицы, а не окно: ширину съедают ещё боковое меню и поля страницы.
  // В тесноте прячем то, что дублируется: роль исполнителя (есть в фильтре и в подсказке у
  // фамилии) и слово «просрочено» (о нём уже говорят значок и цвет); имена автора и
  // исполнителя переносятся на две строки, а заголовок задачи сжимается с многоточием.
  // Порог — 1400: широкой таблице (роль, «· просрочено», имена в строку) нужно ~1320 px уже на
  // демо-именах, и при пороге 1200 окно 1600 px давало коробку 1286 — «Статус» резался справа.
  const [коробка, setКоробка] = React.useState(null);
  const [тесно, setТесно] = React.useState(false);
  React.useEffect(() => {
    if (!коробка || typeof ResizeObserver === 'undefined') return undefined;
    const ro = new ResizeObserver(() => setТесно(коробка.clientWidth < 1400));
    ro.observe(коробка);
    return () => ro.disconnect();
  }, [коробка]);
  const [rows, setRows] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);
  const [ver, setVer] = React.useState(0);            // бамп → перечитать список
  const [busyId, setBusyId] = React.useState(null);
  const [form, setForm] = React.useState(false);
  // Правка: null — закрыта, иначе редактируемая задача. Отдельным состоянием, а не флагом
  // внутри form: форме нужен сам объект, чтобы заполнить поля и посчитать, что изменилось.
  const [edit, setEdit] = React.useState(null);
  // undefined — ещё не знаем, кто мы; null — у учётной записи нет карточки сотрудника.
  // Без своего staffId фильтр «Мои» собрать не из чего: сервер фильтрует по assigneeStaffId.
  const [myStaffId, setMyStaffId] = React.useState(undefined);
  // Своя учётная запись — только чтобы в колонке «Автор» вместо собственного имени стояло
  // «Вы». Ни фильтрация, ни права от неё не зависят, поэтому неудачный /auth/me её не ломает:
  // без неё колонка просто покажет имя.
  const [myUserId, setMyUserId] = React.useState(null);

  React.useEffect(() => {
    let alive = true;
    window.apiCall('/auth/me')
      .then((r) => { if (alive) { setMyStaffId(r.staffId != null ? r.staffId : null); setMyUserId(r.userId != null ? r.userId : null); } })
      .catch(() => { if (alive) setMyStaffId(null); });
    return () => { alive = false; };
  }, []);
  // Администратор без карточки сотрудника исполнителем быть не может — «Мои» у него
  // всегда пусто, поэтому сразу показываем «Все», а не пустой экран.
  React.useEffect(() => { if (myStaffId === null && scope === 'mine') setScope('all'); }, [myStaffId, scope]);

  // Фоновое обновление CRM (index.html, обновитьВитрину): список перечитывается тихо — без
  // заглушки «загрузка» и без экрана ошибки при сбое, поменявшиеся задачи подсвечиваются.
  const обновление = window.useCrmОбновление();
  const обновлениеRef = React.useRef(обновление);
  const прошлыеRef = React.useRef(null);
  React.useEffect(() => {
    if (myStaffId === undefined) return;             // ещё не знаем себя — запрос подождёт
    if (myStaffId === null && scope === 'mine') return; // сейчас переключимся на «Все»
    const фоновое = обновлениеRef.current !== обновление;
    обновлениеRef.current = обновление;
    let alive = true;
    if (!фоновое) { setLoading(true); setError(null); прошлыеRef.current = null; }
    const p = [];
    if (scope === 'mine') p.push('assigneeStaffId=' + myStaffId);
    // Закрытые сервер по умолчанию не отдаёт — на вкладке «Закрытые» просим их явно.
    //
    // Просим ИМЕННО includeDone=1 без status. Закрытых статуса два — выполнена и отменена, —
    // а параметр status у /tasks одиночный, и прежний status=done оставлял отменённую вне
    // всех трёх вкладок разом: «Мои» и «Все» закрытые не показывают вовсе, а эта показывала
    // только выполненные. Маркер «Не произошло» из-за этого было негде увидеть, хотя саму
    // пару маркеров делали по прямой просьбе владельца. includeDone=1 отдаёт список целиком,
    // поэтому незакрытое отсеиваем сами, при разборе ответа.
    if (scope === 'closed') p.push('includeDone=1');
    // Только рабочие — на КАЖДОЙ вкладке: личное преподавателя администратору не отдаётся
    // вовсе (см. `принадлежность`).
    p.push('kind=work');
    window.apiCall('/tasks' + (p.length ? '?' + p.join('&') : ''))
      .then((r) => {
        if (!alive) return;
        // Сервер уже отдал только рабочие (kind=work выше). Отсев здесь — страховка на случай
        // старого сервера, который параметр не знает: личная, попав в ленту, влияла бы на
        // счётчик задач, поиск и пустые состояния, оставаясь невидимой.
        const все = (r.tasks || []).filter((t) => принадлежность(t) !== 'personal');
        const итог = scope === 'closed' ? все.filter(завершена) : все;
        if (фоновое) {
          const свежие = window.изменённыеId(прошлыеRef.current, итог, ['title', 'status', 'dueAt', 'dueFrom', 'priority', 'assigneeStaffId', 'body']);
          const сейчас = Date.now();
          итог.forEach((t) => { if (свежие.has(t.id)) t.__flashAt = сейчас; });
        }
        прошлыеRef.current = итог;
        setRows(итог);
        setLoading(false);
      })
      .catch((e) => { if (alive && !фоновое) { setError(e && e.message ? e.message : 'Не удалось загрузить задачи'); setLoading(false); } });
    return () => { alive = false; };
  }, [scope, myStaffId, ver, обновление]);

  const переключить = async (t) => {
    // Из любого ЗАКРЫТОГО статуса кружок возвращает задачу в работу, не только из
    // «выполнена». Иначе отменённая — тупик: править её нельзя (закрыта), а другого способа
    // открыть заново в разделе нет, и подсказка «верните в работу» обещала бы несбыточное.
    const next = завершена(t) ? 'open' : 'done';
    setBusyId(t.id);
    try {
      const r = await window.apiCall('/tasks/' + t.id, { method: 'PATCH', body: JSON.stringify({ status: next }) });
      // Строку из списка НЕ убираем, хотя из текущей вкладки она этим кликом и выпадает —
      // из «Мои» и «Все» закрытая, из «Закрытых» вернувшаяся в работу: задача, исчезнувшая
      // по клику, не даёт понять, что произошло, и промах уже не отменить.
      // Уедет при следующей загрузке раздела — этого достаточно.
      setRows((rs) => rs.map((x) => (x.id === t.id ? (r.task || Object.assign({}, x, { status: next })) : x)));
      if (onToast) onToast(next === 'done' ? 'Задача выполнена' : 'Задача снова в работе');
    } catch (e) {
      // Отказ сервера — его текстом: у 403 period_locked в нём дата закрытия периода.
      if (onToast) onToast(e && e.message ? e.message : 'Не удалось изменить задачу');
    }
    setBusyId(null);
  };

  const создать = async (payload) => {
    const r = await window.apiCall('/tasks', { method: 'POST', body: JSON.stringify(payload) });
    const созданная = r.task;
    // Созданную задачу должно быть видно: если её поставили другому человеку, а открыт
    // фильтр «Мои», список после сохранения выглядел бы так, будто ничего не создалось.
    const видна = scope === 'all' || (scope === 'mine' && созданная && созданная.assigneeStaffId === myStaffId);
    if (видна) setVer((v) => v + 1); else setScope('all');
    if (onToast) onToast('Задача создана');
  };

  // Патч считает форма — она одна видит и старое, и новое. Ошибку наверх НЕ ловим:
  // её показывает сама форма, там же, где человек правит, и с текстом сервера как есть.
  const сохранить = async (id, patch) => {
    // Пустой патч не отправляем: сервер на нём всё равно двинет updated_at, и в истории
    // появится «кто-то трогал задачу» на действии, которым ничего не изменили.
    if (Object.keys(patch).length === 0) { if (onToast) onToast('Ничего не изменилось'); return; }
    const r = await window.apiCall('/tasks/' + id, { method: 'PATCH', body: JSON.stringify(patch) });
    // Строку обновляем НА МЕСТЕ: перечитывание списка сбросило бы прокрутку и порядок, а
    // человек после сохранения смотрит именно на свою строку. И не убираем её, даже если
    // задача уехала из текущего фильтра (сменили исполнителя при «Мои») — по той же
    // причине, что и в переключить: исчезнувшая строка не объясняет, что произошло.
    setRows((rs) => rs.map((x) => (x.id === id ? (r.task || Object.assign({}, x, patch)) : x)));
    if (onToast) onToast('Задача изменена');
  };

  // Клик по строке — правка. Закрытую (выполненную или отменённую) не открываем: это уже
  // чей-то отчёт о сделанном, и если его переписать задним числом, спорить о том, что
  // именно поручали, станет нечем. Сервер откажет тем же, но сказать это ДО заполненной
  // формы честнее, чем после.
  const открытьПравку = (t) => {
    if (завершена(t)) {
      // Вернуть её в работу сам человек не может, если период закрыт, — и совет «верните»
      // обещал бы несбыточное.
      if (onToast) onToast(t.status === 'done' && t.reopenLocked ? ПЕРИОД_ЗАКРЫТ
        : 'Закрытую задачу не редактируют — сначала верните её в работу');
      return;
    }
    setEdit(t);
  };

  // Сотрудники для выпадающего списка — только работающие: назначать на уволенного нечего.
  const staff = (window.CRM_DATA.STAFF || []).filter((s) => s.status !== 'fired');

  const активныхФильтров = (фРоль ? 1 : 0) + (фИсп != null ? 1 : 0) + (фСрок !== 'any' ? 1 : 0);
  const сброситьФильтры = () => { setФРоль(null); setФИсп(null); setФСрок('any'); };
  // Роль и конкретный исполнитель друг друга не сужают, а ГАСЯТ: вместе они дают пустую
  // ленту («задачи преподавателей, из них — задачи менеджера Ивановой»), и человек решит, что
  // задачи потерялись. Более точный выбор побеждает: назвали фамилию — роль снимается, взяли
  // роль — снимается фамилия, если она из другой роли.
  const выбратьИсполнителя = (v) => {
    setФИсп(v);
    if (v != null && v !== 'none') setФРоль(null);
    // «Мои» — жёсткий серверный отбор по СВОЕМУ staffId, чужие задачи в него не приходят
    // вовсе. Оставить вкладку как есть значило бы показать пустой экран на осмысленный выбор.
    if (v != null && v !== myStaffId && scope === 'mine') setScope('all');
  };
  const выбратьРоль = (v) => {
    setФРоль(v);
    if (v && typeof фИсп === 'number') { const s = сотрудникПоId(фИсп); if (!s || s.role !== v) setФИсп(null); }
    if (v && scope === 'mine') setScope('all');
  };

  const найдено = rows.filter((t) => {
    if (фСрок !== 'any' && !поСроку(t, фСрок)) return false;
    if (фИсп === 'none') { if (t.assigneeStaffId != null) return false; }
    else if (фИсп != null && t.assigneeStaffId !== фИсп) return false;
    if (фРоль && рольИсполнителя(t) !== фРоль) return false;
    if (!q) return true;
    const s = `${t.title || ''} ${t.body || ''} ${t.assigneeName || ''} ${t.authorName || ''}`.toLowerCase();
    return s.includes(q.toLowerCase());
  });
  // Порядок: сначала то, что в работе, внутри — по сроку (ближайшие сверху, бессрочные в
  // конец группы). Просроченное само оказывается наверху — отдельной сортировки не нужно.
  const срокКлюч = (t) => (t.dueAt ? new Date(t.dueAt).getTime() : Infinity);
  const список = найдено.slice().sort((a, b) =>
    (завершена(a) ? 1 : 0) - (завершена(b) ? 1 : 0) || срокКлюч(a) - срокКлюч(b) || a.id - b.id);

  // Третья вкладка — не «Выполненные», а «Закрытые»: в ней лежат оба закрытых статуса, и
  // отменённая стоит рядом с выполненной, отличаясь маркером факта («Не произошло»).
  // Раскладывать их по двум вкладкам нечего: закрытые смотрят, чтобы вспомнить, чем дело
  // кончилось, а «чем кончилось» — это как раз и есть разница между ними.
  const chips = [
    { id: 'mine', label: 'Мои' },
    { id: 'all', label: 'Все' },
    { id: 'closed', label: 'Закрытые' },
  ];
  const th = { textAlign: 'left', fontSize: 11.5, fontWeight: 700, letterSpacing: '0.04em', textTransform: 'uppercase', color: 'var(--text-dim)', padding: '0 18px 13px', whiteSpace: 'nowrap' };
  const td = { padding: '13px 18px', borderTop: '1px solid var(--border)', fontSize: 14, color: 'var(--text)', verticalAlign: 'middle' };

  const skeleton = (
    <div style={{ padding: '6px 18px 10px' }}>
      {[0, 1, 2, 3, 4].map((i) => (
        <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '14px 0', borderTop: i ? '1px solid var(--border)' : 'none' }}>
          <span className="om-skel" style={{ width: 24, height: 24, borderRadius: '50%', background: 'var(--bg-soft)', flex: 'none' }} />
          <span className="om-skel" style={{ height: 14, borderRadius: 4, background: 'var(--bg-soft)', flex: 1, maxWidth: 320 }} />
          <span className="om-skel" style={{ height: 14, width: 140, borderRadius: 4, background: 'var(--bg-soft)' }} />
          <span className="om-skel" style={{ height: 14, width: 80, borderRadius: 4, background: 'var(--bg-soft)' }} />
        </div>
      ))}
    </div>
  );

  // Пусто «совсем» и пусто «по фильтру» — разные сообщения: в первом случае человеку нужно
  // предложить завести первую задачу, во втором — подсказать, что мешает фильтр, а не сервер.
  const пустоСовсем = !q && !активныхФильтров && scope !== 'closed';
  const empty = (
    <div style={{ padding: '56px 24px', textAlign: 'center' }}>
      <div style={{ width: 64, height: 64, margin: '0 auto 18px', borderRadius: 'var(--radius-lg)', background: 'var(--bg-soft)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-dim)' }}>
        <Icon name="check-square" size={30} strokeWidth={1.5} />
      </div>
      {пустоСовсем ? (
        <React.Fragment>
          <h3 style={{ fontSize: 17, fontWeight: 700, margin: '0 0 6px', color: 'var(--text)' }}>
            {scope === 'mine' ? 'На вас пока ничего не назначено' : 'Задач пока нет'}
          </h3>
          <p style={{ fontSize: 14, color: 'var(--text-dim)', margin: '0 auto 18px', maxWidth: 380 }}>
            Здесь то, что нужно не забыть: перезвонить, дособрать документы, назначить пробное.
          </p>
          <Button icon="plus" onClick={() => setForm(true)}>Добавить первую задачу</Button>
        </React.Fragment>
      ) : (
        <React.Fragment>
          <p style={{ fontSize: 14, color: 'var(--text-dim)', margin: 0 }}>
            {q ? 'По запросу ничего не нашлось' : активныхФильтров ? 'Под фильтр ничего не подошло' : 'Закрытых задач пока нет'}
          </p>
          {/* Сброс — прямо в пустом экране: именно тут человек и упирается в то, что задачи
              «пропали», и идти за кнопкой наверх ему уже незачем. */}
          {активныхФильтров > 0 && (
            <div style={{ marginTop: 14 }}><Button variant="secondary" onClick={сброситьФильтры}>Сбросить фильтры</Button></div>
          )}
        </React.Fragment>
      )}
    </div>
  );

  return (
    <div style={{ padding: '24px 32px 40px' }}>
      <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10, marginBottom: 18 }}>
        <div style={{ display: 'inline-flex', gap: 8 }}>
          {chips.map((c) => {
            const on = scope === c.id;
            // «Мои» без карточки сотрудника недоступны — не прячем пункт, а объясняем почему.
            const нельзя = c.id === 'mine' && myStaffId === null;
            return (
              <button key={c.id} onClick={() => { if (!нельзя) setScope(c.id); }} disabled={нельзя}
                title={нельзя ? 'У вашей учётной записи нет карточки сотрудника — задачи на вас не назначают' : undefined}
                style={{
                  padding: '7px 14px', borderRadius: 'var(--radius-pill)', cursor: нельзя ? 'default' : 'pointer',
                  border: '1px solid ' + (on ? 'transparent' : 'var(--border)'),
                  background: on ? 'var(--brand-soft)' : 'var(--bg-card)',
                  color: on ? 'var(--brand-ink)' : 'var(--text-secondary)',
                  fontFamily: 'var(--font-sans)', fontSize: 13, fontWeight: 600, opacity: нельзя ? 0.45 : 1,
                  transition: 'background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease)',
                }}>{c.label}</button>
            );
          })}
        </div>
        {!loading && !error && <span style={{ fontSize: 13, color: 'var(--text-dim)' }}>{список.length} {plural(список.length, ['задача', 'задачи', 'задач'])}</span>}
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '8px 12px', width: 240 }}>
          <Icon name="search" size={16} style={{ color: 'var(--text-dim)' }} />
          <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Поиск по задачам…" style={{ border: 'none', outline: 'none', background: 'transparent', flex: 1, fontFamily: 'var(--font-sans)', fontSize: 13.5, color: 'var(--text)' }} />
        </div>
        {/* Кнопка — рядом с поиском, а не в ряду вкладок: вкладки отвечают на «чьи задачи
            вообще показывать», фильтры — на «что из показанного оставить», и мешать их в
            один ряд значило бы выдать охват за фильтр. Включённый фильтр виден по самой
            кнопке (цвет и число), иначе пропавшие задачи выглядят как поломка. */}
        <button onClick={() => setФильтрыОткрыты((o) => !o)}
          title={активныхФильтров ? 'Фильтры включены' : 'Отобрать по исполнителю и сроку'}
          style={{
            display: 'inline-flex', alignItems: 'center', gap: 7, padding: '8px 13px', cursor: 'pointer',
            borderRadius: 'var(--radius-sm)', fontFamily: 'var(--font-sans)', fontSize: 13.5, fontWeight: 600,
            border: '1px solid ' + (активныхФильтров || фильтрыОткрыты ? 'var(--brand)' : 'var(--border)'),
            background: активныхФильтров ? 'var(--brand-soft)' : 'var(--bg-card)',
            color: активныхФильтров ? 'var(--brand-ink)' : 'var(--text-secondary)',
            transition: 'background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease)',
          }}>
          <Icon name="sliders-horizontal" size={15} />
          Фильтры
          {активныхФильтров > 0 && (
            <span style={{ minWidth: 17, height: 17, padding: '0 5px', borderRadius: 9, background: 'var(--brand)', color: '#fff', fontSize: 11, fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>{активныхФильтров}</span>
          )}
        </button>
        <div style={{ flex: 1 }} />
        <Button icon="plus" onClick={() => setForm(true)}>Добавить задачу</Button>
      </div>

      {фильтрыОткрыты && (
        // Панель РАСКРЫВАЕТСЯ В ПОТОКЕ, а не висит поповером над списком: выпадающий список
        // исполнителей рисуется порталом в body, и поповер, закрывающийся по клику мимо, сам
        // бы схлопывался на выборе человека.
        <Card pad={16} style={{ marginBottom: 14 }}>
          <div style={{ display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-start' }}>
            <div style={{ flex: '1 1 260px', minWidth: 240 }}>
              <div style={{ ...подписьПоля, marginBottom: 8 }}>Исполнитель</div>
              {/* числаРолей={false} — числа людей рядом с фильтром читались бы как число
                  задач; см. разбор в самом ВыборИсполнителя. */}
              <ВыборИсполнителя люди={staff} role={фРоль} onRole={выбратьРоль} числаРолей={false}
                head={[{ value: null, label: 'Любой исполнитель' }, { value: 'none', label: 'Не назначен' }]}
                value={фИсп} onChange={выбратьИсполнителя} placeholder="Любой исполнитель" />
            </div>
            <div style={{ flex: '1 1 320px' }}>
              <div style={{ ...подписьПоля, marginBottom: 8 }}>Срок</div>
              <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
                {ФИЛЬТР_СРОКА.map((f) => {
                  const on = фСрок === f.id;
                  return (
                    <button key={f.id} onClick={() => setФСрок(f.id)} style={{
                      padding: '7px 12px', borderRadius: 'var(--radius-pill)', cursor: 'pointer',
                      border: '1px solid ' + (on ? 'transparent' : 'var(--border)'),
                      background: on ? 'var(--brand-soft)' : 'var(--bg-card)',
                      color: on ? 'var(--brand-ink)' : 'var(--text-secondary)',
                      fontFamily: 'var(--font-sans)', fontSize: 12.5, fontWeight: 600, whiteSpace: 'nowrap',
                      transition: 'background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease)',
                    }}>{f.label}</button>
                  );
                })}
              </div>
            </div>
            <div style={{ flex: 'none', alignSelf: 'center' }}>
              <Button variant="ghost" onClick={сброситьФильтры} style={{ opacity: активныхФильтров ? 1 : 0.4, pointerEvents: активныхФильтров ? 'auto' : 'none' }}>Сбросить</Button>
            </div>
          </div>
        </Card>
      )}

      {/* Что именно включено — словами и со снятием по одному. Показываем и при закрытой
          панели: человек, вернувшийся в раздел, должен видеть причину короткого списка, не
          открывая ничего. */}
      {активныхФильтров > 0 && (
        <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 14 }}>
          <span style={{ fontSize: 12.5, color: 'var(--text-dim)' }}>Отобрано:</span>
          {фРоль && <ФильтрЧип label={рольПодпись(фРоль)} onDrop={() => setФРоль(null)} />}
          {фИсп != null && (
            <ФильтрЧип
              label={фИсп === 'none' ? 'Не назначен' : ((сотрудникПоId(фИсп) || {}).name || 'Сотрудник #' + фИсп)}
              onDrop={() => setФИсп(null)} />
          )}
          {фСрок !== 'any' && <ФильтрЧип label={(ФИЛЬТР_СРОКА.find((f) => f.id === фСрок) || {}).label} onDrop={() => setФСрок('any')} />}
          {активныхФильтров > 1 && (
            <button onClick={сброситьФильтры} style={{ border: 'none', background: 'none', cursor: 'pointer', padding: '0 4px', fontFamily: 'var(--font-sans)', fontSize: 12.5, fontWeight: 600, color: 'var(--brand-ink)', textDecoration: 'underline', textUnderlineOffset: 3 }}>Сбросить всё</button>
          )}
        </div>
      )}

      <Card pad={0} style={{ overflow: 'hidden' }}>
        {loading ? skeleton : error ? (
          <div style={{ padding: '48px 24px', textAlign: 'center' }}>
            <div style={{ color: 'var(--coral-ink)', fontSize: 14, marginBottom: 14 }}>{error}</div>
            <Button variant="secondary" onClick={() => setVer((v) => v + 1)}>Повторить</Button>
          </div>
        ) : список.length === 0 ? empty : (
          <div ref={setКоробка} style={{ overflowX: 'auto' }}>
            <table style={{ width: '100%', borderCollapse: 'collapse', minWidth: тесно ? 900 : 1060 }}>
              <thead><tr>
                <th style={{ ...th, paddingTop: 16, width: 44 }} aria-label="Выполнено" />
                <th style={{ ...th, paddingTop: 16 }}>Задача</th>
                <th style={{ ...th, paddingTop: 16 }}>Автор</th>
                <th style={{ ...th, paddingTop: 16 }}>Исполнитель</th>
                <th style={{ ...th, paddingTop: 16 }}>Срок</th>
                <th style={{ ...th, paddingTop: 16 }}>Приоритет</th>
                <th style={{ ...th, paddingTop: 16 }}>Статус</th>
              </tr></thead>
              {/* Смена ключа перемонтирует все строки, и появление (om-row-in) играет заново.
                  Это нужно на СМЕНЕ НАБОРА одним действием — переключили вкладку, нажали чип
                  роли или срока: набор меняется разом, и мягкий вход показывает, что список
                  другой. Строки поиска в ключе НЕТ: она меняется на каждую букву, и таблица
                  размонтировалась и проигрывала анимацию по десять раз за слово — при обычной
                  скорости набора список мигал не переставая. Без неё уцелевшие строки живут
                  своими key={t.id} и не дёргаются, а те, что возвращаются при стирании буквы,
                  входят по одной — это и есть настоящая смена набора. */}
              <tbody key={`${scope}|${фРоль}|${фИсп}|${фСрок}`}>
                {список.map((t, ri) => {
                  const st = TASK_STATUS[t.status] || TASK_STATUS.open;
                  const pr = TASK_PRIORITY[t.priority] || TASK_PRIORITY.normal;
                  const late = просрочена(t);
                  const done = t.status === 'done';
                  const исп = сотрудникПоId(t.assigneeStaffId);
                  const рольИсп = исп ? рольПодпись(исп.role) : '';
                  const админ = отАдминистратора(t);
                  const тон = TASK_TONE[админ ? 'admin' : 'work'];
                  // Вся строка — вход в правку, как в «Заявках». Кружок «выполнено» гасит
                  // всплытие у себя, иначе отметка о выполнении открывала бы ещё и форму.
                  // Закрытая строка тоже кликается: ответом будет подсказка, почему нельзя.
                  return (
                    <tr key={t.id} className={'row-hover om-row-in' + (window.свежо(t) ? ' om-flash-row' : '')} onClick={() => открытьПравку(t)}
                      style={{ cursor: 'pointer', animationDelay: Math.min(ri * 24, 280) + 'ms', opacity: завершена(t) ? 0.6 : 1 }}>
                      {/* Полоса у края строки — происхождение задачи (см. TASK_TONE), а не
                          заливка: в списке из двух десятков задач сплошной цветной фон
                          перестаёт что-либо выделять. Раньше этой полосой помечалась просрочка,
                          но цветом теперь распоряжается только происхождение — иначе один канал
                          означал бы две разные вещи. О просрочке говорит колонка «Срок»
                          (коралловая подпись со значком), и она же поднимает такие задачи
                          наверх сортировкой.
                          Полоса — на ячейке, потому что тень на <tr> перебивается ховером строки. */}
                      <td title={тон.title} style={{ ...td, width: 44, paddingRight: 0, boxShadow: 'inset 3px 0 0 ' + тон.line }}>
                        <DoneToggle task={t} busy={busyId === t.id} onToggle={переключить} />
                      </td>
                      {/* В тесноте ячейка заголовка забирает остаток ширины и может ужаться до нуля
                          (max-width: 0) — длинный заголовок режется многоточием, а не распирает таблицу. */}
                      <td style={тесно ? { ...td, width: '100%', maxWidth: 0 } : td}>
                        <div style={{ fontWeight: 600, color: done ? 'var(--text-dim)' : 'var(--text)', textDecoration: done ? 'line-through' : 'none', display: 'flex', alignItems: 'center', gap: 8 }}>
                          <span style={{ maxWidth: 460, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={t.title}>{t.title}</span>
                          {/* Плашки «Личная» здесь больше нет: личные задачи в ленту не
                              попадают вовсе (см. `принадлежность`), и помечать в ней нечего. */}
                          {t.entityType && ENTITY_LABEL[t.entityType] && (
                            <span style={{ flex: 'none', padding: '1px 7px', borderRadius: 'var(--radius-xs)', fontSize: 11.5, fontWeight: 600, background: 'var(--bg-soft)', color: 'var(--text-dim)', border: '1px solid var(--border)' }}>
                              {ENTITY_LABEL[t.entityType]}{t.entityId != null ? ' #' + t.entityId : ''}
                            </span>
                          )}
                        </div>
                        {t.body && <div style={{ fontSize: 12.5, color: 'var(--text-dim)', marginTop: 3, maxWidth: 460, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={t.body}>{t.body}</div>}
                      </td>
                      {/* Автор — в каждой строке: поручение сверху и собственная запись живут по
                          разным правилам, и отличать их нужно до того, как строку открыли.
                          Административное дополнительно помечено значком и цветом полосы —
                          «она будет отмечена как администратора» (07:08). */}
                      {/* В тесноте имя переносится на вторую строку: «Гончаренко / Виталий» уже вдвое,
                          и место достаётся заголовку задачи. */}
                      <td style={{ ...td, whiteSpace: тесно ? 'normal' : 'nowrap' }}>
                        <span title={админ ? TASK_TONE.admin.title : undefined}
                          style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 13.5, fontWeight: админ ? 600 : 500, color: админ ? 'var(--gold-ink)' : 'var(--text-secondary)' }}>
                          {админ && <Icon name="shield" size={14} />}
                          {имяАвтора(t, myUserId)}
                        </span>
                      </td>
                      <td style={td}>
                        {t.assigneeStaffId != null ? (
                          <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
                            <Avatar animal={исп ? исп.animal : undefined} tone={исп ? исп.tone : undefined} size={28} />
                            <span title={тесно && рольИсп ? рольИсп : undefined} style={{ fontSize: 13.5, color: 'var(--text-secondary)', whiteSpace: тесно ? 'normal' : 'nowrap' }}>{t.assigneeName || (исп ? исп.name : '—')}</span>
                            {/* Роль — СПРАВА от фамилии и тише её: «справа приписки» (31:34).
                                Тише потому, что ищут человека всё-таки по фамилии, а роль
                                отвечает уже на второй вопрос — кто он такой. */}
                            {рольИсп && !тесно && <span style={{ fontSize: 12.5, color: 'var(--text-dim)', whiteSpace: 'nowrap' }}>{рольИсп}</span>}
                          </div>
                        ) : <span style={{ color: 'var(--text-dim)' }}>Не назначен</span>}
                      </td>
                      <td style={{ ...td, whiteSpace: 'nowrap' }}>
                        {t.dueAt ? (
                          <span title={late && тесно ? 'Просрочено' : undefined} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 13.5, fontWeight: late ? 600 : 500, color: late ? 'var(--coral)' : 'var(--text-secondary)' }}>
                            {late && <Icon name="alert-circle" size={14} />}
                            {периодПодпись(t)}{late && !тесно && <span style={{ fontSize: 12, fontWeight: 600 }}>· просрочено</span>}
                          </span>
                        ) : <span style={{ color: 'var(--text-dim)' }}>—</span>}
                      </td>
                      <td style={td}>
                        <span style={{ display: 'inline-block', padding: '2px 9px', borderRadius: 'var(--radius-xs)', fontSize: 12, fontWeight: 600, background: pr.bg, color: pr.fg, border: '1px solid ' + pr.bd }}>{pr.label}</span>
                      </td>
                      <td style={td}>
                        {/* У закрытой задачи вместо точки — значок факта: точка сообщает «есть
                            какой-то статус», а галочка и крестик читаются как «произошло» и
                            «не произошло» с одного взгляда. Подсказкой держим и прежнее слово:
                            в переписке и в письмах задача по-прежнему «выполнена» или «отменена». */}
                        <span title={st.hint || st.label} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 13, fontWeight: 600, color: st.fg, whiteSpace: 'nowrap' }}>
                          {st.mark
                            ? <span style={{ width: 16, height: 16, borderRadius: '50%', background: st.dot, color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flex: 'none' }}>
                                <Icon name={st.mark} size={11} strokeWidth={3} />
                              </span>
                            : <span style={{ width: 8, height: 8, borderRadius: '50%', background: st.dot, flex: 'none' }} />}
                          {st.label}
                        </span>
                      </td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div>
        )}
      </Card>

      {(form || edit) && (
        // key — чтобы форма пересоздавалась под каждое открытие: поля она заполняет один
        // раз, на монтировании, и без этого правка второй задачи подряд открылась бы с
        // полями первой.
        <TaskForm key={edit ? 'edit-' + edit.id : 'new'} staff={staff} task={edit} defaultAssignee={myStaffId} meUserId={myUserId}
          onClose={() => { setForm(false); setEdit(null); }} onCreate={создать} onSave={сохранить} />
      )}
    </div>
  );
}

Object.assign(window, { Tasks });
