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:
askhat 2026-05-19 11:43:17 +05:00
parent 0b8b026751
commit ec67c31600
8 changed files with 1163 additions and 0 deletions

111
PRD.md
View File

@ -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
View File

@ -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
View 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
View 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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")
.replace(/"/g,"&quot;").replace(/'/g,"&#39;");
}
function init(){
bindEvents();
render();
setInterval(updateClock, 1000);
window.Alerts.start();
}
document.addEventListener("DOMContentLoaded", init);

32
assets/holidays.js Normal file
View 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
View 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
View 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
View 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>