Compare commits

...

1 Commits
main ... pages

6 changed files with 10217 additions and 0 deletions

173
AGENTS.md Normal file
View File

@ -0,0 +1,173 @@
<!-- vibe42-agents-version: v3-guided-2026-06-01 -->
# Vibe42 — учебная песочница для лендингов
Workspace юзера `baglan`. Это **учебная среда**, где обычные люди (не разработчики) пробуют сделать свой первый сайт.
---
## 🎯 ТВОЯ РОЛЬ
Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с:
1. **рабочим лендингом**, опубликованным по адресу `https://pages.git.vibe42.kz/baglan/<repo>/`,
2. ощущением «это было легко» — без серверов, БД, токенов, конфигов.
Юзер не разработчик. Ему важен **результат, который видно в браузере**, а не код.
---
## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет)
1. Поздоровайся коротко: «Привет! Тут за 10 минут собираем лендинг и публикуем его в интернете. О чём хочешь сделать?»
2. Если он не знает — предложи **4 конкретных идеи** (выбирай близкие к нему, не абстрактные):
- Промо хобби (фотография / музыка / спорт)
- Резюме / personal page с контактами
- Афиша мероприятия (концерт, день рождения, мастер-класс)
- Меню заведения / прайс услуг
- Лендинг продукта или будущего проекта (waitlist)
3. Уточни **2 короткие детали**: стиль (тёмный/светлый/яркий) и главную цель (рассказать / собрать заявку / показать работы).
4. Сразу делай `./new-project <name>` и собирай страницу. Не спрашивай разрешения на каждый шаг.
---
## 💬 ЕСЛИ ЮЗЕР ОТВЕЧАЕТ РАСПЛЫВЧАТО
Юзер говорит «сделай что-нибудь» / «ну хз» / «сюрприз» → **не делай ничего абстрактного**.
Скажи: «Давай определимся, я задам 3 коротких вопроса:
1. Это для тебя лично, для проекта/бизнеса, или для события?
2. Главная цель — рассказать о чём-то / собрать заявку / показать портфолио?
3. Любимое настроение — строгое тёмное, лёгкое светлое, яркое цветное?»
После ответов **сразу** предложи 2 конкретных варианта названия+структуры. Дай выбрать и иди делать.
---
## 🚫 ЕСЛИ ЮЗЕР ХОЧЕТ СЛОЖНОЕ — ПЕРЕФОРМУЛИРУЙ В ЛЕНДИНГ
| Запрос | Что делаем вместо |
|--------|-------------------|
| «магазин с корзиной» | лендинг с товарами + кнопка «купить» = ссылка на WhatsApp / Telegram |
| «соцсеть» | лендинг будущего проекта + waitlist-форма (Formspree / Getform) |
| «блог с админкой» | personal-page + ссылки на статьи в Telegram/Medium |
| «приложение для записи» | лендинг услуги + ссылка на Calendly / WhatsApp |
| «сайт с входом юзеров» | публичный лендинг без логина (нам логин не нужен) |
| «бот в Telegram» | лендинг с описанием бота + кнопка `t.me/...` |
**Не говори «это невозможно».** Скажи: «У нас песочница только для статических сайтов. Давай сделаем лендинг, который покажет твою идею — а кнопки/формы свяжем с готовыми сервисами (WhatsApp, Telegram, Formspree)». Юзер счастлив, результат за 15 минут.
---
## 📐 ШАБЛОНЫ СТРАНИЦ (выбирай под идею юзера)
### A — Промо продукта/услуги
**Секции:** Hero (заголовок + подзаголовок + CTA-кнопка) → 3-4 преимущества (иконка emoji + текст) → социальное доказательство (отзыв или цифра) → CTA (кнопка/телефон/мессенджер).
### B — Personal / резюме
**Секции:** Hero (фото-аватарка + имя + одна фраза «кто я») → О себе (1-2 абзаца) → 3-5 карточек проектов/опыта → Контакты (email, telegram, github как ссылки-кнопки).
### C — Афиша мероприятия
**Секции:** Hero (название + дата + место крупно) → Программа (список с временем) → Локация (картинка-placeholder + адрес) → Регистрация (форма Formspree или контакт).
### D — Меню / прайс
**Секции:** Hero (название + слоган) → Меню/прайс (категории с ценами) → Контакты (телефон, адрес, часы работы, карта-картинка).
### E — Waitlist для будущего проекта
**Секции:** Hero (название проекта + одна фраза + email-форма) → 3 фичи «что будет» → FAQ (3 пункта) → CTA (та же email-форма).
Все шаблоны — **одна страница, прокрутка вниз**. Никаких роутов, ничего динамического.
---
## ⚡ РИТУАЛ ПОСЛЕ ПЕРВОГО ЗАПУСКА
Как только готов первый рабочий вариант (даже грубый):
1. **Сразу запушь:**
```bash
git add -A
git commit -m "v1"
git push origin HEAD:pages
```
2. **ОБЯЗАТЕЛЬНО** дай юзеру ссылку **жирно**:
> 🎉 Готово! Твой лендинг здесь: **https://pages.git.vibe42.kz/baglan/<repo>/**
3. Скажи: «Открой в новой вкладке, посмотри. Что хочешь поменять?»
4. Дальше короткие итерации: правка → push → новый URL-показ. Каждые 2-3 правки — push.
---
## ⚠️ ЖЕЛЕЗНЫЕ ПРАВИЛА (НЕ нарушать никогда)
1. **Только статика — HTML + CSS + JS в браузере.**
2. **Никакого бэкенда.** Никаких Node/Express/FastAPI/Django/PHP/Go-серверов. Никаких БД. Никакого Redis.
3. **Никакой аутентификации / OAuth / JWT.**
4. **Никакого Docker, nginx, sudo, системных настроек.**
5. **Никаких тяжёлых сборщиков** (`npm install` дерево на 500МБ). Tailwind — только через CDN.
6. **НИКОГДА `git init` в workspace root (`/workspaces/baglan`)** — это папка-контейнер юзера, не репозиторий.
---
## ✅ ВСЕГДА работай через `./new-project`
Если юзер сказал «сделай сайт NAME» / «создай проект NAME»:
```bash
cd /workspaces/baglan
./new-project NAME # создаёт repo в Gitea + клонит локально в ./NAME/
cd NAME
# теперь создавай index.html / style.css / script.js внутри ./NAME
```
`./new-project` сам создаёт repo, клонит, и копирует туда `AGENTS.md` + `design.md`.
---
## 🌐 Git и публикация
**НЕТ GitHub.** Self-hosted git: **https://git.vibe42.kz**
- Профиль юзера: https://git.vibe42.kz/baglan
- Pages (живые лендинги): https://pages.git.vibe42.kz/baglan/<repo>/
- Креды уже в `/workspaces/baglan/.git-credentials` — git push/clone работают без пароля
- **НЕ спрашивай юзера про GitHub URL / токен** — их не нужно
### Опубликовать лендинг
```bash
git add -A
git commit -m "site"
git push origin HEAD:pages
```
Ветка **`pages`** (Caddy её обслуживает; `gh-pages` тоже работает как fallback). Push → лендинг доступен мгновенно.
---
## 🔧 Когда что-то идёт не так
- **Pages 404** → запушь ветку `pages` снова: `git push origin HEAD:pages -f`
- **Не дёргай Gitea API типа `/repos/.../pages`, `/settings/pages`, `/deploy_keys`** — их нет
- **Не пытайся «настроить Pages через UI Gitea»** — Pages у нас работают только через push в ветку `pages`
- Запуталось — сделай новый чистый проект через `./new-project NAME-v2`, перенеси туда работающий index.html
---
## ❌ Чего НЕ делать НИКОГДА
- ❌ `git init` в workspace root
- ❌ `npm install` с прод-зависимостями (express/mongoose/pg/prisma/next/nuxt)
- ❌ Создавать `server.js` / `app.py` / `main.go` как backend
- ❌ Использовать `gh` CLI или GitHub API
- ❌ Вызывать Gitea Pages-API (его нет)
- ❌ Долгое отлаживание Pages — почти всегда решение «push HEAD:pages»
- ❌ Просить юзера ввести токен/URL/пароль — всё уже настроено
- ❌ Задавать юзеру 10 вопросов подряд (максимум 2-3 за раз)
- ❌ Показывать юзеру голый код больше 1 раза — ему важен результат, а не как написано
- ❌ Предлагать «давай сначала дизайн в Figma» — мы делаем сразу в HTML
- ❌ Говорить «это сложно» — переформулируй в простое
- ❌ Зависать в обсуждениях — сделай первый вариант грубо, потом итерируй
---
## 🎨 design.md
Рядом лежит `design.md` с готовой палитрой, типографикой и стартер-шаблоном `index.html`. **Начинай с него.** Не выдумывай новые цвета — модифицируй существующие.

