Build sultan MVP: home/create/analytics, Apple-style UI
- Home: clock (Asia/Almaty), Open-Meteo weather for Astana, Google News via rss2json proxy filtered for "Казахтелеком"/"Қазақтелеком"/связь Казахстан. - Create: minimal form (title/deadline/assignee/direction/description) with Flatpickr ru-locale, auto-priority from deadline, toast on save. - Analytics: KPIs, load chips (click → filter), full task table with filters and inline actions, 5 Chart.js charts (status/priority/deadlines/directions/timeline). - Hash routing + burger popover menu, frosted glass topbar. - Storage: localStorage v6, no backend.
This commit is contained in:
parent
ff6ef82501
commit
57af72f514
50
PRD.md
50
PRD.md
@ -0,0 +1,50 @@
|
|||||||
|
# PRD: Помощник руководителя по управлению поручениями
|
||||||
|
|
||||||
|
## Что я хочу автоматизировать
|
||||||
|
Сбор, сортировку и контроль исполнения всех моих поручений (своих и поступающих извне) в одном месте, чтобы повысить эффективность управления департаментом по взаимодействию с государственными органами.
|
||||||
|
|
||||||
|
## Кто будет пользоваться
|
||||||
|
- **Я** — директор департамента по взаимодействию с государственными органами АО «Казахтелеком». Создаю поручения, распределяю, контролирую исполнение.
|
||||||
|
- **8 сотрудников моего департамента** — получают поручения, видят свои задачи и задачи коллег, отмечают статус выполнения.
|
||||||
|
- Доступ — только ограниченный круг лиц департамента.
|
||||||
|
|
||||||
|
## Что должно произойти на выходе
|
||||||
|
Я в любой момент вижу полную картину: какие поручения в работе, у кого, насколько срочные, по какому направлению. Каждый сотрудник понимает свой список задач, дедлайны и приоритеты. Ничего не теряется, ничего не забывается, ничего не просрочивается без моего ведома.
|
||||||
|
|
||||||
|
Конкретно:
|
||||||
|
- **Утром** — список приоритетных задач на день (мой и по департаменту).
|
||||||
|
- **Перед дедлайном** — автоматическое напоминание мне и исполнителю.
|
||||||
|
- **Раз в неделю** — отчёт: что сделано, что в работе, что просрочено.
|
||||||
|
|
||||||
|
## Главные функции
|
||||||
|
|
||||||
|
1. **Быстрый ввод поручения из любого источника.**
|
||||||
|
Помощник принимает поручение голосом (диктовка после совещания или из машины), текстом, фотографией протокола или пересланным сообщением из мессенджера. Главное — мысль из головы быстро попадает в систему, без бюрократии.
|
||||||
|
|
||||||
|
2. **Автоматическая сортировка поручений.**
|
||||||
|
Помощник сам определяет:
|
||||||
|
- **Срочность и важность** (по дедлайну, по уровню инициатора — поручение от Премьера/Министра автоматически выше). Я могу вручную поднять приоритет особо важных задач.
|
||||||
|
- **Метод отработки**: работа с госорганами / работа внутри компании / делаю сам / другое.
|
||||||
|
- **Направление**: МИИЦР, Минфин, Комитет телекоммуникаций, регуляторная работа (НПА) и т.д.
|
||||||
|
|
||||||
|
3. **Назначение исполнителей и контроль статуса.**
|
||||||
|
Я передаю поручение конкретному сотруднику (или нескольким) одним нажатием. Сотрудник получает уведомление, видит задачу в своём списке, отмечает прогресс. Я в любой момент вижу, у кого что висит и на какой стадии.
|
||||||
|
|
||||||
|
4. **Уведомления и напоминания.**
|
||||||
|
- Сотруднику — при назначении задачи и за день до дедлайна.
|
||||||
|
- Мне — утренний список приоритетов, предупреждение о приближающихся и просроченных задачах.
|
||||||
|
- Эскалация — если задача просрочена, я узнаю сразу.
|
||||||
|
|
||||||
|
5. **Отчёты и обзор картины.**
|
||||||
|
- **Ежедневно**: список на сегодня.
|
||||||
|
- **Еженедельно**: что выполнено, что в работе, что просрочено, кто загружен сильнее всех.
|
||||||
|
- **По запросу**: все поручения по конкретному госоргану, направлению или сотруднику.
|
||||||
|
|
||||||
|
## Что НЕ входит в задачу
|
||||||
|
|
||||||
|
- **Не заменяет официальную СЭД** (систему электронного документооборота) «Казахтелекома» — не работает с официальными исходящими письмами, подписями, регистрацией документов.
|
||||||
|
- **Не ведёт переписку с госорганами от моего имени** — не отправляет письма, не пишет ответы, не общается с внешними людьми.
|
||||||
|
- **Не хранит конфиденциальные документы и НПА** — это инструмент для управления задачами, не файловое хранилище и не юридическая база.
|
||||||
|
- **Не автоматизирует содержательную работу** (не пишет аналитические записки, не готовит позиции по НПА, не принимает решения за меня).
|
||||||
|
- **Не контролирует сотрудников других департаментов** — только мой департамент (8 человек).
|
||||||
|
- **Не интегрируется на старте с внешними системами** (почта, CRM, кадровые системы) — это можно добавить позже.
|
||||||
148
TRD.md
148
TRD.md
@ -0,0 +1,148 @@
|
|||||||
|
# TRD: Помощник руководителя по управлению поручениями (MVP)
|
||||||
|
|
||||||
|
Цель — рабочий прототип за 1 час. Один пользователь (директор) + 8 сотрудников. Локальный запуск, без авторизации, без интеграций.
|
||||||
|
|
||||||
|
## 1. Объём MVP
|
||||||
|
|
||||||
|
В MVP входит:
|
||||||
|
- Создание поручения текстом (форма).
|
||||||
|
- Назначение исполнителя из списка (8 человек + директор).
|
||||||
|
- Поля: заголовок, описание, исполнитель, дедлайн, приоритет, направление, метод отработки, статус.
|
||||||
|
- Автоприоритет по инициатору (Премьер/Министр → высокий) и дедлайну (<2 дня → высокий).
|
||||||
|
- Список поручений с фильтрами: исполнитель, статус, направление, приоритет.
|
||||||
|
- Смена статуса (новая → в работе → выполнена / просрочена).
|
||||||
|
- Дашборд: «сегодня», «просрочено», «на этой неделе», загрузка по сотрудникам.
|
||||||
|
|
||||||
|
Не входит в MVP (PRD §43–50 + упрощения):
|
||||||
|
- Голос, OCR протокола, парсинг мессенджеров — поле «источник» пишется руками.
|
||||||
|
- Push/email/Telegram-уведомления — только визуальные бейджи в UI.
|
||||||
|
- Авторизация, роли, RBAC — выбор «текущего пользователя» через dropdown.
|
||||||
|
- СЭД, переписка, файлы, НПА.
|
||||||
|
- Внешние системы.
|
||||||
|
|
||||||
|
## 2. Данные
|
||||||
|
|
||||||
|
Одна сущность — `task`. SQLite, одна таблица.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE tasks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
source TEXT, -- кто инициатор: 'Премьер'|'Министр'|'Внутри'|'Сам'|...
|
||||||
|
assignee TEXT NOT NULL, -- ФИО из списка сотрудников
|
||||||
|
created_by TEXT NOT NULL, -- кто создал (директор по умолчанию)
|
||||||
|
direction TEXT, -- 'МИИЦР'|'Минфин'|'Комитет телекоммуникаций'|'НПА'|'Другое'
|
||||||
|
method TEXT, -- 'Госорганы'|'Внутри компании'|'Сам'|'Другое'
|
||||||
|
priority TEXT NOT NULL, -- 'high'|'medium'|'low'
|
||||||
|
status TEXT NOT NULL, -- 'new'|'in_progress'|'done'|'overdue'
|
||||||
|
deadline TEXT, -- ISO date 'YYYY-MM-DD'
|
||||||
|
created_at TEXT NOT NULL, -- ISO datetime
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Справочники (хардкод в коде, не в БД):
|
||||||
|
- `EMPLOYEES = ['Директор', 'Сотрудник 1', ..., 'Сотрудник 8']`
|
||||||
|
- `DIRECTIONS = ['МИИЦР','Минфин','Комитет телекоммуникаций','НПА','Другое']`
|
||||||
|
- `METHODS = ['Госорганы','Внутри компании','Сам','Другое']`
|
||||||
|
- `SOURCES = ['Премьер','Министр','Вице-министр','Внутри','Сам','Другое']`
|
||||||
|
|
||||||
|
Правило автоприоритета (на создании):
|
||||||
|
- `source ∈ {'Премьер','Министр'}` → `high`.
|
||||||
|
- Иначе если `deadline - today <= 2 дня` → `high`.
|
||||||
|
- Иначе если `<= 7 дней` → `medium`.
|
||||||
|
- Иначе `low`.
|
||||||
|
- Директор может вручную поменять.
|
||||||
|
|
||||||
|
Правило `overdue`: вычисляется на лету при чтении — `deadline < today AND status != 'done'`.
|
||||||
|
|
||||||
|
## 3. Стек
|
||||||
|
|
||||||
|
Минимальный, один процесс, ноль сборки:
|
||||||
|
|
||||||
|
- **Backend**: Python 3.11 + FastAPI + SQLite (через `sqlite3` из stdlib, без ORM).
|
||||||
|
- **Frontend**: один `index.html` + ванильный JS (fetch) + минимум CSS. Без сборки, без фреймворков.
|
||||||
|
- **Запуск**: `uvicorn app.main:app --reload`.
|
||||||
|
- **Шаблоны**: FastAPI отдаёт `index.html` как статику, JSON API на `/api/*`.
|
||||||
|
|
||||||
|
Почему так: меньше движущихся частей, чем Node, и нативный SQLite без зависимостей. Один `pip install` — и поехали.
|
||||||
|
|
||||||
|
## 4. Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
sultan/
|
||||||
|
├── PRD.md
|
||||||
|
├── TRD.md
|
||||||
|
├── README.md # как запустить (3 строки)
|
||||||
|
├── requirements.txt # fastapi, uvicorn[standard]
|
||||||
|
├── app/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── main.py # FastAPI app, роуты /api/*, статика
|
||||||
|
│ ├── db.py # init_db(), get_conn()
|
||||||
|
│ ├── models.py # pydantic-схемы Task, TaskCreate, TaskUpdate
|
||||||
|
│ ├── logic.py # compute_priority(), is_overdue()
|
||||||
|
│ └── constants.py # EMPLOYEES, DIRECTIONS, METHODS, SOURCES
|
||||||
|
├── static/
|
||||||
|
│ ├── index.html # формы + список + дашборд
|
||||||
|
│ ├── app.js # fetch /api/*, рендер DOM
|
||||||
|
│ └── styles.css
|
||||||
|
└── data/
|
||||||
|
└── tasks.db # SQLite (создаётся автоматически)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. API
|
||||||
|
|
||||||
|
Все ответы — JSON. Без пагинации.
|
||||||
|
|
||||||
|
| Метод | Путь | Назначение |
|
||||||
|
|--------|---------------------|---------------------------------------------|
|
||||||
|
| GET | `/` | Отдаёт `static/index.html` |
|
||||||
|
| GET | `/api/meta` | Справочники (employees/directions/...) |
|
||||||
|
| GET | `/api/tasks` | Все задачи, опц. фильтры в query |
|
||||||
|
| POST | `/api/tasks` | Создать задачу (приоритет авто) |
|
||||||
|
| PATCH | `/api/tasks/{id}` | Изменить статус/приоритет/исполнителя |
|
||||||
|
| DELETE | `/api/tasks/{id}` | Удалить |
|
||||||
|
| GET | `/api/dashboard` | Агрегаты: today, overdue, week, by_assignee |
|
||||||
|
|
||||||
|
Фильтры на `/api/tasks`: `assignee`, `status`, `direction`, `priority`.
|
||||||
|
|
||||||
|
## 6. UI (один экран, три секции)
|
||||||
|
|
||||||
|
1. **Форма создания** (сверху): заголовок, описание, исполнитель, источник, направление, метод, дедлайн, кнопка «Создать». Приоритет показывается после создания.
|
||||||
|
2. **Дашборд** (плитки): «Сегодня N», «Просрочено N», «На неделе N», «Загрузка: Иванов 5, Петров 3 …».
|
||||||
|
3. **Таблица задач** с фильтрами сверху и инлайн-кнопками: «В работу», «Готово», «Удалить». Просроченные — красная строка. High priority — жирный.
|
||||||
|
|
||||||
|
В шапке dropdown «Я работаю как: …» — выбор текущего пользователя (директор/сотрудник). Влияет на фильтр по умолчанию: сотрудник видит только свои.
|
||||||
|
|
||||||
|
## 7. Как запускать
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd sultan
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Открыть http://localhost:8000. БД создаётся при первом запуске в `data/tasks.db`.
|
||||||
|
|
||||||
|
`requirements.txt`:
|
||||||
|
```
|
||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Порядок реализации (≈60 минут)
|
||||||
|
|
||||||
|
1. (5 мин) Скелет: каталоги, `requirements.txt`, `constants.py`.
|
||||||
|
2. (10 мин) `db.py` + `init_db()` + создание таблицы при старте.
|
||||||
|
3. (10 мин) `main.py`: `/api/meta`, `/api/tasks` GET/POST, статика.
|
||||||
|
4. (5 мин) `logic.py`: `compute_priority`, `is_overdue`.
|
||||||
|
5. (5 мин) PATCH/DELETE + `/api/dashboard`.
|
||||||
|
6. (20 мин) `index.html` + `app.js`: форма, таблица, фильтры, дашборд.
|
||||||
|
7. (5 мин) Сид-данные: 5–10 демо-задач при первом запуске пустой БД.
|
||||||
|
|
||||||
|
## 9. Будущие расширения (вне MVP)
|
||||||
|
|
||||||
|
Голосовой ввод (Whisper API), OCR протокола, Telegram-бот для уведомлений, авторизация (один токен на пользователя), экспорт еженедельного отчёта в PDF/email, интеграция с СЭД.
|
||||||
852
app.js
Normal file
852
app.js
Normal file
@ -0,0 +1,852 @@
|
|||||||
|
// Помощник руководителя — MVP. Хранилище: localStorage.
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'sultan.tasks.v6';
|
||||||
|
const USER_KEY = 'sultan.currentUser.v1';
|
||||||
|
|
||||||
|
// Очистка старых версий ключей (миграция).
|
||||||
|
(function purgeOldKeys() {
|
||||||
|
try {
|
||||||
|
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||||
|
const k = localStorage.key(i);
|
||||||
|
if (k && k.startsWith('sultan.tasks.') && k !== STORAGE_KEY) {
|
||||||
|
localStorage.removeItem(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
})();
|
||||||
|
|
||||||
|
const EMPLOYEES = [
|
||||||
|
'Директор',
|
||||||
|
'Азат',
|
||||||
|
'Айгерим',
|
||||||
|
'Анар',
|
||||||
|
'Арайлым',
|
||||||
|
'Гайнель',
|
||||||
|
'Гаухар',
|
||||||
|
'Елена',
|
||||||
|
'Ислам',
|
||||||
|
];
|
||||||
|
|
||||||
|
const DIRECTIONS = [
|
||||||
|
'ДВГО',
|
||||||
|
'НПА',
|
||||||
|
'Командировки',
|
||||||
|
'Форумы',
|
||||||
|
'Проблемные вопросы',
|
||||||
|
'Протокольные поручения',
|
||||||
|
'ДКБ',
|
||||||
|
'ДРБ',
|
||||||
|
'ОДС',
|
||||||
|
'КТ МИИЦР',
|
||||||
|
'МФ РК',
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_LABEL = {
|
||||||
|
new: 'Новая',
|
||||||
|
in_progress: 'В работе',
|
||||||
|
done: 'Выполнена',
|
||||||
|
overdue: 'Просрочена',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIO_LABEL = { high: 'Высокий', medium: 'Средний', low: 'Низкий' };
|
||||||
|
|
||||||
|
// ---------- storage ----------
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('load failed', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(tasks) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextId(tasks) {
|
||||||
|
return tasks.reduce((m, t) => Math.max(m, t.id), 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- logic ----------
|
||||||
|
|
||||||
|
function todayISO() {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysBetween(aISO, bISO) {
|
||||||
|
const a = new Date(aISO + 'T00:00:00');
|
||||||
|
const b = new Date(bISO + 'T00:00:00');
|
||||||
|
return Math.round((a - b) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePriority(deadline) {
|
||||||
|
const diff = daysBetween(deadline, todayISO());
|
||||||
|
if (diff <= 2) return 'high';
|
||||||
|
if (diff <= 7) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveStatus(task) {
|
||||||
|
if (task.status === 'done') return 'done';
|
||||||
|
if (task.deadline && task.deadline < todayISO()) return 'overdue';
|
||||||
|
return task.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- seed ----------
|
||||||
|
|
||||||
|
function seedTasks() {
|
||||||
|
const t = todayISO();
|
||||||
|
const plus = (n) => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + n);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
const raw = [
|
||||||
|
['Подготовить позицию по проекту изменений в Закон о связи', 'Азат', 'НПА', plus(1)],
|
||||||
|
['Согласовать тарифы с КТ МИИЦР', 'Айгерим', 'КТ МИИЦР', plus(5)],
|
||||||
|
['Командировка в Астану для переговоров с регулятором', 'Анар', 'Командировки', plus(0)],
|
||||||
|
['Согласовать бюджет на 2026 год с Минфином', 'Арайлым', 'МФ РК', plus(10)],
|
||||||
|
['Подготовить ответ по протокольному поручению Премьер-Министра', 'Гайнель', 'Протокольные поручения', plus(-1)],
|
||||||
|
['Организовать участие в Digital Bridge форуме', 'Гаухар', 'Форумы', plus(3)],
|
||||||
|
['Подготовить позицию по тарифной проблематике в регионах', 'Елена', 'Проблемные вопросы', plus(7)],
|
||||||
|
['Согласовать вопросы взаимодействия с ДВГО', 'Ислам', 'ДВГО', plus(14)],
|
||||||
|
];
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return raw.map(([title, assignee, direction, deadline], i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
title,
|
||||||
|
description: '',
|
||||||
|
assignee,
|
||||||
|
created_by: 'Директор',
|
||||||
|
direction,
|
||||||
|
priority: computePriority(deadline),
|
||||||
|
status: 'new',
|
||||||
|
deadline,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTasks() {
|
||||||
|
let tasks = load();
|
||||||
|
if (!tasks) {
|
||||||
|
tasks = seedTasks();
|
||||||
|
save(tasks);
|
||||||
|
}
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- DOM helpers ----------
|
||||||
|
|
||||||
|
function $(sel) { return document.querySelector(sel); }
|
||||||
|
function $$(sel) { return Array.from(document.querySelectorAll(sel)); }
|
||||||
|
|
||||||
|
function fillSelect(el, items, includeAll = false, allLabel = 'Все') {
|
||||||
|
el.innerHTML = '';
|
||||||
|
if (includeAll) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = '';
|
||||||
|
o.textContent = allLabel;
|
||||||
|
el.appendChild(o);
|
||||||
|
}
|
||||||
|
items.forEach((v) => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = v;
|
||||||
|
o.textContent = v;
|
||||||
|
el.appendChild(o);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- state ----------
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
currentUser: localStorage.getItem(USER_KEY) || 'Директор',
|
||||||
|
filters: { assignee: '', status: '', direction: '', priority: '' },
|
||||||
|
view: 'home',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- render ----------
|
||||||
|
|
||||||
|
function renderDashboard(tasks) {
|
||||||
|
const load = {};
|
||||||
|
tasks.forEach((t) => {
|
||||||
|
if (effectiveStatus(t) === 'done') return;
|
||||||
|
load[t.assignee] = (load[t.assignee] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadEl = $('#loadByAssignee');
|
||||||
|
if (!loadEl) return;
|
||||||
|
loadEl.innerHTML = '';
|
||||||
|
Object.entries(load)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.forEach(([name, n]) => {
|
||||||
|
const chip = document.createElement('button');
|
||||||
|
chip.type = 'button';
|
||||||
|
chip.className = 'chip chip-btn';
|
||||||
|
chip.dataset.assignee = name;
|
||||||
|
if (state.filters.assignee === name) chip.classList.add('active');
|
||||||
|
chip.innerHTML = `<span class="chip-name">${escapeHtml(name)}</span><span class="chip-count">${n}</span>`;
|
||||||
|
chip.title = `Показать задачи: ${name}`;
|
||||||
|
loadEl.appendChild(chip);
|
||||||
|
});
|
||||||
|
if (!loadEl.children.length) loadEl.textContent = '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(tasks) {
|
||||||
|
const body = $('#tasksBody');
|
||||||
|
body.innerHTML = '';
|
||||||
|
|
||||||
|
const f = state.filters;
|
||||||
|
let visible = tasks.slice();
|
||||||
|
|
||||||
|
// Сотрудник видит только свои по умолчанию (если фильтр не задан явно).
|
||||||
|
if (state.currentUser !== 'Директор' && !f.assignee) {
|
||||||
|
visible = visible.filter((t) => t.assignee === state.currentUser);
|
||||||
|
}
|
||||||
|
if (f.assignee) visible = visible.filter((t) => t.assignee === f.assignee);
|
||||||
|
if (f.direction) visible = visible.filter((t) => t.direction === f.direction);
|
||||||
|
if (f.priority) visible = visible.filter((t) => t.priority === f.priority);
|
||||||
|
if (f.status) visible = visible.filter((t) => effectiveStatus(t) === f.status);
|
||||||
|
|
||||||
|
visible.sort((a, b) => {
|
||||||
|
const order = { high: 0, medium: 1, low: 2 };
|
||||||
|
if (order[a.priority] !== order[b.priority]) return order[a.priority] - order[b.priority];
|
||||||
|
return (a.deadline || '') .localeCompare(b.deadline || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#emptyHint').hidden = visible.length > 0;
|
||||||
|
|
||||||
|
visible.forEach((t) => {
|
||||||
|
const st = effectiveStatus(t);
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = `prio-${t.priority} ${st === 'overdue' ? 'overdue' : ''} ${st === 'done' ? 'done' : ''}`;
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="title" title="${escapeHtml(t.description || '')}">${escapeHtml(t.title)}</td>
|
||||||
|
<td>${escapeHtml(t.assignee)}</td>
|
||||||
|
<td>${escapeHtml(t.direction || '')}</td>
|
||||||
|
<td>${t.deadline || ''}</td>
|
||||||
|
<td>
|
||||||
|
<select data-id="${t.id}" data-field="priority" class="prio-select">
|
||||||
|
${['high','medium','low'].map(p =>
|
||||||
|
`<option value="${p}" ${p===t.priority?'selected':''}>${PRIO_LABEL[p]}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><span class="badge status-${st}">${STATUS_LABEL[st]}</span></td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
${st !== 'done' && t.status === 'new' ? `<button class="btn btn-sm btn-primary" data-act="start" data-id="${t.id}">▶ В работу</button>` : ''}
|
||||||
|
${st !== 'done' ? `<button class="btn btn-sm btn-success" data-act="done" data-id="${t.id}">✓ Готово</button>` : `<button class="btn btn-sm btn-ghost" data-act="reopen" data-id="${t.id}">↺ Вернуть</button>`}
|
||||||
|
<button class="btn btn-sm btn-icon btn-danger" data-act="delete" data-id="${t.id}" title="Удалить">✕</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
body.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
const tasks = getTasks();
|
||||||
|
if (state.view === 'analytics') {
|
||||||
|
renderDashboard(tasks);
|
||||||
|
renderTable(tasks);
|
||||||
|
renderWorkload(tasks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- workload view (графики) ----------
|
||||||
|
|
||||||
|
const charts = {};
|
||||||
|
|
||||||
|
function buildPerAssignee(tasks) {
|
||||||
|
const map = {};
|
||||||
|
EMPLOYEES.forEach((e) => { map[e] = { new: 0, in_progress: 0, done: 0, overdue: 0, high: 0, medium: 0, low: 0 }; });
|
||||||
|
tasks.forEach((t) => {
|
||||||
|
if (!map[t.assignee]) return;
|
||||||
|
const st = effectiveStatus(t);
|
||||||
|
map[t.assignee][st] = (map[t.assignee][st] || 0) + 1;
|
||||||
|
map[t.assignee][t.priority] = (map[t.assignee][t.priority] || 0) + 1;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeadlineBuckets(tasks) {
|
||||||
|
const buckets = { 'Просрочено': 0, 'Сегодня': 0, 'Завтра': 0, 'Эта неделя': 0, '2 недели': 0, 'Позже': 0, 'Без даты': 0 };
|
||||||
|
const today = todayISO();
|
||||||
|
tasks.forEach((t) => {
|
||||||
|
if (effectiveStatus(t) === 'done') return;
|
||||||
|
if (!t.deadline) { buckets['Без даты']++; return; }
|
||||||
|
const diff = daysBetween(t.deadline, today);
|
||||||
|
if (diff < 0) buckets['Просрочено']++;
|
||||||
|
else if (diff === 0) buckets['Сегодня']++;
|
||||||
|
else if (diff === 1) buckets['Завтра']++;
|
||||||
|
else if (diff <= 7) buckets['Эта неделя']++;
|
||||||
|
else if (diff <= 14) buckets['2 недели']++;
|
||||||
|
else buckets['Позже']++;
|
||||||
|
});
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDirectionStats(tasks) {
|
||||||
|
const m = {};
|
||||||
|
DIRECTIONS.forEach((d) => { m[d] = 0; });
|
||||||
|
tasks.forEach((t) => {
|
||||||
|
if (effectiveStatus(t) === 'done') return;
|
||||||
|
if (t.direction && m[t.direction] !== undefined) m[t.direction]++;
|
||||||
|
});
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTimeline(tasks) {
|
||||||
|
const labels = [], values = [];
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() + i);
|
||||||
|
const iso = d.toISOString().slice(0, 10);
|
||||||
|
labels.push(d.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' }));
|
||||||
|
values.push(tasks.filter((t) => t.deadline === iso && effectiveStatus(t) !== 'done').length);
|
||||||
|
}
|
||||||
|
return { labels, values };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkload(tasks) {
|
||||||
|
if (!window.Chart) return;
|
||||||
|
const active = tasks.filter((t) => effectiveStatus(t) !== 'done');
|
||||||
|
const today = todayISO();
|
||||||
|
const soonCutoff = (() => {
|
||||||
|
const d = new Date(); d.setDate(d.getDate() + 3);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
})();
|
||||||
|
|
||||||
|
$('#kpiActive').textContent = active.length;
|
||||||
|
$('#kpiOverdue').textContent = active.filter((t) => effectiveStatus(t) === 'overdue').length;
|
||||||
|
$('#kpiSoon').textContent = active.filter((t) => t.deadline && t.deadline >= today && t.deadline <= soonCutoff).length;
|
||||||
|
$('#kpiDone').textContent = tasks.filter((t) => t.status === 'done').length;
|
||||||
|
|
||||||
|
const perAssignee = buildPerAssignee(tasks);
|
||||||
|
const employees = EMPLOYEES.filter((e) => e !== 'Директор');
|
||||||
|
|
||||||
|
destroyChart('status');
|
||||||
|
charts.status = new Chart($('#chartStatus'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: employees,
|
||||||
|
datasets: [
|
||||||
|
{ label: 'Новые', data: employees.map((e) => perAssignee[e].new), backgroundColor: '#1972C7' },
|
||||||
|
{ label: 'В работе', data: employees.map((e) => perAssignee[e].in_progress), backgroundColor: '#FF9500' },
|
||||||
|
{ label: 'Просрочено', data: employees.map((e) => perAssignee[e].overdue), backgroundColor: '#FF3B30' },
|
||||||
|
{ label: 'Выполнено', data: employees.map((e) => perAssignee[e].done), backgroundColor: '#34C759' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: chartOpts({ stacked: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
destroyChart('priority');
|
||||||
|
charts.priority = new Chart($('#chartPriority'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: employees,
|
||||||
|
datasets: [
|
||||||
|
{ label: 'Высокий', data: employees.map((e) => perAssignee[e].high), backgroundColor: '#FF3B30' },
|
||||||
|
{ label: 'Средний', data: employees.map((e) => perAssignee[e].medium), backgroundColor: '#FF9500' },
|
||||||
|
{ label: 'Низкий', data: employees.map((e) => perAssignee[e].low), backgroundColor: '#34C759' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: chartOpts({ stacked: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dl = buildDeadlineBuckets(tasks);
|
||||||
|
destroyChart('deadlines');
|
||||||
|
charts.deadlines = new Chart($('#chartDeadlines'), {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: Object.keys(dl),
|
||||||
|
datasets: [{
|
||||||
|
data: Object.values(dl),
|
||||||
|
backgroundColor: ['#FF3B30', '#FF9500', '#FFCC00', '#4FB1F7', '#1972C7', '#0E4A82', '#D2D2D7'],
|
||||||
|
borderWidth: 2, borderColor: '#fff',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { font: { family: 'Inter' }, boxWidth: 14 } } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const dirs = buildDirectionStats(tasks);
|
||||||
|
const dirEntries = Object.entries(dirs).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]);
|
||||||
|
destroyChart('directions');
|
||||||
|
charts.directions = new Chart($('#chartDirections'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: dirEntries.map(([k]) => k),
|
||||||
|
datasets: [{ label: 'Активных задач', data: dirEntries.map(([, n]) => n), backgroundColor: '#1972C7', borderRadius: 6 }],
|
||||||
|
},
|
||||||
|
options: { ...chartOpts(), indexAxis: 'y' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const tl = buildTimeline(tasks);
|
||||||
|
destroyChart('timeline');
|
||||||
|
charts.timeline = new Chart($('#chartTimeline'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: tl.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Дедлайнов в день',
|
||||||
|
data: tl.values,
|
||||||
|
borderColor: '#1972C7',
|
||||||
|
backgroundColor: 'rgba(25, 114, 199, 0.15)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointBackgroundColor: '#1972C7',
|
||||||
|
pointRadius: 4,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: chartOpts(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartOpts({ stacked = false } = {}) {
|
||||||
|
return {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom', labels: { font: { family: 'Inter' }, boxWidth: 14, padding: 12 } },
|
||||||
|
tooltip: { backgroundColor: '#0f1c2e', titleFont: { family: 'Inter' }, bodyFont: { family: 'Inter' } },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { stacked, grid: { display: false }, ticks: { font: { family: 'Inter' } } },
|
||||||
|
y: { stacked, beginAtZero: true, ticks: { precision: 0, font: { family: 'Inter' } }, grid: { color: 'rgba(122, 138, 160, 0.15)' } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyChart(key) {
|
||||||
|
if (charts[key]) { charts[key].destroy(); charts[key] = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- toast ----------
|
||||||
|
|
||||||
|
function showToast(msg, type = 'success') {
|
||||||
|
const t = document.getElementById('toast');
|
||||||
|
if (!t) return;
|
||||||
|
t.textContent = msg;
|
||||||
|
t.className = `toast toast-${type}`;
|
||||||
|
t.hidden = false;
|
||||||
|
// Принудительный reflow для перезапуска анимации.
|
||||||
|
void t.offsetWidth;
|
||||||
|
t.classList.add('visible');
|
||||||
|
clearTimeout(showToast._h);
|
||||||
|
showToast._h = setTimeout(() => {
|
||||||
|
t.classList.remove('visible');
|
||||||
|
setTimeout(() => { t.hidden = true; }, 250);
|
||||||
|
}, 2800);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- home widgets (часы, погода, новости) ----------
|
||||||
|
|
||||||
|
let clockTimer = null;
|
||||||
|
let weatherLoadedAt = 0;
|
||||||
|
let newsLoadedAt = 0;
|
||||||
|
|
||||||
|
function startHomeWidgets() {
|
||||||
|
tickClock();
|
||||||
|
if (clockTimer) clearInterval(clockTimer);
|
||||||
|
clockTimer = setInterval(tickClock, 1000);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - weatherLoadedAt > 10 * 60 * 1000) loadWeather();
|
||||||
|
if (now - newsLoadedAt > 15 * 60 * 1000) loadNews();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickClock() {
|
||||||
|
// Время в часовом поясе Астаны (UTC+5) — независимо от локали браузера.
|
||||||
|
const opts = { timeZone: 'Asia/Almaty' };
|
||||||
|
const now = new Date();
|
||||||
|
const date = now.toLocaleDateString('ru-RU', { ...opts, weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
const time = now.toLocaleTimeString('ru-RU', { ...opts, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
const dateEl = document.getElementById('clockDate');
|
||||||
|
const timeEl = document.getElementById('clockTime');
|
||||||
|
if (dateEl) dateEl.textContent = date.charAt(0).toUpperCase() + date.slice(1);
|
||||||
|
if (timeEl) timeEl.textContent = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WMO = {
|
||||||
|
0: ['Ясно', '☀️'], 1: ['Преимущественно ясно', '🌤️'], 2: ['Переменная облачность', '⛅'], 3: ['Пасмурно', '☁️'],
|
||||||
|
45: ['Туман', '🌫️'], 48: ['Изморозь', '🌫️'],
|
||||||
|
51: ['Морось слабая', '🌦️'], 53: ['Морось', '🌦️'], 55: ['Морось сильная', '🌧️'],
|
||||||
|
61: ['Слабый дождь', '🌦️'], 63: ['Дождь', '🌧️'], 65: ['Сильный дождь', '🌧️'],
|
||||||
|
66: ['Ледяной дождь', '🌧️❄️'], 67: ['Ледяной дождь сильный', '🌧️❄️'],
|
||||||
|
71: ['Слабый снег', '🌨️'], 73: ['Снег', '🌨️'], 75: ['Сильный снег', '❄️'],
|
||||||
|
77: ['Снежная крупа', '❄️'],
|
||||||
|
80: ['Ливень слабый', '🌦️'], 81: ['Ливень', '🌧️'], 82: ['Ливень сильный', '⛈️'],
|
||||||
|
85: ['Снежный заряд', '🌨️'], 86: ['Сильный снежный заряд', '❄️'],
|
||||||
|
95: ['Гроза', '⛈️'], 96: ['Гроза с градом', '⛈️'], 99: ['Сильная гроза с градом', '⛈️'],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadWeather() {
|
||||||
|
const box = document.getElementById('weatherBox');
|
||||||
|
if (!box) return;
|
||||||
|
box.innerHTML = '<div class="weather-loading">Загружаем данные…</div>';
|
||||||
|
try {
|
||||||
|
const url = 'https://api.open-meteo.com/v1/forecast?latitude=51.1605&longitude=71.4704¤t=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m&timezone=Asia%2FAlmaty';
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const j = await r.json();
|
||||||
|
const c = j.current || {};
|
||||||
|
const [label, icon] = WMO[c.weather_code] || ['—', '🌡️'];
|
||||||
|
const t = Math.round(c.temperature_2m);
|
||||||
|
const feels = Math.round(c.apparent_temperature);
|
||||||
|
const hum = Math.round(c.relative_humidity_2m);
|
||||||
|
const wind = Math.round(c.wind_speed_10m);
|
||||||
|
box.innerHTML = `
|
||||||
|
<div class="weather-main">
|
||||||
|
<div class="weather-icon">${icon}</div>
|
||||||
|
<div class="weather-temp">${t > 0 ? '+' : ''}${t}°</div>
|
||||||
|
</div>
|
||||||
|
<div class="weather-label">${label}</div>
|
||||||
|
<div class="weather-meta">
|
||||||
|
<span title="Ощущается как">🌡️ ощущ. ${feels > 0 ? '+' : ''}${feels}°</span>
|
||||||
|
<span title="Влажность">💧 ${hum}%</span>
|
||||||
|
<span title="Ветер, км/ч">💨 ${wind} км/ч</span>
|
||||||
|
</div>
|
||||||
|
<div class="weather-src">Open-Meteo · обновлено ${new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}</div>
|
||||||
|
`;
|
||||||
|
weatherLoadedAt = Date.now();
|
||||||
|
} catch (e) {
|
||||||
|
box.innerHTML = `<div class="weather-error">Не удалось загрузить погоду: ${escapeHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google News RSS даёт свежие новости со ссылками на оригинальные источники.
|
||||||
|
// rss2json — публичный конвертер RSS→JSON без ключа (free tier). Решает CORS.
|
||||||
|
const NEWS_QUERIES = [
|
||||||
|
'"Казахтелеком"',
|
||||||
|
'"Қазақтелеком"',
|
||||||
|
'(связь OR телеком OR оператор OR "5G") Казахстан',
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildGoogleNewsRSS(query) {
|
||||||
|
const q = encodeURIComponent(query);
|
||||||
|
return `https://news.google.com/rss/search?q=${q}&hl=ru&gl=KZ&ceid=KZ:ru`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRSS2JSON(rssUrl) {
|
||||||
|
return 'https://api.rss2json.com/v1/api.json?rss_url=' + encodeURIComponent(rssUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNews() {
|
||||||
|
const list = document.getElementById('newsList');
|
||||||
|
const srcEl = document.getElementById('newsSource');
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = '<li class="news-loading">Загружаем заголовки…</li>';
|
||||||
|
srcEl.textContent = '—';
|
||||||
|
|
||||||
|
const aggregated = [];
|
||||||
|
let anySuccess = false;
|
||||||
|
|
||||||
|
for (const q of NEWS_QUERIES) {
|
||||||
|
const items = await fetchGoogleNewsViaJSON(q);
|
||||||
|
if (items.length) anySuccess = true;
|
||||||
|
aggregated.push(...items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дедуп по «нормализованному» заголовку (Google news redirects к разным URL).
|
||||||
|
const seen = new Set();
|
||||||
|
const items = aggregated
|
||||||
|
.filter((it) => {
|
||||||
|
const key = normalizeForDedup(it.title);
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a, b) => (b._pub || 0) - (a._pub || 0))
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
list.innerHTML = `
|
||||||
|
<li class="news-empty">
|
||||||
|
<div class="news-empty-title">${anySuccess ? 'Релевантных новостей сейчас нет' : 'Живые новости пока недоступны'}</div>
|
||||||
|
<div class="news-empty-sub">${anySuccess
|
||||||
|
? 'Поиск по «Казахтелеком», «Қазақтелеком», «связь Казахстан» не вернул результатов. Попробуйте позже.'
|
||||||
|
: 'Не удалось получить данные через публичный конвертер RSS→JSON (rss2json). Проверьте интернет или нажмите «Обновить».'}</div>
|
||||||
|
</li>`;
|
||||||
|
srcEl.textContent = 'Источник: news.google.com (через rss2json)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNews(items, 'Google News · rss2json.com');
|
||||||
|
newsLoadedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGoogleNewsViaJSON(query) {
|
||||||
|
const rssUrl = buildGoogleNewsRSS(query);
|
||||||
|
const url = buildRSS2JSON(rssUrl);
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, {
|
||||||
|
signal: AbortSignal.timeout ? AbortSignal.timeout(10000) : undefined,
|
||||||
|
});
|
||||||
|
if (!r.ok) return [];
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.status !== 'ok' || !Array.isArray(j.items)) return [];
|
||||||
|
return j.items.map((it) => {
|
||||||
|
const sourceName = extractSourceFromTitle(it.title);
|
||||||
|
const cleanTitle = stripSourceSuffix(it.title);
|
||||||
|
return {
|
||||||
|
title: cleanTitle,
|
||||||
|
link: it.link,
|
||||||
|
host: sourceName || 'news.google.com',
|
||||||
|
date: formatNewsDate(it.pubDate),
|
||||||
|
_pub: it.pubDate ? new Date(it.pubDate).getTime() : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google News задаёт заголовок как «<Заголовок> - <Источник>». Извлекаем источник.
|
||||||
|
function extractSourceFromTitle(t) {
|
||||||
|
const m = String(t || '').match(/ - ([^-]+)$/);
|
||||||
|
return m ? m[1].trim() : '';
|
||||||
|
}
|
||||||
|
function stripSourceSuffix(t) {
|
||||||
|
return String(t || '').replace(/\s+-\s+[^-]+$/, '').trim();
|
||||||
|
}
|
||||||
|
function normalizeForDedup(t) {
|
||||||
|
return String(t || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostOf(u) {
|
||||||
|
try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNewsDate(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
const d = new Date(s);
|
||||||
|
if (isNaN(d)) return '';
|
||||||
|
const diffMin = (Date.now() - d.getTime()) / 60000;
|
||||||
|
if (diffMin < 60) return Math.max(1, Math.round(diffMin)) + ' мин назад';
|
||||||
|
if (diffMin < 60 * 24) return Math.round(diffMin / 60) + ' ч назад';
|
||||||
|
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNews(items, source) {
|
||||||
|
const list = document.getElementById('newsList');
|
||||||
|
const srcEl = document.getElementById('newsSource');
|
||||||
|
list.innerHTML = '';
|
||||||
|
items.forEach((it) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'news-item';
|
||||||
|
li.innerHTML = `
|
||||||
|
<a href="${escapeHtml(it.link)}" target="_blank" rel="noopener noreferrer" class="news-link">
|
||||||
|
<span class="news-title">${escapeHtml(it.title)}</span>
|
||||||
|
<span class="news-meta">
|
||||||
|
${it.host ? `<span class="news-host">${escapeHtml(it.host)}</span>` : ''}
|
||||||
|
${it.date ? `<span class="news-date">${escapeHtml(it.date)}</span>` : ''}
|
||||||
|
<span class="news-arrow" aria-hidden="true">↗</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
srcEl.textContent = `Источники: ${source} · обновлено ${new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- router ----------
|
||||||
|
|
||||||
|
function applyRoute() {
|
||||||
|
const hash = location.hash || '#/home';
|
||||||
|
let view = 'home';
|
||||||
|
if (hash === '#/create') view = 'create';
|
||||||
|
else if (hash === '#/analytics') view = 'analytics';
|
||||||
|
state.view = view;
|
||||||
|
document.body.dataset.view = view;
|
||||||
|
$('#view-home').hidden = view !== 'home';
|
||||||
|
$('#view-create').hidden = view !== 'create';
|
||||||
|
$('#view-analytics').hidden = view !== 'analytics';
|
||||||
|
document.querySelectorAll('.menu-item').forEach((a) => {
|
||||||
|
a.classList.toggle('active', a.dataset.view === view);
|
||||||
|
});
|
||||||
|
closeMenu();
|
||||||
|
renderAll();
|
||||||
|
if (view === 'home') startHomeWidgets();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMenu() {
|
||||||
|
$('#menuPanel').hidden = false;
|
||||||
|
$('#menuBtn').setAttribute('aria-expanded', 'true');
|
||||||
|
$('#menuBtn').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeMenu() {
|
||||||
|
$('#menuPanel').hidden = true;
|
||||||
|
$('#menuBtn').setAttribute('aria-expanded', 'false');
|
||||||
|
$('#menuBtn').classList.remove('open');
|
||||||
|
}
|
||||||
|
function toggleMenu() {
|
||||||
|
if ($('#menuPanel').hidden) openMenu(); else closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- events ----------
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
// Селекты в форме
|
||||||
|
fillSelect($('select[name="assignee"]'), EMPLOYEES);
|
||||||
|
fillSelect($('select[name="direction"]'), DIRECTIONS);
|
||||||
|
|
||||||
|
// Селекты в фильтрах
|
||||||
|
fillSelect($('#fAssignee'), EMPLOYEES, true, 'Все исполнители');
|
||||||
|
fillSelect($('#fDirection'), DIRECTIONS, true, 'Все направления');
|
||||||
|
|
||||||
|
// Текущий пользователь
|
||||||
|
fillSelect($('#currentUser'), EMPLOYEES);
|
||||||
|
$('#currentUser').value = state.currentUser;
|
||||||
|
$('#currentUser').addEventListener('change', (e) => {
|
||||||
|
state.currentUser = e.target.value;
|
||||||
|
localStorage.setItem(USER_KEY, state.currentUser);
|
||||||
|
renderAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Дедлайн — по умолчанию +3 дня. Flatpickr — большой красивый календарь.
|
||||||
|
const dl = new Date();
|
||||||
|
dl.setDate(dl.getDate() + 3);
|
||||||
|
const deadlineInput = $('input[name="deadline"]');
|
||||||
|
deadlineInput.value = dl.toISOString().slice(0, 10);
|
||||||
|
if (window.flatpickr) {
|
||||||
|
window.flatpickr(deadlineInput, {
|
||||||
|
locale: (window.flatpickr.l10ns && window.flatpickr.l10ns.ru) || 'default',
|
||||||
|
dateFormat: 'Y-m-d',
|
||||||
|
altInput: true,
|
||||||
|
altFormat: 'd F Y (l)',
|
||||||
|
minDate: 'today',
|
||||||
|
defaultDate: deadlineInput.value,
|
||||||
|
monthSelectorType: 'static',
|
||||||
|
disableMobile: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форма создания
|
||||||
|
$('#taskForm').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData(e.target);
|
||||||
|
const tasks = getTasks();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const task = {
|
||||||
|
id: nextId(tasks),
|
||||||
|
title: (fd.get('title') || '').trim(),
|
||||||
|
description: (fd.get('description') || '').trim(),
|
||||||
|
assignee: fd.get('assignee'),
|
||||||
|
created_by: state.currentUser,
|
||||||
|
direction: fd.get('direction'),
|
||||||
|
priority: computePriority(fd.get('deadline')),
|
||||||
|
status: 'new',
|
||||||
|
deadline: fd.get('deadline'),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
if (!task.title) return;
|
||||||
|
tasks.push(task);
|
||||||
|
save(tasks);
|
||||||
|
showToast(`Поручение создано · «${task.title}» → ${task.assignee}`);
|
||||||
|
e.target.reset();
|
||||||
|
const d2 = new Date(); d2.setDate(d2.getDate() + 3);
|
||||||
|
const isoDefault = d2.toISOString().slice(0, 10);
|
||||||
|
const dlInput = $('input[name="deadline"]');
|
||||||
|
if (dlInput._flatpickr) dlInput._flatpickr.setDate(isoDefault, true);
|
||||||
|
else dlInput.value = isoDefault;
|
||||||
|
renderAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Фильтры
|
||||||
|
['fAssignee', 'fStatus', 'fDirection', 'fPriority'].forEach((id) => {
|
||||||
|
$('#' + id).addEventListener('change', (e) => {
|
||||||
|
const key = id.replace('f', '').toLowerCase();
|
||||||
|
state.filters[key] = e.target.value;
|
||||||
|
renderAll();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$('#clearFilters').addEventListener('click', () => {
|
||||||
|
state.filters = { assignee: '', status: '', direction: '', priority: '' };
|
||||||
|
['fAssignee', 'fStatus', 'fDirection', 'fPriority'].forEach((id) => { $('#' + id).value = ''; });
|
||||||
|
renderAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Делегирование действий в таблице
|
||||||
|
$('#tasksBody').addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('button[data-act]');
|
||||||
|
if (!btn) return;
|
||||||
|
const id = Number(btn.dataset.id);
|
||||||
|
const act = btn.dataset.act;
|
||||||
|
const tasks = getTasks();
|
||||||
|
const t = tasks.find((x) => x.id === id);
|
||||||
|
if (!t) return;
|
||||||
|
if (act === 'start') t.status = 'in_progress';
|
||||||
|
if (act === 'done') t.status = 'done';
|
||||||
|
if (act === 'reopen') t.status = 'in_progress';
|
||||||
|
if (act === 'delete') {
|
||||||
|
if (!confirm('Удалить поручение?')) return;
|
||||||
|
const idx = tasks.indexOf(t);
|
||||||
|
tasks.splice(idx, 1);
|
||||||
|
}
|
||||||
|
t && (t.updated_at = new Date().toISOString());
|
||||||
|
save(tasks);
|
||||||
|
renderAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#tasksBody').addEventListener('change', (e) => {
|
||||||
|
const sel = e.target.closest('select.prio-select');
|
||||||
|
if (!sel) return;
|
||||||
|
const id = Number(sel.dataset.id);
|
||||||
|
const tasks = getTasks();
|
||||||
|
const t = tasks.find((x) => x.id === id);
|
||||||
|
if (!t) return;
|
||||||
|
t.priority = sel.value;
|
||||||
|
t.updated_at = new Date().toISOString();
|
||||||
|
save(tasks);
|
||||||
|
renderAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Клик по чипу сотрудника → фильтр на исполнителя (toggle).
|
||||||
|
$('#loadByAssignee').addEventListener('click', (e) => {
|
||||||
|
const chip = e.target.closest('.chip-btn');
|
||||||
|
if (!chip) return;
|
||||||
|
const name = chip.dataset.assignee;
|
||||||
|
const same = state.filters.assignee === name;
|
||||||
|
state.filters.assignee = same ? '' : name;
|
||||||
|
$('#fAssignee').value = state.filters.assignee;
|
||||||
|
renderAll();
|
||||||
|
if (!same) {
|
||||||
|
document.getElementById('tasksTable').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сброс демо
|
||||||
|
$('#resetDemo').addEventListener('click', () => {
|
||||||
|
if (!confirm('Удалить все данные и пересоздать демо-поручения?')) return;
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
renderAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Роутер
|
||||||
|
window.addEventListener('hashchange', applyRoute);
|
||||||
|
applyRoute();
|
||||||
|
|
||||||
|
// Меню (бургер + popover)
|
||||||
|
$('#menuBtn').addEventListener('click', (e) => { e.stopPropagation(); toggleMenu(); });
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('#menuPanel') || e.target.closest('#menuBtn')) return;
|
||||||
|
closeMenu();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMenu(); });
|
||||||
|
|
||||||
|
// Главная: ручное обновление новостей.
|
||||||
|
const refreshNewsBtn = document.getElementById('refreshNews');
|
||||||
|
if (refreshNewsBtn) refreshNewsBtn.addEventListener('click', () => { newsLoadedAt = 0; loadNews(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
246
index.html
Normal file
246
index.html
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate" />
|
||||||
|
<meta http-equiv="Pragma" content="no-cache" />
|
||||||
|
<meta http-equiv="Expires" content="0" />
|
||||||
|
<title>ДВГО — Помощник руководителя</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
|
||||||
|
<link rel="stylesheet" href="styles.css?v=22" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="menu-host">
|
||||||
|
<button id="menuBtn" class="menu-btn" aria-label="Открыть меню" aria-haspopup="true" aria-expanded="false">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
|
<div id="menuPanel" class="menu-panel" role="menu" hidden>
|
||||||
|
<a href="#/home" class="menu-item" data-view="home" role="menuitem">
|
||||||
|
<span class="menu-icon">🏠</span>
|
||||||
|
<span class="menu-text">
|
||||||
|
<span class="menu-title">Главная</span>
|
||||||
|
<span class="menu-sub">Время, погода, новости отрасли</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a href="#/create" class="menu-item" data-view="create" role="menuitem">
|
||||||
|
<span class="menu-icon">+</span>
|
||||||
|
<span class="menu-text">
|
||||||
|
<span class="menu-title">Создать поручение</span>
|
||||||
|
<span class="menu-sub">Форма ввода нового поручения</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a href="#/analytics" class="menu-item" data-view="analytics" role="menuitem">
|
||||||
|
<span class="menu-icon">📊</span>
|
||||||
|
<span class="menu-text">
|
||||||
|
<span class="menu-title">Аналитика</span>
|
||||||
|
<span class="menu-sub">Сводка, фильтры, графики</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="#/home" class="brand" id="brandHome" title="На главную">
|
||||||
|
<svg class="logo" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="kt-bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#4FB1F7"/>
|
||||||
|
<stop offset="0.55" stop-color="#2D95E2"/>
|
||||||
|
<stop offset="1" stop-color="#1972C7"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="kt-glow" cx="50%" cy="40%" r="60%">
|
||||||
|
<stop offset="0" stop-color="rgba(255,255,255,0.22)"/>
|
||||||
|
<stop offset="1" stop-color="rgba(255,255,255,0)"/>
|
||||||
|
</radialGradient>
|
||||||
|
<clipPath id="kt-left"><circle cx="38" cy="50" r="27"/></clipPath>
|
||||||
|
<clipPath id="kt-right"><circle cx="62" cy="50" r="27"/></clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect width="100" height="100" rx="22" fill="url(#kt-bg)"/>
|
||||||
|
<rect width="100" height="100" rx="22" fill="url(#kt-glow)"/>
|
||||||
|
|
||||||
|
<!-- Левая сфера: горизонтальные полосы -->
|
||||||
|
<g clip-path="url(#kt-left)" fill="#fff">
|
||||||
|
<rect x="8" y="29" width="60" height="3.5" rx="1.75"/>
|
||||||
|
<rect x="8" y="38" width="60" height="3.5" rx="1.75"/>
|
||||||
|
<rect x="8" y="47" width="60" height="3.5" rx="1.75"/>
|
||||||
|
<rect x="8" y="56" width="60" height="3.5" rx="1.75"/>
|
||||||
|
<rect x="8" y="65" width="60" height="3.5" rx="1.75"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Правая сфера: меридианы + экватор -->
|
||||||
|
<g clip-path="url(#kt-right)" stroke="#fff" stroke-width="3.2" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M62 23 C44 35 44 65 62 77"/>
|
||||||
|
<path d="M62 23 C54 35 54 65 62 77"/>
|
||||||
|
<path d="M62 23 C70 35 70 65 62 77"/>
|
||||||
|
<path d="M62 23 C78 35 78 65 62 77"/>
|
||||||
|
<path d="M35 56 Q62 62 90 56"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Лёгкая тень под сферами -->
|
||||||
|
<ellipse cx="50" cy="85" rx="34" ry="3" fill="rgba(0,0,0,0.10)"/>
|
||||||
|
|
||||||
|
<!-- Обводки сфер для контура -->
|
||||||
|
<circle cx="38" cy="50" r="27" fill="none" stroke="rgba(255,255,255,0.18)" stroke-width="0.8"/>
|
||||||
|
<circle cx="62" cy="50" r="27" fill="none" stroke="rgba(255,255,255,0.18)" stroke-width="0.8"/>
|
||||||
|
</svg>
|
||||||
|
<div class="brand-text">
|
||||||
|
<div class="brand-name">ДВГО</div>
|
||||||
|
<div class="brand-sub">Помощник руководителя · поручения</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="who">
|
||||||
|
<label for="currentUser">Я работаю как</label>
|
||||||
|
<select id="currentUser"></select>
|
||||||
|
<button id="resetDemo" class="btn btn-ghost" title="Очистить данные и пересоздать демо">↺ Сброс демо</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<!-- Главная -->
|
||||||
|
<div id="view-home" class="view">
|
||||||
|
<div class="home-grid">
|
||||||
|
<section class="card home-clock">
|
||||||
|
<div class="lbl">Сегодня</div>
|
||||||
|
<div class="clock-date" id="clockDate">—</div>
|
||||||
|
<div class="clock-time" id="clockTime">—</div>
|
||||||
|
<div class="clock-tz">Время в Астане · UTC+5</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card home-weather">
|
||||||
|
<div class="lbl">Погода · Астана</div>
|
||||||
|
<div id="weatherBox" class="weather-box">
|
||||||
|
<div class="weather-loading">Загружаем данные…</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="news-header">
|
||||||
|
<h2>Новости отрасли</h2>
|
||||||
|
<button id="refreshNews" class="btn btn-ghost" title="Обновить">↻ Обновить</button>
|
||||||
|
</div>
|
||||||
|
<ul id="newsList" class="news-list">
|
||||||
|
<li class="news-loading">Загружаем заголовки…</li>
|
||||||
|
</ul>
|
||||||
|
<p class="hint" id="newsSource">—</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Создать поручение -->
|
||||||
|
<div id="view-create" class="view" hidden>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Новое поручение</h2>
|
||||||
|
<form id="taskForm" class="grid">
|
||||||
|
<label>Заголовок<input type="text" name="title" required placeholder="Подготовить позицию по проекту НПА" /></label>
|
||||||
|
<label>Дедлайн<input type="date" name="deadline" required /></label>
|
||||||
|
|
||||||
|
<label>Исполнитель<select name="assignee" required></select></label>
|
||||||
|
<label>Направление<select name="direction"></select></label>
|
||||||
|
|
||||||
|
<label class="full">Описание<textarea name="description" rows="3" placeholder="Краткая суть, контекст, ожидаемый результат"></textarea></label>
|
||||||
|
|
||||||
|
<div class="actions full">
|
||||||
|
<button type="submit" class="btn btn-primary">+ Создать поручение</button>
|
||||||
|
<span class="hint">Приоритет назначится автоматически. Можно изменить вручную в аналитике.</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Аналитика -->
|
||||||
|
<div id="view-analytics" class="view" hidden>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Сводка по департаменту</h2>
|
||||||
|
<div class="kpis">
|
||||||
|
<div class="kpi"><div class="kpi-num" id="kpiActive">0</div><div class="kpi-lbl">Активных задач</div></div>
|
||||||
|
<div class="kpi"><div class="kpi-num" id="kpiOverdue">0</div><div class="kpi-lbl">Просрочено</div></div>
|
||||||
|
<div class="kpi"><div class="kpi-num" id="kpiSoon">0</div><div class="kpi-lbl">Дедлайн ≤ 3 дн</div></div>
|
||||||
|
<div class="kpi"><div class="kpi-num" id="kpiDone">0</div><div class="kpi-lbl">Выполнено</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Загрузка по сотрудникам</h2>
|
||||||
|
<div class="load" id="loadByAssignee"></div>
|
||||||
|
<p class="hint">Нажмите на сотрудника, чтобы отфильтровать таблицу.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Все поручения</h2>
|
||||||
|
<div class="filters">
|
||||||
|
<select id="fAssignee"><option value="">Все исполнители</option></select>
|
||||||
|
<select id="fStatus">
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
<option value="new">Новая</option>
|
||||||
|
<option value="in_progress">В работе</option>
|
||||||
|
<option value="done">Выполнена</option>
|
||||||
|
<option value="overdue">Просрочена</option>
|
||||||
|
</select>
|
||||||
|
<select id="fDirection"><option value="">Все направления</option></select>
|
||||||
|
<select id="fPriority">
|
||||||
|
<option value="">Все приоритеты</option>
|
||||||
|
<option value="high">Высокий</option>
|
||||||
|
<option value="medium">Средний</option>
|
||||||
|
<option value="low">Низкий</option>
|
||||||
|
</select>
|
||||||
|
<button id="clearFilters" class="btn btn-ghost">Сбросить фильтры</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table id="tasksTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Заголовок</th>
|
||||||
|
<th>Исполнитель</th>
|
||||||
|
<th>Направление</th>
|
||||||
|
<th>Дедлайн</th>
|
||||||
|
<th>Приоритет</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tasksBody"></tbody>
|
||||||
|
</table>
|
||||||
|
<p id="emptyHint" class="hint" hidden>Нет поручений по текущим фильтрам.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Задачи по сотрудникам (по статусу)</h2>
|
||||||
|
<div class="chart-wrap"><canvas id="chartStatus"></canvas></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Задачи по сотрудникам (по приоритету)</h2>
|
||||||
|
<div class="chart-wrap"><canvas id="chartPriority"></canvas></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="charts-row">
|
||||||
|
<section class="card">
|
||||||
|
<h2>Распределение по дедлайнам</h2>
|
||||||
|
<div class="chart-wrap small"><canvas id="chartDeadlines"></canvas></div>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Задачи по направлениям</h2>
|
||||||
|
<div class="chart-wrap small"><canvas id="chartDirections"></canvas></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Поступление задач в ближайшие 14 дней</h2>
|
||||||
|
<div class="chart-wrap"><canvas id="chartTimeline"></canvas></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/ru.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="app.js?v=23"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
916
styles.css
Normal file
916
styles.css
Normal file
@ -0,0 +1,916 @@
|
|||||||
|
/* Қазақтелеком — Помощник руководителя. Apple-style. Палитра — синий. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Brand — извлечено из логотипа: sky #4FB1F7, mid #2D95E2, royal #1972C7 */
|
||||||
|
--kt-blue: #1972C7;
|
||||||
|
--kt-blue-dark: #115C9F;
|
||||||
|
--kt-blue-darker: #0E4A82;
|
||||||
|
--kt-blue-deep: #0A3A66;
|
||||||
|
--kt-blue-light: #4FB1F7;
|
||||||
|
--kt-blue-mid: #2D95E2;
|
||||||
|
--kt-accent: #FFD23F;
|
||||||
|
|
||||||
|
/* Apple-ish neutrals */
|
||||||
|
--bg: #FBFBFD;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #F5F5F7;
|
||||||
|
--ink: #1D1D1F;
|
||||||
|
--ink-2: #424245;
|
||||||
|
--muted: #86868B;
|
||||||
|
--border: #D2D2D7;
|
||||||
|
--border-strong: #B6B6BB;
|
||||||
|
--hairline: rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
--accent: var(--kt-blue);
|
||||||
|
--accent-hover: var(--kt-blue-dark);
|
||||||
|
--danger: #FF3B30; /* iOS red */
|
||||||
|
--danger-hover: #D70015;
|
||||||
|
--warn: #FF9500; /* iOS orange */
|
||||||
|
--ok: #34C759; /* iOS green */
|
||||||
|
|
||||||
|
/* Apple-style радиусы */
|
||||||
|
--r-sm: 8px;
|
||||||
|
--r-md: 12px;
|
||||||
|
--r-lg: 18px;
|
||||||
|
--r-xl: 24px;
|
||||||
|
--r-pill: 980px;
|
||||||
|
|
||||||
|
/* Тени — очень мягкие */
|
||||||
|
--sh-sm: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
|
--sh-md: 0 6px 20px rgba(0, 0, 0, 0.06);
|
||||||
|
--sh-lg: 0 20px 50px rgba(0, 0, 0, 0.10);
|
||||||
|
--sh-btn: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body { height: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 0% 0%, rgba(79, 177, 247, 0.08) 0%, transparent 40%),
|
||||||
|
radial-gradient(circle at 100% 100%, rgba(25, 114, 199, 0.06) 0%, transparent 50%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
letter-spacing: -0.003em;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Topbar (Apple frosted glass) ---------- */
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 22px;
|
||||||
|
height: 56px;
|
||||||
|
background: linear-gradient(180deg, rgba(79, 177, 247, 0.10) 0%, rgba(251, 251, 253, 0.78) 100%);
|
||||||
|
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||||
|
backdrop-filter: saturate(180%) blur(20px);
|
||||||
|
color: var(--ink);
|
||||||
|
border-bottom: 1px solid rgba(25, 114, 199, 0.18);
|
||||||
|
box-shadow: 0 1px 0 rgba(25, 114, 199, 0.06);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand { display: flex; align-items: center; gap: 10px; }
|
||||||
|
|
||||||
|
/* ---------- Hamburger menu + popover ---------- */
|
||||||
|
|
||||||
|
.menu-host { position: relative; }
|
||||||
|
|
||||||
|
.menu-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.menu-btn:hover { background: rgba(0, 0, 0, 0.06); }
|
||||||
|
.menu-btn:focus-visible { outline: 2px solid var(--kt-blue); outline-offset: 2px; }
|
||||||
|
.menu-btn span {
|
||||||
|
display: block;
|
||||||
|
width: 16px;
|
||||||
|
height: 1.5px;
|
||||||
|
background: var(--ink);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.menu-btn.open span:nth-child(1) { transform: translateY(5.5px) rotate(45deg); }
|
||||||
|
.menu-btn.open span:nth-child(2) { opacity: 0; }
|
||||||
|
.menu-btn.open span:nth-child(3) { transform: translateY(-5.5px) rotate(-45deg); }
|
||||||
|
|
||||||
|
.menu-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 12px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 340px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||||
|
backdrop-filter: saturate(180%) blur(20px);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
box-shadow: var(--sh-lg);
|
||||||
|
padding: 8px;
|
||||||
|
z-index: 100;
|
||||||
|
animation: menuPop 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.menu-panel[hidden] { display: none; }
|
||||||
|
@keyframes menuPop {
|
||||||
|
from { opacity: 0; transform: translateY(-6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--ink);
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.menu-item:hover { background: var(--surface-2); }
|
||||||
|
.menu-item.active { background: rgba(25, 114, 199, 0.10); }
|
||||||
|
.menu-item.active .menu-title { color: var(--kt-blue); }
|
||||||
|
.menu-item.active .menu-icon { background: var(--kt-blue); color: #fff; }
|
||||||
|
|
||||||
|
.menu-icon {
|
||||||
|
width: 38px; height: 38px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--ink);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||||
|
.menu-title { font-size: 15px; font-weight: 500; line-height: 1.2; letter-spacing: -0.01em; }
|
||||||
|
.menu-sub { font-size: 12px; color: var(--muted); line-height: 1.3; }
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
filter: drop-shadow(0 2px 6px rgba(25, 114, 199, 0.30));
|
||||||
|
}
|
||||||
|
.brand-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--kt-blue);
|
||||||
|
}
|
||||||
|
.brand-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 400;
|
||||||
|
margin-top: 1px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
|
body[data-view="home"] .who { display: none; }
|
||||||
|
.who label { color: var(--muted); font-size: 12px; font-weight: 400; }
|
||||||
|
.who select {
|
||||||
|
padding: 6px 28px 6px 12px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%2386868B' stroke-width='1.5'><path d='M3 5l3 3 3-3'/></svg>");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 10px center;
|
||||||
|
background-size: 10px 10px;
|
||||||
|
}
|
||||||
|
.who select:hover { background-color: var(--surface-2); border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
/* ---------- Layout (Apple spacing) ---------- */
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 40px auto 80px;
|
||||||
|
padding: 0 22px;
|
||||||
|
display: grid;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(25, 114, 199, 0.08);
|
||||||
|
border-radius: var(--r-xl);
|
||||||
|
padding: 32px 36px;
|
||||||
|
box-shadow: 0 1px 3px rgba(25, 114, 199, 0.05), 0 4px 16px rgba(25, 114, 199, 0.04);
|
||||||
|
}
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 22px;
|
||||||
|
padding-left: 14px;
|
||||||
|
border-left: 3px solid var(--kt-blue);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Form (Apple-style inputs) ---------- */
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px 24px;
|
||||||
|
}
|
||||||
|
.grid label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink-2);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.grid label.full { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
.grid input, .grid select, .grid textarea {
|
||||||
|
padding: 13px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface-2);
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
font-weight: 400;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
.grid input:hover, .grid select:hover, .grid textarea:hover {
|
||||||
|
background: #efeff1;
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
.grid input:focus, .grid select:focus, .grid textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
background: var(--surface);
|
||||||
|
border-color: var(--kt-blue);
|
||||||
|
box-shadow: 0 0 0 4px rgba(25, 114, 199, 0.18);
|
||||||
|
}
|
||||||
|
.grid select {
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%2386868B' stroke-width='1.5'><path d='M3 5l3 3 3-3'/></svg>");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 14px center;
|
||||||
|
background-size: 12px 12px;
|
||||||
|
padding-right: 38px;
|
||||||
|
}
|
||||||
|
.grid textarea { resize: vertical; min-height: 80px; line-height: 1.5; }
|
||||||
|
|
||||||
|
.actions { display: flex; align-items: center; gap: 18px; }
|
||||||
|
.hint { color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
/* ---------- Buttons (Apple pill) ---------- */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 11px 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease, transform 0.08s ease;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.btn:active { transform: scale(0.97); }
|
||||||
|
.btn:focus-visible { outline: 4px solid rgba(25, 114, 199, 0.30); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--kt-blue);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: var(--kt-blue-mid); }
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: var(--ok);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-success:hover { background: #2DB14F; }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: rgba(255, 59, 48, 0.10);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: var(--danger); color: #fff; }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { background: #EBEBED; }
|
||||||
|
|
||||||
|
.btn-sm { font-size: 13px; padding: 6px 14px; font-weight: 500; }
|
||||||
|
.btn-icon { padding: 6px 10px; }
|
||||||
|
|
||||||
|
/* ---------- Dashboard tiles ---------- */
|
||||||
|
|
||||||
|
.dashboard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.tile {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--sh-sm);
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.tile:hover { transform: translateY(-2px); box-shadow: var(--sh-md); }
|
||||||
|
.tile::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background: var(--kt-blue);
|
||||||
|
}
|
||||||
|
.tile.danger::before { background: var(--danger); }
|
||||||
|
.tile.wide::before { background: var(--kt-accent); }
|
||||||
|
|
||||||
|
.tile .num { font-size: 32px; font-weight: 700; line-height: 1; color: var(--ink); }
|
||||||
|
.tile.danger .num { color: var(--danger); }
|
||||||
|
.tile .lbl { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.07em; font-weight: 600; }
|
||||||
|
|
||||||
|
.load { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 4px; }
|
||||||
|
.load .chip {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: none;
|
||||||
|
color: var(--ink);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.load .chip-btn {
|
||||||
|
font-family: inherit;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 8px 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease, transform 0.08s ease;
|
||||||
|
}
|
||||||
|
.load .chip-btn:hover { background: #EBEBED; }
|
||||||
|
.load .chip-btn:active { transform: scale(0.97); }
|
||||||
|
.load .chip-btn.active {
|
||||||
|
background: var(--kt-blue);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.load .chip-btn:focus-visible { outline: 4px solid rgba(25, 114, 199, 0.30); outline-offset: 2px; }
|
||||||
|
.load .chip-count {
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
color: var(--ink-2);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
padding: 2px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 22px;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.load .chip-btn.active .chip-count {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Filters ---------- */
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.filters select {
|
||||||
|
padding: 8px 32px 8px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--surface);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%2386868B' stroke-width='1.5'><path d='M3 5l3 3 3-3'/></svg>");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 12px center;
|
||||||
|
background-size: 10px 10px;
|
||||||
|
}
|
||||||
|
.filters select:hover { background-color: var(--surface-2); }
|
||||||
|
.filters select:focus { outline: none; border-color: var(--kt-blue); box-shadow: 0 0 0 4px rgba(25,114,199,0.18); }
|
||||||
|
|
||||||
|
/* ---------- Table (Apple-style минимализм) ---------- */
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 16px 14px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: transparent;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
tbody tr { transition: background 0.15s ease; }
|
||||||
|
tbody tr:hover td { background: var(--surface-2); }
|
||||||
|
tr.overdue td { background: rgba(255, 59, 48, 0.05); }
|
||||||
|
tr.overdue:hover td { background: rgba(255, 59, 48, 0.10); }
|
||||||
|
tr.done td { color: var(--muted); }
|
||||||
|
tr.done td.title { text-decoration: line-through; }
|
||||||
|
tr.prio-high td.title { font-weight: 600; color: var(--ink); }
|
||||||
|
|
||||||
|
td.title { max-width: 380px; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 11px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.badge.high { background: rgba(255, 59, 48, 0.12); color: var(--danger); }
|
||||||
|
.badge.medium { background: rgba(255, 149, 0, 0.14); color: var(--warn); }
|
||||||
|
.badge.low { background: rgba(52, 199, 89, 0.14); color: var(--ok); }
|
||||||
|
.badge.status-new { background: rgba(25, 114, 199, 0.12); color: var(--kt-blue); }
|
||||||
|
.badge.status-in_progress { background: rgba(255, 149, 0, 0.14); color: var(--warn); }
|
||||||
|
.badge.status-done { background: rgba(52, 199, 89, 0.14); color: var(--ok); }
|
||||||
|
.badge.status-overdue { background: rgba(255, 59, 48, 0.12); color: var(--danger); }
|
||||||
|
|
||||||
|
td.actions-cell { white-space: nowrap; display: flex; gap: 8px; flex-wrap: nowrap; }
|
||||||
|
|
||||||
|
.prio-select {
|
||||||
|
padding: 5px 24px 5px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%2386868B' stroke-width='1.5'><path d='M3 5l3 3 3-3'/></svg>");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center;
|
||||||
|
background-size: 9px 9px;
|
||||||
|
}
|
||||||
|
.prio-select:hover { background-color: var(--surface-2); }
|
||||||
|
.prio-select:focus { outline: none; border-color: var(--kt-blue); box-shadow: 0 0 0 3px rgba(25,114,199,0.15); }
|
||||||
|
|
||||||
|
/* ---------- Flatpickr (большой брендированный календарь) ---------- */
|
||||||
|
|
||||||
|
.flatpickr-input.form-control.input,
|
||||||
|
.flatpickr-alt-input,
|
||||||
|
input.flatpickr-input {
|
||||||
|
padding: 12px 14px !important;
|
||||||
|
border: 1px solid var(--border-strong) !important;
|
||||||
|
border-radius: var(--r-sm) !important;
|
||||||
|
font-size: 15px !important;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit !important;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #fff
|
||||||
|
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%231473C7' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><rect x='3' y='4' width='18' height='18' rx='2'/><line x1='16' y1='2' x2='16' y2='6'/><line x1='8' y1='2' x2='8' y2='6'/><line x1='3' y1='10' x2='21' y2='10'/></svg>") no-repeat right 12px center;
|
||||||
|
background-size: 18px 18px;
|
||||||
|
padding-right: 40px !important;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.flatpickr-alt-input:hover { border-color: var(--ink-2) !important; }
|
||||||
|
.flatpickr-alt-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--kt-blue) !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(30, 143, 219, 0.18) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-calendar {
|
||||||
|
width: 360px !important;
|
||||||
|
font-family: 'Inter', sans-serif !important;
|
||||||
|
border-radius: var(--r-lg) !important;
|
||||||
|
box-shadow: var(--sh-lg) !important;
|
||||||
|
border: 1px solid var(--border) !important;
|
||||||
|
padding: 8px !important;
|
||||||
|
}
|
||||||
|
.flatpickr-calendar.arrowTop:before,
|
||||||
|
.flatpickr-calendar.arrowTop:after { border-bottom-color: var(--kt-blue) !important; }
|
||||||
|
|
||||||
|
.flatpickr-months {
|
||||||
|
background: linear-gradient(135deg, var(--kt-blue) 0%, var(--kt-blue-dark) 100%);
|
||||||
|
border-radius: var(--r-md) var(--r-md) 0 0;
|
||||||
|
padding: 12px 8px;
|
||||||
|
margin: -8px -8px 8px;
|
||||||
|
}
|
||||||
|
.flatpickr-months .flatpickr-month,
|
||||||
|
.flatpickr-current-month,
|
||||||
|
.flatpickr-current-month .flatpickr-monthDropdown-months,
|
||||||
|
.flatpickr-current-month input.cur-year,
|
||||||
|
.flatpickr-monthDropdown-month {
|
||||||
|
color: #fff !important;
|
||||||
|
fill: #fff !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
.flatpickr-current-month .flatpickr-monthDropdown-months {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.flatpickr-monthDropdown-month { color: var(--ink) !important; }
|
||||||
|
.flatpickr-months .flatpickr-prev-month,
|
||||||
|
.flatpickr-months .flatpickr-next-month {
|
||||||
|
color: #fff !important;
|
||||||
|
fill: #fff !important;
|
||||||
|
padding: 8px !important;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
}
|
||||||
|
.flatpickr-months .flatpickr-prev-month:hover,
|
||||||
|
.flatpickr-months .flatpickr-next-month:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: #fff !important;
|
||||||
|
fill: #fff !important;
|
||||||
|
}
|
||||||
|
.flatpickr-months .flatpickr-prev-month svg,
|
||||||
|
.flatpickr-months .flatpickr-next-month svg { fill: #fff !important; }
|
||||||
|
|
||||||
|
.flatpickr-weekdays {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
.flatpickr-weekday {
|
||||||
|
color: var(--muted) !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-days { width: 100% !important; }
|
||||||
|
.dayContainer {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 4px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.flatpickr-day {
|
||||||
|
border-radius: var(--r-sm) !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
height: 40px !important;
|
||||||
|
line-height: 40px !important;
|
||||||
|
max-width: calc(100% / 7 - 2px) !important;
|
||||||
|
margin: 1px;
|
||||||
|
color: var(--ink);
|
||||||
|
border: none !important;
|
||||||
|
transition: background 0.12s ease, color 0.12s ease;
|
||||||
|
}
|
||||||
|
.flatpickr-day:hover {
|
||||||
|
background: #e7f1fb !important;
|
||||||
|
color: var(--kt-blue-darker) !important;
|
||||||
|
}
|
||||||
|
.flatpickr-day.today {
|
||||||
|
background: #fff4d6 !important;
|
||||||
|
color: var(--warn) !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
}
|
||||||
|
.flatpickr-day.today:hover { background: #ffe9a8 !important; }
|
||||||
|
.flatpickr-day.selected,
|
||||||
|
.flatpickr-day.selected:hover {
|
||||||
|
background: var(--kt-blue) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
box-shadow: 0 2px 6px rgba(20, 115, 199, 0.35);
|
||||||
|
}
|
||||||
|
.flatpickr-day.flatpickr-disabled,
|
||||||
|
.flatpickr-day.prevMonthDay,
|
||||||
|
.flatpickr-day.nextMonthDay {
|
||||||
|
color: var(--border-strong) !important;
|
||||||
|
}
|
||||||
|
.flatpickr-day.flatpickr-disabled:hover { background: transparent !important; }
|
||||||
|
|
||||||
|
/* ---------- Home (Apple hero-style) ---------- */
|
||||||
|
|
||||||
|
.brand { text-decoration: none; color: inherit; }
|
||||||
|
.brand:hover .brand-name { opacity: 0.7; }
|
||||||
|
|
||||||
|
.home-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1fr 1fr;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-clock,
|
||||||
|
.home-weather {
|
||||||
|
color: var(--ink);
|
||||||
|
border: none;
|
||||||
|
padding: 48px 44px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 320px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.home-clock { background: var(--surface); }
|
||||||
|
.home-weather {
|
||||||
|
background: linear-gradient(180deg, rgba(79, 177, 247, 0.18) 0%, rgba(45, 149, 226, 0.06) 60%, #FBFBFD 100%);
|
||||||
|
}
|
||||||
|
.home-clock .lbl,
|
||||||
|
.home-weather .lbl {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-date {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--ink-2);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.clock-time {
|
||||||
|
font-size: 96px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.clock-tz {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-loading, .weather-error { font-size: 15px; color: var(--muted); }
|
||||||
|
.weather-error { color: var(--danger); }
|
||||||
|
.weather-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.weather-icon { font-size: 80px; line-height: 1; }
|
||||||
|
.weather-temp {
|
||||||
|
font-size: 96px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.weather-label {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--ink-2);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.weather-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 22px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.weather-src {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: auto;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.news-header h2 { margin: 0; }
|
||||||
|
|
||||||
|
.news-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.news-loading { padding: 16px 0; color: var(--muted); font-size: 15px; }
|
||||||
|
.news-link {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 22px 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--ink);
|
||||||
|
border-top: 1px solid var(--hairline);
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.news-item:first-child .news-link { border-top: none; }
|
||||||
|
.news-link:hover { color: var(--kt-blue); }
|
||||||
|
.news-link:focus-visible { outline: 2px solid var(--kt-blue); outline-offset: 2px; border-radius: var(--r-sm); }
|
||||||
|
|
||||||
|
.news-title {
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.news-meta { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
|
||||||
|
.news-date {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.news-arrow {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
transition: color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.news-link:hover .news-arrow { color: var(--kt-blue); transform: translate(3px, -3px); }
|
||||||
|
.news-link:hover .news-date { color: var(--kt-blue); }
|
||||||
|
|
||||||
|
.news-host {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
.news-link:hover .news-host { color: var(--kt-blue); background: rgba(0, 113, 227, 0.08); }
|
||||||
|
|
||||||
|
.news-empty {
|
||||||
|
padding: 48px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.news-empty-title { font-size: 19px; font-weight: 500; color: var(--ink); margin-bottom: 10px; letter-spacing: -0.01em; }
|
||||||
|
.news-empty-sub { font-size: 15px; color: var(--muted); line-height: 1.5; max-width: 480px; margin: 0 auto; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.home-grid { grid-template-columns: 1fr; }
|
||||||
|
.clock-time { font-size: 48px; }
|
||||||
|
.weather-temp { font-size: 44px; }
|
||||||
|
.weather-icon { font-size: 44px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Toast ---------- */
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
background: #0f1c2e;
|
||||||
|
color: #fff;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
box-shadow: var(--sh-lg);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 1000;
|
||||||
|
max-width: 420px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
transition: opacity 0.22s ease, transform 0.22s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
border-left: 4px solid var(--kt-blue);
|
||||||
|
}
|
||||||
|
.toast.visible { opacity: 1; transform: translateY(0); }
|
||||||
|
.toast.toast-success { border-left-color: #1a7f37; }
|
||||||
|
.toast.toast-error { border-left-color: var(--danger); }
|
||||||
|
|
||||||
|
/* ---------- Workload view (Apple-style KPI + charts) ---------- */
|
||||||
|
|
||||||
|
.view { display: grid; gap: 28px; }
|
||||||
|
.view[hidden] { display: none; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
.kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.kpi {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: 24px 26px;
|
||||||
|
}
|
||||||
|
.kpi-num {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.kpi-lbl {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.kpi:nth-child(2) .kpi-num { color: var(--danger); }
|
||||||
|
.kpi:nth-child(3) .kpi-num { color: var(--warn); }
|
||||||
|
.kpi:nth-child(4) .kpi-num { color: var(--ok); }
|
||||||
|
|
||||||
|
.chart-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 340px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.chart-wrap.small { height: 280px; }
|
||||||
|
|
||||||
|
.charts-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.kpis { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.charts-row { grid-template-columns: 1fr; }
|
||||||
|
.chart-wrap, .chart-wrap.small { height: 280px; }
|
||||||
|
.menu-panel { min-width: 280px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Responsive ---------- */
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
.dashboard { grid-template-columns: 1fr 1fr; }
|
||||||
|
.topbar { padding: 12px 16px; }
|
||||||
|
main { padding: 0 12px; margin: 16px auto; }
|
||||||
|
.card { padding: 16px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 540px) {
|
||||||
|
.dashboard { grid-template-columns: 1fr; }
|
||||||
|
.brand-sub { display: none; }
|
||||||
|
table { font-size: 12px; }
|
||||||
|
th, td { padding: 8px 6px; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user