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:
sultan 2026-05-22 14:26:18 +05:00
parent ff6ef82501
commit 57af72f514
5 changed files with 2212 additions and 0 deletions

50
PRD.md
View File

@ -0,0 +1,50 @@
# PRD: Помощник руководителя по управлению поручениями
## Что я хочу автоматизировать
Сбор, сортировку и контроль исполнения всех моих поручений (своих и поступающих извне) в одном месте, чтобы повысить эффективность управления департаментом по взаимодействию с государственными органами.
## Кто будет пользоваться
- **Я** — директор департамента по взаимодействию с государственными органами АО «Казахтелеком». Создаю поручения, распределяю, контролирую исполнение.
- **8 сотрудников моего департамента** — получают поручения, видят свои задачи и задачи коллег, отмечают статус выполнения.
- Доступ — только ограниченный круг лиц департамента.
## Что должно произойти на выходе
Я в любой момент вижу полную картину: какие поручения в работе, у кого, насколько срочные, по какому направлению. Каждый сотрудник понимает свой список задач, дедлайны и приоритеты. Ничего не теряется, ничего не забывается, ничего не просрочивается без моего ведома.
Конкретно:
- **Утром** — список приоритетных задач на день (мой и по департаменту).
- **Перед дедлайном** — автоматическое напоминание мне и исполнителю.
- **Раз в неделю** — отчёт: что сделано, что в работе, что просрочено.
## Главные функции
1. **Быстрый ввод поручения из любого источника.**
Помощник принимает поручение голосом (диктовка после совещания или из машины), текстом, фотографией протокола или пересланным сообщением из мессенджера. Главное — мысль из головы быстро попадает в систему, без бюрократии.
2. **Автоматическая сортировка поручений.**
Помощник сам определяет:
- **Срочность и важность** (по дедлайну, по уровню инициатора — поручение от Премьера/Министра автоматически выше). Я могу вручную поднять приоритет особо важных задач.
- **Метод отработки**: работа с госорганами / работа внутри компании / делаю сам / другое.
- **Направление**: МИИЦР, Минфин, Комитет телекоммуникаций, регуляторная работа (НПА) и т.д.
3. **Назначение исполнителей и контроль статуса.**
Я передаю поручение конкретному сотруднику (или нескольким) одним нажатием. Сотрудник получает уведомление, видит задачу в своём списке, отмечает прогресс. Я в любой момент вижу, у кого что висит и на какой стадии.
4. **Уведомления и напоминания.**
- Сотруднику — при назначении задачи и за день до дедлайна.
- Мне — утренний список приоритетов, предупреждение о приближающихся и просроченных задачах.
- Эскалация — если задача просрочена, я узнаю сразу.
5. **Отчёты и обзор картины.**
- **Ежедневно**: список на сегодня.
- **Еженедельно**: что выполнено, что в работе, что просрочено, кто загружен сильнее всех.
- **По запросу**: все поручения по конкретному госоргану, направлению или сотруднику.
## Что НЕ входит в задачу
- **Не заменяет официальную СЭД** (систему электронного документооборота) «Казахтелекома» — не работает с официальными исходящими письмами, подписями, регистрацией документов.
- **Не ведёт переписку с госорганами от моего имени** — не отправляет письма, не пишет ответы, не общается с внешними людьми.
- **Не хранит конфиденциальные документы и НПА** — это инструмент для управления задачами, не файловое хранилище и не юридическая база.
- **Не автоматизирует содержательную работу** (не пишет аналитические записки, не готовит позиции по НПА, не принимает решения за меня).
- **Не контролирует сотрудников других департаментов** — только мой департамент (8 человек).
- **Не интегрируется на старте с внешними системами** (почта, CRM, кадровые системы) — это можно добавить позже.

148
TRD.md
View File

@ -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
View 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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&current=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
View 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
View 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; }
}