Compare commits
No commits in common. "726497fe9963d780bfbc189a377416524f1e94ab" and "349caf237b2e6e562632438b634e73c7a7b66a05" have entirely different histories.
726497fe99
...
349caf237b
127
PRD.md
127
PRD.md
@ -1,127 +0,0 @@
|
|||||||
# PRD: Помощник руководителя по распределению задач
|
|
||||||
|
|
||||||
## 1. Проблема
|
|
||||||
|
|
||||||
Руководитель получает задачи от бизнеса (через чат, почту, устно) и вручную решает, кому их передать. Это занимает время, требует помнить зоны ответственности каждого сотрудника и приводит к ошибкам (задача уходит не туда → задержки, переделки).
|
|
||||||
|
|
||||||
## 2. Цель
|
|
||||||
|
|
||||||
Сервис-помощник, который принимает на вход формулировку задачи от бизнеса и автоматически предлагает ответственного сотрудника (или пару дублёров) на основе зон ответственности.
|
|
||||||
|
|
||||||
## 3. Целевой пользователь
|
|
||||||
|
|
||||||
Руководитель команды/отдела (1 человек на старте). Возможное расширение — тимлиды смежных команд.
|
|
||||||
|
|
||||||
## 4. Сценарий использования (MVP)
|
|
||||||
|
|
||||||
1. Руководитель открывает веб-страницу помощника.
|
|
||||||
2. Вставляет текст задачи от бизнеса (1-2 абзаца).
|
|
||||||
3. Нажимает «Определить ответственного».
|
|
||||||
4. Получает:
|
|
||||||
- основного ответственного (ФИО) + дублёра;
|
|
||||||
- категорию задачи (мобилка / CRM / SMS / …);
|
|
||||||
- короткое обоснование («в задаче упомянуто push-уведомление → мобильное приложение → Алина»);
|
|
||||||
- кнопку «Скопировать сообщение для сотрудника» (готовый текст для пересылки).
|
|
||||||
|
|
||||||
## 5. Справочник зон ответственности
|
|
||||||
|
|
||||||
**Источник данных — руководитель вводит сам.** Помощник предоставляет UI-форму, в которой руководитель добавляет/редактирует/удаляет сотрудников и их зоны ответственности.
|
|
||||||
|
|
||||||
Поля по каждому сотруднику:
|
|
||||||
- ФИО;
|
|
||||||
- основная зона ответственности (одна из существующих или новая);
|
|
||||||
- роль в зоне: основной / дублёр;
|
|
||||||
- статус: активен / в отпуске / недоступен (флажок, ставится руководителем вручную ИЛИ автоматически из системы «Симбейс» — см. §7);
|
|
||||||
- **взаимозаменяющий сотрудник** (`delegate`) — заполняется при статусе «в отпуске». Указывает руководитель. Может быть кем угодно из справочника, не обязательно дублёр из той же зоны;
|
|
||||||
- (опц.) контакт для пересылки сообщения (Telegram / e-mail).
|
|
||||||
|
|
||||||
Хранение: JSON-файл в репо или `localStorage` на MVP. Экспорт/импорт справочника одной кнопкой (JSON) — чтобы не потерять при переустановке.
|
|
||||||
|
|
||||||
Стартовый список зон (заполняется руководителем):
|
|
||||||
|
|
||||||
| Направление | Основной | Дублёр |
|
|
||||||
|---|---|---|
|
|
||||||
| Мобильное приложение | Алина | Адлет |
|
|
||||||
| CRM-система | Динара | Камила |
|
|
||||||
| SMS-сообщения / рассылки | Михаил | Мухамед |
|
|
||||||
| *(добавляется по мере роста)* | | |
|
|
||||||
|
|
||||||
Каждое направление содержит:
|
|
||||||
- список ключевых слов / синонимов («push», «iOS», «Android», «приложение» → мобилка);
|
|
||||||
- список систем («Bitrix», «amoCRM» → CRM);
|
|
||||||
- примеры задач (для few-shot подсказки LLM).
|
|
||||||
|
|
||||||
## 6. Логика подбора
|
|
||||||
|
|
||||||
**Вариант A (MVP, без LLM):** поиск по ключевым словам из справочника. Категории ранжируются по числу совпадений / весу ключевых слов.
|
|
||||||
|
|
||||||
**Вариант B (next):** LLM-классификатор (Claude). На вход — текст задачи + справочник, на выход — список категорий с оценкой уверенности + обоснование. Лучше работает с размытыми формулировками («сделайте так, чтобы клиенты получали уведомления о платежах»).
|
|
||||||
|
|
||||||
**Обработка нескольких категорий.** Если задача попадает более чем в одно направление (или уверенность ниже порога — **< 70%, зафиксировано**), помощник **не выбирает за руководителя**, а показывает все подходящие варианты с обоснованием и весами. Руководитель кликом выбирает, куда направить задачу. Выбор фиксируется как обучающий пример для будущих улучшений классификатора.
|
|
||||||
|
|
||||||
**Edge cases:**
|
|
||||||
- **В зоне один сотрудник без дублёра** → показать его + предупреждение «нет резервного исполнителя».
|
|
||||||
- **Основной в отпуске** → задача уходит взаимозаменяющему сотруднику, указанному руководителем (см. §5). Если не указан — дублёру по зоне.
|
|
||||||
- **Все сотрудники зоны в отпуске и нет замещающих** → результат «нет доступного ответственного, требуется решение руководителя». Помощник предлагает руководителю либо взять на себя, либо назначить из соседней зоны вручную.
|
|
||||||
|
|
||||||
Старт с A, переход на B при появлении ошибок классификации.
|
|
||||||
|
|
||||||
## 7. Функциональные требования
|
|
||||||
|
|
||||||
- [ ] Веб-форма ввода задачи (textarea + кнопка).
|
|
||||||
- [ ] Вывод результата: ответственный, дублёр, категория, обоснование.
|
|
||||||
- [ ] **Режим выбора при нескольких категориях:** карточки всех подходящих направлений с обоснованием и весом → клик руководителя выбирает финального ответственного.
|
|
||||||
- [ ] CRUD-форма для справочника сотрудников (добавить / редактировать / удалить / отметить «в отпуске»). **Доступ на изменение — только у руководителя** (вход по пин-коду / паролю). Остальные пользователи видят справочник только в режиме чтения (если им вообще откроют ссылку).
|
|
||||||
- [ ] Сценарий смены сотрудника: руководитель открывает раздел «Сотрудники» → находит запись → меняет ФИО / зону / статус → сохраняет. Изменения применяются мгновенно к следующим задачам. Прошлые решения в истории не переписываются (фиксируют, кто был назначен на момент задачи).
|
|
||||||
- [ ] Журнал изменений справочника: кто / когда / что поменял (для аудита, на MVP — простой список в JSON).
|
|
||||||
- [ ] Экспорт / импорт справочника в JSON (бэкап).
|
|
||||||
- [ ] Кнопка «Скопировать сообщение» (формирует текст: «@Алина, прилетела задача от бизнеса: <текст>. Ты основная по мобилке, посмотри плиз»).
|
|
||||||
- [ ] Редактируемый справочник зон ответственности (JSON/YAML в репо или простая форма в UI).
|
|
||||||
- [ ] **История задач** (последние 100 записей):
|
|
||||||
- читаема и руководителю, и взаимозаменяющему сотруднику (общий доступ по ссылке, не в `localStorage` одного браузера);
|
|
||||||
- для каждой задачи: исходный текст, выбранная зона, назначенный исполнитель, дата, комментарии руководителя/исполнителя;
|
|
||||||
- **кнопка «Скопировать историю задачи»** — выгружает текст всей задачи + комментариев в буфер обмена для пересылки.
|
|
||||||
- [ ] Возможность вручную переопределить ответственного (если помощник ошибся) — фиксируется как «обучающий пример».
|
|
||||||
- [ ] **Указание взаимозаменяющего сотрудника** при отметке «в отпуске». Без замещающего сотрудник в отпуске не может быть сохранён, если он единственный «основной» в зоне.
|
|
||||||
- [ ] **Интеграция с системой «Симбейс»** (приказы об отпусках сотрудников):
|
|
||||||
- помощник периодически (раз в сутки) опрашивает Симбейс по списку сотрудников из справочника;
|
|
||||||
- если найден активный приказ на отпуск — статус автоматически переключается в «в отпуске» с датами;
|
|
||||||
- руководителю приходит уведомление и просьба указать замещающего, если он ещё не указан;
|
|
||||||
- при ручном расхождении со статусом из Симбейс — приоритет у Симбейс (источник истины).
|
|
||||||
- [ ] **Двуязычный интерфейс RU / EN.** Переключатель языка в шапке. Все надписи в UI и шаблоны сообщений для пересылки — на двух языках.
|
|
||||||
|
|
||||||
## 8. Нефункциональные требования
|
|
||||||
|
|
||||||
- Хостинг: статика на Gitea Pages (вариант A — чистый JS).
|
|
||||||
- Без внешних БД на MVP.
|
|
||||||
- Время ответа: < 1 сек (вариант A), < 5 сек (вариант B с LLM).
|
|
||||||
- Доступ к классификатору задач — по ссылке (без авторизации на MVP).
|
|
||||||
- **Доступ к разделу «Сотрудники» (CRUD) — защищён пин-кодом / паролем, известным только руководителю.** Без него — режим только чтения.
|
|
||||||
|
|
||||||
## 9. Метрики успеха
|
|
||||||
|
|
||||||
- Доля задач, по которым руководитель **согласился** с предложенным ответственным без правок — целевое значение ≥ 70% после первых 30 задач.
|
|
||||||
- Сокращение времени на распределение одной задачи: с ~2-3 мин (вручную) до < 30 сек.
|
|
||||||
|
|
||||||
## 10. Вне scope MVP
|
|
||||||
|
|
||||||
- Интеграция с почтой / Telegram / Slack как **канал входа задачи** (на MVP — только веб-форма; пересылка ответственному — копированием текста).
|
|
||||||
- Постановка задачи в трекер (Jira/Bitrix).
|
|
||||||
- Многопользовательский режим с ролями (на MVP — только разделение «руководитель / все остальные»).
|
|
||||||
- Учёт больничных и точной загрузки (часы). Только бинарный статус «в отпуске / активен».
|
|
||||||
|
|
||||||
## 11. План реализации
|
|
||||||
|
|
||||||
1. **v0.1** — статическая страница + JSON-справочник + поиск по ключевым словам + i18n RU/EN + кнопка «Скопировать сообщение». Деплой на Gitea Pages.
|
|
||||||
2. **v0.2** — CRUD справочника, пин-код, журнал изменений, поле «замещающий», история задач (общая, не в `localStorage`), копирование истории, edge cases (зона без дублёра / все в отпуске).
|
|
||||||
3. **v0.3** — LLM-классификатор (вариант B) для размытых формулировок.
|
|
||||||
4. **v0.4** — интеграция с системой «Симбейс» (приказы на отпуска), уведомления руководителю о новых приказах.
|
|
||||||
5. **v0.5** — внешние каналы входа задачи (Telegram / e-mail).
|
|
||||||
|
|
||||||
## 12. Открытые вопросы
|
|
||||||
|
|
||||||
- **Симбейс**: какой формат API (REST / SOAP / выгрузка)? Какие поля приказа доступны (ФИО, табельный №, даты, тип отпуска)? Как сопоставлять сотрудников из справочника помощника с записями в Симбейс — по ФИО, табельному номеру, e-mail?
|
|
||||||
- **Общая история задач** требует серверного хранения (не `localStorage`). Где хранить — простой JSON в репо `gitea-pages` (push при каждом изменении) или отдельный лёгкий бэкенд? Решение влияет на архитектуру v0.2.
|
|
||||||
- **Контакты сотрудников** (Telegram / e-mail) лежат в публичном `directory.json` на Gitea Pages — допустимо ли это политикой безопасности? Если нет — выносим контакты в приватный слой (бэкенд) уже в v0.2.
|
|
||||||
- Нужна ли модерация LLM-ответа человеком на 100% задач или только при низкой уверенности?
|
|
||||||
- Уведомления руководителю о новом приказе из Симбейс — куда слать (e-mail / Telegram-бот)?
|
|
||||||
586
TRD.md
586
TRD.md
@ -1,586 +0,0 @@
|
|||||||
# TRD: Помощник руководителя по распределению задач
|
|
||||||
|
|
||||||
Технический документ к [PRD.md](PRD.md). Описывает архитектуру, структуры данных, алгоритмы и план реализации MVP.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Архитектура
|
|
||||||
|
|
||||||
### 1.1. Высокоуровневая схема
|
|
||||||
|
|
||||||
```
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ Браузер (Gitea Pages) │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────┐ ┌────────────────┐ ┌──────────────────┐ │
|
|
||||||
│ │ Задача (UI) │ │ Сотрудники(UI) │ │ История (UI) │ │
|
|
||||||
│ └──────┬───────┘ └────────┬───────┘ └────────┬─────────┘ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ ▼ ▼ ▼ │
|
|
||||||
│ ┌────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ app.js — классификация, CRUD, история │ │
|
|
||||||
│ └────┬─────────────────────┬─────────────────────────────┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ ▼ ▼ │
|
|
||||||
│ ┌─────────────┐ ┌─────────────────┐ │
|
|
||||||
│ │ directory. │ │ localStorage │ │
|
|
||||||
│ │ json (репо) │ │ (history, │ │
|
|
||||||
│ │ │ │ overrides) │ │
|
|
||||||
│ └─────────────┘ └─────────────────┘ │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2. Стек
|
|
||||||
|
|
||||||
- **Хостинг:** Gitea Pages (ветка `gitea-pages`, топик `gitea-pages-allowall`, см. [GITEA.md](GITEA.md)).
|
|
||||||
- **Фронтенд:** чистый HTML/CSS/JS без сборки (Vanilla). Никакого Node, никакого билда — деплой = `git push origin gitea-pages`.
|
|
||||||
- **Хранилище:**
|
|
||||||
- Справочник зон + сотрудников — `directory.json` в репозитории (ветка `gitea-pages`). Источник истины.
|
|
||||||
- История задач, переопределения, журнал изменений — `localStorage` в браузере руководителя.
|
|
||||||
- Экспорт/импорт — скачать/загрузить `directory.json` через `<a download>` и `<input type="file">`.
|
|
||||||
- **LLM (v0.3):** API Claude (`claude-haiku-4-5-20251001` для классификатора — быстрый и дешёвый). Вызов из браузера через прокси (см. §6) или временно через CORS-разрешённый прямой вызов.
|
|
||||||
|
|
||||||
### 1.3. Почему не докер / не бэкенд
|
|
||||||
|
|
||||||
PRD §8: MVP на статике. Gitea Pages бесплатен, автодеплой. Бэкенд понадобится только для:
|
|
||||||
- защиты API-ключа Claude (v0.3+);
|
|
||||||
- многопользовательского доступа (вне scope MVP);
|
|
||||||
- настоящей авторизации (на MVP пин-код в браузере — компромисс).
|
|
||||||
|
|
||||||
Когда понадобится — поднимаем сервис через `docker-compose.yml` + Traefik по правилам [GITEA.md](GITEA.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Структуры данных
|
|
||||||
|
|
||||||
### 2.1. `directory.json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"updatedAt": "2026-05-22T10:00:00Z",
|
|
||||||
"zones": [
|
|
||||||
{
|
|
||||||
"id": "mobile",
|
|
||||||
"title": "Мобильное приложение",
|
|
||||||
"keywords": ["push", "ios", "android", "приложение", "мобилка", "apk", "ipa"],
|
|
||||||
"systems": ["AppStore", "Google Play", "Firebase"],
|
|
||||||
"examples": [
|
|
||||||
"Сделать push-уведомление по платежам",
|
|
||||||
"Поменять иконку приложения в iOS"
|
|
||||||
],
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "crm",
|
|
||||||
"title": "CRM-система",
|
|
||||||
"keywords": ["crm", "црм", "клиент", "сделка", "карточка", "amocrm", "bitrix", "битрикс"],
|
|
||||||
"systems": ["Bitrix24", "amoCRM"],
|
|
||||||
"examples": ["Добавить поле «промо-код» в карточку клиента"],
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "sms",
|
|
||||||
"title": "SMS-сообщения / рассылки",
|
|
||||||
"keywords": ["sms", "смс", "рассылка", "уведомление по смс", "сообщение"],
|
|
||||||
"systems": ["SMSC", "Twilio"],
|
|
||||||
"examples": ["Запустить SMS-рассылку по базе клиентов"],
|
|
||||||
"weight": 1.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"people": [
|
|
||||||
{ "id": "p1", "name": "Алина", "nameEn": "Alina", "zoneId": "mobile", "role": "primary", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-001", "contact": "@alina_tg" },
|
|
||||||
{ "id": "p2", "name": "Адлет", "nameEn": "Adlet", "zoneId": "mobile", "role": "backup", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-002", "contact": "@adlet_tg" },
|
|
||||||
{ "id": "p3", "name": "Динара", "nameEn": "Dinara", "zoneId": "crm", "role": "primary", "status": "vacation", "delegateId": "p4", "vacationUntil": "2026-06-15", "simbaseId": "EMP-003", "contact": "dinara@x.kz" },
|
|
||||||
{ "id": "p4", "name": "Камила", "nameEn": "Kamila", "zoneId": "crm", "role": "backup", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-004", "contact": "kamila@x.kz" },
|
|
||||||
{ "id": "p5", "name": "Михаил", "nameEn": "Mikhail","zoneId": "sms", "role": "primary", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-005", "contact": "@mikhail_tg" },
|
|
||||||
{ "id": "p6", "name": "Мухамед","nameEn": "Mukhamed","zoneId":"sms", "role": "backup", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-006", "contact": "@muhamed_tg" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
Поля:
|
|
||||||
- `delegateId` — id замещающего сотрудника. Обязателен при `status == "vacation"`, если человек — единственный `primary` в зоне без активного дублёра.
|
|
||||||
- `vacationUntil` — дата окончания отпуска (ISO). Заполняется автоматически из Симбейс (см. §6.2) или вручную.
|
|
||||||
- `simbaseId` — внешний идентификатор сотрудника в системе «Симбейс». Используется для сопоставления при синхронизации приказов.
|
|
||||||
- `nameEn` — ФИО латиницей для англоязычного UI (i18n).
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2. localStorage (только локальные настройки руководителя)
|
|
||||||
|
|
||||||
| Ключ | Содержимое |
|
|
||||||
|---|---|
|
|
||||||
| `app.pinHash` | SHA-256 пин-кода руководителя (см. §5) |
|
|
||||||
| `app.lang` | `"ru"` или `"en"` — выбранный язык интерфейса |
|
|
||||||
| `app.directoryOverride` | локальный патч справочника, если правки в UI ещё не выгружены в репо (см. §4) |
|
|
||||||
|
|
||||||
### 2.3. Общая история задач — `history.json` в репо
|
|
||||||
|
|
||||||
Чтобы историю видел **и руководитель, и взаимозаменяющий сотрудник** (PRD §7), история не лежит в `localStorage`. Хранится файлом `history.json` в ветке `gitea-pages` рядом с `directory.json`. Изменения коммитятся через Gitea API (см. §7.4).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type HistoryEntry = {
|
|
||||||
id: string; // uuid
|
|
||||||
createdAt: string; // ISO
|
|
||||||
taskText: string;
|
|
||||||
suggestions: Array<{ zoneId: string; score: number; reason: string }>;
|
|
||||||
chosenZoneId: string | null;
|
|
||||||
chosenPersonId: string | null;
|
|
||||||
delegateUsed: boolean; // true, если задача ушла замещающему вместо основного
|
|
||||||
manualOverride: boolean;
|
|
||||||
comments: Array<{
|
|
||||||
at: string;
|
|
||||||
authorRole: "manager" | "assignee" | "delegate";
|
|
||||||
authorName: string;
|
|
||||||
text: string;
|
|
||||||
}>;
|
|
||||||
status: "open" | "in_progress" | "done" | "cancelled";
|
|
||||||
};
|
|
||||||
|
|
||||||
type AuditEntry = { // в audit.json в репо
|
|
||||||
at: string;
|
|
||||||
action: "create" | "update" | "delete";
|
|
||||||
entity: "zone" | "person";
|
|
||||||
entityId: string;
|
|
||||||
before: object | null;
|
|
||||||
after: object | null;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Файлы в репо: `directory.json`, `history.json`, `audit.json` (все в корне ветки `gitea-pages`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Алгоритм классификации (вариант A, MVP)
|
|
||||||
|
|
||||||
### 3.1. Нормализация текста
|
|
||||||
|
|
||||||
```js
|
|
||||||
function normalize(s) {
|
|
||||||
return s.toLowerCase()
|
|
||||||
.replace(/ё/g, "е")
|
|
||||||
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
|
||||||
.split(/\s+/).filter(Boolean);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2. Скоринг зоны
|
|
||||||
|
|
||||||
Для каждой зоны:
|
|
||||||
```
|
|
||||||
score(zone) = Σ ( hits(keyword) * weight(keyword) )
|
|
||||||
+ Σ ( hits(system) * 1.5 )
|
|
||||||
where hits(token) = число вхождений токена в нормализованный текст задачи
|
|
||||||
```
|
|
||||||
|
|
||||||
Базовый `weight(keyword) = 1.0`. Имена систем имеют повышенный вес (1.5), т.к. это сильный сигнал (упомянули «amoCRM» — почти точно CRM).
|
|
||||||
|
|
||||||
### 3.3. Решение
|
|
||||||
|
|
||||||
```
|
|
||||||
sorted = zones.sort(desc by score).filter(score > 0)
|
|
||||||
if len(sorted) == 0: → «не удалось определить», показать все зоны
|
|
||||||
elif len(sorted) == 1: → показать одну зону
|
|
||||||
elif sorted[0].score >= 2 * sorted[1]: → показать топ-1 как основной, остальные «также возможно»
|
|
||||||
else: → режим выбора (PRD §6): показать топ-N (N ≤ 3) карточками
|
|
||||||
```
|
|
||||||
|
|
||||||
Порог `2x` подобран эмпирически; вынесен в конфиг (`config.dominanceFactor = 2.0`).
|
|
||||||
|
|
||||||
### 3.4. Назначение конкретного человека (с учётом отпусков и замещений)
|
|
||||||
|
|
||||||
```
|
|
||||||
function assignPerson(zoneId):
|
|
||||||
candidates = people.filter(p => p.zoneId == zoneId)
|
|
||||||
primary = candidates.find(p => p.role == "primary")
|
|
||||||
backup = candidates.find(p => p.role == "backup")
|
|
||||||
|
|
||||||
// Шаг 1: основной активен → берём его
|
|
||||||
if primary && primary.status == "active":
|
|
||||||
return { main: primary, fallback: backup if backup && backup.status == "active" else null }
|
|
||||||
|
|
||||||
// Шаг 2: основной в отпуске + указан замещающий → берём замещающего
|
|
||||||
if primary && primary.status == "vacation" && primary.delegateId:
|
|
||||||
delegate = people.find(p => p.id == primary.delegateId)
|
|
||||||
if delegate && delegate.status == "active":
|
|
||||||
return { main: delegate, fallback: null, note: "замещает " + primary.name }
|
|
||||||
|
|
||||||
// Шаг 3: основной недоступен / без замещающего → дублёр зоны
|
|
||||||
if backup && backup.status == "active":
|
|
||||||
return { main: backup, fallback: null, note: "основной недоступен" }
|
|
||||||
|
|
||||||
// Шаг 4: все в зоне неактивны
|
|
||||||
return { main: null, fallback: null, error: "NO_ASSIGNEE", message: "Все сотрудники зоны недоступны. Решение требуется от руководителя." }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.5. Edge case: все в зоне в отпуске (PRD §6)
|
|
||||||
|
|
||||||
Если `assignPerson()` вернул `error == "NO_ASSIGNEE"`, UI выводит карточку:
|
|
||||||
- заголовок «Нет доступного ответственного»;
|
|
||||||
- список сотрудников зоны со статусами и датами возвращения из отпуска (`vacationUntil`);
|
|
||||||
- две кнопки:
|
|
||||||
- «Назначить вручную» → модалка со всем справочником, руководитель выбирает любого;
|
|
||||||
- «Принять на себя» → исполнитель = руководитель.
|
|
||||||
|
|
||||||
В историю запись пишется с `manualOverride: true` и комментарием о причине.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. UI
|
|
||||||
|
|
||||||
### 4.1. Страницы (одностраничник с табами)
|
|
||||||
|
|
||||||
1. **«Задача»** (по умолчанию):
|
|
||||||
- `<textarea>` — ввод задачи.
|
|
||||||
- Кнопка «Определить ответственного».
|
|
||||||
- Блок результата: карточка(и) зон → выбор → карточка человека → кнопка «Скопировать сообщение».
|
|
||||||
|
|
||||||
2. **«Сотрудники»** (защищено пин-кодом):
|
|
||||||
- Таблица сотрудников: ФИО, зона, роль, статус, контакт, действия (✎ / 🗑).
|
|
||||||
- Кнопка «+ Добавить сотрудника».
|
|
||||||
- Раздел «Зоны»: список зон, редактирование keywords/systems/examples.
|
|
||||||
- Кнопки «Экспорт JSON» / «Импорт JSON».
|
|
||||||
- Подраздел «Журнал изменений» — табличный вывод `app.audit`.
|
|
||||||
|
|
||||||
3. **«История»** (доступна и руководителю, и замещающему сотруднику):
|
|
||||||
- Список последних 100 задач: дата, текст, зона, исполнитель, статус (open / in_progress / done / cancelled).
|
|
||||||
- Клик по записи → раскрытие: полный текст задачи + хронологическая лента комментариев (руководитель / исполнитель / замещающий).
|
|
||||||
- Поле ввода нового комментария (для исполнителя и замещающего — без пин-кода, только подпись именем из URL-параметра `?as=<personId>` или выбор из дропдауна).
|
|
||||||
- **Кнопка «Скопировать историю задачи»** — выгружает в буфер обмена форматированный текст: задача + все комментарии + статус. Удобно переслать в чат.
|
|
||||||
- Фильтры: по зоне, по исполнителю, по статусу.
|
|
||||||
|
|
||||||
### 4.4. Локализация (RU / EN)
|
|
||||||
|
|
||||||
- Переключатель `RU | EN` в шапке.
|
|
||||||
- Все строки UI вынесены в `i18n.json`:
|
|
||||||
```json
|
|
||||||
{ "ru": { "btn.classify": "Определить ответственного", ... },
|
|
||||||
"en": { "btn.classify": "Find assignee", ... } }
|
|
||||||
```
|
|
||||||
- ФИО хранятся в `name` (RU) + `nameEn` (EN, опц.). Если `nameEn` пуст — отображается `name`.
|
|
||||||
- Шаблоны сообщений для пересылки — два варианта по языку.
|
|
||||||
- Хранение выбранного языка в `localStorage["app.lang"]`.
|
|
||||||
|
|
||||||
### 4.2. Компоненты (без фреймворка)
|
|
||||||
|
|
||||||
```
|
|
||||||
index.html
|
|
||||||
├─ <header> навигация (3 таба)
|
|
||||||
├─ <main id="view-task"> ← по умолчанию
|
|
||||||
├─ <main id="view-people" hidden>
|
|
||||||
└─ <main id="view-history" hidden>
|
|
||||||
```
|
|
||||||
|
|
||||||
`app.js` подписывается на клики по навигации, переключает `hidden` и рендерит контент из шаблонов (`<template>`-теги в `index.html`).
|
|
||||||
|
|
||||||
### 4.3. Сообщение для пересылки
|
|
||||||
|
|
||||||
Шаблон RU:
|
|
||||||
```
|
|
||||||
@{{name}}, прилетела задача от бизнеса:
|
|
||||||
|
|
||||||
«{{taskText}}»
|
|
||||||
|
|
||||||
Ты {{role_label}} по направлению «{{zone}}».{{delegate_note}} Посмотри, пожалуйста.
|
|
||||||
Ссылка на историю задачи: {{history_url}}
|
|
||||||
```
|
|
||||||
Шаблон EN:
|
|
||||||
```
|
|
||||||
@{{nameEn}}, a new business task:
|
|
||||||
|
|
||||||
"{{taskText}}"
|
|
||||||
|
|
||||||
You are {{role_label}} for "{{zone}}".{{delegate_note}} Please take a look.
|
|
||||||
Task history: {{history_url}}
|
|
||||||
```
|
|
||||||
`role_label`: «основной(ая)» / «дублёр» / «замещающий» (или EN: `primary` / `backup` / `delegate`).
|
|
||||||
`delegate_note` появляется, если задача ушла замещающему: « Ты замещаешь {{primaryName}}, который в отпуске до {{vacationUntil}}.»
|
|
||||||
`history_url` — прямая ссылка на запись в истории: `https://zhanakova.telecom.quest/zhanakova/#/history/{{taskId}}`.
|
|
||||||
|
|
||||||
Копирование через `navigator.clipboard.writeText`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Защита раздела «Сотрудники»
|
|
||||||
|
|
||||||
### 5.1. Модель угроз
|
|
||||||
|
|
||||||
Ссылка на сайт может быть публичной (Gitea Pages). Нельзя пускать рандомных людей в CRUD справочника. Нужна простая аутентификация без бэкенда.
|
|
||||||
|
|
||||||
### 5.2. Решение (MVP)
|
|
||||||
|
|
||||||
1. При первом открытии вкладки «Сотрудники» — модалка «Задайте пин-код руководителя» (4-6 символов). Хэшируется SHA-256 → `app.pinHash` в `localStorage`.
|
|
||||||
2. Дальнейший вход в раздел — ввод пин-кода. Сверка с хэшем.
|
|
||||||
3. Пин-код **не** улучшает security перед злоумышленником с доступом к `localStorage`/devtools. Это барьер от случайных правок коллегами, которые откроют ссылку. **Явная пометка в UI:** «Это не криптографическая защита».
|
|
||||||
4. Сам `directory.json` лежит в публичном репо Gitea — данные сотрудников видны всем, у кого есть ссылка. Если данные чувствительные — переходим на бэкенд (v0.4+, см. §6).
|
|
||||||
|
|
||||||
### 5.3. Реализация хэша
|
|
||||||
|
|
||||||
```js
|
|
||||||
async function sha256(text) {
|
|
||||||
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
||||||
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. LLM-классификатор (вариант B, v0.3)
|
|
||||||
|
|
||||||
### 6.1. Архитектура
|
|
||||||
|
|
||||||
Прямой вызов Claude API из браузера **не** допускается — ключ утечёт. Поднимаем минимальный прокси:
|
|
||||||
|
|
||||||
```
|
|
||||||
[Браузер] ──HTTPS──▶ [proxy.telecom.quest] ──HTTPS──▶ [api.anthropic.com]
|
|
||||||
(Docker + Traefik,
|
|
||||||
хранит ANTHROPIC_API_KEY)
|
|
||||||
```
|
|
||||||
|
|
||||||
`docker-compose.yml` в репо помощника (см. [GITEA.md](GITEA.md) §«Сервисы и динамика»):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
task-router-proxy:
|
|
||||||
image: node:22-alpine
|
|
||||||
working_dir: /app
|
|
||||||
volumes: [".:/app"]
|
|
||||||
command: node proxy.js
|
|
||||||
environment:
|
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
|
||||||
expose: ["8080"]
|
|
||||||
networks: [traefik]
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.taskrouter.rule=Host(`task-router.telecom.quest`)"
|
|
||||||
- "traefik.http.routers.taskrouter.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.taskrouter.tls=true"
|
|
||||||
- "traefik.http.services.taskrouter.loadbalancer.server.port=8080"
|
|
||||||
|
|
||||||
networks:
|
|
||||||
traefik:
|
|
||||||
external: true
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2. Промпт классификатора (черновик)
|
|
||||||
|
|
||||||
```
|
|
||||||
Ты — классификатор задач. Дан список направлений и текст задачи от бизнеса.
|
|
||||||
Верни JSON-массив топ-3 направлений, отсортированный по уверенности.
|
|
||||||
|
|
||||||
Направления:
|
|
||||||
{{zones_json}}
|
|
||||||
|
|
||||||
Задача:
|
|
||||||
"""
|
|
||||||
{{task_text}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
Формат ответа (строго JSON, без префиксов/постфиксов):
|
|
||||||
[{ "zoneId": "...", "confidence": 0.0-1.0, "reason": "одно предложение почему" }, ...]
|
|
||||||
```
|
|
||||||
|
|
||||||
Модель: `claude-haiku-4-5-20251001`. Кэшируем системную часть промпта (список зон) — prompt caching.
|
|
||||||
|
|
||||||
### 6.3. Fallback
|
|
||||||
|
|
||||||
Если запрос к LLM упал (таймаут / 5xx) — откатываемся на алгоритм §3. Логируем неудачу в `audit.json`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6A. Интеграция с системой «Симбейс» (приказы об отпусках)
|
|
||||||
|
|
||||||
### 6A.1. Цель
|
|
||||||
|
|
||||||
Автоматически синхронизировать статус сотрудников (`active` / `vacation`) и `vacationUntil` со внутренней HR-системой Симбейс. Чтобы руководитель не отмечал отпуска вручную.
|
|
||||||
|
|
||||||
### 6A.2. Архитектура
|
|
||||||
|
|
||||||
Pull-модель: cron-задача на том же прокси (см. §6.1) опрашивает Симбейс раз в сутки (`0 6 * * *`):
|
|
||||||
|
|
||||||
```
|
|
||||||
[Cron на proxy.telecom.quest]
|
|
||||||
│
|
|
||||||
├── GET https://simbase.<internal>/api/vacations?from=<today>
|
|
||||||
│ Authorization: Bearer <SIMBASE_API_TOKEN>
|
|
||||||
│
|
|
||||||
├── Для каждого приказа находим сотрудника в directory.json по simbaseId
|
|
||||||
│
|
|
||||||
├── Обновляем status, vacationUntil
|
|
||||||
│
|
|
||||||
├── Если нет delegateId → ставим флаг "needs_delegate"
|
|
||||||
│
|
|
||||||
└── PUSH в Gitea (commit + push в gitea-pages)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6A.3. Контракт API (предположения, требуют подтверждения у владельцев Симбейс)
|
|
||||||
|
|
||||||
Запрос:
|
|
||||||
```http
|
|
||||||
GET /api/vacations?from=2026-05-22&to=2026-12-31
|
|
||||||
```
|
|
||||||
Ожидаемый ответ:
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"employeeId": "EMP-003",
|
|
||||||
"fullName": "Динара Иванова",
|
|
||||||
"vacationStart": "2026-05-25",
|
|
||||||
"vacationEnd": "2026-06-15",
|
|
||||||
"orderNumber": "О-2026-451",
|
|
||||||
"orderDate": "2026-05-10"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Сопоставление: `directory.people[i].simbaseId == response[j].employeeId`. Если совпадения нет — приказ игнорируется и логируется в `audit.json` как `unmatched_vacation_order`.
|
|
||||||
|
|
||||||
### 6A.4. Реакция на отсутствие замещающего
|
|
||||||
|
|
||||||
Если в результате синка сотрудник переведён в `vacation`, но у него `delegateId == null` и нет активного `backup` в зоне — отправляется уведомление руководителю:
|
|
||||||
- e-mail с темой «Требуется замещающий для {{name}}»;
|
|
||||||
- в UI помощника при следующем входе — баннер «1 сотрудник требует замещающего».
|
|
||||||
|
|
||||||
Канал уведомления — конфиг (`config.notificationChannel`: `email` / `telegram` / `none`).
|
|
||||||
|
|
||||||
### 6A.5. Приоритет источников
|
|
||||||
|
|
||||||
Симбейс — источник истины для отпусков. При расхождении между ручной отметкой и Симбейс — побеждает Симбейс. Ручная отметка возможна только если сотрудник в Симбейс не заведён или приказа нет.
|
|
||||||
|
|
||||||
### 6A.6. Защита токена
|
|
||||||
|
|
||||||
`SIMBASE_API_TOKEN` — только в env прокси. Никогда не уходит в браузер. Прокси `task-router` сам инициирует синк, без участия фронта.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Деплой
|
|
||||||
|
|
||||||
### 7.1. Структура репозитория
|
|
||||||
|
|
||||||
```
|
|
||||||
.
|
|
||||||
├─ index.html # вход
|
|
||||||
├─ app.js # вся логика
|
|
||||||
├─ styles.css
|
|
||||||
├─ directory.json # справочник зон и сотрудников
|
|
||||||
├─ history.json # общая история задач (v0.2+)
|
|
||||||
├─ audit.json # журнал изменений (v0.2+)
|
|
||||||
├─ i18n.json # строки RU/EN
|
|
||||||
├─ README.md
|
|
||||||
├─ PRD.md
|
|
||||||
├─ TRD.md
|
|
||||||
└─ (v0.3+) proxy.js, docker-compose.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.4. Запись истории/справочника обратно в репо
|
|
||||||
|
|
||||||
История и правки CRUD сохраняются в Gitea через Contents API:
|
|
||||||
```
|
|
||||||
PUT https://git.telecom.quest/api/v1/repos/kt-innovators/zhanakova/contents/history.json
|
|
||||||
Authorization: token <GITEA_PAT>
|
|
||||||
Body: { "branch": "gitea-pages", "content": "<base64 нового JSON>", "sha": "<текущий sha файла>", "message": "..." }
|
|
||||||
```
|
|
||||||
|
|
||||||
`GITEA_PAT` хранится в прокси (см. §6). Фронт делает POST на прокси, прокси делает PUT в Gitea. Так PAT не попадает в браузер.
|
|
||||||
|
|
||||||
Альтернатива на v0.1 (до прокси): руководитель вручную делает «Экспорт JSON» → коммит через VS Code в браузере. История пишется в `localStorage` временно, до перехода на v0.2.
|
|
||||||
|
|
||||||
### 7.2. Ветка `gitea-pages`
|
|
||||||
|
|
||||||
Согласно [GITEA.md](GITEA.md):
|
|
||||||
|
|
||||||
1. Создать ветку `gitea-pages` в репо `kt-innovators/zhanakova`.
|
|
||||||
2. Залить `index.html`, `app.js`, `styles.css`, `directory.json` в корень ветки.
|
|
||||||
3. Добавить топик `gitea-pages-allowall` к репозиторию.
|
|
||||||
4. Открыть `https://zhanakova.telecom.quest/zhanakova/`.
|
|
||||||
|
|
||||||
Автодеплой: каждый `git push origin gitea-pages` обновляет сайт.
|
|
||||||
|
|
||||||
### 7.3. Локальная разработка
|
|
||||||
|
|
||||||
Достаточно `python3 -m http.server 8000` в корне → `http://localhost:8000`. Без билда, без npm.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. План реализации (тех. разбивка по версиям PRD §11)
|
|
||||||
|
|
||||||
### v0.1 — статика + поиск по словам + i18n
|
|
||||||
|
|
||||||
- [ ] `index.html` + минимальный CSS (3 таба) + переключатель RU/EN.
|
|
||||||
- [ ] `i18n.json` со всеми строками (RU + EN).
|
|
||||||
- [ ] `directory.json` со стартовыми зонами и сотрудниками (PRD §5 таблица).
|
|
||||||
- [ ] `app.js`: загрузка `directory.json`, normalize, скоринг (§3), рендер карточек.
|
|
||||||
- [ ] Кнопка «Скопировать сообщение» (два шаблона по языкам).
|
|
||||||
- [ ] Деплой на Gitea Pages.
|
|
||||||
|
|
||||||
### v0.2 — CRUD справочника + общая история + edge cases
|
|
||||||
|
|
||||||
- [ ] Таб «Сотрудники» с CRUD (зоны + люди).
|
|
||||||
- [ ] Поле `delegateId`, обязательное при `status == "vacation"` для единственного primary.
|
|
||||||
- [ ] Пин-код, SHA-256 в `localStorage`.
|
|
||||||
- [ ] `audit.json` (через прокси + Gitea API).
|
|
||||||
- [ ] Экспорт/импорт `directory.json` (download/upload).
|
|
||||||
- [ ] Таб «История» — `history.json` в репо, кнопка «Скопировать историю задачи».
|
|
||||||
- [ ] Комментарии в задачах (manager / assignee / delegate).
|
|
||||||
- [ ] Кнопка «переопределить ответственного» с фиксацией `manualOverride`.
|
|
||||||
- [ ] Edge case: все в зоне в отпуске → модалка «назначить вручную / принять на себя».
|
|
||||||
- [ ] Прокси для записи в Gitea (PAT в env).
|
|
||||||
|
|
||||||
### v0.3 — LLM-классификатор
|
|
||||||
|
|
||||||
- [ ] Прокси `proxy.js` (Node http server → Anthropic API), общий с §7.4.
|
|
||||||
- [ ] `docker-compose.yml` + Traefik labels.
|
|
||||||
- [ ] Фронт: переключатель «Использовать LLM» в настройках.
|
|
||||||
- [ ] Fallback на алгоритм §3 при ошибке.
|
|
||||||
- [ ] Промпт-кэширование зон.
|
|
||||||
|
|
||||||
### v0.4 — Симбейс (отпуска)
|
|
||||||
|
|
||||||
- [ ] Поле `simbaseId` у сотрудников (миграция в `directory.json`).
|
|
||||||
- [ ] Cron на прокси: ежедневный pull `/api/vacations` (§6A).
|
|
||||||
- [ ] Запись `status` / `vacationUntil` обратно в `directory.json`.
|
|
||||||
- [ ] Уведомление руководителю при отсутствии замещающего (e-mail / Telegram).
|
|
||||||
- [ ] Баннер в UI «N сотрудников требуют замещающего».
|
|
||||||
|
|
||||||
### v0.5 — внешние каналы входа задачи
|
|
||||||
|
|
||||||
- [ ] Telegram-бот: пересылка сообщения боту → POST на прокси → классификация.
|
|
||||||
- [ ] E-mail-приёмник (IMAP) → новая задача в `history.json`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Тестирование
|
|
||||||
|
|
||||||
Юнит-тесты не критичны на MVP (Vanilla JS, мало логики), но стоит сделать:
|
|
||||||
|
|
||||||
- [ ] `test/classifier.test.js` — функция `score()` на 10-15 примерах из PRD §5 (Node + `node:test`).
|
|
||||||
- [ ] Ручной чек-лист сценариев:
|
|
||||||
1. Задача с одной явной категорией («сделать SMS-рассылку») → одна карточка.
|
|
||||||
2. Задача с двумя категориями («push в мобилке и SMS дублирующее») → две карточки на выбор.
|
|
||||||
3. Размытая задача («уведомить клиентов») → все зоны с низкими весами.
|
|
||||||
4. Сотрудник «в отпуске» → дублёр промоутится.
|
|
||||||
5. Пин-код неверный → раздел «Сотрудники» не открывается.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Риски и митигации
|
|
||||||
|
|
||||||
| Риск | Митигация |
|
|
||||||
|---|---|
|
|
||||||
| `directory.json` в публичном репо — данные сотрудников видны | Не класть телефоны/email в публичную ветку (поле `contact` маскировать или хранить отдельно через прокси с v0.2). |
|
|
||||||
| Пин-код легко обойти через devtools | Прямо сказано в UI. Защита от случайных правок, не от злоумышленника. |
|
|
||||||
| LLM-ответ невалидный JSON | Валидация по схеме, fallback на алгоритм §3. |
|
|
||||||
| Утечка `ANTHROPIC_API_KEY` / `GITEA_PAT` / `SIMBASE_API_TOKEN` | Только в env прокси, никогда в браузер. Ротация ключей раз в квартал. |
|
|
||||||
| Руководитель потерял пин-код | Кнопка «Сбросить» очищает `app.pinHash` (требует ручное подтверждение). |
|
|
||||||
| Конфликт ручной отметки и Симбейс | Симбейс — источник истины (§6A.5). Ручная отметка перезаписывается при синке. |
|
|
||||||
| Симбейс недоступен | Cron логирует ошибку в `audit.json`, статусы не меняет. Алёрт руководителю при 3 подряд неудачных синках. |
|
|
||||||
| Сотрудник в Симбейс есть, в `directory.json` нет | Приказ игнорируется, запись `unmatched_vacation_order` в `audit.json` для разбора руководителем. |
|
|
||||||
| Все в зоне в отпуске | Edge case §3.5: ручное назначение / эскалация руководителю. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Открытые технические вопросы
|
|
||||||
|
|
||||||
- **Симбейс API** — endpoint, метод аутентификации, формат полей. Нужны от владельцев системы. До получения — контракт §6A.3 черновой.
|
|
||||||
- **Соответствие сотрудников Симбейс ↔ помощник** — по `simbaseId` (табельный номер) или по ФИО? Решено: `simbaseId` обязателен с v0.4, при отсутствии — fallback на нормализованное ФИО с предупреждением.
|
|
||||||
- **Контакты в публичном `directory.json`** — допустимо ли по политике безопасности компании. Если нет → выносим `contact` в приватный слой через прокси уже в v0.2.
|
|
||||||
- **Канал уведомлений** руководителю о новых приказах — e-mail или Telegram-бот? Решается в v0.4, влияет на конфиг прокси.
|
|
||||||
507
app.js
507
app.js
@ -1,507 +0,0 @@
|
|||||||
// ===== State =====
|
|
||||||
const state = {
|
|
||||||
directory: null,
|
|
||||||
i18n: null,
|
|
||||||
lang: localStorage.getItem("app.lang") || "ru",
|
|
||||||
history: { entries: [] },
|
|
||||||
pendingResult: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DOMINANCE = 2.0;
|
|
||||||
|
|
||||||
// ===== Init =====
|
|
||||||
async function init() {
|
|
||||||
const [dir, i18n, history] = await Promise.all([
|
|
||||||
loadDirectory(),
|
|
||||||
fetchJson("i18n.json"),
|
|
||||||
loadHistory(),
|
|
||||||
]);
|
|
||||||
state.directory = dir;
|
|
||||||
state.i18n = i18n;
|
|
||||||
state.history = history;
|
|
||||||
bindUI();
|
|
||||||
applyLang();
|
|
||||||
renderPeople();
|
|
||||||
renderHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDirectory() {
|
|
||||||
const stored = localStorage.getItem("app.directory");
|
|
||||||
if (stored) return JSON.parse(stored);
|
|
||||||
return fetchJson("directory.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadHistory() {
|
|
||||||
const stored = localStorage.getItem("app.history");
|
|
||||||
if (stored) return JSON.parse(stored);
|
|
||||||
return fetchJson("history.json").catch(() => ({ entries: [] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveDirectory() {
|
|
||||||
localStorage.setItem("app.directory", JSON.stringify(state.directory));
|
|
||||||
}
|
|
||||||
function saveHistory() {
|
|
||||||
localStorage.setItem("app.history", JSON.stringify(state.history));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJson(path) {
|
|
||||||
const r = await fetch(path);
|
|
||||||
if (!r.ok) throw new Error(`Cannot load ${path}`);
|
|
||||||
return r.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== i18n =====
|
|
||||||
function t(key, vars = {}) {
|
|
||||||
let s = (state.i18n[state.lang] || {})[key] || key;
|
|
||||||
for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, v);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
function applyLang() {
|
|
||||||
document.documentElement.lang = state.lang;
|
|
||||||
document.querySelectorAll("[data-i18n]").forEach(el => el.textContent = t(el.dataset.i18n));
|
|
||||||
document.querySelectorAll("[data-i18n-attr]").forEach(el => {
|
|
||||||
const [attr, key] = el.dataset.i18nAttr.split(":");
|
|
||||||
el.setAttribute(attr, t(key));
|
|
||||||
});
|
|
||||||
document.querySelectorAll(".lang button").forEach(b => b.classList.toggle("active", b.dataset.lang === state.lang));
|
|
||||||
if (state.pendingResult) renderResult(state.pendingResult);
|
|
||||||
renderPeople();
|
|
||||||
renderHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Classifier (вариант A — keyword scoring) =====
|
|
||||||
function normalize(s) {
|
|
||||||
return s.toLowerCase()
|
|
||||||
.replace(/ё/g, "е")
|
|
||||||
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
|
||||||
.split(/\s+/).filter(Boolean);
|
|
||||||
}
|
|
||||||
function scoreZones(taskText) {
|
|
||||||
const tokens = normalize(taskText);
|
|
||||||
const tokenSet = tokens.join(" ");
|
|
||||||
return state.directory.zones.map(zone => {
|
|
||||||
let score = 0;
|
|
||||||
const hits = [];
|
|
||||||
for (const kw of zone.keywords) {
|
|
||||||
const n = normalize(kw).join(" ");
|
|
||||||
const count = (tokenSet.match(new RegExp("\\b" + escapeReg(n) + "\\b", "g")) || []).length;
|
|
||||||
if (count > 0) { score += count * 1.0; hits.push(kw); }
|
|
||||||
}
|
|
||||||
for (const sys of zone.systems) {
|
|
||||||
const n = normalize(sys).join(" ");
|
|
||||||
const count = (tokenSet.match(new RegExp("\\b" + escapeReg(n) + "\\b", "g")) || []).length;
|
|
||||||
if (count > 0) { score += count * 1.5; hits.push(sys); }
|
|
||||||
}
|
|
||||||
return { zoneId: zone.id, score, hits };
|
|
||||||
}).filter(x => x.score > 0).sort((a, b) => b.score - a.score);
|
|
||||||
}
|
|
||||||
function escapeReg(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
|
||||||
|
|
||||||
function assignPerson(zoneId) {
|
|
||||||
const candidates = state.directory.people.filter(p => p.zoneId === zoneId);
|
|
||||||
const primary = candidates.find(p => p.role === "primary");
|
|
||||||
const backup = candidates.find(p => p.role === "backup");
|
|
||||||
|
|
||||||
if (primary && primary.status === "active") {
|
|
||||||
return { main: primary, fallback: backup && backup.status === "active" ? backup : null };
|
|
||||||
}
|
|
||||||
if (primary && primary.status === "vacation" && primary.delegateId) {
|
|
||||||
const delegate = state.directory.people.find(p => p.id === primary.delegateId);
|
|
||||||
if (delegate && delegate.status === "active") {
|
|
||||||
return { main: delegate, fallback: null, delegateFor: primary };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (backup && backup.status === "active") {
|
|
||||||
return { main: backup, fallback: null, fromBackup: primary };
|
|
||||||
}
|
|
||||||
return { error: "NO_ASSIGNEE", zoneId };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== UI: classify task =====
|
|
||||||
function onClassify() {
|
|
||||||
const text = document.getElementById("task-input").value.trim();
|
|
||||||
if (!text) return;
|
|
||||||
const sorted = scoreZones(text);
|
|
||||||
const result = { text, sorted, multi: false, chosenZoneId: null };
|
|
||||||
|
|
||||||
if (sorted.length === 0) {
|
|
||||||
result.multi = true;
|
|
||||||
result.allZones = state.directory.zones.map(z => ({ zoneId: z.id, score: 0, hits: [] }));
|
|
||||||
result.noSignal = true;
|
|
||||||
} else if (sorted.length === 1 || sorted[0].score >= DOMINANCE * (sorted[1]?.score || 0)) {
|
|
||||||
result.chosenZoneId = sorted[0].zoneId;
|
|
||||||
} else {
|
|
||||||
result.multi = true;
|
|
||||||
}
|
|
||||||
state.pendingResult = result;
|
|
||||||
renderResult(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderResult(result) {
|
|
||||||
const root = document.getElementById("task-result");
|
|
||||||
root.innerHTML = "";
|
|
||||||
|
|
||||||
if (result.chosenZoneId) {
|
|
||||||
renderAssignee(root, result.chosenZoneId, result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// multi-choice
|
|
||||||
const heading = document.createElement("p");
|
|
||||||
heading.textContent = result.noSignal ? t("task.no_signal") : t("task.multi");
|
|
||||||
root.appendChild(heading);
|
|
||||||
|
|
||||||
const zones = result.noSignal ? result.allZones : result.sorted.slice(0, 3);
|
|
||||||
for (const z of zones) {
|
|
||||||
const zone = state.directory.zones.find(x => x.id === z.zoneId);
|
|
||||||
const card = document.createElement("div");
|
|
||||||
card.className = "zone-card choice";
|
|
||||||
card.innerHTML = `
|
|
||||||
<h3>${escapeHtml(zoneTitle(zone))}</h3>
|
|
||||||
<div class="score">${t("task.score")}: ${z.score.toFixed(1)}${z.hits.length ? " — " + z.hits.map(escapeHtml).join(", ") : ""}</div>
|
|
||||||
`;
|
|
||||||
card.onclick = () => {
|
|
||||||
result.chosenZoneId = z.zoneId;
|
|
||||||
renderResult(result);
|
|
||||||
};
|
|
||||||
root.appendChild(card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAssignee(root, zoneId, result) {
|
|
||||||
const zone = state.directory.zones.find(z => z.id === zoneId);
|
|
||||||
const assignment = assignPerson(zoneId);
|
|
||||||
|
|
||||||
if (assignment.error === "NO_ASSIGNEE") {
|
|
||||||
const err = document.createElement("div");
|
|
||||||
err.className = "error-card";
|
|
||||||
err.innerHTML = `
|
|
||||||
<strong>${t("task.none")}</strong>
|
|
||||||
<div style="margin-top:8px; font-size:13px;">${t("people.zone")}: ${escapeHtml(zoneTitle(zone))}</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="ghost" id="assign-manual">${t("task.assign_manual")}</button>
|
|
||||||
<button class="ghost" id="take-self">${t("task.take_self")}</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
root.appendChild(err);
|
|
||||||
err.querySelector("#assign-manual").onclick = () => manualAssign(result, zone);
|
|
||||||
err.querySelector("#take-self").onclick = () => takeSelf(result, zone);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const main = assignment.main;
|
|
||||||
const role = assignment.delegateFor ? "delegate" : (main.role === "primary" ? "primary" : "backup");
|
|
||||||
const card = document.createElement("div");
|
|
||||||
card.className = "assignee-card";
|
|
||||||
let note = "";
|
|
||||||
if (assignment.delegateFor) {
|
|
||||||
note = `<div class="note">${t("task.delegate_note", {
|
|
||||||
primary: personName(assignment.delegateFor),
|
|
||||||
until: assignment.delegateFor.vacationUntil || "—"
|
|
||||||
})}</div>`;
|
|
||||||
} else if (assignment.fromBackup) {
|
|
||||||
note = `<div class="note">${t("task.no_backup")}</div>`;
|
|
||||||
} else if (!assignment.fallback) {
|
|
||||||
note = `<div class="note">${t("task.no_backup")}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const msg = buildMessage(main, role, zone, result.text, assignment.delegateFor);
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="name">${escapeHtml(personName(main))}</div>
|
|
||||||
<div class="meta">${t("task.assignee")} · ${t("role." + role)} · ${escapeHtml(zoneTitle(zone))} · ${escapeHtml(main.contact || "—")}</div>
|
|
||||||
${assignment.fallback ? `<div class="meta">${t("task.fallback")}: ${escapeHtml(personName(assignment.fallback))}</div>` : ""}
|
|
||||||
${note}
|
|
||||||
<pre>${escapeHtml(msg)}</pre>
|
|
||||||
<div class="actions">
|
|
||||||
<button id="copy-msg">${t("task.copy")}</button>
|
|
||||||
<button id="save-history" class="ghost">${t("task.save")}</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
root.appendChild(card);
|
|
||||||
card.querySelector("#copy-msg").onclick = () => { navigator.clipboard.writeText(msg); toast(t("task.copied")); };
|
|
||||||
card.querySelector("#save-history").onclick = () => saveTaskToHistory(result, zone, main, role);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMessage(person, role, zone, taskText, primaryIfDelegate) {
|
|
||||||
const template = t("msg.template");
|
|
||||||
const delegate = primaryIfDelegate
|
|
||||||
? " " + t("task.delegate_note", { primary: personName(primaryIfDelegate), until: primaryIfDelegate.vacationUntil || "—" })
|
|
||||||
: "";
|
|
||||||
return template
|
|
||||||
.replaceAll("{name}", personName(person))
|
|
||||||
.replaceAll("{task}", taskText)
|
|
||||||
.replaceAll("{role}", t("role." + role))
|
|
||||||
.replaceAll("{zone}", zoneTitle(zone))
|
|
||||||
.replaceAll("{delegate}", delegate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function manualAssign(result, zone) {
|
|
||||||
const list = state.directory.people.filter(p => p.status === "active");
|
|
||||||
const opts = list.map(p => `${personName(p)} (${zoneTitle(state.directory.zones.find(z => z.id === p.zoneId))})`);
|
|
||||||
const pick = prompt(opts.map((o, i) => `${i + 1}. ${o}`).join("\n"));
|
|
||||||
const idx = parseInt(pick, 10) - 1;
|
|
||||||
if (isNaN(idx) || idx < 0 || idx >= list.length) return;
|
|
||||||
const main = list[idx];
|
|
||||||
const root = document.getElementById("task-result");
|
|
||||||
root.innerHTML = "";
|
|
||||||
result.chosenZoneId = zone.id;
|
|
||||||
result.manualPersonId = main.id;
|
|
||||||
const card = document.createElement("div");
|
|
||||||
card.className = "assignee-card";
|
|
||||||
const msg = buildMessage(main, "primary", zone, result.text, null);
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="name">${escapeHtml(personName(main))}</div>
|
|
||||||
<div class="meta">${t("task.assignee")} · ${escapeHtml(zoneTitle(zone))} · ${escapeHtml(main.contact || "—")}</div>
|
|
||||||
<pre>${escapeHtml(msg)}</pre>
|
|
||||||
<div class="actions">
|
|
||||||
<button id="copy-msg">${t("task.copy")}</button>
|
|
||||||
<button id="save-history" class="ghost">${t("task.save")}</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
root.appendChild(card);
|
|
||||||
card.querySelector("#copy-msg").onclick = () => { navigator.clipboard.writeText(msg); toast(t("task.copied")); };
|
|
||||||
card.querySelector("#save-history").onclick = () => saveTaskToHistory(result, zone, main, "primary", true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function takeSelf(result, zone) {
|
|
||||||
const self = { id: "manager", name: state.lang === "ru" ? "Руководитель" : "Manager", nameEn: "Manager", contact: "" };
|
|
||||||
const root = document.getElementById("task-result");
|
|
||||||
root.innerHTML = "";
|
|
||||||
const card = document.createElement("div");
|
|
||||||
card.className = "assignee-card";
|
|
||||||
card.innerHTML = `<div class="name">${escapeHtml(personName(self))}</div>
|
|
||||||
<div class="meta">${t("task.take_self")}</div>
|
|
||||||
<div class="actions"><button id="save-history" class="ghost">${t("task.save")}</button></div>`;
|
|
||||||
root.appendChild(card);
|
|
||||||
card.querySelector("#save-history").onclick = () => saveTaskToHistory(result, zone, self, "primary", true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveTaskToHistory(result, zone, main, role, manualOverride = false) {
|
|
||||||
const entry = {
|
|
||||||
id: "h-" + Date.now(),
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
taskText: result.text,
|
|
||||||
suggestions: result.sorted ? result.sorted.map(s => ({ zoneId: s.zoneId, score: s.score, reason: s.hits.join(", ") })) : [],
|
|
||||||
chosenZoneId: zone.id,
|
|
||||||
chosenPersonId: main.id,
|
|
||||||
delegateUsed: role === "delegate",
|
|
||||||
manualOverride,
|
|
||||||
comments: [],
|
|
||||||
status: "open",
|
|
||||||
};
|
|
||||||
state.history.entries.unshift(entry);
|
|
||||||
state.history.entries = state.history.entries.slice(0, 100);
|
|
||||||
saveHistory();
|
|
||||||
renderHistory();
|
|
||||||
toast(t("task.saved"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== People view =====
|
|
||||||
function renderPeople() {
|
|
||||||
const tbody = document.querySelector("#people-table tbody");
|
|
||||||
if (!tbody) return;
|
|
||||||
tbody.innerHTML = "";
|
|
||||||
for (const p of state.directory.people) {
|
|
||||||
const tr = document.createElement("tr");
|
|
||||||
tr.className = p.status;
|
|
||||||
const zone = state.directory.zones.find(z => z.id === p.zoneId);
|
|
||||||
const delegate = p.delegateId ? state.directory.people.find(x => x.id === p.delegateId) : null;
|
|
||||||
tr.innerHTML = `
|
|
||||||
<td>${escapeHtml(personName(p))}</td>
|
|
||||||
<td>${escapeHtml(zoneTitle(zone))}</td>
|
|
||||||
<td>${t("role." + p.role)}</td>
|
|
||||||
<td>
|
|
||||||
<select data-pid="${p.id}" data-field="status">
|
|
||||||
<option value="active" ${p.status === "active" ? "selected" : ""}>${t("status.active")}</option>
|
|
||||||
<option value="vacation" ${p.status === "vacation" ? "selected" : ""}>${t("status.vacation")}</option>
|
|
||||||
<option value="inactive" ${p.status === "inactive" ? "selected" : ""}>${t("status.inactive")}</option>
|
|
||||||
</select>
|
|
||||||
${p.vacationUntil ? `<div style="font-size:11px;color:#9ca3af;">${p.vacationUntil}</div>` : ""}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<select data-pid="${p.id}" data-field="delegateId" ${p.status !== "vacation" ? "disabled" : ""}>
|
|
||||||
<option value="">—</option>
|
|
||||||
${state.directory.people.filter(x => x.id !== p.id && x.status === "active").map(x =>
|
|
||||||
`<option value="${x.id}" ${p.delegateId === x.id ? "selected" : ""}>${escapeHtml(personName(x))}</option>`
|
|
||||||
).join("")}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>${escapeHtml(p.contact || "—")}</td>
|
|
||||||
<td><button class="ghost" data-pid="${p.id}" data-action="del">🗑</button></td>
|
|
||||||
`;
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
|
||||||
tbody.querySelectorAll("select").forEach(sel => {
|
|
||||||
sel.onchange = () => {
|
|
||||||
const p = state.directory.people.find(x => x.id === sel.dataset.pid);
|
|
||||||
const field = sel.dataset.field;
|
|
||||||
p[field] = sel.value || null;
|
|
||||||
if (field === "status" && sel.value !== "vacation") {
|
|
||||||
p.delegateId = null;
|
|
||||||
p.vacationUntil = null;
|
|
||||||
}
|
|
||||||
saveDirectory();
|
|
||||||
renderPeople();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
tbody.querySelectorAll("[data-action=del]").forEach(btn => {
|
|
||||||
btn.onclick = () => {
|
|
||||||
if (!confirm("Удалить сотрудника?")) return;
|
|
||||||
state.directory.people = state.directory.people.filter(x => x.id !== btn.dataset.pid);
|
|
||||||
saveDirectory();
|
|
||||||
renderPeople();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Simbase sync (mock) =====
|
|
||||||
async function simbaseSync() {
|
|
||||||
const log = document.getElementById("simbase-log");
|
|
||||||
log.className = "simbase-log show";
|
|
||||||
log.textContent = "→ GET /api/vacations (mock: simbase-mock.json)…";
|
|
||||||
try {
|
|
||||||
const data = await fetchJson("simbase-mock.json");
|
|
||||||
let updated = 0;
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
for (const order of data.vacations) {
|
|
||||||
const person = state.directory.people.find(p => p.simbaseId === order.employeeId);
|
|
||||||
if (!person) continue;
|
|
||||||
if (today >= order.vacationStart && today <= order.vacationEnd) {
|
|
||||||
if (person.status !== "vacation" || person.vacationUntil !== order.vacationEnd) {
|
|
||||||
person.status = "vacation";
|
|
||||||
person.vacationUntil = order.vacationEnd;
|
|
||||||
updated++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
saveDirectory();
|
|
||||||
renderPeople();
|
|
||||||
log.textContent = "✓ " + t("people.simbase_done", { n: updated });
|
|
||||||
setTimeout(() => log.classList.remove("show"), 4000);
|
|
||||||
} catch (e) {
|
|
||||||
log.textContent = "✗ Simbase error: " + e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== History view =====
|
|
||||||
function renderHistory() {
|
|
||||||
const root = document.getElementById("history-list");
|
|
||||||
if (!root) return;
|
|
||||||
root.innerHTML = "";
|
|
||||||
if (!state.history.entries.length) {
|
|
||||||
root.innerHTML = `<p>${t("history.empty")}</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const e of state.history.entries) {
|
|
||||||
const zone = state.directory.zones.find(z => z.id === e.chosenZoneId);
|
|
||||||
const person = state.directory.people.find(p => p.id === e.chosenPersonId);
|
|
||||||
const item = document.createElement("div");
|
|
||||||
item.className = "history-item";
|
|
||||||
const date = new Date(e.createdAt).toLocaleString(state.lang === "ru" ? "ru-RU" : "en-US");
|
|
||||||
item.innerHTML = `
|
|
||||||
<h4>${escapeHtml(zone ? zoneTitle(zone) : "?")} → ${escapeHtml(person ? personName(person) : "?")} <span class="status ${e.status}">${t("history.status." + e.status)}</span></h4>
|
|
||||||
<div class="date">${escapeHtml(date)}</div>
|
|
||||||
<div class="task-text">${escapeHtml(e.taskText)}</div>
|
|
||||||
<div class="actions"><button class="ghost" data-copy="${e.id}">${t("history.copy")}</button></div>
|
|
||||||
<div class="comments">${(e.comments || []).map(c => `
|
|
||||||
<div class="comment"><span class="who">${escapeHtml(c.authorName)}</span> <span class="when">${new Date(c.at).toLocaleString(state.lang === "ru" ? "ru-RU" : "en-US")}</span><div>${escapeHtml(c.text)}</div></div>
|
|
||||||
`).join("")}
|
|
||||||
<div class="comment-form">
|
|
||||||
<select data-as="${e.id}">
|
|
||||||
<option value="manager">${t("history.comment_as")}: ${state.lang === "ru" ? "Руководитель" : "Manager"}</option>
|
|
||||||
${person ? `<option value="assignee">${t("history.comment_as")}: ${escapeHtml(personName(person))}</option>` : ""}
|
|
||||||
</select>
|
|
||||||
<input type="text" placeholder="${t("history.comment_placeholder")}" data-input="${e.id}">
|
|
||||||
<button data-send="${e.id}">${t("history.send")}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
root.appendChild(item);
|
|
||||||
}
|
|
||||||
root.querySelectorAll("[data-copy]").forEach(btn => {
|
|
||||||
btn.onclick = () => {
|
|
||||||
const e = state.history.entries.find(x => x.id === btn.dataset.copy);
|
|
||||||
navigator.clipboard.writeText(formatHistoryText(e));
|
|
||||||
toast(t("history.copied"));
|
|
||||||
};
|
|
||||||
});
|
|
||||||
root.querySelectorAll("[data-send]").forEach(btn => {
|
|
||||||
btn.onclick = () => {
|
|
||||||
const id = btn.dataset.send;
|
|
||||||
const e = state.history.entries.find(x => x.id === id);
|
|
||||||
const input = root.querySelector(`[data-input="${id}"]`);
|
|
||||||
const as = root.querySelector(`[data-as="${id}"]`).value;
|
|
||||||
const text = input.value.trim();
|
|
||||||
if (!text) return;
|
|
||||||
const person = state.directory.people.find(p => p.id === e.chosenPersonId);
|
|
||||||
e.comments = e.comments || [];
|
|
||||||
e.comments.push({
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
authorRole: as,
|
|
||||||
authorName: as === "manager" ? (state.lang === "ru" ? "Руководитель" : "Manager") : personName(person),
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
if (e.status === "open") e.status = "in_progress";
|
|
||||||
saveHistory();
|
|
||||||
renderHistory();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatHistoryText(e) {
|
|
||||||
const zone = state.directory.zones.find(z => z.id === e.chosenZoneId);
|
|
||||||
const person = state.directory.people.find(p => p.id === e.chosenPersonId);
|
|
||||||
const lines = [
|
|
||||||
`Задача: ${e.taskText}`,
|
|
||||||
`Зона: ${zone ? zoneTitle(zone) : "?"}`,
|
|
||||||
`Исполнитель: ${person ? personName(person) : "?"}`,
|
|
||||||
`Статус: ${t("history.status." + e.status)}`,
|
|
||||||
"",
|
|
||||||
"Комментарии:",
|
|
||||||
...(e.comments || []).map(c => `[${new Date(c.at).toLocaleString()}] ${c.authorName}: ${c.text}`),
|
|
||||||
];
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Helpers =====
|
|
||||||
function personName(p) { return state.lang === "en" && p.nameEn ? p.nameEn : p.name; }
|
|
||||||
function zoneTitle(z) { return state.lang === "en" && z.titleEn ? z.titleEn : z.title; }
|
|
||||||
function escapeHtml(s) { return (s ?? "").toString().replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); }
|
|
||||||
|
|
||||||
function toast(msg) {
|
|
||||||
let el = document.querySelector(".toast");
|
|
||||||
if (!el) { el = document.createElement("div"); el.className = "toast"; document.body.appendChild(el); }
|
|
||||||
el.textContent = msg;
|
|
||||||
el.classList.add("show");
|
|
||||||
setTimeout(() => el.classList.remove("show"), 1800);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Bindings =====
|
|
||||||
function bindUI() {
|
|
||||||
document.querySelectorAll(".tabs button").forEach(b => {
|
|
||||||
b.onclick = () => {
|
|
||||||
document.querySelectorAll(".tabs button").forEach(x => x.classList.toggle("active", x === b));
|
|
||||||
document.querySelectorAll(".view").forEach(v => v.classList.toggle("hidden", v.id !== "view-" + b.dataset.tab));
|
|
||||||
};
|
|
||||||
});
|
|
||||||
document.querySelectorAll(".lang button").forEach(b => {
|
|
||||||
b.onclick = () => { state.lang = b.dataset.lang; localStorage.setItem("app.lang", state.lang); applyLang(); };
|
|
||||||
});
|
|
||||||
document.getElementById("task-classify").onclick = onClassify;
|
|
||||||
document.getElementById("simbase-sync").onclick = simbaseSync;
|
|
||||||
document.getElementById("people-export").onclick = () => {
|
|
||||||
const blob = new Blob([JSON.stringify(state.directory, null, 2)], { type: "application/json" });
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = "directory.json";
|
|
||||||
a.click();
|
|
||||||
};
|
|
||||||
document.getElementById("people-import").onchange = async (e) => {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
state.directory = JSON.parse(await file.text());
|
|
||||||
saveDirectory();
|
|
||||||
renderPeople();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"updatedAt": "2026-05-22T10:00:00Z",
|
|
||||||
"zones": [
|
|
||||||
{
|
|
||||||
"id": "mobile",
|
|
||||||
"title": "Мобильное приложение",
|
|
||||||
"titleEn": "Mobile app",
|
|
||||||
"keywords": ["push", "ios", "android", "приложение", "мобилка", "apk", "ipa", "уведомление", "пуш"],
|
|
||||||
"systems": ["AppStore", "Google Play", "Firebase"],
|
|
||||||
"examples": [
|
|
||||||
"Сделать push-уведомление по платежам",
|
|
||||||
"Поменять иконку приложения в iOS"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "crm",
|
|
||||||
"title": "CRM-система",
|
|
||||||
"titleEn": "CRM",
|
|
||||||
"keywords": ["crm", "црм", "клиент", "сделка", "карточка", "amocrm", "bitrix", "битрикс", "лид"],
|
|
||||||
"systems": ["Bitrix24", "amoCRM"],
|
|
||||||
"examples": ["Добавить поле «промо-код» в карточку клиента"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "sms",
|
|
||||||
"title": "SMS-сообщения",
|
|
||||||
"titleEn": "SMS",
|
|
||||||
"keywords": ["sms", "смс", "рассылка", "сообщение", "shortcode"],
|
|
||||||
"systems": ["SMSC", "Twilio"],
|
|
||||||
"examples": ["Запустить SMS-рассылку по базе клиентов"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "web",
|
|
||||||
"title": "Веб-сайт",
|
|
||||||
"titleEn": "Web",
|
|
||||||
"keywords": ["сайт", "лендинг", "форма", "html", "веб", "страница", "landing"],
|
|
||||||
"systems": ["WordPress", "Tilda"],
|
|
||||||
"examples": ["Поменять текст на главной странице сайта"]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"people": [
|
|
||||||
{ "id": "p1", "name": "Алина", "nameEn": "Alina", "zoneId": "mobile", "role": "primary", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-001", "contact": "@alina_tg" },
|
|
||||||
{ "id": "p2", "name": "Адлет", "nameEn": "Adlet", "zoneId": "mobile", "role": "backup", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-002", "contact": "@adlet_tg" },
|
|
||||||
{ "id": "p3", "name": "Динара", "nameEn": "Dinara", "zoneId": "crm", "role": "primary", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-003", "contact": "dinara@x.kz" },
|
|
||||||
{ "id": "p4", "name": "Камила", "nameEn": "Kamila", "zoneId": "crm", "role": "backup", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-004", "contact": "kamila@x.kz" },
|
|
||||||
{ "id": "p5", "name": "Михаил", "nameEn": "Mikhail", "zoneId": "sms", "role": "primary", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-005", "contact": "@mikhail_tg" },
|
|
||||||
{ "id": "p6", "name": "Мухамед","nameEn": "Mukhamed","zoneId": "sms", "role": "backup", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-006", "contact": "@muhamed_tg" },
|
|
||||||
{ "id": "p7", "name": "Сауле", "nameEn": "Saule", "zoneId": "web", "role": "primary", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-007", "contact": "@saule_tg" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
33
history.json
33
history.json
@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"id": "h-001",
|
|
||||||
"createdAt": "2026-05-18T09:14:00Z",
|
|
||||||
"taskText": "Бизнес просит сделать push-уведомление клиентам о статусе платежа в мобильном приложении на iOS и Android.",
|
|
||||||
"suggestions": [{ "zoneId": "mobile", "score": 4.5, "reason": "ключевые слова: push, ios, android, мобильное" }],
|
|
||||||
"chosenZoneId": "mobile",
|
|
||||||
"chosenPersonId": "p1",
|
|
||||||
"delegateUsed": false,
|
|
||||||
"manualOverride": false,
|
|
||||||
"comments": [
|
|
||||||
{ "at": "2026-05-18T09:15:00Z", "authorRole": "manager", "authorName": "Руководитель", "text": "Срок до конца недели." },
|
|
||||||
{ "at": "2026-05-18T11:42:00Z", "authorRole": "assignee", "authorName": "Алина", "text": "Взяла в работу, оценка 2 дня." }
|
|
||||||
],
|
|
||||||
"status": "in_progress"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "h-002",
|
|
||||||
"createdAt": "2026-05-19T14:00:00Z",
|
|
||||||
"taskText": "Добавить поле «промо-код» в карточку клиента в CRM Bitrix24.",
|
|
||||||
"suggestions": [{ "zoneId": "crm", "score": 6.0, "reason": "ключевые слова: crm, клиент, карточка, bitrix" }],
|
|
||||||
"chosenZoneId": "crm",
|
|
||||||
"chosenPersonId": "p4",
|
|
||||||
"delegateUsed": true,
|
|
||||||
"manualOverride": false,
|
|
||||||
"comments": [
|
|
||||||
{ "at": "2026-05-19T14:05:00Z", "authorRole": "manager", "authorName": "Руководитель", "text": "Динара в отпуске, ушло Камиле как дублёру." }
|
|
||||||
],
|
|
||||||
"status": "open"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
116
i18n.json
116
i18n.json
@ -1,116 +0,0 @@
|
|||||||
{
|
|
||||||
"ru": {
|
|
||||||
"app.title": "Помощник руководителя",
|
|
||||||
"app.subtitle": "Распределение задач от бизнеса",
|
|
||||||
"tab.task": "Задача",
|
|
||||||
"tab.people": "Сотрудники",
|
|
||||||
"tab.history": "История",
|
|
||||||
"task.placeholder": "Вставьте сюда текст задачи от бизнеса…",
|
|
||||||
"task.classify": "Определить ответственного",
|
|
||||||
"task.result": "Результат",
|
|
||||||
"task.multi": "Задача попадает в несколько направлений. Выберите:",
|
|
||||||
"task.score": "вес",
|
|
||||||
"task.assignee": "Ответственный",
|
|
||||||
"task.fallback": "Резерв",
|
|
||||||
"task.copy": "Скопировать сообщение",
|
|
||||||
"task.copied": "Скопировано",
|
|
||||||
"task.save": "Сохранить в историю",
|
|
||||||
"task.saved": "Сохранено",
|
|
||||||
"task.none": "Нет доступного ответственного. Все в зоне в отпуске.",
|
|
||||||
"task.assign_manual": "Назначить вручную",
|
|
||||||
"task.take_self": "Принять на себя",
|
|
||||||
"task.delegate_note": "Замещает {primary} (отпуск до {until})",
|
|
||||||
"task.no_backup": "В зоне нет резервного исполнителя",
|
|
||||||
"task.no_signal": "Не удалось определить направление. Все зоны равновероятны.",
|
|
||||||
"people.add": "+ Добавить сотрудника",
|
|
||||||
"people.name": "ФИО",
|
|
||||||
"people.zone": "Зона",
|
|
||||||
"people.role": "Роль",
|
|
||||||
"people.status": "Статус",
|
|
||||||
"people.delegate": "Замещающий",
|
|
||||||
"people.contact": "Контакт",
|
|
||||||
"people.actions": "Действия",
|
|
||||||
"people.simbase_sync": "Синхронизация с Симбейс",
|
|
||||||
"people.simbase_done": "Синхронизация выполнена. Обновлено: {n}",
|
|
||||||
"people.export": "Экспорт JSON",
|
|
||||||
"people.import": "Импорт JSON",
|
|
||||||
"people.pin_prompt": "Введите пин-код руководителя для правок",
|
|
||||||
"people.pin_setup": "Задайте пин-код руководителя (4-6 символов)",
|
|
||||||
"people.pin_wrong": "Неверный пин-код",
|
|
||||||
"role.primary": "основной",
|
|
||||||
"role.backup": "дублёр",
|
|
||||||
"role.delegate": "замещающий",
|
|
||||||
"status.active": "активен",
|
|
||||||
"status.vacation": "в отпуске",
|
|
||||||
"status.inactive": "недоступен",
|
|
||||||
"history.empty": "История пуста",
|
|
||||||
"history.copy": "Скопировать историю задачи",
|
|
||||||
"history.copied": "Скопировано",
|
|
||||||
"history.add_comment": "Добавить комментарий",
|
|
||||||
"history.comment_as": "Подписаться как",
|
|
||||||
"history.comment_placeholder": "Текст комментария…",
|
|
||||||
"history.send": "Отправить",
|
|
||||||
"history.status.open": "открыта",
|
|
||||||
"history.status.in_progress": "в работе",
|
|
||||||
"history.status.done": "выполнена",
|
|
||||||
"history.status.cancelled": "отменена",
|
|
||||||
"msg.template": "@{name}, прилетела задача от бизнеса:\n\n«{task}»\n\nТы {role} по направлению «{zone}».{delegate} Посмотри, пожалуйста."
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"app.title": "Manager's assistant",
|
|
||||||
"app.subtitle": "Routing business tasks to the team",
|
|
||||||
"tab.task": "Task",
|
|
||||||
"tab.people": "People",
|
|
||||||
"tab.history": "History",
|
|
||||||
"task.placeholder": "Paste the business task text here…",
|
|
||||||
"task.classify": "Find assignee",
|
|
||||||
"task.result": "Result",
|
|
||||||
"task.multi": "Task matches multiple zones. Choose:",
|
|
||||||
"task.score": "score",
|
|
||||||
"task.assignee": "Assignee",
|
|
||||||
"task.fallback": "Backup",
|
|
||||||
"task.copy": "Copy message",
|
|
||||||
"task.copied": "Copied",
|
|
||||||
"task.save": "Save to history",
|
|
||||||
"task.saved": "Saved",
|
|
||||||
"task.none": "No available assignee. All in zone are on vacation.",
|
|
||||||
"task.assign_manual": "Assign manually",
|
|
||||||
"task.take_self": "Take it myself",
|
|
||||||
"task.delegate_note": "Substituting for {primary} (vacation until {until})",
|
|
||||||
"task.no_backup": "No backup in zone",
|
|
||||||
"task.no_signal": "Could not detect zone. All zones equally likely.",
|
|
||||||
"people.add": "+ Add person",
|
|
||||||
"people.name": "Name",
|
|
||||||
"people.zone": "Zone",
|
|
||||||
"people.role": "Role",
|
|
||||||
"people.status": "Status",
|
|
||||||
"people.delegate": "Delegate",
|
|
||||||
"people.contact": "Contact",
|
|
||||||
"people.actions": "Actions",
|
|
||||||
"people.simbase_sync": "Sync with Simbase",
|
|
||||||
"people.simbase_done": "Sync complete. Updated: {n}",
|
|
||||||
"people.export": "Export JSON",
|
|
||||||
"people.import": "Import JSON",
|
|
||||||
"people.pin_prompt": "Enter manager PIN to edit",
|
|
||||||
"people.pin_setup": "Set manager PIN (4-6 chars)",
|
|
||||||
"people.pin_wrong": "Wrong PIN",
|
|
||||||
"role.primary": "primary",
|
|
||||||
"role.backup": "backup",
|
|
||||||
"role.delegate": "delegate",
|
|
||||||
"status.active": "active",
|
|
||||||
"status.vacation": "on vacation",
|
|
||||||
"status.inactive": "inactive",
|
|
||||||
"history.empty": "History is empty",
|
|
||||||
"history.copy": "Copy task history",
|
|
||||||
"history.copied": "Copied",
|
|
||||||
"history.add_comment": "Add comment",
|
|
||||||
"history.comment_as": "Sign as",
|
|
||||||
"history.comment_placeholder": "Comment text…",
|
|
||||||
"history.send": "Send",
|
|
||||||
"history.status.open": "open",
|
|
||||||
"history.status.in_progress": "in progress",
|
|
||||||
"history.status.done": "done",
|
|
||||||
"history.status.cancelled": "cancelled",
|
|
||||||
"msg.template": "@{name}, a new business task:\n\n\"{task}\"\n\nYou are the {role} for \"{zone}\".{delegate} Please take a look."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
71
index.html
71
index.html
@ -1,71 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>Помощник руководителя</title>
|
|
||||||
<link rel="stylesheet" href="styles.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<div class="brand">
|
|
||||||
<h1 data-i18n="app.title"></h1>
|
|
||||||
<p class="subtitle" data-i18n="app.subtitle"></p>
|
|
||||||
</div>
|
|
||||||
<div class="header-controls">
|
|
||||||
<nav class="tabs">
|
|
||||||
<button data-tab="task" class="active" data-i18n="tab.task"></button>
|
|
||||||
<button data-tab="people" data-i18n="tab.people"></button>
|
|
||||||
<button data-tab="history" data-i18n="tab.history"></button>
|
|
||||||
</nav>
|
|
||||||
<div class="lang">
|
|
||||||
<button data-lang="ru" class="active">RU</button>
|
|
||||||
<button data-lang="en">EN</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<!-- TAB: TASK -->
|
|
||||||
<section id="view-task" class="view">
|
|
||||||
<textarea id="task-input" rows="6" data-i18n-attr="placeholder:task.placeholder"></textarea>
|
|
||||||
<button id="task-classify" class="primary" data-i18n="task.classify"></button>
|
|
||||||
<div id="task-result"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- TAB: PEOPLE -->
|
|
||||||
<section id="view-people" class="view hidden">
|
|
||||||
<div class="toolbar">
|
|
||||||
<button id="simbase-sync" data-i18n="people.simbase_sync"></button>
|
|
||||||
<button id="people-export" data-i18n="people.export"></button>
|
|
||||||
<label class="btn-like">
|
|
||||||
<span data-i18n="people.import"></span>
|
|
||||||
<input type="file" id="people-import" accept="application/json" hidden>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div id="simbase-log" class="simbase-log"></div>
|
|
||||||
<table id="people-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th data-i18n="people.name"></th>
|
|
||||||
<th data-i18n="people.zone"></th>
|
|
||||||
<th data-i18n="people.role"></th>
|
|
||||||
<th data-i18n="people.status"></th>
|
|
||||||
<th data-i18n="people.delegate"></th>
|
|
||||||
<th data-i18n="people.contact"></th>
|
|
||||||
<th data-i18n="people.actions"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody></tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- TAB: HISTORY -->
|
|
||||||
<section id="view-history" class="view hidden">
|
|
||||||
<div id="history-list"></div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
616
mvp.html
616
mvp.html
@ -1,616 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>Помощник руководителя</title>
|
|
||||||
<style>
|
|
||||||
*{box-sizing:border-box}
|
|
||||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;margin:0;background:#f5f6f8;color:#1d1f23;line-height:1.45}
|
|
||||||
header{background:#1d1f23;color:#fff;padding:16px 24px;display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:12px}
|
|
||||||
header h1{margin:0;font-size:20px}
|
|
||||||
header .subtitle{margin:2px 0 0;font-size:13px;opacity:.7}
|
|
||||||
.header-controls{display:flex;gap:16px;align-items:center;flex-wrap:wrap}
|
|
||||||
.tabs button,.lang button{background:transparent;color:#fff;border:1px solid #3a3d44;padding:6px 14px;border-radius:6px;cursor:pointer;font:inherit}
|
|
||||||
.tabs button.active,.lang button.active{background:#4a8cff;border-color:#4a8cff}
|
|
||||||
.lang button{padding:6px 10px;font-size:12px}
|
|
||||||
main{padding:24px;max-width:1100px;margin:0 auto}
|
|
||||||
.view.hidden{display:none}
|
|
||||||
textarea{width:100%;font:inherit;padding:12px;border:1px solid #d0d4dc;border-radius:6px;resize:vertical;background:#fff}
|
|
||||||
button.primary,button:not(.ghost):not(.tabs button):not(.lang button){background:#4a8cff;color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font:inherit;margin-top:12px}
|
|
||||||
button.ghost{background:transparent;border:1px solid #d0d4dc;color:#1d1f23}
|
|
||||||
button:hover{opacity:.9}
|
|
||||||
.btn-like{display:inline-block;background:#4a8cff;color:#fff;padding:8px 16px;border-radius:6px;cursor:pointer;margin-top:12px}
|
|
||||||
#task-result{margin-top:24px;display:flex;flex-direction:column;gap:16px}
|
|
||||||
.zone-card,.assignee-card,.history-item{background:#fff;border:1px solid #e2e5eb;border-radius:8px;padding:16px}
|
|
||||||
.zone-card.choice{cursor:pointer;transition:border-color .15s}
|
|
||||||
.zone-card.choice:hover{border-color:#4a8cff}
|
|
||||||
.zone-card h3{margin:0 0 6px;font-size:15px}
|
|
||||||
.zone-card .score{font-size:12px;color:#6b7280}
|
|
||||||
.assignee-card{border-color:#4a8cff}
|
|
||||||
.assignee-card .name{font-size:18px;font-weight:600}
|
|
||||||
.assignee-card .meta{color:#6b7280;font-size:13px;margin-top:4px}
|
|
||||||
.assignee-card .note{background:#fff3cd;padding:8px 12px;border-radius:6px;margin-top:8px;font-size:13px}
|
|
||||||
.assignee-card pre{background:#f5f6f8;padding:10px;border-radius:6px;white-space:pre-wrap;font-size:13px;margin-top:12px;font-family:ui-monospace,"SF Mono",Menlo,monospace}
|
|
||||||
.assignee-card .actions{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
|
|
||||||
.error-card{background:#fff5f5;border:1px solid #f5b1b1;padding:16px;border-radius:8px}
|
|
||||||
.error-card .actions{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
|
|
||||||
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px;align-items:center}
|
|
||||||
.simbase-log{background:#e7f3ff;border:1px solid #b8daff;padding:10px 14px;border-radius:6px;font-size:13px;margin-bottom:16px;display:none}
|
|
||||||
.simbase-log.show{display:block}
|
|
||||||
table{width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden}
|
|
||||||
th,td{padding:10px 12px;text-align:left;border-bottom:1px solid #e2e5eb;font-size:14px;vertical-align:middle}
|
|
||||||
th{background:#f8f9fb;font-weight:600;font-size:13px}
|
|
||||||
tr.vacation td{background:#fffbea}
|
|
||||||
tr.inactive td{color:#9ca3af}
|
|
||||||
td select,td input{font:inherit;padding:4px 6px;border:1px solid #d0d4dc;border-radius:4px}
|
|
||||||
td button{padding:4px 10px;margin:0 2px}
|
|
||||||
.history-item{margin-bottom:12px}
|
|
||||||
.history-item h4{margin:0 0 6px;font-size:14px}
|
|
||||||
.history-item .date{color:#6b7280;font-size:12px}
|
|
||||||
.history-item .task-text{margin:8px 0;font-size:14px}
|
|
||||||
.history-item .status{display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;background:#e2e5eb}
|
|
||||||
.history-item .status.in_progress{background:#cfe8ff}
|
|
||||||
.history-item .status.done{background:#c6f6d5}
|
|
||||||
.history-item .status.cancelled{background:#fed7d7}
|
|
||||||
.history-item .comments{margin-top:12px;border-top:1px solid #e2e5eb;padding-top:10px}
|
|
||||||
.history-item .comment{font-size:13px;padding:6px 0;border-bottom:1px dashed #eef0f4}
|
|
||||||
.history-item .comment:last-child{border-bottom:none}
|
|
||||||
.history-item .comment .who{font-weight:600}
|
|
||||||
.history-item .comment .when{color:#9ca3af;font-size:11px;margin-left:6px}
|
|
||||||
.history-item .comment-form{margin-top:10px;display:flex;gap:6px;flex-wrap:wrap;align-items:center}
|
|
||||||
.history-item .comment-form select,.history-item .comment-form input{padding:6px 8px;font:inherit;border:1px solid #d0d4dc;border-radius:4px}
|
|
||||||
.history-item .comment-form input{flex:1;min-width:200px}
|
|
||||||
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#1d1f23;color:#fff;padding:10px 20px;border-radius:6px;font-size:14px;opacity:0;transition:opacity .2s;pointer-events:none;z-index:1000}
|
|
||||||
.toast.show{opacity:1}
|
|
||||||
.banner{background:#fff8e1;border:1px solid #ffe082;border-radius:6px;padding:8px 12px;font-size:13px;margin-bottom:16px}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<div class="brand">
|
|
||||||
<h1 data-i18n="app.title"></h1>
|
|
||||||
<p class="subtitle" data-i18n="app.subtitle"></p>
|
|
||||||
</div>
|
|
||||||
<div class="header-controls">
|
|
||||||
<nav class="tabs">
|
|
||||||
<button data-tab="task" class="active" data-i18n="tab.task"></button>
|
|
||||||
<button data-tab="people" data-i18n="tab.people"></button>
|
|
||||||
<button data-tab="history" data-i18n="tab.history"></button>
|
|
||||||
</nav>
|
|
||||||
<div class="lang">
|
|
||||||
<button data-lang="ru" class="active">RU</button>
|
|
||||||
<button data-lang="en">EN</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<section id="view-task" class="view">
|
|
||||||
<textarea id="task-input" rows="6" data-i18n-attr="placeholder:task.placeholder"></textarea>
|
|
||||||
<button id="task-classify" class="primary" data-i18n="task.classify"></button>
|
|
||||||
<div id="task-result"></div>
|
|
||||||
</section>
|
|
||||||
<section id="view-people" class="view hidden">
|
|
||||||
<div class="toolbar">
|
|
||||||
<button id="simbase-sync" data-i18n="people.simbase_sync"></button>
|
|
||||||
<button id="people-reset" class="ghost" data-i18n="people.reset"></button>
|
|
||||||
</div>
|
|
||||||
<div id="simbase-log" class="simbase-log"></div>
|
|
||||||
<table id="people-table">
|
|
||||||
<thead><tr>
|
|
||||||
<th data-i18n="people.name"></th>
|
|
||||||
<th data-i18n="people.zone"></th>
|
|
||||||
<th data-i18n="people.role"></th>
|
|
||||||
<th data-i18n="people.status"></th>
|
|
||||||
<th data-i18n="people.delegate"></th>
|
|
||||||
<th data-i18n="people.contact"></th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody></tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
<section id="view-history" class="view hidden">
|
|
||||||
<div id="history-list"></div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// ===== EMBEDDED DATA =====
|
|
||||||
const SEED_DIRECTORY = {
|
|
||||||
version: 1,
|
|
||||||
zones: [
|
|
||||||
{ id:"mobile", title:"Мобильное приложение", titleEn:"Mobile app",
|
|
||||||
keywords:["push","ios","android","приложение","мобилка","apk","ipa","уведомление","пуш"],
|
|
||||||
systems:["AppStore","Google Play","Firebase"] },
|
|
||||||
{ id:"crm", title:"CRM-система", titleEn:"CRM",
|
|
||||||
keywords:["crm","црм","клиент","сделка","карточка","amocrm","bitrix","битрикс","лид"],
|
|
||||||
systems:["Bitrix24","amoCRM"] },
|
|
||||||
{ id:"sms", title:"SMS-сообщения", titleEn:"SMS",
|
|
||||||
keywords:["sms","смс","рассылка","сообщение","shortcode"],
|
|
||||||
systems:["SMSC","Twilio"] },
|
|
||||||
{ id:"web", title:"Веб-сайт", titleEn:"Web",
|
|
||||||
keywords:["сайт","лендинг","форма","html","веб","страница","landing"],
|
|
||||||
systems:["WordPress","Tilda"] }
|
|
||||||
],
|
|
||||||
people: [
|
|
||||||
{ id:"p1", name:"Алина", nameEn:"Alina", zoneId:"mobile", role:"primary", status:"active", delegateId:null, vacationUntil:null, simbaseId:"EMP-001", contact:"@alina_tg" },
|
|
||||||
{ id:"p2", name:"Адлет", nameEn:"Adlet", zoneId:"mobile", role:"backup", status:"active", delegateId:null, vacationUntil:null, simbaseId:"EMP-002", contact:"@adlet_tg" },
|
|
||||||
{ id:"p3", name:"Динара", nameEn:"Dinara", zoneId:"crm", role:"primary", status:"active", delegateId:null, vacationUntil:null, simbaseId:"EMP-003", contact:"dinara@x.kz" },
|
|
||||||
{ id:"p4", name:"Камила", nameEn:"Kamila", zoneId:"crm", role:"backup", status:"active", delegateId:null, vacationUntil:null, simbaseId:"EMP-004", contact:"kamila@x.kz" },
|
|
||||||
{ id:"p5", name:"Михаил", nameEn:"Mikhail", zoneId:"sms", role:"primary", status:"active", delegateId:null, vacationUntil:null, simbaseId:"EMP-005", contact:"@mikhail_tg" },
|
|
||||||
{ id:"p6", name:"Мухамед", nameEn:"Mukhamed", zoneId:"sms", role:"backup", status:"active", delegateId:null, vacationUntil:null, simbaseId:"EMP-006", contact:"@muhamed_tg" },
|
|
||||||
{ id:"p7", name:"Сауле", nameEn:"Saule", zoneId:"web", role:"primary", status:"active", delegateId:null, vacationUntil:null, simbaseId:"EMP-007", contact:"@saule_tg" }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const SEED_HISTORY = { entries: [
|
|
||||||
{ id:"h-001", createdAt:"2026-05-18T09:14:00Z",
|
|
||||||
taskText:"Бизнес просит push-уведомление клиентам о статусе платежа в мобильном приложении iOS и Android.",
|
|
||||||
suggestions:[{zoneId:"mobile",score:4.5,reason:"push, ios, android, приложение"}],
|
|
||||||
chosenZoneId:"mobile", chosenPersonId:"p1", delegateUsed:false, manualOverride:false, status:"in_progress",
|
|
||||||
comments:[
|
|
||||||
{ at:"2026-05-18T09:15:00Z", authorRole:"manager", authorName:"Руководитель", text:"Срок до конца недели." },
|
|
||||||
{ at:"2026-05-18T11:42:00Z", authorRole:"assignee", authorName:"Алина", text:"Взяла в работу, оценка 2 дня." }
|
|
||||||
]},
|
|
||||||
{ id:"h-002", createdAt:"2026-05-19T14:00:00Z",
|
|
||||||
taskText:"Добавить поле «промо-код» в карточку клиента в CRM Bitrix24.",
|
|
||||||
suggestions:[{zoneId:"crm",score:6,reason:"crm, клиент, карточка, bitrix"}],
|
|
||||||
chosenZoneId:"crm", chosenPersonId:"p4", delegateUsed:true, manualOverride:false, status:"open",
|
|
||||||
comments:[{ at:"2026-05-19T14:05:00Z", authorRole:"manager", authorName:"Руководитель", text:"Динара в отпуске, ушло Камиле как дублёру." }]}
|
|
||||||
]};
|
|
||||||
|
|
||||||
const SIMBASE_MOCK = { vacations: [
|
|
||||||
{ employeeId:"EMP-003", fullName:"Динара Иванова", vacationStart:"2026-05-20", vacationEnd:"2026-06-10", orderNumber:"О-2026-451" },
|
|
||||||
{ employeeId:"EMP-005", fullName:"Михаил Петров", vacationStart:"2026-05-22", vacationEnd:"2026-05-29", orderNumber:"О-2026-477" }
|
|
||||||
]};
|
|
||||||
|
|
||||||
const I18N = {
|
|
||||||
ru: {
|
|
||||||
"app.title":"Помощник руководителя", "app.subtitle":"Распределение задач от бизнеса",
|
|
||||||
"tab.task":"Задача", "tab.people":"Сотрудники", "tab.history":"История",
|
|
||||||
"task.placeholder":"Вставьте сюда текст задачи от бизнеса…",
|
|
||||||
"task.classify":"Определить ответственного", "task.multi":"Задача попадает в несколько направлений. Выберите:",
|
|
||||||
"task.score":"вес", "task.assignee":"Ответственный", "task.fallback":"Резерв",
|
|
||||||
"task.copy":"Скопировать сообщение", "task.copied":"Скопировано",
|
|
||||||
"task.save":"Сохранить в историю", "task.saved":"Сохранено",
|
|
||||||
"task.none":"Нет доступного ответственного. Все в зоне в отпуске.",
|
|
||||||
"task.assign_manual":"Назначить вручную", "task.take_self":"Принять на себя",
|
|
||||||
"task.delegate_note":"Замещает {primary} (отпуск до {until})",
|
|
||||||
"task.no_backup":"В зоне нет резервного исполнителя",
|
|
||||||
"task.no_signal":"Не удалось определить направление. Все зоны равновероятны.",
|
|
||||||
"people.name":"ФИО", "people.zone":"Зона", "people.role":"Роль", "people.status":"Статус",
|
|
||||||
"people.delegate":"Замещающий", "people.contact":"Контакт",
|
|
||||||
"people.simbase_sync":"Синхронизация с Симбейс", "people.simbase_done":"Синхронизация выполнена. Обновлено: {n}",
|
|
||||||
"people.reset":"Сброс к моку",
|
|
||||||
"role.primary":"основной", "role.backup":"дублёр", "role.delegate":"замещающий",
|
|
||||||
"status.active":"активен", "status.vacation":"в отпуске", "status.inactive":"недоступен",
|
|
||||||
"history.empty":"История пуста", "history.copy":"Скопировать историю задачи", "history.copied":"Скопировано",
|
|
||||||
"history.comment_as":"Подписаться как", "history.comment_placeholder":"Текст комментария…", "history.send":"Отправить",
|
|
||||||
"history.status.open":"открыта", "history.status.in_progress":"в работе", "history.status.done":"выполнена", "history.status.cancelled":"отменена",
|
|
||||||
"msg.template":"@{name}, прилетела задача от бизнеса:\n\n«{task}»\n\nТы {role} по направлению «{zone}».{delegate} Посмотри, пожалуйста."
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
"app.title":"Manager's assistant", "app.subtitle":"Routing business tasks to the team",
|
|
||||||
"tab.task":"Task", "tab.people":"People", "tab.history":"History",
|
|
||||||
"task.placeholder":"Paste the business task text here…",
|
|
||||||
"task.classify":"Find assignee", "task.multi":"Task matches multiple zones. Choose:",
|
|
||||||
"task.score":"score", "task.assignee":"Assignee", "task.fallback":"Backup",
|
|
||||||
"task.copy":"Copy message", "task.copied":"Copied",
|
|
||||||
"task.save":"Save to history", "task.saved":"Saved",
|
|
||||||
"task.none":"No available assignee. All in zone are on vacation.",
|
|
||||||
"task.assign_manual":"Assign manually", "task.take_self":"Take it myself",
|
|
||||||
"task.delegate_note":"Substituting for {primary} (vacation until {until})",
|
|
||||||
"task.no_backup":"No backup in zone",
|
|
||||||
"task.no_signal":"Could not detect zone. All zones equally likely.",
|
|
||||||
"people.name":"Name", "people.zone":"Zone", "people.role":"Role", "people.status":"Status",
|
|
||||||
"people.delegate":"Delegate", "people.contact":"Contact",
|
|
||||||
"people.simbase_sync":"Sync with Simbase", "people.simbase_done":"Sync complete. Updated: {n}",
|
|
||||||
"people.reset":"Reset to mock",
|
|
||||||
"role.primary":"primary", "role.backup":"backup", "role.delegate":"delegate",
|
|
||||||
"status.active":"active", "status.vacation":"on vacation", "status.inactive":"inactive",
|
|
||||||
"history.empty":"History is empty", "history.copy":"Copy task history", "history.copied":"Copied",
|
|
||||||
"history.comment_as":"Sign as", "history.comment_placeholder":"Comment text…", "history.send":"Send",
|
|
||||||
"history.status.open":"open", "history.status.in_progress":"in progress", "history.status.done":"done", "history.status.cancelled":"cancelled",
|
|
||||||
"msg.template":"@{name}, a new business task:\n\n\"{task}\"\n\nYou are the {role} for \"{zone}\".{delegate} Please take a look."
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== STATE =====
|
|
||||||
const LS = {
|
|
||||||
dir: "mvp.directory",
|
|
||||||
hist: "mvp.history",
|
|
||||||
lang: "mvp.lang"
|
|
||||||
};
|
|
||||||
const state = {
|
|
||||||
directory: JSON.parse(localStorage.getItem(LS.dir) || "null") || structuredClone(SEED_DIRECTORY),
|
|
||||||
history: JSON.parse(localStorage.getItem(LS.hist) || "null") || structuredClone(SEED_HISTORY),
|
|
||||||
lang: localStorage.getItem(LS.lang) || "ru",
|
|
||||||
pendingResult: null
|
|
||||||
};
|
|
||||||
const DOMINANCE = 2.0;
|
|
||||||
function saveDir(){ localStorage.setItem(LS.dir, JSON.stringify(state.directory)); }
|
|
||||||
function saveHist(){ localStorage.setItem(LS.hist, JSON.stringify(state.history)); }
|
|
||||||
|
|
||||||
// ===== i18n =====
|
|
||||||
function t(key, vars={}) {
|
|
||||||
let s = (I18N[state.lang]||{})[key] || key;
|
|
||||||
for (const [k,v] of Object.entries(vars)) s = s.replaceAll("{"+k+"}", v);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
function applyLang() {
|
|
||||||
document.documentElement.lang = state.lang;
|
|
||||||
document.querySelectorAll("[data-i18n]").forEach(el => el.textContent = t(el.dataset.i18n));
|
|
||||||
document.querySelectorAll("[data-i18n-attr]").forEach(el => {
|
|
||||||
const [attr, key] = el.dataset.i18nAttr.split(":");
|
|
||||||
el.setAttribute(attr, t(key));
|
|
||||||
});
|
|
||||||
document.querySelectorAll(".lang button").forEach(b => b.classList.toggle("active", b.dataset.lang === state.lang));
|
|
||||||
if (state.pendingResult) renderResult(state.pendingResult);
|
|
||||||
renderPeople(); renderHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Classifier =====
|
|
||||||
function normalize(s){
|
|
||||||
return s.toLowerCase().replace(/ё/g,"е").replace(/[^\p{L}\p{N}\s]/gu," ").split(/\s+/).filter(Boolean);
|
|
||||||
}
|
|
||||||
function escapeReg(s){ return s.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"); }
|
|
||||||
function scoreZones(text){
|
|
||||||
const tokenStr = normalize(text).join(" ");
|
|
||||||
return state.directory.zones.map(z => {
|
|
||||||
let score = 0; const hits = [];
|
|
||||||
for (const k of z.keywords) {
|
|
||||||
const n = normalize(k).join(" ");
|
|
||||||
const c = (tokenStr.match(new RegExp("\\b"+escapeReg(n)+"\\b","g"))||[]).length;
|
|
||||||
if (c>0){ score += c*1.0; hits.push(k); }
|
|
||||||
}
|
|
||||||
for (const sys of z.systems) {
|
|
||||||
const n = normalize(sys).join(" ");
|
|
||||||
const c = (tokenStr.match(new RegExp("\\b"+escapeReg(n)+"\\b","g"))||[]).length;
|
|
||||||
if (c>0){ score += c*1.5; hits.push(sys); }
|
|
||||||
}
|
|
||||||
return { zoneId:z.id, score, hits };
|
|
||||||
}).filter(x => x.score>0).sort((a,b)=>b.score-a.score);
|
|
||||||
}
|
|
||||||
function assignPerson(zoneId){
|
|
||||||
const cs = state.directory.people.filter(p => p.zoneId===zoneId);
|
|
||||||
const primary = cs.find(p => p.role==="primary");
|
|
||||||
const backup = cs.find(p => p.role==="backup");
|
|
||||||
if (primary && primary.status==="active")
|
|
||||||
return { main:primary, fallback: backup && backup.status==="active" ? backup : null };
|
|
||||||
if (primary && primary.status==="vacation" && primary.delegateId) {
|
|
||||||
const d = state.directory.people.find(p => p.id===primary.delegateId);
|
|
||||||
if (d && d.status==="active") return { main:d, fallback:null, delegateFor:primary };
|
|
||||||
}
|
|
||||||
if (backup && backup.status==="active") return { main:backup, fallback:null, fromBackup:primary };
|
|
||||||
return { error:"NO_ASSIGNEE", zoneId };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Task view =====
|
|
||||||
function onClassify(){
|
|
||||||
const text = document.getElementById("task-input").value.trim();
|
|
||||||
if (!text) return;
|
|
||||||
const sorted = scoreZones(text);
|
|
||||||
const result = { text, sorted, multi:false, chosenZoneId:null };
|
|
||||||
if (sorted.length===0){
|
|
||||||
result.multi = true; result.noSignal = true;
|
|
||||||
result.allZones = state.directory.zones.map(z => ({ zoneId:z.id, score:0, hits:[] }));
|
|
||||||
} else if (sorted.length===1 || sorted[0].score >= DOMINANCE*(sorted[1]?.score||0)) {
|
|
||||||
result.chosenZoneId = sorted[0].zoneId;
|
|
||||||
} else {
|
|
||||||
result.multi = true;
|
|
||||||
}
|
|
||||||
state.pendingResult = result;
|
|
||||||
renderResult(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderResult(result){
|
|
||||||
const root = document.getElementById("task-result");
|
|
||||||
root.innerHTML = "";
|
|
||||||
if (result.chosenZoneId) { renderAssignee(root, result.chosenZoneId, result); return; }
|
|
||||||
const h = document.createElement("p");
|
|
||||||
h.textContent = result.noSignal ? t("task.no_signal") : t("task.multi");
|
|
||||||
root.appendChild(h);
|
|
||||||
const zones = result.noSignal ? result.allZones : result.sorted.slice(0,3);
|
|
||||||
for (const z of zones) {
|
|
||||||
const zone = state.directory.zones.find(x=>x.id===z.zoneId);
|
|
||||||
const card = document.createElement("div");
|
|
||||||
card.className = "zone-card choice";
|
|
||||||
card.innerHTML = `<h3>${esc(zoneTitle(zone))}</h3>
|
|
||||||
<div class="score">${t("task.score")}: ${z.score.toFixed(1)}${z.hits.length?" — "+z.hits.map(esc).join(", "):""}</div>`;
|
|
||||||
card.onclick = () => { result.chosenZoneId = z.zoneId; renderResult(result); };
|
|
||||||
root.appendChild(card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAssignee(root, zoneId, result){
|
|
||||||
const zone = state.directory.zones.find(z=>z.id===zoneId);
|
|
||||||
const a = assignPerson(zoneId);
|
|
||||||
if (a.error==="NO_ASSIGNEE") {
|
|
||||||
const err = document.createElement("div");
|
|
||||||
err.className = "error-card";
|
|
||||||
err.innerHTML = `<strong>${t("task.none")}</strong>
|
|
||||||
<div style="margin-top:8px;font-size:13px">${t("people.zone")}: ${esc(zoneTitle(zone))}</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="ghost" id="assign-manual">${t("task.assign_manual")}</button>
|
|
||||||
<button class="ghost" id="take-self">${t("task.take_self")}</button>
|
|
||||||
</div>`;
|
|
||||||
root.appendChild(err);
|
|
||||||
err.querySelector("#assign-manual").onclick = () => manualAssign(result, zone);
|
|
||||||
err.querySelector("#take-self").onclick = () => takeSelf(result, zone);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const main = a.main;
|
|
||||||
const role = a.delegateFor ? "delegate" : (main.role==="primary" ? "primary" : "backup");
|
|
||||||
let note = "";
|
|
||||||
if (a.delegateFor) {
|
|
||||||
note = `<div class="note">${t("task.delegate_note", { primary: pName(a.delegateFor), until: a.delegateFor.vacationUntil||"—" })}</div>`;
|
|
||||||
} else if (a.fromBackup || !a.fallback) {
|
|
||||||
note = `<div class="note">${t("task.no_backup")}</div>`;
|
|
||||||
}
|
|
||||||
const msg = buildMessage(main, role, zone, result.text, a.delegateFor);
|
|
||||||
const card = document.createElement("div");
|
|
||||||
card.className = "assignee-card";
|
|
||||||
card.innerHTML = `<div class="name">${esc(pName(main))}</div>
|
|
||||||
<div class="meta">${t("task.assignee")} · ${t("role."+role)} · ${esc(zoneTitle(zone))} · ${esc(main.contact||"—")}</div>
|
|
||||||
${a.fallback ? `<div class="meta">${t("task.fallback")}: ${esc(pName(a.fallback))}</div>` : ""}
|
|
||||||
${note}
|
|
||||||
<pre>${esc(msg)}</pre>
|
|
||||||
<div class="actions">
|
|
||||||
<button id="copy-msg">${t("task.copy")}</button>
|
|
||||||
<button id="save-history" class="ghost">${t("task.save")}</button>
|
|
||||||
</div>`;
|
|
||||||
root.appendChild(card);
|
|
||||||
card.querySelector("#copy-msg").onclick = () => copyToClipboard(msg, t("task.copied"));
|
|
||||||
card.querySelector("#save-history").onclick = () => saveTaskToHistory(result, zone, main, role);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMessage(person, role, zone, taskText, primaryIfDelegate){
|
|
||||||
const delegate = primaryIfDelegate
|
|
||||||
? " " + t("task.delegate_note", { primary: pName(primaryIfDelegate), until: primaryIfDelegate.vacationUntil||"—" })
|
|
||||||
: "";
|
|
||||||
return t("msg.template")
|
|
||||||
.replaceAll("{name}", pName(person))
|
|
||||||
.replaceAll("{task}", taskText)
|
|
||||||
.replaceAll("{role}", t("role."+role))
|
|
||||||
.replaceAll("{zone}", zoneTitle(zone))
|
|
||||||
.replaceAll("{delegate}", delegate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function manualAssign(result, zone){
|
|
||||||
const list = state.directory.people.filter(p => p.status==="active");
|
|
||||||
const opts = list.map((p,i) => `${i+1}. ${pName(p)} (${zoneTitle(state.directory.zones.find(z=>z.id===p.zoneId))})`);
|
|
||||||
const pick = prompt(opts.join("\n"));
|
|
||||||
const idx = parseInt(pick,10)-1;
|
|
||||||
if (isNaN(idx)||idx<0||idx>=list.length) return;
|
|
||||||
const main = list[idx];
|
|
||||||
const root = document.getElementById("task-result"); root.innerHTML="";
|
|
||||||
const msg = buildMessage(main, "primary", zone, result.text, null);
|
|
||||||
const card = document.createElement("div"); card.className="assignee-card";
|
|
||||||
card.innerHTML = `<div class="name">${esc(pName(main))}</div>
|
|
||||||
<div class="meta">${t("task.assignee")} · ${esc(zoneTitle(zone))} · ${esc(main.contact||"—")}</div>
|
|
||||||
<pre>${esc(msg)}</pre>
|
|
||||||
<div class="actions">
|
|
||||||
<button id="copy-msg">${t("task.copy")}</button>
|
|
||||||
<button id="save-history" class="ghost">${t("task.save")}</button>
|
|
||||||
</div>`;
|
|
||||||
root.appendChild(card);
|
|
||||||
card.querySelector("#copy-msg").onclick = () => copyToClipboard(msg, t("task.copied"));
|
|
||||||
card.querySelector("#save-history").onclick = () => saveTaskToHistory(result, zone, main, "primary", true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function takeSelf(result, zone){
|
|
||||||
const self = { id:"manager", name: state.lang==="ru"?"Руководитель":"Manager", nameEn:"Manager", contact:"" };
|
|
||||||
const root = document.getElementById("task-result"); root.innerHTML = "";
|
|
||||||
const card = document.createElement("div"); card.className = "assignee-card";
|
|
||||||
card.innerHTML = `<div class="name">${esc(pName(self))}</div>
|
|
||||||
<div class="meta">${t("task.take_self")}</div>
|
|
||||||
<div class="actions"><button id="save-history" class="ghost">${t("task.save")}</button></div>`;
|
|
||||||
root.appendChild(card);
|
|
||||||
card.querySelector("#save-history").onclick = () => saveTaskToHistory(result, zone, self, "primary", true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveTaskToHistory(result, zone, main, role, manualOverride=false){
|
|
||||||
const entry = {
|
|
||||||
id:"h-"+Date.now(),
|
|
||||||
createdAt:new Date().toISOString(),
|
|
||||||
taskText:result.text,
|
|
||||||
suggestions: result.sorted ? result.sorted.map(s=>({zoneId:s.zoneId,score:s.score,reason:s.hits.join(", ")})) : [],
|
|
||||||
chosenZoneId: zone.id,
|
|
||||||
chosenPersonId: main.id,
|
|
||||||
delegateUsed: role==="delegate",
|
|
||||||
manualOverride,
|
|
||||||
comments: [],
|
|
||||||
status: "open"
|
|
||||||
};
|
|
||||||
state.history.entries.unshift(entry);
|
|
||||||
state.history.entries = state.history.entries.slice(0,100);
|
|
||||||
saveHist(); renderHistory(); toast(t("task.saved"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== People view =====
|
|
||||||
function renderPeople(){
|
|
||||||
const tbody = document.querySelector("#people-table tbody"); if (!tbody) return;
|
|
||||||
tbody.innerHTML = "";
|
|
||||||
for (const p of state.directory.people) {
|
|
||||||
const tr = document.createElement("tr"); tr.className = p.status;
|
|
||||||
const zone = state.directory.zones.find(z=>z.id===p.zoneId);
|
|
||||||
tr.innerHTML = `
|
|
||||||
<td>${esc(pName(p))}</td>
|
|
||||||
<td>${esc(zoneTitle(zone))}</td>
|
|
||||||
<td>${t("role."+p.role)}</td>
|
|
||||||
<td>
|
|
||||||
<select data-pid="${p.id}" data-field="status">
|
|
||||||
<option value="active" ${p.status==="active"?"selected":""}>${t("status.active")}</option>
|
|
||||||
<option value="vacation" ${p.status==="vacation"?"selected":""}>${t("status.vacation")}</option>
|
|
||||||
<option value="inactive" ${p.status==="inactive"?"selected":""}>${t("status.inactive")}</option>
|
|
||||||
</select>
|
|
||||||
${p.vacationUntil?`<div style="font-size:11px;color:#9ca3af">${p.vacationUntil}</div>`:""}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<select data-pid="${p.id}" data-field="delegateId" ${p.status!=="vacation"?"disabled":""}>
|
|
||||||
<option value="">—</option>
|
|
||||||
${state.directory.people.filter(x=>x.id!==p.id && x.status==="active").map(x =>
|
|
||||||
`<option value="${x.id}" ${p.delegateId===x.id?"selected":""}>${esc(pName(x))}</option>`
|
|
||||||
).join("")}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>${esc(p.contact||"—")}</td>`;
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
|
||||||
tbody.querySelectorAll("select").forEach(sel => {
|
|
||||||
sel.onchange = () => {
|
|
||||||
const p = state.directory.people.find(x => x.id===sel.dataset.pid);
|
|
||||||
p[sel.dataset.field] = sel.value || null;
|
|
||||||
if (sel.dataset.field==="status" && sel.value!=="vacation") {
|
|
||||||
p.delegateId=null; p.vacationUntil=null;
|
|
||||||
}
|
|
||||||
saveDir(); renderPeople();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function simbaseSync(){
|
|
||||||
const log = document.getElementById("simbase-log");
|
|
||||||
log.className = "simbase-log show";
|
|
||||||
log.textContent = "→ GET /api/vacations (mock)…";
|
|
||||||
let updated = 0;
|
|
||||||
const today = new Date().toISOString().slice(0,10);
|
|
||||||
for (const order of SIMBASE_MOCK.vacations) {
|
|
||||||
const person = state.directory.people.find(p => p.simbaseId===order.employeeId);
|
|
||||||
if (!person) continue;
|
|
||||||
if (today >= order.vacationStart && today <= order.vacationEnd) {
|
|
||||||
if (person.status!=="vacation" || person.vacationUntil!==order.vacationEnd) {
|
|
||||||
person.status="vacation"; person.vacationUntil = order.vacationEnd;
|
|
||||||
updated++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
saveDir(); renderPeople();
|
|
||||||
log.textContent = "✓ " + t("people.simbase_done",{n:updated});
|
|
||||||
setTimeout(()=>log.classList.remove("show"), 4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetMock(){
|
|
||||||
if (!confirm("Сбросить всё к моку?")) return;
|
|
||||||
state.directory = structuredClone(SEED_DIRECTORY);
|
|
||||||
state.history = structuredClone(SEED_HISTORY);
|
|
||||||
saveDir(); saveHist(); renderPeople(); renderHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== History view =====
|
|
||||||
function renderHistory(){
|
|
||||||
const root = document.getElementById("history-list"); if (!root) return;
|
|
||||||
root.innerHTML = "";
|
|
||||||
if (!state.history.entries.length) { root.innerHTML = `<p>${t("history.empty")}</p>`; return; }
|
|
||||||
for (const e of state.history.entries) {
|
|
||||||
const zone = state.directory.zones.find(z=>z.id===e.chosenZoneId);
|
|
||||||
const person = state.directory.people.find(p=>p.id===e.chosenPersonId);
|
|
||||||
const item = document.createElement("div"); item.className = "history-item";
|
|
||||||
const date = new Date(e.createdAt).toLocaleString(state.lang==="ru"?"ru-RU":"en-US");
|
|
||||||
item.innerHTML = `
|
|
||||||
<h4>${esc(zone?zoneTitle(zone):"?")} → ${esc(person?pName(person):"?")}
|
|
||||||
<span class="status ${e.status}">${t("history.status."+e.status)}</span></h4>
|
|
||||||
<div class="date">${esc(date)}</div>
|
|
||||||
<div class="task-text">${esc(e.taskText)}</div>
|
|
||||||
<div class="actions"><button class="ghost" data-copy="${e.id}">${t("history.copy")}</button></div>
|
|
||||||
<div class="comments">${(e.comments||[]).map(c => `
|
|
||||||
<div class="comment"><span class="who">${esc(c.authorName)}</span>
|
|
||||||
<span class="when">${new Date(c.at).toLocaleString(state.lang==="ru"?"ru-RU":"en-US")}</span>
|
|
||||||
<div>${esc(c.text)}</div></div>`).join("")}
|
|
||||||
<div class="comment-form">
|
|
||||||
<select data-as="${e.id}">
|
|
||||||
<option value="manager">${t("history.comment_as")}: ${state.lang==="ru"?"Руководитель":"Manager"}</option>
|
|
||||||
${person ? `<option value="assignee">${t("history.comment_as")}: ${esc(pName(person))}</option>` : ""}
|
|
||||||
</select>
|
|
||||||
<input type="text" placeholder="${t("history.comment_placeholder")}" data-input="${e.id}">
|
|
||||||
<button data-send="${e.id}">${t("history.send")}</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
root.appendChild(item);
|
|
||||||
}
|
|
||||||
root.querySelectorAll("[data-copy]").forEach(btn => {
|
|
||||||
btn.onclick = () => {
|
|
||||||
const e = state.history.entries.find(x=>x.id===btn.dataset.copy);
|
|
||||||
copyToClipboard(formatHistoryText(e), t("history.copied"));
|
|
||||||
};
|
|
||||||
});
|
|
||||||
root.querySelectorAll("[data-send]").forEach(btn => {
|
|
||||||
btn.onclick = () => {
|
|
||||||
const id = btn.dataset.send;
|
|
||||||
const e = state.history.entries.find(x=>x.id===id);
|
|
||||||
const input = root.querySelector(`[data-input="${id}"]`);
|
|
||||||
const as = root.querySelector(`[data-as="${id}"]`).value;
|
|
||||||
const text = input.value.trim(); if (!text) return;
|
|
||||||
const person = state.directory.people.find(p=>p.id===e.chosenPersonId);
|
|
||||||
e.comments = e.comments || [];
|
|
||||||
e.comments.push({
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
authorRole: as,
|
|
||||||
authorName: as==="manager" ? (state.lang==="ru"?"Руководитель":"Manager") : pName(person),
|
|
||||||
text
|
|
||||||
});
|
|
||||||
if (e.status==="open") e.status = "in_progress";
|
|
||||||
saveHist(); renderHistory();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatHistoryText(e){
|
|
||||||
const zone = state.directory.zones.find(z=>z.id===e.chosenZoneId);
|
|
||||||
const person = state.directory.people.find(p=>p.id===e.chosenPersonId);
|
|
||||||
const lines = [
|
|
||||||
`Задача: ${e.taskText}`,
|
|
||||||
`Зона: ${zone?zoneTitle(zone):"?"}`,
|
|
||||||
`Исполнитель: ${person?pName(person):"?"}`,
|
|
||||||
`Статус: ${t("history.status."+e.status)}`,
|
|
||||||
"",
|
|
||||||
"Комментарии:",
|
|
||||||
...(e.comments||[]).map(c => `[${new Date(c.at).toLocaleString()}] ${c.authorName}: ${c.text}`)
|
|
||||||
];
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Helpers =====
|
|
||||||
function pName(p){ return state.lang==="en" && p.nameEn ? p.nameEn : p.name; }
|
|
||||||
function zoneTitle(z){ return state.lang==="en" && z.titleEn ? z.titleEn : z.title; }
|
|
||||||
function esc(s){ return (s??"").toString().replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
|
||||||
function copyToClipboard(text, toastMsg){
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
||||||
navigator.clipboard.writeText(text).then(()=>toast(toastMsg)).catch(()=>fallbackCopy(text, toastMsg));
|
|
||||||
} else {
|
|
||||||
fallbackCopy(text, toastMsg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function fallbackCopy(text, toastMsg){
|
|
||||||
const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.left="-9999px";
|
|
||||||
document.body.appendChild(ta); ta.select();
|
|
||||||
try { document.execCommand("copy"); toast(toastMsg); } catch(e){}
|
|
||||||
document.body.removeChild(ta);
|
|
||||||
}
|
|
||||||
function toast(msg){
|
|
||||||
let el = document.querySelector(".toast");
|
|
||||||
if (!el) { el = document.createElement("div"); el.className="toast"; document.body.appendChild(el); }
|
|
||||||
el.textContent = msg; el.classList.add("show");
|
|
||||||
setTimeout(()=>el.classList.remove("show"), 1800);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Bindings =====
|
|
||||||
function bindUI(){
|
|
||||||
document.querySelectorAll(".tabs button").forEach(b => {
|
|
||||||
b.onclick = () => {
|
|
||||||
document.querySelectorAll(".tabs button").forEach(x => x.classList.toggle("active", x===b));
|
|
||||||
document.querySelectorAll(".view").forEach(v => v.classList.toggle("hidden", v.id !== "view-"+b.dataset.tab));
|
|
||||||
};
|
|
||||||
});
|
|
||||||
document.querySelectorAll(".lang button").forEach(b => {
|
|
||||||
b.onclick = () => { state.lang = b.dataset.lang; localStorage.setItem(LS.lang, state.lang); applyLang(); };
|
|
||||||
});
|
|
||||||
document.getElementById("task-classify").onclick = onClassify;
|
|
||||||
document.getElementById("simbase-sync").onclick = simbaseSync;
|
|
||||||
document.getElementById("people-reset").onclick = resetMock;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Init =====
|
|
||||||
bindUI(); applyLang(); renderPeople(); renderHistory();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"_comment": "Мок системы Симбейс. В реальной интеграции — GET /api/vacations. Здесь просто читается как файл.",
|
|
||||||
"vacations": [
|
|
||||||
{
|
|
||||||
"employeeId": "EMP-003",
|
|
||||||
"fullName": "Динара Иванова",
|
|
||||||
"vacationStart": "2026-05-20",
|
|
||||||
"vacationEnd": "2026-06-10",
|
|
||||||
"orderNumber": "О-2026-451",
|
|
||||||
"orderDate": "2026-05-10"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"employeeId": "EMP-005",
|
|
||||||
"fullName": "Михаил Петров",
|
|
||||||
"vacationStart": "2026-05-22",
|
|
||||||
"vacationEnd": "2026-05-29",
|
|
||||||
"orderNumber": "О-2026-477",
|
|
||||||
"orderDate": "2026-05-15"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
153
styles.css
153
styles.css
@ -1,153 +0,0 @@
|
|||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
background: #f5f6f8;
|
|
||||||
color: #1d1f23;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
header {
|
|
||||||
background: #1d1f23;
|
|
||||||
color: #fff;
|
|
||||||
padding: 16px 24px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
header h1 { margin: 0; font-size: 20px; }
|
|
||||||
header .subtitle { margin: 2px 0 0; font-size: 13px; opacity: 0.7; }
|
|
||||||
.header-controls { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
|
|
||||||
.tabs button, .lang button {
|
|
||||||
background: transparent;
|
|
||||||
color: #fff;
|
|
||||||
border: 1px solid #3a3d44;
|
|
||||||
padding: 6px 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
.tabs button.active, .lang button.active { background: #4a8cff; border-color: #4a8cff; }
|
|
||||||
.lang button { padding: 6px 10px; font-size: 12px; }
|
|
||||||
|
|
||||||
main { padding: 24px; max-width: 1100px; margin: 0 auto; }
|
|
||||||
.view.hidden { display: none; }
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
width: 100%;
|
|
||||||
font: inherit;
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid #d0d4dc;
|
|
||||||
border-radius: 6px;
|
|
||||||
resize: vertical;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
button.primary, button:not(.ghost):not(.tabs button):not(.lang button) {
|
|
||||||
background: #4a8cff;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
button.ghost { background: transparent; border: 1px solid #d0d4dc; color: #1d1f23; }
|
|
||||||
button:hover { opacity: 0.9; }
|
|
||||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
|
|
||||||
.btn-like {
|
|
||||||
display: inline-block;
|
|
||||||
background: #4a8cff;
|
|
||||||
color: #fff;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#task-result { margin-top: 24px; display: flex; flex-direction: column; gap: 16px; }
|
|
||||||
|
|
||||||
.zone-card, .assignee-card, .history-item {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e5eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
.zone-card.choice { cursor: pointer; transition: border-color 0.15s; }
|
|
||||||
.zone-card.choice:hover { border-color: #4a8cff; }
|
|
||||||
.zone-card h3 { margin: 0 0 6px; font-size: 15px; }
|
|
||||||
.zone-card .score { font-size: 12px; color: #6b7280; }
|
|
||||||
.zone-card .reason { margin-top: 6px; font-size: 13px; color: #4b5563; }
|
|
||||||
|
|
||||||
.assignee-card { border-color: #4a8cff; }
|
|
||||||
.assignee-card .name { font-size: 18px; font-weight: 600; }
|
|
||||||
.assignee-card .meta { color: #6b7280; font-size: 13px; margin-top: 4px; }
|
|
||||||
.assignee-card .note { background: #fff3cd; padding: 8px 12px; border-radius: 6px; margin-top: 8px; font-size: 13px; }
|
|
||||||
.assignee-card pre {
|
|
||||||
background: #f5f6f8;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
font-size: 13px;
|
|
||||||
margin-top: 12px;
|
|
||||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
|
||||||
}
|
|
||||||
.assignee-card .actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
|
||||||
|
|
||||||
.error-card { background: #fff5f5; border: 1px solid #f5b1b1; padding: 16px; border-radius: 8px; }
|
|
||||||
.error-card .actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
|
||||||
|
|
||||||
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; align-items: center; }
|
|
||||||
.simbase-log {
|
|
||||||
background: #e7f3ff;
|
|
||||||
border: 1px solid #b8daff;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.simbase-log.show { display: block; }
|
|
||||||
|
|
||||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; }
|
|
||||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #e2e5eb; font-size: 14px; vertical-align: middle; }
|
|
||||||
th { background: #f8f9fb; font-weight: 600; font-size: 13px; }
|
|
||||||
tr.vacation td { background: #fffbea; }
|
|
||||||
tr.inactive td { color: #9ca3af; }
|
|
||||||
td select, td input { font: inherit; padding: 4px 6px; border: 1px solid #d0d4dc; border-radius: 4px; }
|
|
||||||
td button { padding: 4px 10px; margin: 0 2px; }
|
|
||||||
|
|
||||||
.history-item { margin-bottom: 12px; }
|
|
||||||
.history-item h4 { margin: 0 0 6px; font-size: 14px; }
|
|
||||||
.history-item .date { color: #6b7280; font-size: 12px; }
|
|
||||||
.history-item .task-text { margin: 8px 0; font-size: 14px; }
|
|
||||||
.history-item .status { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; background: #e2e5eb; }
|
|
||||||
.history-item .status.in_progress { background: #cfe8ff; }
|
|
||||||
.history-item .status.done { background: #c6f6d5; }
|
|
||||||
.history-item .status.cancelled { background: #fed7d7; }
|
|
||||||
.history-item .comments { margin-top: 12px; border-top: 1px solid #e2e5eb; padding-top: 10px; }
|
|
||||||
.history-item .comment { font-size: 13px; padding: 6px 0; border-bottom: 1px dashed #eef0f4; }
|
|
||||||
.history-item .comment:last-child { border-bottom: none; }
|
|
||||||
.history-item .comment .who { font-weight: 600; }
|
|
||||||
.history-item .comment .when { color: #9ca3af; font-size: 11px; margin-left: 6px; }
|
|
||||||
.history-item .comment-form { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
|
||||||
.history-item .comment-form select, .history-item .comment-form input { padding: 6px 8px; font: inherit; border: 1px solid #d0d4dc; border-radius: 4px; }
|
|
||||||
.history-item .comment-form input { flex: 1; min-width: 200px; }
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 24px; left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: #1d1f23;
|
|
||||||
color: #fff;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
.toast.show { opacity: 1; }
|
|
||||||
Reference in New Issue
Block a user