feat: Daily Deal Status Tracker MVP
TRD + SPA: localStorage, цветовая индикация green/red, триггеры 17:00/18:30/10:00, Telegram-уведомления, производственный календарь РФ 2026, симуляция времени для AC-3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0b8b026751
commit
ec67c31600
111
PRD.md
111
PRD.md
@ -0,0 +1,111 @@
|
||||
Product Requirement Document (PRD)
|
||||
|
||||
## 1. Общая информация о продукте
|
||||
|
||||
* **Название проекта:** Модуль ежедневного контроля статусов переговоров (Daily Deal Status Tracker)
|
||||
* **Инициатор:** Отдел продаж (Менеджер по продажам)
|
||||
* **Статус документа:** Черновик (На согласовании)
|
||||
* **Целевая платформа:** CRM-система организации (интеграция / кастомный модуль) / Корпоративный таск-менеджер
|
||||
|
||||
---
|
||||
|
||||
## 2. Бизнес-контекст и цели
|
||||
|
||||
### 2.1 Проблема
|
||||
В процессе ведения b2b-сделок и длинных циклов продаж менеджеры ведут переговоры со множеством клиентов параллельно. На этапе активных переговоров (согласование цены, обсуждение договора, утверждение КП) критически важно осуществлять ежедневные касания или отслеживать внутренний прогресс.
|
||||
|
||||
**Текущие «боли»:**
|
||||
* **Отсутствие прозрачности:** Руководитель отдела продаж (РОП) и смежные подразделения не видят актуальный статус по «горячим» сделкам на текущий день без ручного опроса менеджеров.
|
||||
* **Забытые сделки:** Менеджеры из-за высокой когнитивной нагрузки забывают зафиксировать промежуточные итоги созвонов/переписок в тот же день.
|
||||
* **Сложность планирования:** Сложно прогнозировать закрытие сделок в конце месяца, так как данные в CRM устаревают на 2-3 дня.
|
||||
|
||||
### 2.2 Цели продукта
|
||||
1. **Повышение актуальности данных:** Обеспечить 100% обновление статусов по всем сделкам в фазе активных переговоров в конце каждого рабочего дня.
|
||||
2. **Снижение рутины для РОПа:** Автоматизировать контроль за своевременным ведением CRM-системы, избавив руководителя от микроменеджмента.
|
||||
3. **Ускорение сделок:** Минимизировать простои в переговорах за счет жесткого контроля «дней бездействия».
|
||||
|
||||
### 2.3 Бизнес-метрики (KPI)
|
||||
* `% Сделок без актуального статуса` в конце рабочего дня = 0%.
|
||||
* Среднее время нахождения сделки на этапе «Переговоры» сокращено на 15%.
|
||||
* Время, затрачиваемое РОПом на ручную проверку пайплайна, сокращено до 10 минут в день (вместо 1–2 часов).
|
||||
|
||||
---
|
||||
|
||||
## 3. Целевая аудитория (Персоны)
|
||||
|
||||
1. **Менеджер по продажам (Алексей):** Ведет одновременно 15–20 активных переговоров. Ему нужен простой интерфейс для быстрого ввода итогов дня, чтобы система не отвлекала от самих продаж, но при этом напоминала о забытых клиентах.
|
||||
2. **Руководитель отдела продаж / РОП (Елена):** Ей нужен единый экран управления («дэшборд»), где мгновенно видны все «красные» (проблемные) сделки, по которым менеджеры не зафиксировали прогресс.
|
||||
|
||||
---
|
||||
|
||||
## 4. Функциональные требования (Functional Requirements)
|
||||
|
||||
### 4.1 Логика попадания сделок в реестр ежедневного контроля
|
||||
* **FR-1.1:** Система должна автоматически включать сделку в перечень ежедневного контроля, если она переходит на один из этапов воронки: `«Согласование КП»`, `«Обсуждение условий»`, `«Согласование договора»`.
|
||||
* **FR-1.2:** Система должна автоматически включать сделку в реестр при присвоении ей тега `#Переговоры_Контроль` независимо от этапа воронки.
|
||||
* **FR-1.3:** Сделка исключается из реестра при переходе на этапы `«Успешно реализовано»`, `«Закрыто и не реализовано»` или при ручном снятии тега/флага контроля.
|
||||
|
||||
### 4.2 Требования к полям данных сделки
|
||||
В карточке сделки и в табличном представлении должны поддерживаться следующие атрибуты:
|
||||
* **Имя поля:** `Текущий статус (Лог дня)`
|
||||
* *Тип:* Текстовое многострочное поле.
|
||||
* *Описание:* Сюда вносится суть сегодняшнего контакта (например: *«ЛПР взял паузу до завтра, согласовывает бюджет с финдиректором»*).
|
||||
* **Имя поля:** `Дата последнего обновления статуса`
|
||||
* *Тип:* Дата и время (системное).
|
||||
* *Описание:* Заполняется автоматически в момент любого изменения поля `Текущий статус (Лог дня)`.
|
||||
* **Имя поля:** `Дней без обновлений`
|
||||
* *Тип:* Вычисляемое числовое поле.
|
||||
* *Формула:* `Текущая_Дата - Дата_последнего_обновления_статуса`. Считает календарные (или рабочие, настраивается отдельно) дни.
|
||||
|
||||
### 4.3 Интерфейс: Рабочее пространство («Экран контроля»)
|
||||
* **FR-3.1:** Должен быть реализован вид «Таблица» (или специализированный Kanban), доступный менеджеру, где отображаются только его сделки, требующие ежедневного обновления.
|
||||
* **FR-3.2:** **Цветовая индикация строк/карточек:**
|
||||
* *Зеленый:* Поле `Текущий статус (Лог дня)` изменено сегодня (в текущие календарные сутки).
|
||||
* *Красный:* Поле `Текущий статус (Лог дня)` НЕ изменялось более 24 часов (или с начала текущего рабочего дня).
|
||||
* **FR-3.3:** Сортировка по умолчанию: сверху отображаются сделки с наибольшим значением поля `Дней без обновлений` (самые критичные).
|
||||
|
||||
### 4.4 Автоматические уведомления и триггеры (Информирование)
|
||||
* **FR-4.1 (Первое напоминание менеджеру):** Ежедневно в **17:00** (по местному времени филиала) система проверяет все активные сделки в реестре менеджера. Если у сделки статус равен «Красный» (не обновлено сегодня), менеджеру отправляется пуш-уведомление внутри CRM или в мессенджер (Telegram/Slack):
|
||||
> 🔔 *«Внимание, [Имя_Менеджера]! По сделке [Название_Сделки/Компании] сегодня не обновлен статус переговоров. Пожалуйста, актуализируйте данные до конца рабочего дня.»*
|
||||
* **FR-4.2 (Второе напоминание менеджеру):** Ежедневно в **18:30** система производит повторную проверку. Если статус по-прежнему не обновлен, отправляется строгое предупреждение:
|
||||
> ⚠️ *«Дедлайн пропущен! Статус по [Название_Сделки] не обновлен. Данные будут переданы руководителю.»*
|
||||
* **FR-4.3 (Эскалация руководителю):** На следующий рабочий день в **10:00**, если поле так и не было заполнено, система отправляет РОПу сводный отчет (в Telegram или на Email) со списком «просроченных» сделок в формате:
|
||||
> 📊 *«Просрочки по обновлению статусов переговоров на [Дата]:
|
||||
1. Менеджер Алексей — Сделка 'ООО Вектор' (2 дня без обновлений)
|
||||
2. Менеджер Иван — Сделка 'ТД Альянс' (1 день без обновлений)»*
|
||||
|
||||
---
|
||||
|
||||
## 5. Нефункциональные требования (Non-Functional Requirements)
|
||||
|
||||
### 5.1 Надежность и производительность
|
||||
* Проверка триггеров времени (17:00, 18:30, 10:00) должна происходить без задержек (допустимое отклонение +/- 2 минуты).
|
||||
* При обновлении текста статуса пересчет цвета карточки на экране должен происходить мгновенно (в течение 1 секунды) без необходимости принудительной перезагрузки страницы пользователем.
|
||||
|
||||
### 5.2 Безопасность и права доступа
|
||||
* Менеджер имеет право видеть и редактировать статусы только в своих сделках (где он назначен ответственным).
|
||||
* Руководитель отдела продаж (РОП) имеет доступ к общему экрану по всем менеджерам своего подразделения с возможностью фильтрации по конкретному сотруднику.
|
||||
|
||||
### 5.3 Интеграции
|
||||
* Система уведомлений должна иметь API-подключение к корпоративному шлюзу (например, Telegram Bot API) для стабильной отправки сообщений за пределы CRM.
|
||||
|
||||
---
|
||||
|
||||
## 6. Критерии приемки (Acceptance Criteria)
|
||||
|
||||
Выполнение задачи считается успешным, если:
|
||||
1. При переводе тестовой сделки на этап «Обсуждение условий» она автоматически появляется на специальном экране контроля.
|
||||
2. Если написать в поле «Текущий статус» любой текст сегодня, карточка подсвечивается зеленым.
|
||||
3. Если изменить системное время на сервере/компьютере на +1 день (имитация отсутствия активности), карточка этой сделки автоматически становится красной, а счетчик дней без обновлений показывает «1».
|
||||
4. В 17:00 бот присылает корректное уведомление со ссылкой на конкретную «красную» сделку.
|
||||
5. Поля логов сохраняют историю изменений (каждое новое ежедневное обновление не затирает старое, а добавляется в историю в виде примечания/системного лога с таймстампом).
|
||||
|
||||
---
|
||||
|
||||
## 7. Риски и открытые вопросы
|
||||
|
||||
| Риск / Вопрос | Способ минимизации / Решение |
|
||||
| :--- | :--- |
|
||||
| Менеджеры начнут писать отписки ради галочки (например, просто ставить точку или писать «ок»). | Внедрить регулярный выборочный аудит со стороны РОПа. Внедрить валидацию поля на минимальное количество символов (например, не менее 15 символов). |
|
||||
| Что делать, если менеджер в отпуске или на больничном? Сделки будут гореть «красным». | Предусмотреть в системе статус «Менеджер временно отсутствует». При его включении триггеры эскалации на РОПа по сделкам данного сотрудника замораживаются, либо сделки временно перенаправляются на замещающего коллегу. |
|
||||
| Учет выходных и праздничных дней. | Счетчик `Дней без обновлений` должен учитывать производственный календарь и не отправлять алерты в субботу и воскресенье. |
|
||||
182
TRD.md
182
TRD.md
@ -0,0 +1,182 @@
|
||||
# Technical Requirement Document (TRD)
|
||||
|
||||
## 1. Обзор реализации
|
||||
|
||||
**Проект:** Daily Deal Status Tracker (модуль ежедневного контроля переговоров).
|
||||
**Тип MVP:** Standalone веб-приложение (Single Page Application) с локальным хранилищем + опциональный Telegram-бот для уведомлений.
|
||||
**Деплой:** Gitea Pages (статический фронтенд) + опциональный backend через docker-compose+Traefik для бота.
|
||||
|
||||
## 2. Архитектура
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Браузер пользователя │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ index.html (SPA) │ │
|
||||
│ │ ├─ app.js — логика, состояние, рендер │ │
|
||||
│ │ ├─ store.js — слой localStorage │ │
|
||||
│ │ ├─ alerts.js — таймеры 17:00 / 18:30 / 10:00 │ │
|
||||
│ │ └─ style.css │ │
|
||||
│ └────────────────┬───────────────────────────────┘ │
|
||||
└───────────────────┼──────────────────────────────────┘
|
||||
│ (fetch, опционально)
|
||||
▼
|
||||
┌───────────────────────────┐
|
||||
│ Telegram Bot API (HTTP) │
|
||||
│ через прокси/webhook │
|
||||
└───────────────────────────┘
|
||||
```
|
||||
|
||||
Для MVP backend необязателен — все таймеры и хранение работают в браузере (вкладка должна быть открыта; при закрытии расписание восстанавливается при следующем входе).
|
||||
|
||||
## 3. Технологический стек
|
||||
|
||||
| Слой | Технология | Причина |
|
||||
|------|-----------|---------|
|
||||
| UI | Vanilla JS + HTML + CSS | Деплой Gitea Pages без билда |
|
||||
| Состояние | `localStorage` (JSON) | Без БД; гарантированно работает на статике |
|
||||
| Время / триггеры | `setInterval` + `Date` | Простая симуляция расписания |
|
||||
| Уведомления | Browser Notifications API + Telegram Bot API | Двойной канал |
|
||||
| Деплой | Ветка `gitea-pages` + топик `gitea-pages-allowall` | См. GITEA.md |
|
||||
|
||||
## 4. Модель данных
|
||||
|
||||
### 4.1 Сделка (`Deal`)
|
||||
```json
|
||||
{
|
||||
"id": "uuid-v4",
|
||||
"company": "ООО Вектор",
|
||||
"manager": "Алексей",
|
||||
"stage": "Согласование КП",
|
||||
"tag": "#Переговоры_Контроль",
|
||||
"currentStatus": "ЛПР взял паузу...",
|
||||
"lastUpdate": "2026-05-19T14:32:00+05:00",
|
||||
"history": [
|
||||
{ "ts": "2026-05-18T17:10:00+05:00", "text": "Согласован бюджет" }
|
||||
],
|
||||
"managerAway": false
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Этапы воронки (включают сделку в контроль)
|
||||
- `Согласование КП`
|
||||
- `Обсуждение условий`
|
||||
- `Согласование договора`
|
||||
|
||||
### 4.3 Финальные этапы (исключают)
|
||||
- `Успешно реализовано`
|
||||
- `Закрыто и не реализовано`
|
||||
|
||||
## 5. Функциональные блоки → реализация
|
||||
|
||||
| FR | Реализация |
|
||||
|----|------------|
|
||||
| FR-1.1 | При создании/редактировании сделки автоматически выставляется флаг `inControl=true`, если `stage` ∈ контрольные |
|
||||
| FR-1.2 | То же при `tag === "#Переговоры_Контроль"` |
|
||||
| FR-1.3 | `inControl=false` при финальных этапах или ручном снятии |
|
||||
| FR-2.x | Поля `currentStatus`, `lastUpdate`, вычисление `daysWithoutUpdate = floor((now - lastUpdate) / 86400_000)` |
|
||||
| FR-3.1 | Таблица + Kanban-вид, фильтр по текущему менеджеру (выбор в шапке) |
|
||||
| FR-3.2 | CSS-класс `.row-green` / `.row-red` в зависимости от того, был ли `lastUpdate` сегодня |
|
||||
| FR-3.3 | `Array.sort` по `daysWithoutUpdate` desc |
|
||||
| FR-4.1 | `setInterval` каждую минуту: если `hh:mm === 17:00` и сделка красная — `notify()` |
|
||||
| FR-4.2 | То же на `18:30` со строгим текстом |
|
||||
| FR-4.3 | На `10:00` следующего рабочего дня — формирование текста сводки и отправка через Telegram Bot API (если задан токен) |
|
||||
|
||||
## 6. UI / Экраны
|
||||
|
||||
### 6.1 Шапка
|
||||
- Селектор «Я — менеджер: [Алексей ▾]» / «Я — РОП»
|
||||
- Кнопка `+ Новая сделка`
|
||||
- Текущее время (для отладки + ручной сдвиг даты)
|
||||
|
||||
### 6.2 Экран контроля менеджера
|
||||
- Таблица: Компания | Этап | Текущий статус | Дней без обновлений | Цвет
|
||||
- Inline-редактирование поля `currentStatus` (textarea)
|
||||
- Сортировка: красные сверху, по `daysWithoutUpdate` desc
|
||||
|
||||
### 6.3 Экран РОПа
|
||||
- Тот же список, но по всем менеджерам, с фильтром
|
||||
- Сводка «Просрочки на сегодня» (рендерится текст из FR-4.3)
|
||||
- Кнопка `Отправить в Telegram` (вызов API)
|
||||
|
||||
### 6.4 Карточка сделки
|
||||
- Поля редактирования
|
||||
- История изменений (`history[]`) с таймстампом
|
||||
- Чекбокс `Менеджер временно отсутствует` → замораживает алерты
|
||||
|
||||
## 7. Валидация
|
||||
- Минимум **15 символов** в `currentStatus` при сохранении (риск отписок из PRD §7)
|
||||
- При попытке сохранить короче — показывать ошибку и не обновлять `lastUpdate`
|
||||
|
||||
## 8. Производственный календарь
|
||||
Простая реализация: статический массив выходных и праздников РФ 2026.
|
||||
```js
|
||||
const HOLIDAYS_2026 = ["2026-01-01", ..., "2026-05-09", ...];
|
||||
const isWorkDay = (d) => d.getDay() !== 0 && d.getDay() !== 6 && !HOLIDAYS_2026.includes(iso(d));
|
||||
```
|
||||
- Алерты не отправляются в выходные.
|
||||
- `daysWithoutUpdate` (рабочие дни) — счетчик пропускает выходные.
|
||||
|
||||
## 9. Тестовый сдвиг времени
|
||||
Для проверки Acceptance §6.3 («изменить системное время на +1 день») — в UI есть отладочный input `Симулировать дату`, который переопределяет `Date.now()` через прокси.
|
||||
|
||||
## 10. Уведомления
|
||||
|
||||
### 10.1 Локально (в браузере)
|
||||
`Notification.requestPermission()` при первом запуске, далее `new Notification(text)` в триггерах.
|
||||
|
||||
### 10.2 Telegram
|
||||
- Настройки бота хранятся в `localStorage`: `TG_BOT_TOKEN`, `TG_CHAT_ID_MANAGER`, `TG_CHAT_ID_ROP`.
|
||||
- Отправка: `POST https://api.telegram.org/bot<TOKEN>/sendMessage`.
|
||||
- При отсутствии токена — только локальные уведомления.
|
||||
|
||||
## 11. Безопасность и права
|
||||
В рамках MVP роли разделяются клиентским переключателем (Менеджер / РОП). Полноценный auth — out of scope (требует backend; в проде интегрировать в существующий CRM SSO).
|
||||
|
||||
## 12. Файловая структура
|
||||
|
||||
```
|
||||
askhat/
|
||||
├─ index.html
|
||||
├─ assets/
|
||||
│ ├─ app.js
|
||||
│ ├─ store.js
|
||||
│ ├─ alerts.js
|
||||
│ ├─ holidays.js
|
||||
│ └─ style.css
|
||||
├─ PRD.md
|
||||
├─ TRD.md
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
## 13. План работ (порядок реализации)
|
||||
|
||||
1. Скелет `index.html` + стили.
|
||||
2. `store.js` — CRUD сделок в localStorage + seed-данные.
|
||||
3. `app.js` — рендер таблицы, цветовая индикация, сортировка, редактирование.
|
||||
4. Валидация `currentStatus` (≥15 символов).
|
||||
5. Карточка сделки + история.
|
||||
6. Экран РОПа + сводка.
|
||||
7. `alerts.js` — таймеры 17:00 / 18:30 / 10:00, локальные нотификации.
|
||||
8. Telegram-интеграция (поля настроек + fetch).
|
||||
9. Производственный календарь + статус «отсутствует».
|
||||
10. Отладочный сдвиг даты.
|
||||
11. Деплой на Gitea Pages (ветка `gitea-pages` + топик).
|
||||
|
||||
## 14. Acceptance Criteria → проверка
|
||||
|
||||
| AC | Как проверить в MVP |
|
||||
|----|---------------------|
|
||||
| AC-1 | Создать сделку с этапом «Обсуждение условий» → появляется в реестре |
|
||||
| AC-2 | Ввести текст в `currentStatus` → строка зеленая |
|
||||
| AC-3 | Сдвинуть симулируемую дату на +1 день → строка красная, счетчик `1` |
|
||||
| AC-4 | В отладке выставить время `17:00` → срабатывает локальная нотификация + сообщение в Telegram (если настроен) |
|
||||
| AC-5 | Каждое сохранение `currentStatus` пушит запись в `history[]` с таймстампом |
|
||||
|
||||
## 15. Out of scope (для последующих итераций)
|
||||
|
||||
- Реальная CRM-интеграция (Bitrix24/amoCRM API).
|
||||
- Аутентификация / SSO.
|
||||
- Бэкенд-хранилище и мульти-устройство sync.
|
||||
- Полноценный продакшн-деплой Telegram-бота через docker-compose.
|
||||
- Аудит-выборки РОПом и AI-валидация отписок.
|
||||
119
assets/alerts.js
Normal file
119
assets/alerts.js
Normal file
@ -0,0 +1,119 @@
|
||||
// Триггеры 17:00 / 18:30 / 10:00 + отправка в Telegram
|
||||
|
||||
function notifyBrowser(text){
|
||||
try{
|
||||
if("Notification" in window && Notification.permission === "granted"){
|
||||
new Notification("Daily Deal Status Tracker", { body: text });
|
||||
}
|
||||
}catch(e){}
|
||||
window.toast(text, "ok", 8000);
|
||||
}
|
||||
|
||||
async function sendTelegram(chatId, text){
|
||||
const cfg = window.TelegramCfg.load();
|
||||
if(!cfg.token || !chatId) return { skipped: true };
|
||||
try{
|
||||
const r = await fetch(`https://api.telegram.org/bot${cfg.token}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ chat_id: chatId, text, parse_mode: "HTML" })
|
||||
});
|
||||
return await r.json();
|
||||
}catch(e){
|
||||
console.error("telegram send fail",e);
|
||||
return { error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
window.Alerts = {
|
||||
notifyBrowser,
|
||||
sendTelegram,
|
||||
|
||||
// Сводка для РОПа — формат из FR-4.3
|
||||
buildRopSummary(){
|
||||
const overdue = window.Store.list()
|
||||
.filter(d => window.Store.inControl(d) && !d.managerAway)
|
||||
.filter(d => window.Store.colorOf(d) === "red")
|
||||
.map(d => ({ d, days: window.Store.daysWithoutUpdate(d) }))
|
||||
.filter(x => x.days >= 1)
|
||||
.sort((a,b) => b.days - a.days);
|
||||
if(overdue.length === 0) return "Просрочек нет ✅";
|
||||
const dateStr = window.now().toLocaleDateString("ru-RU");
|
||||
let out = `📊 Просрочки по обновлению статусов переговоров на ${dateStr}:\n`;
|
||||
overdue.forEach((x, i) => {
|
||||
out += `${i+1}. Менеджер ${x.d.manager} — Сделка '${x.d.company}' (${x.days} ${plural(x.days,"день","дня","дней")} без обновлений)\n`;
|
||||
});
|
||||
return out;
|
||||
},
|
||||
|
||||
// Текст напоминания менеджеру (FR-4.1)
|
||||
buildManagerReminder(deal, strict){
|
||||
if(strict){
|
||||
return `⚠️ Дедлайн пропущен! Статус по '${deal.company}' не обновлён. Данные будут переданы руководителю.`;
|
||||
}
|
||||
return `🔔 Внимание, ${deal.manager}! По сделке '${deal.company}' сегодня не обновлён статус переговоров. Пожалуйста, актуализируйте данные до конца рабочего дня.`;
|
||||
},
|
||||
|
||||
// Главный тикер — каждую минуту
|
||||
tick(){
|
||||
const now = window.now();
|
||||
if(!window.isWorkDay(now)) return; // выходной — алерты замораживаем (PRD §7)
|
||||
const hh = now.getHours();
|
||||
const mm = now.getMinutes();
|
||||
const todayISO = now.toISOString().slice(0,10);
|
||||
window.AlertedLog.clearOld();
|
||||
|
||||
// 17:00 — первое напоминание менеджерам
|
||||
if(hh === 17 && mm === 0){
|
||||
this.runManagerReminders(false, todayISO);
|
||||
}
|
||||
// 18:30 — строгое
|
||||
if(hh === 18 && mm === 30){
|
||||
this.runManagerReminders(true, todayISO);
|
||||
}
|
||||
// 10:00 — эскалация РОПу
|
||||
if(hh === 10 && mm === 0){
|
||||
this.runRopEscalation(todayISO);
|
||||
}
|
||||
},
|
||||
|
||||
runManagerReminders(strict, todayISO){
|
||||
const cfg = window.TelegramCfg.load();
|
||||
window.Store.list().forEach(deal => {
|
||||
if(!window.Store.inControl(deal) || deal.managerAway) return;
|
||||
if(window.Store.colorOf(deal) !== "red") return;
|
||||
const key = `${todayISO}:${strict?"strict":"first"}:${deal.id}`;
|
||||
if(window.AlertedLog.has(key)) return;
|
||||
const text = this.buildManagerReminder(deal, strict);
|
||||
notifyBrowser(text);
|
||||
if(cfg.chatManager) sendTelegram(cfg.chatManager, text);
|
||||
window.AlertedLog.mark(key);
|
||||
});
|
||||
},
|
||||
|
||||
runRopEscalation(todayISO){
|
||||
const key = `${todayISO}:rop`;
|
||||
if(window.AlertedLog.has(key)) return;
|
||||
const text = this.buildRopSummary();
|
||||
if(text.startsWith("Просрочек нет")) return;
|
||||
notifyBrowser("Сводка отправлена РОПу");
|
||||
const cfg = window.TelegramCfg.load();
|
||||
if(cfg.chatRop) sendTelegram(cfg.chatRop, text);
|
||||
window.AlertedLog.mark(key);
|
||||
},
|
||||
|
||||
start(){
|
||||
if("Notification" in window && Notification.permission === "default"){
|
||||
Notification.requestPermission();
|
||||
}
|
||||
this.tick();
|
||||
setInterval(() => this.tick(), 60*1000);
|
||||
}
|
||||
};
|
||||
|
||||
function plural(n, one, few, many){
|
||||
const mod10 = n % 10, mod100 = n % 100;
|
||||
if(mod10 === 1 && mod100 !== 11) return one;
|
||||
if(mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few;
|
||||
return many;
|
||||
}
|
||||
309
assets/app.js
Normal file
309
assets/app.js
Normal file
@ -0,0 +1,309 @@
|
||||
// UI слой: рендер, события, модалки
|
||||
|
||||
const $ = (s, el=document) => el.querySelector(s);
|
||||
const $$ = (s, el=document) => [...el.querySelectorAll(s)];
|
||||
|
||||
const state = {
|
||||
role: "manager",
|
||||
currentManager: null
|
||||
};
|
||||
|
||||
// Toast helper (используется и из alerts.js)
|
||||
window.toast = function(msg, type, ttl){
|
||||
const host = $("#toast-host");
|
||||
if(!host) return;
|
||||
const el = document.createElement("div");
|
||||
el.className = "toast" + (type ? " "+type : "");
|
||||
el.textContent = msg;
|
||||
host.appendChild(el);
|
||||
setTimeout(() => el.remove(), ttl || 4000);
|
||||
};
|
||||
|
||||
function fmtDateTime(iso){
|
||||
if(!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString("ru-RU", { day:"2-digit", month:"2-digit", year:"numeric", hour:"2-digit", minute:"2-digit" });
|
||||
}
|
||||
|
||||
function refreshManagersList(){
|
||||
const managers = window.Store.managers();
|
||||
// селектор в шапке
|
||||
const sel = $("#manager-select");
|
||||
const cur = state.currentManager || managers[0];
|
||||
state.currentManager = cur;
|
||||
sel.innerHTML = managers.map(m => `<option value="${m}" ${m===cur?"selected":""}>${m}</option>`).join("");
|
||||
// datalist в модалке
|
||||
$("#manager-datalist").innerHTML = managers.map(m => `<option value="${m}">`).join("");
|
||||
// фильтр в РОП-вьюхе
|
||||
const ropF = $("#rop-filter");
|
||||
const ropVal = ropF.value;
|
||||
ropF.innerHTML = `<option value="">— все —</option>` + managers.map(m => `<option value="${m}" ${m===ropVal?"selected":""}>${m}</option>`).join("");
|
||||
}
|
||||
|
||||
function renderManagerView(){
|
||||
$("#manager-name-tag").textContent = state.currentManager ? `(${state.currentManager})` : "";
|
||||
const tbody = $("#deals-tbody");
|
||||
const deals = window.Store.list()
|
||||
.filter(d => window.Store.inControl(d))
|
||||
.filter(d => d.manager === state.currentManager)
|
||||
.map(d => ({ d, days: window.Store.daysWithoutUpdate(d), color: window.Store.colorOf(d) }))
|
||||
.sort((a,b) => b.days - a.days);
|
||||
|
||||
if(deals.length === 0){
|
||||
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center;color:#999;padding:30px">Нет сделок на контроле. Создайте первую через «+ Сделка».</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = deals.map(({d, days, color}) => `
|
||||
<tr class="row-${color} ${d.managerAway?'row-away':''}" data-id="${d.id}">
|
||||
<td>
|
||||
<b>${escapeHtml(d.company)}</b>
|
||||
${d.managerAway ? '<span class="away-badge">отсутствует</span>' : ''}
|
||||
${d.tag ? `<div style="font-size:11px;color:#666">${escapeHtml(d.tag)}</div>` : ''}
|
||||
</td>
|
||||
<td>${escapeHtml(d.stage)}</td>
|
||||
<td class="status-cell">
|
||||
<textarea data-id="${d.id}" placeholder="Внесите итог сегодняшнего контакта (мин. ${window.MIN_STATUS_LEN} симв.)">${escapeHtml(d.currentStatus||"")}</textarea>
|
||||
</td>
|
||||
<td><span class="days-badge ${days >= 2 ? 'urgent' : ''}">${days}</span></td>
|
||||
<td>${fmtDateTime(d.lastUpdate)}</td>
|
||||
<td><button class="actions-btn" data-edit="${d.id}">Открыть</button></td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
$$("#deals-tbody textarea").forEach(ta => {
|
||||
const origVal = ta.value;
|
||||
ta.addEventListener("blur", e => {
|
||||
const val = e.target.value;
|
||||
const id = e.target.dataset.id;
|
||||
if(val === origVal) return;
|
||||
try{
|
||||
window.Store.updateStatus(id, val);
|
||||
window.toast("Статус сохранён ✓", "ok");
|
||||
render();
|
||||
}catch(err){
|
||||
window.toast(err.message, "error");
|
||||
e.target.value = origVal;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$$("#deals-tbody [data-edit]").forEach(btn => {
|
||||
btn.addEventListener("click", () => openDealModal(btn.dataset.edit));
|
||||
});
|
||||
}
|
||||
|
||||
function renderRopView(){
|
||||
const filter = $("#rop-filter").value;
|
||||
const all = window.Store.list()
|
||||
.filter(d => window.Store.inControl(d))
|
||||
.filter(d => !filter || d.manager === filter)
|
||||
.map(d => ({ d, days: window.Store.daysWithoutUpdate(d), color: window.Store.colorOf(d) }))
|
||||
.sort((a,b) => b.days - a.days);
|
||||
|
||||
$("#rop-summary").textContent = window.Alerts.buildRopSummary();
|
||||
|
||||
const tbody = $("#rop-tbody");
|
||||
if(all.length === 0){
|
||||
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;color:#999;padding:30px">Нет активных сделок</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = all.map(({d, days, color}) => `
|
||||
<tr class="row-${color} ${d.managerAway?'row-away':''}">
|
||||
<td>${escapeHtml(d.manager)}${d.managerAway ? ' <span class="away-badge">отсут.</span>' : ''}</td>
|
||||
<td>${escapeHtml(d.company)}</td>
|
||||
<td>${escapeHtml(d.stage)}</td>
|
||||
<td><span class="days-badge ${days >= 2 ? 'urgent' : ''}">${days}</span></td>
|
||||
<td>${escapeHtml(d.currentStatus || "—")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function render(){
|
||||
refreshManagersList();
|
||||
$("#manager-view").hidden = state.role !== "manager";
|
||||
$("#rop-view").hidden = state.role !== "rop";
|
||||
$("#manager-wrap").style.display = state.role === "manager" ? "" : "none";
|
||||
if(state.role === "manager") renderManagerView();
|
||||
else renderRopView();
|
||||
updateClock();
|
||||
}
|
||||
|
||||
function updateClock(){
|
||||
const now = window.now();
|
||||
const sim = window.getSimNow();
|
||||
$("#now-display").textContent = (sim ? "🧪 " : "") + now.toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
function openDealModal(id){
|
||||
const form = $("#deal-form");
|
||||
form.reset();
|
||||
$("#history-block").hidden = true;
|
||||
$("#delete-deal").hidden = true;
|
||||
$("#modal-title").textContent = "Новая сделка";
|
||||
|
||||
if(id){
|
||||
const d = window.Store.get(id);
|
||||
if(!d) return;
|
||||
form.id.value = d.id;
|
||||
form.company.value = d.company;
|
||||
form.manager.value = d.manager;
|
||||
form.stage.value = d.stage;
|
||||
form.tag.value = d.tag || "";
|
||||
form.currentStatus.value = d.currentStatus || "";
|
||||
form.managerAway.checked = !!d.managerAway;
|
||||
$("#modal-title").textContent = "Сделка: " + d.company;
|
||||
$("#delete-deal").hidden = false;
|
||||
if(d.history && d.history.length){
|
||||
$("#history-block").hidden = false;
|
||||
$("#history-list").innerHTML = d.history.map(h => `<li><b>${fmtDateTime(h.ts)}:</b> ${escapeHtml(h.text)}</li>`).join("");
|
||||
}
|
||||
} else {
|
||||
form.manager.value = state.currentManager || "";
|
||||
}
|
||||
$("#modal").hidden = false;
|
||||
}
|
||||
|
||||
function closeDealModal(){ $("#modal").hidden = true; }
|
||||
|
||||
function bindEvents(){
|
||||
$("#role-select").addEventListener("change", e => {
|
||||
state.role = e.target.value;
|
||||
render();
|
||||
});
|
||||
$("#manager-select").addEventListener("change", e => {
|
||||
state.currentManager = e.target.value;
|
||||
renderManagerView();
|
||||
});
|
||||
$("#rop-filter").addEventListener("change", renderRopView);
|
||||
|
||||
$("#add-deal").addEventListener("click", () => openDealModal(null));
|
||||
$("#modal-close").addEventListener("click", closeDealModal);
|
||||
$("#modal").addEventListener("click", e => { if(e.target.id === "modal") closeDealModal(); });
|
||||
|
||||
$("#deal-form").addEventListener("submit", e => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const id = fd.get("id") || null;
|
||||
const existing = id ? window.Store.get(id) : null;
|
||||
const statusText = (fd.get("currentStatus") || "").trim();
|
||||
const oldStatus = existing ? (existing.currentStatus || "") : "";
|
||||
const statusChanged = statusText !== oldStatus;
|
||||
|
||||
if(statusChanged && statusText && statusText.length < window.MIN_STATUS_LEN){
|
||||
window.toast(`Минимум ${window.MIN_STATUS_LEN} символов в статусе`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const deal = existing || { id: null, history: [] };
|
||||
deal.company = fd.get("company").trim();
|
||||
deal.manager = fd.get("manager").trim();
|
||||
deal.stage = fd.get("stage");
|
||||
deal.tag = fd.get("tag") || "";
|
||||
deal.managerAway = fd.get("managerAway") === "on";
|
||||
|
||||
if(statusChanged && statusText){
|
||||
deal.currentStatus = statusText;
|
||||
deal.lastUpdate = window.now().toISOString();
|
||||
deal.history = deal.history || [];
|
||||
deal.history.unshift({ ts: deal.lastUpdate, text: statusText });
|
||||
}
|
||||
|
||||
window.Store.upsert(deal);
|
||||
closeDealModal();
|
||||
render();
|
||||
window.toast("Сделка сохранена ✓", "ok");
|
||||
});
|
||||
|
||||
$("#delete-deal").addEventListener("click", () => {
|
||||
const id = $("#deal-form").id.value;
|
||||
if(!id) return;
|
||||
if(!confirm("Удалить сделку?")) return;
|
||||
window.Store.remove(id);
|
||||
closeDealModal();
|
||||
render();
|
||||
});
|
||||
|
||||
// Симулируемая дата
|
||||
$("#sim-date").addEventListener("change", e => {
|
||||
const v = e.target.value;
|
||||
if(v){
|
||||
const iso = new Date(v).toISOString();
|
||||
window.setSimNow(iso);
|
||||
} else {
|
||||
window.setSimNow(null);
|
||||
}
|
||||
render();
|
||||
});
|
||||
$("#sim-reset").addEventListener("click", () => {
|
||||
window.setSimNow(null);
|
||||
$("#sim-date").value = "";
|
||||
render();
|
||||
});
|
||||
|
||||
// Telegram
|
||||
$("#open-settings").addEventListener("click", () => {
|
||||
const cfg = window.TelegramCfg.load();
|
||||
$("#tg-form").token.value = cfg.token || "";
|
||||
$("#tg-form").chatManager.value = cfg.chatManager || "";
|
||||
$("#tg-form").chatRop.value = cfg.chatRop || "";
|
||||
$("#tg-modal").hidden = false;
|
||||
});
|
||||
$("#tg-close").addEventListener("click", () => $("#tg-modal").hidden = true);
|
||||
$("#tg-modal").addEventListener("click", e => { if(e.target.id === "tg-modal") $("#tg-modal").hidden = true; });
|
||||
$("#tg-form").addEventListener("submit", e => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
window.TelegramCfg.save({
|
||||
token: fd.get("token").trim(),
|
||||
chatManager: fd.get("chatManager").trim(),
|
||||
chatRop: fd.get("chatRop").trim()
|
||||
});
|
||||
$("#tg-modal").hidden = true;
|
||||
window.toast("Настройки Telegram сохранены ✓", "ok");
|
||||
});
|
||||
$("#tg-test").addEventListener("click", async () => {
|
||||
const cfg = {
|
||||
token: $("#tg-form").token.value.trim(),
|
||||
chatManager: $("#tg-form").chatManager.value.trim(),
|
||||
chatRop: $("#tg-form").chatRop.value.trim()
|
||||
};
|
||||
if(!cfg.token || (!cfg.chatManager && !cfg.chatRop)){
|
||||
window.toast("Заполните токен и хотя бы один chat ID", "error");
|
||||
return;
|
||||
}
|
||||
const target = cfg.chatManager || cfg.chatRop;
|
||||
const r = await fetch(`https://api.telegram.org/bot${cfg.token}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ chat_id: target, text: "✅ Тест из Daily Deal Status Tracker" })
|
||||
}).then(r => r.json()).catch(e => ({ ok: false, description: e.message }));
|
||||
window.toast(r.ok ? "Сообщение отправлено ✓" : ("Ошибка: " + (r.description || "—")), r.ok ? "ok" : "error");
|
||||
});
|
||||
|
||||
$("#send-summary").addEventListener("click", async () => {
|
||||
const cfg = window.TelegramCfg.load();
|
||||
if(!cfg.token || !cfg.chatRop){
|
||||
window.toast("Сначала настройте Telegram (Bot Token + Chat ID РОПа)", "error");
|
||||
return;
|
||||
}
|
||||
const text = window.Alerts.buildRopSummary();
|
||||
const r = await window.Alerts.sendTelegram(cfg.chatRop, text);
|
||||
window.toast(r.ok ? "Сводка отправлена ✓" : "Ошибка отправки", r.ok ? "ok" : "error");
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s){
|
||||
return (s ?? "").toString()
|
||||
.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")
|
||||
.replace(/"/g,""").replace(/'/g,"'");
|
||||
}
|
||||
|
||||
function init(){
|
||||
bindEvents();
|
||||
render();
|
||||
setInterval(updateClock, 1000);
|
||||
window.Alerts.start();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
32
assets/holidays.js
Normal file
32
assets/holidays.js
Normal file
@ -0,0 +1,32 @@
|
||||
// Производственный календарь РФ 2026 (официальные нерабочие праздничные дни + перенесённые выходные)
|
||||
// Источник: проект постановления Правительства РФ
|
||||
window.HOLIDAYS_2026 = new Set([
|
||||
"2026-01-01","2026-01-02","2026-01-03","2026-01-04","2026-01-05","2026-01-06","2026-01-07","2026-01-08",
|
||||
"2026-02-23",
|
||||
"2026-03-09",
|
||||
"2026-05-01","2026-05-04","2026-05-11",
|
||||
"2026-06-12",
|
||||
"2026-11-04"
|
||||
]);
|
||||
|
||||
window.isWorkDay = function(d){
|
||||
const day = d.getDay();
|
||||
if(day === 0 || day === 6) return false;
|
||||
const iso = d.toISOString().slice(0,10);
|
||||
return !window.HOLIDAYS_2026.has(iso);
|
||||
};
|
||||
|
||||
// Подсчёт рабочих дней между двумя датами (без учёта last)
|
||||
window.workDaysBetween = function(from, to){
|
||||
if(to <= from) return 0;
|
||||
let cnt = 0;
|
||||
const cur = new Date(from);
|
||||
cur.setHours(0,0,0,0);
|
||||
const end = new Date(to);
|
||||
end.setHours(0,0,0,0);
|
||||
while(cur < end){
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
if(window.isWorkDay(cur)) cnt++;
|
||||
}
|
||||
return cnt;
|
||||
};
|
||||
199
assets/store.js
Normal file
199
assets/store.js
Normal file
@ -0,0 +1,199 @@
|
||||
// Слой данных: localStorage + утилиты времени
|
||||
|
||||
const STORAGE_KEY = "ddst.deals.v1";
|
||||
const TG_KEY = "ddst.telegram.v1";
|
||||
const SIM_KEY = "ddst.sim.v1";
|
||||
const ALERTED_KEY = "ddst.alerted.v1";
|
||||
|
||||
const CONTROL_STAGES = new Set(["Согласование КП","Обсуждение условий","Согласование договора"]);
|
||||
const FINAL_STAGES = new Set(["Успешно реализовано","Закрыто и не реализовано"]);
|
||||
const CONTROL_TAG = "#Переговоры_Контроль";
|
||||
const MIN_STATUS_LEN = 15;
|
||||
|
||||
window.MIN_STATUS_LEN = MIN_STATUS_LEN;
|
||||
window.CONTROL_STAGES = CONTROL_STAGES;
|
||||
window.FINAL_STAGES = FINAL_STAGES;
|
||||
window.CONTROL_TAG = CONTROL_TAG;
|
||||
|
||||
function uuid(){
|
||||
return (crypto && crypto.randomUUID) ? crypto.randomUUID()
|
||||
: "id-"+Math.random().toString(36).slice(2,10)+"-"+Date.now();
|
||||
}
|
||||
|
||||
// Симулируемое "сейчас" — для отладки/Acceptance §6.3
|
||||
window.now = function(){
|
||||
const sim = localStorage.getItem(SIM_KEY);
|
||||
if(sim) return new Date(sim);
|
||||
return new Date();
|
||||
};
|
||||
window.setSimNow = function(iso){
|
||||
if(iso) localStorage.setItem(SIM_KEY, iso);
|
||||
else localStorage.removeItem(SIM_KEY);
|
||||
};
|
||||
window.getSimNow = function(){ return localStorage.getItem(SIM_KEY); };
|
||||
|
||||
function load(){
|
||||
try{
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if(!raw){
|
||||
const seed = seedDeals();
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(seed));
|
||||
return seed;
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
}catch(e){
|
||||
console.error("store load fail",e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function save(deals){
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(deals));
|
||||
}
|
||||
|
||||
function inControl(deal){
|
||||
if(FINAL_STAGES.has(deal.stage)) return false;
|
||||
return CONTROL_STAGES.has(deal.stage) || deal.tag === CONTROL_TAG;
|
||||
}
|
||||
|
||||
function isSameLocalDay(a, b){
|
||||
return a.getFullYear() === b.getFullYear()
|
||||
&& a.getMonth() === b.getMonth()
|
||||
&& a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
function daysWithoutUpdate(deal){
|
||||
if(!deal.lastUpdate) return 999;
|
||||
const last = new Date(deal.lastUpdate);
|
||||
return window.workDaysBetween(last, window.now());
|
||||
}
|
||||
|
||||
function colorOf(deal){
|
||||
if(!deal.lastUpdate) return "red";
|
||||
const last = new Date(deal.lastUpdate);
|
||||
return isSameLocalDay(last, window.now()) ? "green" : "red";
|
||||
}
|
||||
|
||||
function seedDeals(){
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getTime() - 24*3600*1000);
|
||||
const twoDays = new Date(now.getTime() - 2*24*3600*1000);
|
||||
return [
|
||||
{
|
||||
id: uuid(),
|
||||
company: "ООО Вектор",
|
||||
manager: "Алексей",
|
||||
stage: "Обсуждение условий",
|
||||
tag: "#Переговоры_Контроль",
|
||||
currentStatus: "Первичный созвон проведён, ждём ТЗ от клиента",
|
||||
lastUpdate: twoDays.toISOString(),
|
||||
history: [
|
||||
{ ts: twoDays.toISOString(), text: "Первичный созвон проведён, ждём ТЗ от клиента" }
|
||||
],
|
||||
managerAway: false
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
company: "ТД Альянс",
|
||||
manager: "Иван",
|
||||
stage: "Согласование КП",
|
||||
tag: "",
|
||||
currentStatus: "Отправлено коммерческое предложение, ждём ответ",
|
||||
lastUpdate: yesterday.toISOString(),
|
||||
history: [
|
||||
{ ts: yesterday.toISOString(), text: "Отправлено коммерческое предложение, ждём ответ" }
|
||||
],
|
||||
managerAway: false
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
company: "ИП Серов",
|
||||
manager: "Алексей",
|
||||
stage: "Согласование договора",
|
||||
tag: "",
|
||||
currentStatus: "Согласовали финальную редакцию договора, ждём подписи",
|
||||
lastUpdate: now.toISOString(),
|
||||
history: [
|
||||
{ ts: now.toISOString(), text: "Согласовали финальную редакцию договора, ждём подписи" }
|
||||
],
|
||||
managerAway: false
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
window.Store = {
|
||||
list(){ return load(); },
|
||||
get(id){ return load().find(d => d.id === id); },
|
||||
upsert(deal){
|
||||
const deals = load();
|
||||
const idx = deals.findIndex(d => d.id === deal.id);
|
||||
if(idx === -1){
|
||||
deal.id = deal.id || uuid();
|
||||
deal.history = deal.history || [];
|
||||
deals.push(deal);
|
||||
} else {
|
||||
deals[idx] = deal;
|
||||
}
|
||||
save(deals);
|
||||
return deal;
|
||||
},
|
||||
remove(id){
|
||||
const deals = load().filter(d => d.id !== id);
|
||||
save(deals);
|
||||
},
|
||||
updateStatus(id, text){
|
||||
if(!text || text.trim().length < MIN_STATUS_LEN){
|
||||
throw new Error("Минимум "+MIN_STATUS_LEN+" символов в статусе");
|
||||
}
|
||||
const deals = load();
|
||||
const d = deals.find(x => x.id === id);
|
||||
if(!d) throw new Error("Сделка не найдена");
|
||||
const ts = window.now().toISOString();
|
||||
d.currentStatus = text.trim();
|
||||
d.lastUpdate = ts;
|
||||
d.history = d.history || [];
|
||||
d.history.unshift({ ts, text: text.trim() });
|
||||
save(deals);
|
||||
return d;
|
||||
},
|
||||
inControl,
|
||||
daysWithoutUpdate,
|
||||
colorOf,
|
||||
managers(){
|
||||
const set = new Set(load().map(d => d.manager).filter(Boolean));
|
||||
if(set.size === 0){ set.add("Алексей"); set.add("Иван"); }
|
||||
return [...set];
|
||||
},
|
||||
resetAll(){ localStorage.removeItem(STORAGE_KEY); }
|
||||
};
|
||||
|
||||
window.TelegramCfg = {
|
||||
load(){
|
||||
try{ return JSON.parse(localStorage.getItem(TG_KEY) || "{}"); }
|
||||
catch(e){ return {}; }
|
||||
},
|
||||
save(cfg){ localStorage.setItem(TG_KEY, JSON.stringify(cfg)); }
|
||||
};
|
||||
|
||||
window.AlertedLog = {
|
||||
load(){
|
||||
try{ return JSON.parse(localStorage.getItem(ALERTED_KEY) || "{}"); }
|
||||
catch(e){ return {}; }
|
||||
},
|
||||
save(obj){ localStorage.setItem(ALERTED_KEY, JSON.stringify(obj)); },
|
||||
mark(key){
|
||||
const o = this.load();
|
||||
o[key] = true;
|
||||
this.save(o);
|
||||
},
|
||||
has(key){ return !!this.load()[key]; },
|
||||
clearOld(){
|
||||
const o = this.load();
|
||||
const todayISO = window.now().toISOString().slice(0,10);
|
||||
const next = {};
|
||||
for(const k of Object.keys(o)){
|
||||
if(k.startsWith(todayISO)) next[k] = o[k];
|
||||
}
|
||||
this.save(next);
|
||||
}
|
||||
};
|
||||
64
assets/style.css
Normal file
64
assets/style.css
Normal file
@ -0,0 +1,64 @@
|
||||
:root{
|
||||
--bg:#f5f6fa;
|
||||
--fg:#222;
|
||||
--muted:#666;
|
||||
--border:#dcdfe6;
|
||||
--green:#d6f5d6;
|
||||
--green-bd:#34b53a;
|
||||
--red:#ffd6d6;
|
||||
--red-bd:#e63946;
|
||||
--primary:#2962ff;
|
||||
--primary-hov:#1e4fcc;
|
||||
--danger:#e63946;
|
||||
--shadow:0 4px 16px rgba(0,0,0,.08);
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--fg)}
|
||||
.topbar{display:flex;justify-content:space-between;align-items:center;padding:12px 20px;background:#fff;border-bottom:1px solid var(--border);box-shadow:var(--shadow);flex-wrap:wrap;gap:8px}
|
||||
.brand{font-weight:600;font-size:18px}
|
||||
.controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
||||
.controls label{display:flex;gap:4px;align-items:center;font-size:13px;color:var(--muted)}
|
||||
.controls select,.controls input{padding:4px 6px;border:1px solid var(--border);border-radius:4px;background:#fff;font-size:13px}
|
||||
.clock{font-family:ui-monospace,Menlo,monospace;font-size:13px;color:var(--muted);min-width:140px}
|
||||
button{padding:6px 12px;border:1px solid var(--border);background:#fff;border-radius:4px;cursor:pointer;font-size:13px}
|
||||
button:hover{background:#f0f0f0}
|
||||
button.primary{background:var(--primary);color:#fff;border-color:var(--primary)}
|
||||
button.primary:hover{background:var(--primary-hov)}
|
||||
button.danger{background:var(--danger);color:#fff;border-color:var(--danger)}
|
||||
main{padding:20px;max-width:1400px;margin:0 auto}
|
||||
h2{margin:0 0 8px 0}
|
||||
h3{margin:18px 0 8px 0}
|
||||
.hint{color:var(--muted);font-size:13px;margin:0 0 12px 0}
|
||||
table{width:100%;border-collapse:collapse;background:#fff;box-shadow:var(--shadow);border-radius:6px;overflow:hidden}
|
||||
th,td{padding:10px 12px;text-align:left;border-bottom:1px solid var(--border);font-size:14px;vertical-align:top}
|
||||
th{background:#fafbfc;font-weight:600;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.5px}
|
||||
tr:last-child td{border-bottom:none}
|
||||
tr.row-green{background:var(--green)}
|
||||
tr.row-green td:first-child{border-left:4px solid var(--green-bd)}
|
||||
tr.row-red{background:var(--red)}
|
||||
tr.row-red td:first-child{border-left:4px solid var(--red-bd)}
|
||||
tr.row-away{opacity:.55}
|
||||
td.status-cell textarea{width:100%;min-height:50px;border:1px solid var(--border);border-radius:4px;padding:6px;font-family:inherit;font-size:13px;resize:vertical;background:transparent}
|
||||
td.status-cell textarea:focus{background:#fff;outline:2px solid var(--primary)}
|
||||
.days-badge{display:inline-block;padding:2px 8px;border-radius:12px;background:#eee;font-weight:600;font-size:13px}
|
||||
.days-badge.urgent{background:var(--danger);color:#fff}
|
||||
.modal{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;z-index:1000}
|
||||
.modal-content{background:#fff;border-radius:8px;padding:24px;max-width:600px;width:95%;max-height:90vh;overflow-y:auto;position:relative;box-shadow:var(--shadow)}
|
||||
.modal-close{position:absolute;top:8px;right:8px;border:none;background:transparent;font-size:20px;cursor:pointer}
|
||||
.modal form label{display:flex;flex-direction:column;gap:4px;margin-bottom:12px;font-size:13px;color:var(--muted)}
|
||||
.modal form label.checkbox{flex-direction:row;align-items:center;color:var(--fg)}
|
||||
.modal form input,.modal form select,.modal form textarea{padding:8px;border:1px solid var(--border);border-radius:4px;font-size:14px;font-family:inherit}
|
||||
.form-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
|
||||
.form-actions button.danger{margin-right:auto}
|
||||
#history-list{padding-left:18px;font-size:13px;max-height:200px;overflow-y:auto}
|
||||
#history-list li{margin-bottom:4px;color:var(--muted)}
|
||||
#history-list li b{color:var(--fg)}
|
||||
.summary-block{background:#fff;padding:14px;border-radius:6px;box-shadow:var(--shadow);white-space:pre-wrap;font-family:ui-monospace,Menlo,monospace;font-size:13px;min-height:60px}
|
||||
.rop-controls{display:flex;gap:12px;align-items:center;margin-bottom:8px;flex-wrap:wrap}
|
||||
#toast-host{position:fixed;bottom:20px;right:20px;display:flex;flex-direction:column;gap:8px;z-index:2000}
|
||||
.toast{background:#222;color:#fff;padding:10px 14px;border-radius:4px;box-shadow:var(--shadow);font-size:13px;max-width:320px;animation:slidein .3s ease}
|
||||
.toast.error{background:var(--danger)}
|
||||
.toast.ok{background:var(--green-bd)}
|
||||
@keyframes slidein{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}}
|
||||
.actions-btn{padding:4px 8px;font-size:12px}
|
||||
.away-badge{font-size:11px;background:#888;color:#fff;padding:2px 6px;border-radius:10px;margin-left:6px}
|
||||
147
index.html
Normal file
147
index.html
Normal file
@ -0,0 +1,147 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Daily Deal Status Tracker</title>
|
||||
<link rel="stylesheet" href="./assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">📊 Daily Deal Status Tracker</div>
|
||||
<div class="controls">
|
||||
<label>Я:
|
||||
<select id="role-select">
|
||||
<option value="manager">Менеджер</option>
|
||||
<option value="rop">РОП</option>
|
||||
</select>
|
||||
</label>
|
||||
<label id="manager-wrap">Менеджер:
|
||||
<select id="manager-select"></select>
|
||||
</label>
|
||||
<label>Симулир. дата:
|
||||
<input type="datetime-local" id="sim-date">
|
||||
<button id="sim-reset" type="button" title="Сброс на реальное время">⟲</button>
|
||||
</label>
|
||||
<span id="now-display" class="clock"></span>
|
||||
<button id="open-settings" type="button">⚙ Telegram</button>
|
||||
<button id="add-deal" type="button" class="primary">+ Сделка</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section id="manager-view">
|
||||
<h2>Мои сделки на контроле <span id="manager-name-tag"></span></h2>
|
||||
<p class="hint">Красные строки — статус не обновлён сегодня. Сортировка по «Дней без обновлений».</p>
|
||||
<table id="deals-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Компания</th>
|
||||
<th>Этап</th>
|
||||
<th>Текущий статус (Лог дня)</th>
|
||||
<th>Дней без обнов.</th>
|
||||
<th>Последнее обновление</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="deals-tbody"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="rop-view" hidden>
|
||||
<h2>Экран РОПа: все менеджеры</h2>
|
||||
<div class="rop-controls">
|
||||
<label>Фильтр по менеджеру:
|
||||
<select id="rop-filter"><option value="">— все —</option></select>
|
||||
</label>
|
||||
<button id="send-summary" type="button">📨 Отправить сводку в Telegram</button>
|
||||
</div>
|
||||
<h3>Просрочки на сегодня</h3>
|
||||
<pre id="rop-summary" class="summary-block"></pre>
|
||||
<h3>Полный реестр</h3>
|
||||
<table id="rop-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Менеджер</th>
|
||||
<th>Компания</th>
|
||||
<th>Этап</th>
|
||||
<th>Дней без обнов.</th>
|
||||
<th>Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rop-tbody"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- модалка сделки -->
|
||||
<div id="modal" class="modal" hidden>
|
||||
<div class="modal-content">
|
||||
<button class="modal-close" id="modal-close" type="button">✕</button>
|
||||
<h3 id="modal-title">Сделка</h3>
|
||||
<form id="deal-form">
|
||||
<input type="hidden" name="id">
|
||||
<label>Компания <input name="company" required></label>
|
||||
<label>Менеджер
|
||||
<input name="manager" list="manager-datalist" required>
|
||||
<datalist id="manager-datalist"></datalist>
|
||||
</label>
|
||||
<label>Этап
|
||||
<select name="stage" required>
|
||||
<option>Согласование КП</option>
|
||||
<option>Обсуждение условий</option>
|
||||
<option>Согласование договора</option>
|
||||
<option>Успешно реализовано</option>
|
||||
<option>Закрыто и не реализовано</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Тег
|
||||
<select name="tag">
|
||||
<option value="">— нет —</option>
|
||||
<option value="#Переговоры_Контроль">#Переговоры_Контроль</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Текущий статус (Лог дня) — мин. 15 символов
|
||||
<textarea name="currentStatus" rows="3" placeholder="Например: ЛПР взял паузу до завтра, согласовывает бюджет"></textarea>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="managerAway"> Менеджер временно отсутствует
|
||||
</label>
|
||||
<div id="history-block" hidden>
|
||||
<h4>История изменений</h4>
|
||||
<ul id="history-list"></ul>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" id="delete-deal" class="danger" hidden>Удалить</button>
|
||||
<button type="submit" class="primary">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- модалка Telegram -->
|
||||
<div id="tg-modal" class="modal" hidden>
|
||||
<div class="modal-content">
|
||||
<button class="modal-close" id="tg-close" type="button">✕</button>
|
||||
<h3>Настройки Telegram</h3>
|
||||
<form id="tg-form">
|
||||
<label>Bot Token <input name="token" placeholder="123456:ABC..."></label>
|
||||
<label>Chat ID менеджера <input name="chatManager" placeholder="@username или ID"></label>
|
||||
<label>Chat ID РОПа <input name="chatRop" placeholder="@username или ID"></label>
|
||||
<div class="form-actions">
|
||||
<button type="button" id="tg-test">Тест</button>
|
||||
<button type="submit" class="primary">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="hint">Если поля пустые — уведомления только в браузере.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-host"></div>
|
||||
|
||||
<script src="./assets/holidays.js"></script>
|
||||
<script src="./assets/store.js"></script>
|
||||
<script src="./assets/alerts.js"></script>
|
||||
<script src="./assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user