546
app.js Normal file
View File

@ -0,0 +1,546 @@
// Глобальные данные
let allObjects = [];
let filteredObjects = [];
let map = null;
let markers = [];
// Загрузка данных
async function loadData() {
try {
const response = await fetch('data.json');
allObjects = await response.json();
filteredObjects = [...allObjects];
initDashboard();
initMap();
populateFilters();
renderObjects();
renderProblems();
renderAnalytics();
updateNotifications();
} catch (error) {
console.error('Ошибка загрузки данных:', error);
}
}
// Инициализация панели
function initDashboard() {
const total = allObjects.length;
const active = allObjects.filter(o => ['План', 'Обследование', 'СМР'].includes(o.status)).length;
const completed = allObjects.filter(o => ['Готов', 'Сдан'].includes(o.status)).length;
const problems = allObjects.filter(o => o.has_problem).length;
const overdue = allObjects.filter(o => o.is_overdue).length;
const portsPlan = allObjects.reduce((sum, o) => sum + (o.ports || 0), 0);
const portsFact = allObjects.filter(o => ['Готов', 'Сдан'].includes(o.status))
.reduce((sum, o) => sum + (o.ports || 0), 0);
const percent = total > 0 ? ((completed / total) * 100).toFixed(1) : 0;
document.getElementById('kpi-total').textContent = total;
document.getElementById('kpi-active').textContent = active;
document.getElementById('kpi-completed').textContent = completed;
document.getElementById('kpi-problems').textContent = problems;
document.getElementById('kpi-overdue').textContent = overdue;
document.getElementById('kpi-ports-plan').textContent = portsPlan.toLocaleString();
document.getElementById('kpi-ports-fact').textContent = portsFact.toLocaleString();
document.getElementById('kpi-percent').textContent = percent + '%';
// График статусов
const statusCounts = {};
allObjects.forEach(o => {
statusCounts[o.status] = (statusCounts[o.status] || 0) + 1;
});
const ctx = document.getElementById('statusChart').getContext('2d');
new Chart(ctx, {
type: 'doughnut',
data: {
labels: Object.keys(statusCounts),
datasets: [{
data: Object.values(statusCounts),
backgroundColor: [
'#6b7280', // План
'#f59e0b', // Обследование
'#0ea5e9', // СМР
'#ef4444', // Проблема
'#10b981', // Готов
'#10b981' // Сдан
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
}
// Инициализация карты
function initMap() {
map = L.map('map').setView([48.0196, 66.9237], 5);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Маркеры объектов
allObjects.forEach(obj => {
const [lat, lng] = obj.coordinates;
let color = '#1e40af';
if (obj.is_overdue) color = '#ef4444';
else if (obj.is_approaching) color = '#f59e0b';
else if (obj.status === 'Сдан') color = '#10b981';
else if (obj.status === 'Проблема') color = '#ef4444';
const marker = L.circleMarker([lat, lng], {
radius: 8,
fillColor: color,
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
});
marker.bindPopup(`
<div class="popup-content">
<h3>${obj.city}</h3>
<p>${obj.address}</p>
<p><strong>Портов:</strong> ${obj.ports}</p>
<p><strong>Статус:</strong> ${obj.status}</p>
<p><strong>Ответственный:</strong> ${obj.manager}</p>
</div>
`);
marker.on('click', () => openModal(obj));
markers.push(marker);
marker.addTo(map);
});
}
// Заполнение фильтров
function populateFilters() {
const regions = [...new Set(allObjects.map(o => o.region).filter(Boolean))].sort();
const cities = [...new Set(allObjects.map(o => o.city).filter(Boolean))].sort();
const managers = [...new Set(allObjects.map(o => o.manager).filter(Boolean))].sort();
const regionSelect = document.getElementById('filter-region');
const citySelect = document.getElementById('filter-city');
const managerSelect = document.getElementById('filter-manager');
regions.forEach(r => {
regionSelect.innerHTML += `<option value="${r}">${r}</option>`;
});
cities.forEach(c => {
citySelect.innerHTML += `<option value="${c}">${c}</option>`;
});
managers.forEach(m => {
managerSelect.innerHTML += `<option value="${m}">${m}</option>`;
});
}
// Фильтрация объектов
function filterObjects() {
const region = document.getElementById('filter-region').value;
const city = document.getElementById('filter-city').value;
const status = document.getElementById('filter-status').value;
const manager = document.getElementById('filter-manager').value;
const search = document.getElementById('search-input').value.toLowerCase();
filteredObjects = allObjects.filter(o => {
if (region && o.region !== region) return false;
if (city && o.city !== city) return false;
if (status && o.status !== status) return false;
if (manager && o.manager !== manager) return false;
if (search && !o.address.toLowerCase().includes(search) &&
!o.city.toLowerCase().includes(search)) return false;
return true;
});
renderObjects();
}
// Рендер объектов
function renderObjects() {
const container = document.getElementById('objects-list');
container.innerHTML = '';
filteredObjects.forEach(obj => {
const card = createObjectCard(obj);
container.innerHTML += card;
});
}
// Создание карточки объекта
function createObjectCard(obj) {
const overdueClass = obj.is_overdue ? 'overdue' : '';
const approachingClass = obj.is_approaching && !obj.is_overdue ? 'approaching' : '';
const statusClass = `status-${obj.status.toLowerCase().replace(/ /g, '-')}`;
return `
<div class="object-card ${overdueClass} ${approachingClass} ${statusClass}" onclick="openModal(${JSON.stringify(obj).replace(/"/g, '&quot;')})">
<div class="flex justify-between items-start mb-3">
<h3 class="font-bold text-lg text-dark">${obj.city}</h3>
<span class="status-badge ${obj.status.toLowerCase().replace(/ /g, '-')}">${obj.status}</span>
</div>
<p class="text-gray-600 text-sm mb-2">${obj.address}</p>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">Тип:</span>
<span class="font-medium">${obj.object_type}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Портов:</span>
<span class="font-medium">${obj.ports}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">План:</span>
<span class="font-medium">${formatDate(obj.plan_end_date)}</span>
</div>
${obj.fact_end_date ? `
<div class="flex justify-between">
<span class="text-gray-500">Факт:</span>
<span class="font-medium text-green-600">${formatDate(obj.fact_end_date)}</span>
</div>
` : ''}
<div class="flex justify-between">
<span class="text-gray-500">Ответственный:</span>
<span class="font-medium">${obj.manager}</span>
</div>
</div>
${obj.is_overdue ? `
<div class="mt-3 p-2 bg-red-50 rounded text-red-700 text-xs font-semibold">
⚠️ Просрочено на ${getDaysOverdue(obj.plan_end_date)} дн.
</div>
` : ''}
${obj.is_approaching && !obj.is_overdue ? `
<div class="mt-3 p-2 bg-yellow-50 rounded text-yellow-700 text-xs font-semibold">
⏰ Срок истекает через ${getDaysUntil(obj.plan_end_date)} дн.
</div>
` : ''}
</div>
`;
}
// Рендер проблемных объектов
function renderProblems() {
const problems = allObjects.filter(o => o.has_problem);
const container = document.getElementById('problems-list');
container.innerHTML = '';
problems.forEach(obj => {
container.innerHTML += createObjectCard(obj);
});
}
// Аналитика
function renderAnalytics() {
const regions = {};
allObjects.forEach(obj => {
if (!regions[obj.region]) {
regions[obj.region] = {
plan: 0,
fact: 0,
portsPlan: 0,
portsFact: 0
};
}
regions[obj.region].plan++;
if (['Готов', 'Сдан'].includes(obj.status)) {
regions[obj.region].fact++;
regions[obj.region].portsFact += obj.ports || 0;
}
regions[obj.region].portsPlan += obj.ports || 0;
});
const regionNames = Object.keys(regions);
const planData = regionNames.map(r => regions[r].plan);
const factData = regionNames.map(r => regions[r].fact);
const percentData = regionNames.map(r =>
regions[r].plan > 0 ? ((regions[r].fact / regions[r].plan) * 100).toFixed(1) : 0
);
const portsPlanData = regionNames.map(r => regions[r].portsPlan);
const portsFactData = regionNames.map(r => regions[r].portsFact);
// План vs Факт
new Chart(document.getElementById('regionsChart').getContext('2d'), {
type: 'bar',
data: {
labels: regionNames,
datasets: [
{
label: 'План',
data: planData,
backgroundColor: '#1e40af'
},
{
label: 'Факт',
data: factData,
backgroundColor: '#10b981'
}
]
},
options: {
responsive: true,
scales: {
y: { beginAtZero: true }
}
}
});
// Процент выполнения
new Chart(document.getElementById('percentChart').getContext('2d'), {
type: 'bar',
data: {
labels: regionNames,
datasets: [{
label: '% выполнения',
data: percentData,
backgroundColor: percentData.map(p => p >= 100 ? '#10b981' : p >= 50 ? '#f59e0b' : '#ef4444')
}]
},
options: {
responsive: true,
scales: {
y: { beginAtZero: true, max: 100 }
}
}
});
// Порты
new Chart(document.getElementById('portsChart').getContext('2d'), {
type: 'bar',
data: {
labels: regionNames,
datasets: [
{
label: 'Портов по плану',
data: portsPlanData,
backgroundColor: '#6366f1'
},
{
label: 'Портов факт',
data: portsFactData,
backgroundColor: '#10b981'
}
]
},
options: {
responsive: true,
scales: {
y: { beginAtZero: true }
}
}
});
}
// Уведомления
function updateNotifications() {
const overdue = allObjects.filter(o => o.is_overdue);
const approaching = allObjects.filter(o => o.is_approaching && !o.is_overdue);
const badge = document.getElementById('notif-badge');
badge.textContent = overdue.length + approaching.length;
const list = document.getElementById('notif-list');
list.innerHTML = '';
overdue.forEach(obj => {
list.innerHTML += `
<div class="notif-item danger" onclick="showObject(${obj.id}); toggleNotifications();">
<p class="font-semibold text-red-800">Просрочен: ${obj.city}</p>
<p class="text-sm text-red-600">${obj.address}</p>
<p class="text-xs text-red-500 mt-1">План: ${formatDate(obj.plan_end_date)}</p>
</div>
`;
});
approaching.forEach(obj => {
list.innerHTML += `
<div class="notif-item warning" onclick="showObject(${obj.id}); toggleNotifications();">
<p class="font-semibold text-yellow-800">Срок подходит: ${obj.city}</p>
<p class="text-sm text-yellow-600">${obj.address}</p>
<p class="text-xs text-yellow-500 mt-1">План: ${formatDate(obj.plan_end_date)}</p>
</div>
`;
});
if (overdue.length === 0 && approaching.length === 0) {
list.innerHTML = '<p class="text-gray-500 text-center py-4">Нет уведомлений</p>';
}
}
function toggleNotifications() {
const notif = document.getElementById('notifications');
notif.classList.toggle('hidden');
}
// Модальное окно
function openModal(obj) {
document.getElementById('modal-title').textContent = `${obj.city} - ${obj.address}`;
const content = document.getElementById('modal-content');
content.innerHTML = `
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="bg-gray-50 p-3 rounded-lg">
<p class="text-gray-500 text-sm">Тип объекта</p>
<p class="font-semibold">${obj.object_type}</p>
</div>
<div class="bg-gray-50 p-3 rounded-lg">
<p class="text-gray-500 text-sm">Статус</p>
<p class="font-semibold">${obj.status}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="bg-gray-50 p-3 rounded-lg">
<p class="text-gray-500 text-sm">Количество портов</p>
<p class="font-semibold text-2xl">${obj.ports}</p>
</div>
<div class="bg-gray-50 p-3 rounded-lg">
<p class="text-gray-500 text-sm">Проникновение</p>
<p class="font-semibold text-2xl">${obj.penetration || 0}%</p>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between border-b pb-2">
<span class="text-gray-600">Регион:</span>
<span class="font-medium">${obj.region}</span>
</div>
<div class="flex justify-between border-b pb-2">
<span class="text-gray-600">Адрес:</span>
<span class="font-medium">${obj.address}</span>
</div>
<div class="flex justify-between border-b pb-2">
<span class="text-gray-600">Ответственный:</span>
<span class="font-medium">${obj.manager}</span>
</div>
<div class="flex justify-between border-b pb-2">
<span class="text-gray-600">Дата начала:</span>
<span class="font-medium">${formatDate(obj.start_date)}</span>
</div>
<div class="flex justify-between border-b pb-2">
<span class="text-gray-600">Плановая дата:</span>
<span class="font-medium">${formatDate(obj.plan_end_date)}</span>
</div>
${obj.fact_end_date ? `
<div class="flex justify-between border-b pb-2">
<span class="text-gray-600">Фактическая дата:</span>
<span class="font-medium text-green-600">${formatDate(obj.fact_end_date)}</span>
</div>
` : ''}
<div class="flex justify-between border-b pb-2">
<span class="text-gray-600">Домов:</span>
<span class="font-medium">${obj.houses || 0}</span>
</div>
<div class="flex justify-between border-b pb-2">
<span class="text-gray-600">Квартир:</span>
<span class="font-medium">${obj.apartments || 0}</span>
</div>
</div>
${obj.problem_description ? `
<div class="bg-red-50 p-4 rounded-lg">
<p class="font-semibold text-red-800 mb-2">⚠️ Описание проблемы</p>
<p class="text-red-700">${obj.problem_description}</p>
</div>
` : ''}
${obj.comment ? `
<div class="bg-blue-50 p-4 rounded-lg">
<p class="font-semibold text-blue-800 mb-2">💬 Комментарий</p>
<p class="text-blue-700">${obj.comment}</p>
</div>
` : ''}
${obj.is_overdue ? `
<div class="bg-red-100 p-4 rounded-lg border-2 border-red-300">
<p class="font-bold text-red-800">🚨 ОБЪЕКТ ПРОСРОЧЕН</p>
<p class="text-red-700 mt-1">Просрочка: ${getDaysOverdue(obj.plan_end_date)} дн.</p>
</div>
` : ''}
${obj.is_approaching && !obj.is_overdue ? `
<div class="bg-yellow-100 p-4 rounded-lg border-2 border-yellow-300">
<p class="font-bold text-yellow-800">⏰ ВНИМАНИЕ: СРОК ПОДХОДИТ</p>
<p class="text-yellow-700 mt-1">Осталось дней: ${getDaysUntil(obj.plan_end_date)}</p>
</div>
` : ''}
</div>
`;
document.getElementById('object-modal').classList.remove('hidden');
}
function closeModal() {
document.getElementById('object-modal').classList.add('hidden');
}
function showObject(id) {
const obj = allObjects.find(o => o.id === id);
if (obj) openModal(obj);
}
// Переключение секций
function showSection(sectionId) {
document.querySelectorAll('.section').forEach(s => s.classList.add('hidden'));
document.getElementById(sectionId).classList.remove('hidden');
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
event.target.classList.add('active');
if (sectionId === 'objects') {
setTimeout(() => map.invalidateSize(), 300);
}
}
// Вспомогательные функции
function formatDate(dateStr) {
if (!dateStr) return '—';
const date = new Date(dateStr);
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function getDaysOverdue(dateStr) {
const today = new Date('2026-08-17');
const planDate = new Date(dateStr);
const diff = Math.floor((today - planDate) / (1000 * 60 * 60 * 24));
return diff > 0 ? diff : 0;
}
function getDaysUntil(dateStr) {
const today = new Date('2026-08-17');
const planDate = new Date(dateStr);
const diff = Math.floor((planDate - today) / (1000 * 60 * 60 * 24));
return diff > 0 ? diff : 0;
}
// Закрытие модального окна по клику вне
document.getElementById('object-modal').addEventListener('click', (e) => {
if (e.target.id === 'object-modal') closeModal();
});
// Закрытие уведомлений по клику вне
document.addEventListener('click', (e) => {
const notif = document.getElementById('notifications');
const btn = e.target.closest('button[onclick*="toggleNotifications"]');
if (!notif.contains(e.target) && !btn && !notif.classList.contains('hidden')) {
notif.classList.add('hidden');
}
});
// Инициализация при загрузке
document.addEventListener('DOMContentLoaded', loadData);

8827
data.json Normal file

File diff suppressed because it is too large Load Diff

111
design.md Normal file

File diff suppressed because one or more lines are too long

304
index.html Normal file
View File

@ -0,0 +1,304 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GPON Control KZ - Система контроля строительства</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="style.css">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#1e40af',
secondary: '#0ea5e9',
accent: '#f59e0b',
success: '#10b981',
danger: '#ef4444',
warning: '#f59e0b',
dark: '#1e293b',
light: '#f8fafc'
}
}
}
}
</script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body class="bg-gray-50">
<!-- Навигация -->
<nav class="bg-dark text-white shadow-lg sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4">
<div class="flex justify-between items-center h-16">
<div class="flex items-center space-x-3">
<svg class="w-8 h-8 text-secondary" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z"/>
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z"/>
</svg>
<span class="text-xl font-bold">GPON Control KZ</span>
</div>
<div class="hidden md:flex space-x-6">
<button onclick="showSection('dashboard')" class="nav-btn active">Главная</button>
<button onclick="showSection('objects')" class="nav-btn">Объекты</button>
<button onclick="showSection('problems')" class="nav-btn">Проблемные</button>
<button onclick="showSection('analytics')" class="nav-btn">Аналитика</button>
</div>
<div class="flex items-center space-x-4">
<button onclick="toggleNotifications()" class="relative">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
</svg>
<span id="notif-badge" class="absolute -top-2 -right-2 bg-danger text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">0</span>
</button>
<div class="text-sm">
<div class="font-semibold">Шорабек Б.</div>
<div class="text-gray-400 text-xs">Руководитель проекта</div>
</div>
</div>
</div>
</div>
</nav>
<!-- Уведомления -->
<div id="notifications" class="fixed right-4 top-20 z-50 hidden">
<div class="bg-white rounded-lg shadow-xl p-4 w-80 max-h-96 overflow-y-auto">
<h3 class="font-bold text-lg mb-3">Уведомления</h3>
<div id="notif-list"></div>
</div>
</div>
<!-- Главная панель -->
<section id="dashboard" class="section">
<div class="max-w-7xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold text-dark mb-6">Панель управления</h1>
<!-- KPI карточки -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-xl shadow-md p-6 border-l-4 border-primary">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-500 text-sm">Всего объектов</p>
<p id="kpi-total" class="text-3xl font-bold text-dark">0</p>
</div>
<div class="bg-blue-100 p-3 rounded-full">
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-md p-6 border-l-4 border-secondary">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-500 text-sm">В работе</p>
<p id="kpi-active" class="text-3xl font-bold text-dark">0</p>
</div>
<div class="bg-cyan-100 p-3 rounded-full">
<svg class="w-8 h-8 text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-md p-6 border-l-4 border-success">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-500 text-sm">Завершено</p>
<p id="kpi-completed" class="text-3xl font-bold text-dark">0</p>
</div>
<div class="bg-green-100 p-3 rounded-full">
<svg class="w-8 h-8 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-md p-6 border-l-4 border-danger">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-500 text-sm">Проблемные</p>
<p id="kpi-problems" class="text-3xl font-bold text-dark">0</p>
</div>
<div class="bg-red-100 p-3 rounded-full">
<svg class="w-8 h-8 text-danger" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-md p-6 border-l-4 border-warning">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-500 text-sm">Просрочено</p>
<p id="kpi-overdue" class="text-3xl font-bold text-dark">0</p>
</div>
<div class="bg-yellow-100 p-3 rounded-full">
<svg class="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-md p-6 border-l-4 border-purple-500">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-500 text-sm">Портов по плану</p>
<p id="kpi-ports-plan" class="text-3xl font-bold text-dark">0</p>
</div>
<div class="bg-purple-100 p-3 rounded-full">
<svg class="w-8 h-8 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-md p-6 border-l-4 border-green-500">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-500 text-sm">Портов факт</p>
<p id="kpi-ports-fact" class="text-3xl font-bold text-dark">0</p>
</div>
<div class="bg-green-100 p-3 rounded-full">
<svg class="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-md p-6 border-l-4 border-indigo-500">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-500 text-sm">% выполнения</p>
<p id="kpi-percent" class="text-3xl font-bold text-dark">0%</p>
</div>
<div class="bg-indigo-100 p-3 rounded-full">
<svg class="w-8 h-8 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
</svg>
</div>
</div>
</div>
</div>
<!-- Карта и статусы -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Карта -->
<div class="lg:col-span-2 bg-white rounded-xl shadow-md p-6">
<h2 class="text-xl font-bold text-dark mb-4">Карта объектов Казахстана</h2>
<div id="map" class="h-96 rounded-lg"></div>
</div>
<!-- Статусы -->
<div class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-xl font-bold text-dark mb-4">Статусы объектов</h2>
<div id="status-chart-container" class="h-64">
<canvas id="statusChart"></canvas>
</div>
</div>
</div>
</div>
</section>
<!-- Объекты -->
<section id="objects" class="section hidden">
<div class="max-w-7xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold text-dark mb-6">Все объекты</h1>
<!-- Фильтры -->
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<select id="filter-region" onchange="filterObjects()" class="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary">
<option value="">Все регионы</option>
</select>
<select id="filter-city" onchange="filterObjects()" class="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary">
<option value="">Все города</option>
</select>
<select id="filter-status" onchange="filterObjects()" class="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary">
<option value="">Все статусы</option>
<option value="План">План</option>
<option value="Обследование">Обследование</option>
<option value="СМР">СМР</option>
<option value="Проблема">Проблема</option>
<option value="Готов">Готов</option>
<option value="Сдан">Сдан</option>
</select>
<select id="filter-manager" onchange="filterObjects()" class="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary">
<option value="">Все ответственные</option>
</select>
</div>
<div class="mt-4">
<input type="text" id="search-input" oninput="filterObjects()" placeholder="Поиск по адресу или названию..." class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary">
</div>
</div>
<!-- Список объектов -->
<div id="objects-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"></div>
</div>
</section>
<!-- Проблемные объекты -->
<section id="problems" class="section hidden">
<div class="max-w-7xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold text-dark mb-6 flex items-center">
<svg class="w-8 h-8 text-danger mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
Проблемные объекты
</h1>
<div id="problems-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"></div>
</div>
</section>
<!-- Аналитика -->
<section id="analytics" class="section hidden">
<div class="max-w-7xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold text-dark mb-6">Аналитика по регионам</h1>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<div class="bg-white rounded-xl shadow-md p-6">
<h3 class="text-lg font-bold mb-4">План vs Факт по регионам</h3>
<canvas id="regionsChart"></canvas>
</div>
<div class="bg-white rounded-xl shadow-md p-6">
<h3 class="text-lg font-bold mb-4">Процент выполнения по регионам</h3>
<canvas id="percentChart"></canvas>
</div>
</div>
<div class="bg-white rounded-xl shadow-md p-6">
<h3 class="text-lg font-bold mb-4">Количество портов по регионам</h3>
<canvas id="portsChart"></canvas>
</div>
</div>
</section>
<!-- Модальное окно объекта -->
<div id="object-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="p-6">
<div class="flex justify-between items-start mb-4">
<h2 id="modal-title" class="text-2xl font-bold text-dark"></h2>
<button onclick="closeModal()" class="text-gray-500 hover:text-gray-700">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div id="modal-content"></div>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

256
style.css Normal file
View File

@ -0,0 +1,256 @@
/* Основные стили */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.section {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Навигация */
.nav-btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: all 0.3s;
background: transparent;
color: #cbd5e1;
border: none;
cursor: pointer;
font-size: 0.95rem;
}
.nav-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.nav-btn.active {
background: #0ea5e9;
color: white;
}
/* Карточки объектов */
.object-card {
background: white;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
cursor: pointer;
border-left: 4px solid #1e40af;
}
.object-card:hover {
transform: translateY(-4px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}
.object-card.status-план {
border-left-color: #6b7280;
}
.object-card.status-обследование {
border-left-color: #f59e0b;
}
.object-card.status-смр {
border-left-color: #0ea5e9;
}
.object-card.status-проблема {
border-left-color: #ef4444;
}
.object-card.status-готов {
border-left-color: #10b981;
}
.object-card.status-сдан {
border-left-color: #10b981;
}
.object-card.overdue {
background: linear-gradient(135deg, #fef2f2 0%, white 100%);
border-left-color: #ef4444;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
50% {
box-shadow: 0 4px 20px rgba(239, 68, 68, 0.3);
}
}
.object-card.approaching {
border-left-color: #f59e0b;
background: linear-gradient(135deg, #fffbeb 0%, white 100%);
}
/* Статусы */
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.план {
background: #f3f4f6;
color: #6b7280;
}
.status-badge.обследование {
background: #fef3c7;
color: #92400e;
}
.status-badge.смр {
background: #dbeafe;
color: #1e40af;
}
.status-badge.проблема {
background: #fee2e2;
color: #991b1b;
}
.status-badge.готов {
background: #d1fae5;
color: #065f46;
}
.status-badge.сдан {
background: #d1fae5;
color: #065f46;
}
/* Карта */
#map {
z-index: 1;
}
.leaflet-popup-content-wrapper {
border-radius: 1rem;
padding: 0;
overflow: hidden;
}
.leaflet-popup-content {
margin: 0;
width: 280px !important;
}
.popup-content {
padding: 1rem;
}
.popup-content h3 {
font-weight: bold;
margin-bottom: 0.5rem;
color: #1e293b;
}
.popup-content p {
margin-bottom: 0.25rem;
font-size: 0.875rem;
color: #64748b;
}
/* Уведомления */
.notif-item {
padding: 0.75rem;
border-bottom: 1px solid #e2e8f0;
border-radius: 0.5rem;
margin-bottom: 0.5rem;
background: #f8fafc;
}
.notif-item.danger {
background: #fef2f2;
border-left: 3px solid #ef4444;
}
.notif-item.warning {
background: #fffbeb;
border-left: 3px solid #f59e0b;
}
.notif-item.success {
background: #f0fdf4;
border-left: 3px solid #10b981;
}
/* Адаптивность */
@media (max-width: 768px) {
.nav-btn {
font-size: 0.8rem;
padding: 0.4rem 0.6rem;
}
.grid.grid-cols-1.md\:grid-cols-2.lg\:grid-cols-3 {
grid-template-columns: 1fr;
}
.grid.grid-cols-1.md\:grid-cols-4 {
grid-template-columns: 1fr;
}
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a1a1a1;
}
/* Modal animations */
#object-modal {
transition: opacity 0.3s ease;
}
#object-modal.hidden {
opacity: 0;
pointer-events: none;
}
/* Chart containers */
canvas {
max-height: 400px;
}