Кабинет в чужом портале
Это разбор личного кабинета сотрудника, который мы делали для компании с коробочным Битрикс24. Называется «Мой ВЕСТ»: показатели, задания на день, дебиторка, табель и внутренние материалы в одном месте. Данные кабинет получает из закрытого контура и сам туда ни разу не стучится. Кода в тексте почти не будет, речь про решения и про то, чем за каждое пришлось заплатить.
Откуда взялась задача
У компании давно было хранилище: выручка, маржа, дебиторка, отгрузки, всё считалось и лежало в DWH. Смотрели на это трое аналитиков. Менеджер узнавал свои цифры из письма раз в месяц, а про долг клиента обычно от бухгалтера, когда тот звонил.
Хотели простую вещь: человек открывает портал и видит свои показатели, свои задания на сегодня и материалы, которые его касаются. Руководителю то же самое по отделу, плюс табель. Портал уже был, Битрикс24 в коробке, так что кабинету предстояло жить внутри чужого продукта и по его правилам.
Ограничения
Почти вся архитектура выросла из трёх ограничений, и каждое отрезало по половине вариантов.
Первое: 1С, MS SQL и хранилище стоят в локальной сети, открывать туда порт наружу нельзя. Это даже не обсуждалось. Второе: портал не наш, мы в нём гости и можем пользоваться только REST и теми встройками, которые Битрикс разрешает. Третье бытовое, но важное. Торговые представители и склад заходят с телефона чаще, чем с компьютера, так что без нормальной мобильной версии кабинет увидела бы половина людей.
Как данные попадают в кабинет
Очевидный вариант, когда кабинет сам подключается к DWH и спрашивает что нужно, отпал первым. Это дырка в периметре, VPN, белые списки и долгий разговор с безопасностью, который ничем хорошим не кончается.
Поэтому направление развернули. Внутри контура в Airflow крутится DAG: он собирает витрины и раз в сутки, в 5:30, отправляет их наружу по HTTPS. Кабинет только принимает. Он не знает, где стоит хранилище, и никуда не ходит. Десять датасетов, приём батчами по 5000 строк, идемпотентность по номеру выгрузки. Токенов отправителя несколько, чтобы ротировать ключ без остановки выгрузки.
Цена понятная: данные не в реальном времени. Для показателей и дебиторки этого хватает, кабинет честно пишет, когда обновлялся. Для сценариев вроде «клиент прямо сейчас в трубке» не хватает, и такие задачи в кабинет сознательно не заводили.
Справочник и факты
Показатели устроены парой таблиц: metrics_catalog описывает метрику, как её называть и что считать хорошим ростом, а kpi_facts хранит числа по сотрудникам за период. Фронт знает только правила форматирования: деньги, проценты, штуки.
Когда добавляли задания на день, очень хотелось захардкодить виды («звонок», «дебиторка») и под каждый нарисовать свою карточку. Вместо этого повторили ту же схему. task_types: справочник, где у вида задания есть заголовок, тон, значок, набор действий и коды исходов. tasks: сами строки. Кабинет не понимает смысла задания, он рисует то, что пришло из витрины: заголовок, пояснение, цифры и кнопки. Новый вид задания теперь строка в справочнике на стороне ETL, а не релиз фронтенда.
Обратная сторона: все формулировки уехали в ETL. Кривой заголовок в витрине означает кривой заголовок у сотрудника, и на фронте его не починить. Пришлось писать отдельный документ-контракт для команды хранилища.
Вход
Формы входа у кабинета нет, как и таблицы пользователей с хешами. Личность подтверждает Битрикс: передаёт во фрейм токен текущего сотрудника, кабинет проверяет этот токен у портала и выдаёт поверх свою короткую сессию.
На первом же тесте вылезли две вещи. Чужой портал нужно отсекать до похода в сеть, по идентификатору портала и домену, иначе валидный токен с соседнего Битрикса открывал бы наш кабинет. И браузеры режут сторонние cookie внутри фрейма, поэтому сессия едет двумя путями сразу: в cookie и во фрагменте адреса, который на сервер не уходит и в логах не оседает.
Всего способов войти три: приложение портала, страница портала и фрейм. Каждый по-своему доказывает, кто пришёл, но дальше все ведут в один и тот же выпуск сессии.
Вход со страницы портала
Когда понадобилось открывать кабинет прямо страницей портала, оказалось, что спрашивать Битрикс «кто это» не нужно. Он уже ответил на вопрос, когда пустил человека на страницу. Страница знает $USER->GetID(), подписывает пару «идентификатор сотрудника плюс метка времени» общим секретом, кабинет проверяет подпись, находит сотрудника и выдаёт сессию. Ссылка живёт две минуты.
Подпись решает ровно одну задачу: отличить «портал говорит, что это сотрудник 42» от «кто-то набрал uid=42 руками». Пока общий секрет не задан, этот вход отвечает 404; неиспользуемая дверь наружу торчать не должна.
Зависимость от часов получили в нагрузку. Если время на портале и на сервере кабинета разъедется больше чем на две минуты, вход перестанет работать. С внятным сообщением, но перестанет.
Главная страница портала
Заказчик хотел видеть кабинет блоком на главной портала. Прежде чем что-то делать, я вычитал каталог мест встройки Битрикс24 целиком: сорок с лишним кодов, карточки CRM, задачи, чаты, телефония, календарь, профиль. Главной страницы среди них нет.
У главной свой механизм, виджеты «Вайба». Но это не фрейм. Разработчик отдаёт Vue-шаблон и адрес обработчика, свой JavaScript запрещён, из обработчиков доступны три предопределённые функции. Перенести туда готовое приложение нельзя в принципе.
Биться в эту дверь не стали. Коробочная версия даёт то, чего нет в облаке: доступ к файлам портала. Своя страница /lk/ с фреймом на весь экран решает исходную задачу лучше виджета и не ломается при обновлении платформы. Виджет оставили как второй сценарий, короткая сводка на главной, клик ведёт в полный кабинет. Минус очевиден, решение привязано к коробке. Если компания когда-нибудь переедет в облако, эта дверь закроется, останутся пункт меню и виджет.
Табель
В табеле тридцать один день на человека. На компьютере это привычная сетка «люди на дни», и она работает: виден весь отдел разом, клик по клетке ставит восьмёрку, правая кнопка открывает карточку дня.
На телефоне ту же таблицу медиазапросами не починить. Пробовали. Закреплённая колонка с фамилией съедала почти весь экран, дни уезжали в горизонтальную прокрутку, которой не видно, и сотрудник просто не знал, что правее что-то есть.
В итоге раскладок две, и ветку выбирает компонент, а не CSS. Телефон получает список сотрудников, у каждого месяц клетками по неделям: семь колонок вместо тридцати одной, и читается это привычнее таблицы. Правого клика на телефоне нет, поэтому там нажатие сразу открывает карточку дня нижней шторкой, с кнопкой «Рабочий день, 8 ч» первой. На компьютере быстрый клик оставили, потому что проставлять приходится десятками.
Два пути в коде вместо одного, да. Но данные и действия у них общие, различается только разметка.
Навигация
Сначала меню собиралось прямо в JSX, и пока раскладка была одна, это никого не беспокоило. Когда появился телефон, та же простыня приехала туда лентой с горизонтальной прокруткой: одиннадцать пунктов подряд, без заголовков групп, половина за краем экрана. Найти «Табель» можно было только перебором.
Структуру разделов вынесли в данные. Функция собирает группы по правам сотрудника, а раскладки рисуют их по-своему: на компьютере колонка, на телефоне четыре частых раздела нижней панелью плюс список «Ещё», где пункты стоят под своими заголовками.
Права здесь не прячут кнопки, а определяют, какие разделы вообще существуют для человека. Профиль отвечает, есть ли у него показатели, дебиторка, табель, подчинённые. У склада нет ни одного из этих разделов, и меню у него короткое по-настоящему, без серых пунктов.
Два меню рядом
Побочный эффект встройки: колонка разделов кабинета оказывается рядом с колонкой разделов портала. Два вертикальных списка подряд читаются как один сбитый, человек ищет «Задания» и не понимает, в чьём меню смотреть.
Колонку кабинета по умолчанию свернули в полосу значков. Она разворачивается по наведению поверх содержимого, не сдвигая страницу; раздвигать страницу под курсором нельзя, таблицы начинают прыгать. Кому неудобно, тот закрепляет её кнопкой, выбор запоминается. Неожиданный бонус достался табелю: на ноутбуке в сетку теперь помещается 28 дней вместо 20.
Плотность
Первая версия выглядела просторной и современной, и заказчик сказал ровно то, что говорят в таких случаях: слишком много воздуха, дизайн должен быть корпоративным и функциональным.
Пройтись по файлу и всюду убавить отступы было бы быстрее всего, и через неделю разнобой вернулся бы. Вместо этого зазоры и радиусы свели к короткому набору токенов, три отступа и три радиуса, и переписали компоненты через них. Плотность держится на том, что вариантов мало и они повторяются.
С главной вышло в два захода. Сначала дела поставили выше цифр, логика была в том, что рабочий день начинается со списка, а не с отчёта. Через месяц пришла обратная связь: пять полных карточек занимали весь первый экран, и до показателей никто не долистывал. Развернули обратно, но не просто поменяли блоки местами, а сжали задание до строки, а полный разбор унесли на отдельный экран.
Замеры
Мерил на одних и тех же демо-данных, на ширинах 1440 и 390.
| Что мерил | Было | Стало | Разница | ||
|---|---|---|---|---|---|
| Блок «Мой день», компьютер | 995 px | 389 px | −61 % | ||
| Блок «Мой день», телефон | 1422 px | 590 px | −59 % | ||
| Высота главной, компьютер | 1798 px | 1208 px | −33 % | ||
| Высота главной, телефон | 2794 px | 1977 px | −29 % | ||
| Показатели видны без прокрутки | нет | да | |||
Последняя строка показывает, как решения тянут друг друга: свёрнутая колонка задумывалась против двух меню, а помогла табелю.
Что проверялось
На бэкенде 225 тестов, и большая часть не про «функция вернула число», а про правила видимости и отказы. Чужой портал не пускают, сотрудник без прав не видит чужой отдел, подделанная подпись отклоняется, просроченная ссылка отклоняется, подмена идентификатора при настоящей подписи тоже.
Отдельная история с двумя языками. Подпись входа считает PHP на портале, а проверяет Python в кабинете, и разойтись они могут на мелочи вроде порядка аргументов. Ловить такое в бою неприятно, поэтому прогон сделали сквозным: ссылку подписал php, обработчик её принял и открыл сессию нужного сотрудника.
Интерфейс проверяли в настоящем браузере на демо-данных. Каждый экран снимался на 1440 и 390 пикселях, отдельно смотрели, что ни одна страница не уезжает вбок. Сорок снимков показали то, чего в коде не видно.
Что осталось недоделанным
- Данные раз в сутки. Для «сколько я продал» нормально, для «клиент только что оплатил» нет. Если такие сценарии появятся, push придётся дополнять событиями, учащать выгрузку не выход.
- Кабинет во фрейме не разговаривает с порталом: не умеет открывать слайдеры Битрикса и не отдаёт наружу свои события. Пока не требовалось, но это следующая стена.
- Виджет главной получает личность смотрящего не так, как приложение. Документация об этом молчит, официальный пример обходит вопрос стороной. Обработчик написан так, чтобы работать в обоих случаях, но окончательный ответ даст только живой портал.
- Контракт с командой хранилища надо было заводить раньше. Первые три месяца формат датасетов уточнялся письмами, и это стоило дороже, чем документ на две страницы в самом начале.
Стек
Бэкенд на FastAPI и PostgreSQL, миграции на Alembic, асинхронный SQLAlchemy. Фронт на React с TypeScript и Vite, без UI-библиотеки: свой набор компонентов и около 3200 строк CSS, потому что интерфейс должен стоять рядом с Битриксом и не спорить с ним. Разворачивается контейнерами, фронт отдаёт nginx, он же проксирует API. Выгрузка живёт в Airflow на стороне заказчика. В цифрах: 21 модель, 59 эндпоинтов, 10 датасетов, 225 тестов, примерно 14 тысяч строк.
Ничего экзотического, и это сознательно: проект живёт у клиента, поддерживать его будет не тот, кто писал.
Снимки сделаны на демонстрационных данных: сотрудники, клиенты, суммы и ИНН вымышлены. Числа замеров получены в браузере на одних и тех же данных до и после изменений.
