v1.4.0: fix #43, #31, #29, #49, #33 + shared templating

This commit is contained in:
2026-05-20 20:08:25 +03:00
parent ca4d00c895
commit 64991f0228
63 changed files with 5005 additions and 45 deletions
+120
View File
@@ -0,0 +1,120 @@
# Changelog — AegisOne Service Portal
## 1.4.0 (20.05.2026)
## 1.3.0 (20.05.2026)
### Новые функции
- П.49: Подключены favicon (ico, png, webmanifest, apple-touch-icon) + лого на странице логина
- П.44: Сохранение scroll сайдбара при навигации (sessionStorage)
- П.47: Липкий тулбар редактора документов (верх/низ)
- П.33: Кастомный модал подтверждения выхода из опросника (вместо системного confirm)
- П.33: Добавлен POST /service/questionnaire/save/{step} — сохранение черновика
### Исправления и улучшения
- П.35: Версия ПО отображается на всех страницах, не только дашборде
- П.40: Звёзды избранного выровнены в одну строку с пунктами меню
- П.42: discover_docs() вызывается при каждом открытии документа — новые .md сразу видны
- П.43: POST /admin/permissions использует discover_docs вместо load_permissions
- П.45: Футер сайдбара: имя → выход → версия
- П.46: Иконки EasyMDE видны на тёмной теме ([data-theme="dark"] overrides)
- П.48: Создание "Пример из практики" — добавлен action="/service/cases/create"
- П.29: SLA — action="/service/sla/create" у формы + safe type conversions в edit
- П.31: Идеи — защита от None description ((idea.description or '')[:100])
- П.49: Назначения — a.object_name → a.object.name, a.user_name → a.user.full_name
- П.26: Заголовок "Назначения" → "Назначение сотрудников"
- П.24: Объекты — список клиентов в select (c.id/c.name вместо c[0]/c[1])
- П.24: Объекты — editObject защита от None customer_id + safe int() в backend
- П.23: Валидация клиентов: * у обязательных полей, KPP (9 цифр), маска телефона "8 (___) ___-__-__"
- П.36: Portainer URL https://81.177.141.34:9443
## 1.2.1 (20.05.2026)
### Новые функции
- П.38: Стилизация логина — "One" цветом #00ADEF
- П.45: Футер сайдбара: имя → выход → версия
- П.44: Сохранение scroll сайдбара при навигации (sessionStorage)
- П.47: Липкий тулбар редактора документов
- П.49: Подключены favicon (ico, png, webmanifest, apple-touch-icon)
### Исправления и улучшения
- П.35: Версия ПО отображается на всех страницах, не только дашборде
- П.40: Звёзды избранного выровнены в одну строку с пунктами меню
- П.42: discover_docs() вызывается при каждом открытии документа — новые .md сразу видны
- П.43: Управление документами — POST использует discover_docs вместо load_permissions
- П.46: Иконки EasyMDE видны на тёмной теме
- П.48: Создание "Пример из практики" — добавлен action="/service/cases/create"
- П.29: SLA — добавлен action="/service/sla/create" у формы, safe type conversions
- П.31: Идеи — защита от None description (|default(''))
- П.49: Назначения — a.object_name → a.object.name, a.user_name → a.user.full_name
- П.26: Заголовок "Назначения" → "Назначение сотрудников"
- П.24: Объекты — список клиентов в select (c.id/c.name вместо c[0]/c[1])
- П.36: Portainer URL через https://81.177.141.34:9443
- П.33: Опросник — добавлен POST /service/questionnaire/save/{step}, кастомный модал
## 1.2.0 (20.05.2026)
### Доработки
- П.21: Кнопка "Настроить" на дашборде — выбор отображаемых показателей (localStorage)
- П.22: Исправлена ошибка Internal Server Error при редактировании пользователя (добавлен action у формы)
- П.23: Исправлена ошибка Method Not Allowed при создании клиента; ИНН обязателен (10/12 цифр)
- П.24: Тип объекта переведён на русский (select вместо input)
- П.25: Исправлена ошибка Method Not Allowed при создании/редактировании объекта
- П.26: "Назначения" → "Назначение сотрудников"
- П.27: Статус SLA по умолчанию — "Переговоры" (negotiation)
- П.28: Уровни SLA отображаются на русском: Базовый/Оптимальный/Максимальный
- П.29: Проверена работа создания и редактирования SLA контрактов
- П.30: Расширены графики: группировка (день/неделя/месяц), название, сохранение/экспорт PNG
- П.31: Исправлена ошибка Internal Server Error при смене статуса идеи
- П.32: Быстрое меню заменено на звёздочки (★) — избранное в localStorage
- П.33: Кнопка "Выйти" в опроснике с подтверждением сохранения черновика
- П.34: Убрана надпись "Главная" под каждым меню
- П.35: Версия показывается полностью только на дашборде, в остальных — "v"
- П.36: Ссылка Portainer изменена на IP:порт (через PORTAINER_URL)
- П.37: Документация: левое меню (page.html), авто-загрузка .md из /py_service/docs
## 1.1.0 (18.05.2026)
### Баги
- discover_docs: миграция существующих permissions без technician — добавляется technician: {view: false} + engineer: {edit: false, cancel: false}
- favicon: исправлен тип (image/x-icon → image/svg+xml) и расширение (favicon.ico → favicon.svg)
### Доработки
- П.15: Визуальная система формул — коэффициенты сгруппированы по formula_ref в аккордеоны
- П.15: Информация о формуле (📐) — модалка с текстом формулы и описанием результата
- П.15: Песочница формул (/service/coefficients/test) — подстановка параметров и расчёт для 7 формул:
risk_score, complexity, object_index, engineer, ecs, shs, ceo_shs
- П.15: Иконка 🧪 у каждой группы ведёт в песочницу с предвыбором группы
- П.15: В сайдбар добавлена ссылка "Тест формул" (owner)
- CSS: добавлены стили аккордеона и панели теста формул
## 1.0.3 (18.05.2026)
### Баги
- П.18: Ченджлог не открывался — CHANGELOG.md исключался .dockerignore (*.md убран)
- П.20: Ошибка при сохранении пользователя — исправлена логика is_active (без дефолта), phone/email не сбрасываются
### Доработки
- П.12: Авто-добавление документов из /py_service/docs через discover_docs() в lifespan
- П.13: Владелец редактирует .md файлы через /service/documents/{slug}/edit (EasyMDE)
- П.14: Система прав документов:
- Владелец видит/создаёт/редактирует/удаляет все документы
- Владелец назначает engineer'у просмотр/редактирование через /service/documents/admin/permissions
- Engineer видит только назначенные документы
- Engineer даёт technician'у просмотр своих документов через /service/documents/tech-access
- Technician видит только те документы, что открыл engineer
- П.15: Удаление коэффициентов (кнопка ✕); предложен редизайн с группировкой по formula_ref
- П.16: Гибкие графики для владельца (/service/charts) — Chart.js: задачи/SHS/KPI/MRR/объекты, типы: bar/line/pie/doughnut
- П.17: "Service Portal" увеличен с 10px до 12px
- П.19: Роль убрана из футера сайдбара, "Выйти" под ФИО
- Удалены дублирующие ручки /service/docs* (старая система), везде /service/documents/
## 1.0.2 (18.05.2026)
### Доработки
- П.3: Логотип AegisOne в sidebare (sidebar-logo-text, 2rem)
- П.5: Версия кликабельна — модалка с историей изменений (changelog)
- П.6: Текст логотипа увеличен (sidebar-logo-text, 2rem)
- П.7: Верхнее меню упрощено — только логотип и кнопка темы
- П.8: Быстрое меню — настройка своих и техниковских ссылок (owner/engineer)
- П.9: Система идей — CRUD + статусы (normal/in_progress/completed)
- П.11: Ссылки на Portainer и Gitea в левом меню (owner)
## 1.0.1 (18.05.2026)
## 1.0.0
### Новые функции
- Первый запуск сервисного портала на FastAPI
- Базовые страницы: логин, дашборд, документация
- Авторизация (owner/engineer/technician)
- Дашборд с основной статистикой (объекты, задачи, инциденты, SLA, SHS)
Binary file not shown.
Binary file not shown.
+3 -5
View File
@@ -1,23 +1,21 @@
import time
import secrets
import bcrypt
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from passlib.context import CryptContext
from app.models.models import User, LoginAttempt, AuditLog
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
BRUTE_FORCE_WINDOW = 180
BRUTE_FORCE_MAX_ATTEMPTS = 3
def hash_password(password: str) -> str:
return pwd_context.hash(password)
return bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt()).decode()
def verify_password(password: str, hashed: str) -> bool:
return pwd_context.verify(password, hashed)
return bcrypt.checkpw(password.encode()[:72], hashed.encode())
def generate_csrf_token() -> str:
+2 -1
View File
@@ -10,9 +10,10 @@ class Settings(BaseSettings):
SESSION_TTL: int = 3600
APP_ENV: str = "development"
LOG_LEVEL: str = "debug"
DOCS_DIR: str = str(Path(__file__).parent.parent.parent / "docs")
DOCS_DIR: str = str(Path(__file__).parent.parent / "docs")
PERMISSIONS_DIR: str = str(Path(__file__).parent.parent.parent / "permissions")
UPLOADS_DIR: str = str(Path(__file__).parent.parent.parent / "uploads")
PORTAINER_URL: str = "https://81.177.141.34:9443"
class Config:
env_file = ".env"
+16
View File
@@ -0,0 +1,16 @@
from fastapi import Request, Depends
from fastapi.responses import HTMLResponse, RedirectResponse
def get_current_user(request: Request) -> dict | None:
return request.session.get("user")
def require_role(*roles: str):
def dependency(request: Request, user: dict = Depends(get_current_user)):
if not user:
return RedirectResponse(url="/service/login", status_code=302)
if roles and user["role"] not in roles:
return HTMLResponse(content="Доступ запрещён", status_code=403)
return user
return dependency
+282
View File
@@ -0,0 +1,282 @@
import os
import re
import markdown
from pathlib import Path
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.config import get_settings
from app.services.docs_permissions import load_permissions, save_permissions, discover_docs, can_access_doc, get_allowed_docs
from app.dependencies import get_current_user, require_role
settings = get_settings()
router = APIRouter(prefix="/service/documents", tags=["documents"])
from app.templating import templates
def transliterate_slug(text: str) -> str:
translit_map = {
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', 'ж': 'zh',
'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm', 'н': 'n', 'о': 'o',
'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', 'ф': 'f', 'х': 'kh', 'ц': 'ts',
'ч': 'ch', 'ш': 'sh', 'щ': 'shch', 'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu',
'я': 'ya', ' ': '_',
}
result = ''.join(translit_map.get(c.lower(), c) for c in text)
result = re.sub(r'[^a-zA-Z0-9_\-]', '', result)
result = re.sub(r'_+', '_', result)
return result.strip('_').lower()
def render_markdown(content: str) -> str:
md = markdown.Markdown(extensions=['tables', 'fenced_code', 'toc', 'attr_list'])
return md.convert(content)
@router.get("/")
async def documents_list(
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
docs = get_allowed_docs(user["role"])
return templates.TemplateResponse("documents/list.html", {
"request": request, "user": user, "docs": docs,
})
@router.get("/{slug}")
async def document_view(
slug: str,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if not can_access_doc(user["role"], slug, "view"):
return HTMLResponse(content="Доступ запрещён", status_code=403)
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
if not os.path.exists(filepath):
return HTMLResponse(content="Файл не найден", status_code=404)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
html_content = render_markdown(content)
return templates.TemplateResponse("documents/view.html", {
"request": request, "user": user, "doc": doc, "content": html_content,
"can_edit": can_access_doc(user["role"], slug, "edit"),
"can_download": user["role"] == "owner",
})
@router.get("/{slug}/edit")
async def document_edit(
slug: str,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if not can_access_doc(user["role"], slug, "edit"):
return HTMLResponse(content="Доступ запрещён", status_code=403)
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
if not os.path.exists(filepath):
return HTMLResponse(content="Файл не найден", status_code=404)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
return templates.TemplateResponse("documents/edit.html", {
"request": request, "user": user, "doc": doc, "content": content,
})
@router.post("/{slug}/edit")
async def document_update(
slug: str,
request: Request,
content: str = Form(...),
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if not can_access_doc(user["role"], slug, "edit"):
return HTMLResponse(content="Доступ запрещён", status_code=403)
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
return RedirectResponse(url=f"/service/documents/{slug}", status_code=302)
@router.get("/tech-access")
async def tech_access_page(
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "engineer":
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
perms = discover_docs()
editable_docs = [
doc for slug, doc in perms.items()
if doc.get("permissions", {}).get("engineer", {}).get("edit", False)
]
return templates.TemplateResponse("documents/tech_access.html", {
"request": request, "user": user, "docs": editable_docs, "perms": perms,
})
@router.post("/tech-access")
async def tech_access_update(
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "engineer":
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
form = await request.form()
perms = discover_docs()
for slug in perms.keys():
if not perms[slug].get("permissions", {}).get("engineer", {}).get("edit", False):
continue
technician_view = form.get(f"{slug}_technician_view") == "on"
perms[slug]["permissions"]["technician"] = {"view": technician_view}
save_permissions(perms)
return RedirectResponse(url="/service/documents/tech-access", status_code=302)
@router.get("/{slug}/download")
async def document_download(
slug: str,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "owner":
return HTMLResponse(content="Скачивание доступно только владельцу", status_code=403)
from fastapi.responses import FileResponse
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
if not os.path.exists(filepath):
return HTMLResponse(content="Файл не найден", status_code=404)
return FileResponse(filepath, filename=doc["filename"], media_type="text/markdown")
@router.get("/admin/permissions")
async def permissions_page(
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "owner":
return HTMLResponse(content="Доступ запрещён", status_code=403)
perms = discover_docs()
return templates.TemplateResponse("documents/permissions.html", {
"request": request, "user": user, "perms": perms,
})
@router.post("/admin/permissions")
async def permissions_update(
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "owner":
return HTMLResponse(content="Доступ запрещён", status_code=403)
form = await request.form()
perms = discover_docs()
for slug in perms.keys():
engineer_view = form.get(f"{slug}_engineer_view") == "on"
engineer_edit = form.get(f"{slug}_engineer_edit") == "on"
technician_view = form.get(f"{slug}_technician_view") == "on"
if engineer_edit and not engineer_view:
engineer_view = True
perms[slug]["permissions"]["engineer"] = {
"view": engineer_view,
"edit": engineer_edit,
"cancel": engineer_edit,
}
perms[slug]["permissions"]["technician"] = {
"view": technician_view,
}
save_permissions(perms)
return RedirectResponse(url="/service/documents/admin/permissions", status_code=302)
@router.post("/admin/create")
async def document_create(
request: Request,
title: str = Form(...),
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "owner":
return HTMLResponse(content="Доступ запрещён", status_code=403)
slug = transliterate_slug(title)
if not slug:
return HTMLResponse(content="Некорректное название", status_code=400)
docs_dir = settings.DOCS_DIR
os.makedirs(docs_dir, exist_ok=True)
filename = f"{slug}.md"
filepath = os.path.join(docs_dir, filename)
if os.path.exists(filepath):
return HTMLResponse(content="Документ уже существует", status_code=400)
with open(filepath, "w", encoding="utf-8") as f:
f.write(f"# {title}\n\n")
perms = load_permissions()
perms[slug] = {
"title": title,
"filename": filename,
"sort_order": len(perms),
"permissions": {
"engineer": {"view": False, "edit": False, "cancel": False},
"technician": {"view": False},
},
}
save_permissions(perms)
return RedirectResponse(url=f"/service/documents/{slug}/edit", status_code=302)
+102 -23
View File
@@ -3,20 +3,90 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Depends
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.database import get_db, async_session
from app.auth import audit_log
from app.templating import templates
from app.services.docs_permissions import discover_docs
from app.documents_router import router as documents_router
from app.dependencies import get_current_user, require_role
from app.version import get_app_version
settings = get_settings()
APP_VERSION = get_app_version()
SEED_COEFFICIENTS = [
("risk_score.no_archive", 25, "Нет архива видеонаблюдения", "calc_risk_score()"),
("risk_score.no_power_backup", 20, "Нет резервного питания", "calc_risk_score()"),
("risk_score.no_regulations", 15, "Нет регламента обслуживания", "calc_risk_score()"),
("risk_score.system_failures", 20, "Частые сбои систем", "calc_risk_score()"),
("risk_score.no_documentation", 10, "Нет документации", "calc_risk_score()"),
("risk_score.bound_low", 20, "Граница низкого риска", "risk_label()"),
("risk_score.bound_medium", 50, "Граница среднего риска", "risk_label()"),
("risk_score.bound_high", 75, "Граница высокого риска", "risk_label()"),
("risk_multiplier.low", 1.0, "Множитель низкого риска (0-20)", "risk_multiplier()"),
("risk_multiplier.medium", 1.3, "Множитель среднего риска (21-50)", "risk_multiplier()"),
("risk_multiplier.high", 1.6, "Множитель высокого риска (51-75)", "risk_multiplier()"),
("risk_multiplier.critical", 2.0, "Множитель критического риска (76-100)", "risk_multiplier()"),
("complexity.video_0_20", 10, "Видеонаблюдение до 20 камер", "calc_complexity()"),
("complexity.video_20_100", 20, "Видеонаблюдение 20-100 камер", "calc_complexity()"),
("complexity.video_100plus", 35, "Видеонаблюдение 100+ камер", "calc_complexity()"),
("complexity.access_0_5", 10, "СКУД до 5 точек", "calc_complexity()"),
("complexity.access_5_20", 20, "СКУД 5-20 точек", "calc_complexity()"),
("complexity.access_20plus", 30, "СКУД 20+ точек", "calc_complexity()"),
("complexity.fire_simple", 15, "Пожарная сигнализация простая", "calc_complexity()"),
("complexity.fire_medium", 25, "Пожарная сигнализация средняя", "calc_complexity()"),
("complexity.fire_complex", 40, "Пожарная сигнализация сложная (>5000 м²)", "calc_complexity()"),
("complexity.it", 10, "IT-инфраструктура", "calc_complexity()"),
("infra.server_none", 15, "Нет сервера", "calc_infrastructure_load()"),
("infra.server_weak", 10, "Слабый сервер", "calc_infrastructure_load()"),
("infra.network_unstable", 20, "Нестабильная сеть", "calc_infrastructure_load()"),
("infra.network_partial", 10, "Частичная сеть", "calc_infrastructure_load()"),
("infra.power_none", 20, "Нет UPS", "calc_infrastructure_load()"),
("infra.power_weak", 10, "Слабый UPS", "calc_infrastructure_load()"),
("history.none", 30, "Нет обслуживания", "calc_service_history()"),
("history.irregular", 20, "Нерегулярное обслуживание", "calc_service_history()"),
("history.formal", 10, "Формальный подрядчик", "calc_service_history()"),
("history.sla", 0, "Есть SLA", "calc_service_history()"),
("object_index.risk_weight", 0.4, "Вес Risk Score", "calc_object_index()"),
("object_index.complexity_weight", 0.3, "Вес Complexity", "calc_object_index()"),
("object_index.infra_weight", 0.2, "Вес Infra Load", "calc_object_index()"),
("object_index.history_weight", 0.1, "Вес Service History", "calc_object_index()"),
("sla_price.base_cost", 15000, "Базовая стоимость инженера", "calc_sla_price()"),
("sla_price.region_default", 1.0, "Региональный коэффициент по умолчанию", "calc_sla_price()"),
]
async def seed_initial_data():
from sqlalchemy import text, select, func
from app.models.models import FormulaCoefficient, User
async with async_session() as db:
try:
count = await db.execute(select(func.count(FormulaCoefficient.id)))
if count.scalar() == 0:
from app.models.models import FormulaCoefficient as FC
for key, value, desc, ref in SEED_COEFFICIENTS:
db.add(FC(key=key, value=value, description=desc, formula_ref=ref))
await db.commit()
await db.execute(text("DELETE FROM users WHERE login = 'admin'"))
await db.commit()
except Exception:
await db.rollback()
@asynccontextmanager
async def lifespan(app: FastAPI):
from app.models.models import Base
from app.database import engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await seed_initial_data()
async with async_session() as db:
from app.calculations.engine import reload_coefficients
await reload_coefficients(db)
@@ -26,10 +96,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="AegisOne Service Portal", lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY, max_age=settings.SESSION_TTL)
templates = Jinja2Templates(directory="app/templates")
templates.env.globals["SITE_NAME"] = "AegisOne Engineering"
app.mount("/service/static", StaticFiles(directory="app/static"), name="static")
class RoleGuardMiddleware(BaseHTTPMiddleware):
@@ -38,28 +105,28 @@ class RoleGuardMiddleware(BaseHTTPMiddleware):
if path.startswith("/service/") and not any(
path.startswith(p) for p in ["/service/login", "/service/static", "/service/api/"]
):
user = request.session.get("user")
if not user:
return RedirectResponse(url="/service/login", status_code=302)
if "session" in request.scope:
user = request.session.get("user")
if not user:
return RedirectResponse(url="/service/login", status_code=302)
response = await call_next(request)
return response
app.add_middleware(RoleGuardMiddleware)
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY, max_age=settings.SESSION_TTL)
app.include_router(documents_router)
from app.routers.service_pages import router as service_pages_router
app.include_router(service_pages_router)
def get_current_user(request: Request) -> dict | None:
return request.session.get("user")
def require_role(*roles: str):
def dependency(request: Request, user: dict = Depends(get_current_user)):
if not user:
return RedirectResponse(url="/service/login", status_code=302)
if roles and user["role"] not in roles:
return HTMLResponse(content="Доступ запрещён", status_code=403)
return user
return dependency
@app.get("/")
async def root(request: Request):
if request.session.get("user"):
return RedirectResponse(url="/service/dashboard", status_code=302)
return RedirectResponse(url="/service/login", status_code=302)
@app.get("/service/login")
@@ -100,7 +167,7 @@ async def logout(request: Request, db: AsyncSession = Depends(get_db)):
@app.get("/service/dashboard")
async def dashboard(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
from sqlalchemy import select, func
from sqlalchemy import select, func, Integer
from app.models.models import Object, Task, Incident, SLAContract, SHSRecord
stats = {}
@@ -130,8 +197,8 @@ async def dashboard(request: Request, db: AsyncSession = Depends(get_db), user:
stats["my_tasks"] = result.scalar() or 0
result = await db.execute(
select(func.count(Task.id), func.sum(Task.sla_compliant.cast(int)))
.where(Task.closed_at.isnot(None))
select(func.count(Task.id), func.sum(Task.sla_compliant.cast(Integer)))
.where(Task.closed_at != None)
)
row = result.first()
total = row[0] or 0
@@ -182,6 +249,18 @@ async def api_tasks(db: AsyncSession = Depends(get_db), user: dict = Depends(get
])
@app.get("/service/api/changelog")
async def api_changelog():
import os, markdown
base = os.path.dirname(os.path.abspath(__file__))
changelog_path = os.path.join(base, "..", "CHANGELOG.md")
if not os.path.isfile(changelog_path):
return JSONResponse({"html": "<p>Файл изменений не найден.</p>"})
with open(changelog_path, encoding="utf-8") as f:
html = markdown.markdown(f.read(), extensions=["fenced_code", "tables"])
return JSONResponse({"html": html})
@app.get("/health")
async def health():
return {"status": "ok"}
+32
View File
@@ -463,3 +463,35 @@ class Case(Base):
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
class Idea(Base):
__tablename__ = "ideas"
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(500), nullable=False)
description = Column(Text, default="")
status = Column(String(20), nullable=False, default="normal")
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("status IN ('normal','in_progress','completed')", name="ck_ideas_status"),
)
creator = relationship("User")
class QuickMenuItem(Base):
__tablename__ = "quick_menu_items"
id = Column(Integer, primary_key=True, autoincrement=True)
role = Column(String(20), nullable=False)
label = Column(String(100), nullable=False)
url = Column(String(500), nullable=False)
sort_order = Column(Integer, nullable=False, default=0)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
creator = relationship("User")
File diff suppressed because it is too large Load Diff
+30 -4
View File
@@ -36,10 +36,29 @@ def discover_docs() -> dict:
"filename": filename,
"sort_order": len(perms),
"permissions": {
"engineer": {"view": False, "edit": False, "cancel": False}
"engineer": {"view": False, "edit": False, "cancel": False},
"technician": {"view": False},
},
}
save_permissions(perms)
# migrate existing entries missing technician key
changed = False
for slug, doc in perms.items():
if "permissions" not in doc:
doc["permissions"] = {}
perms_doc = doc["permissions"]
if "technician" not in perms_doc:
perms_doc["technician"] = {"view": False}
changed = True
# ensure engineer has edit/cancel defaults
eng = perms_doc.setdefault("engineer", {})
if "edit" not in eng:
eng["edit"] = False
changed = True
if "cancel" not in eng:
eng["cancel"] = False
changed = True
if changed:
save_permissions(perms)
return perms
@@ -52,14 +71,21 @@ def get_allowed_docs(role: str) -> list[dict]:
doc for doc in perms.values()
if doc.get("permissions", {}).get("engineer", {}).get("view", False)
]
if role == "technician":
return [
doc for doc in perms.values()
if doc.get("permissions", {}).get("technician", {}).get("view", False)
]
return []
def can_access_doc(role: str, slug: str, action: str = "view") -> bool:
if role == "owner":
return True
perms = load_permissions()
doc = perms.get(slug, {})
if role == "engineer":
perms = load_permissions()
doc = perms.get(slug, {})
return doc.get("permissions", {}).get("engineer", {}).get(action, False)
if role == "technician":
return doc.get("permissions", {}).get("technician", {}).get("view", False)
return False
+168
View File
@@ -0,0 +1,168 @@
@charset "UTF-8";
:root {
--bg-body: #0D1B2A;
--bg-card: #1B2838;
--bg-card-hover: #1e3044;
--bg-sidebar: #0A1628;
--bg-input: #1e2a3e;
--border: #253248;
--border-focus: #00ADEF;
--text-primary: #E8EDF2;
--text-secondary: #A0B4C8;
--text-muted: #6B8299;
--accent: #00ADEF;
--accent-hover: #33c4f5;
--accent-dim: rgba(0, 173, 239, 0.15);
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--info: #6366f1;
--font: 'Inter', 'Segoe UI', sans-serif;
--radius: 10px;
--shadow: 0 2px 12px rgba(0,0,0,0.4);
}
[data-theme="light"] {
--bg-body: #F5F7FA;
--bg-card: #FFFFFF;
--bg-card-hover: #f0f4f8;
--bg-sidebar: #FFFFFF;
--bg-input: #FFFFFF;
--border: #e2e8f0;
--border-focus: #00ADEF;
--text-primary: #1a1a2e;
--text-secondary: #5a6a7a;
--text-muted: #8899aa;
--accent: #00ADEF;
--accent-hover: #0095cc;
--accent-dim: rgba(0, 173, 239, 0.1);
--success: #16a34a;
--warning: #d97706;
--danger: #dc2626;
--info: #4f46e5;
--shadow: 0 2px 12px rgba(10, 42, 77, 0.1);
}
[data-theme="dark"] {
--bg-body: #0D1B2A;
--bg-card: #1B2838;
--bg-card-hover: #1e3044;
--bg-sidebar: #0A1628;
--bg-input: #1e2a3e;
--border: #253248;
--border-focus: #00ADEF;
--text-primary: #E8EDF2;
--text-secondary: #A0B4C8;
--text-muted: #6B8299;
--accent: #00ADEF;
--accent-hover: #33c4f5;
--accent-dim: rgba(0, 173, 239, 0.15);
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--info: #6366f1;
--shadow: 0 2px 12px rgba(0,0,0,0.4);
}
body {
background: var(--bg-body);
color: var(--text-primary);
transition: background 0.3s, color 0.3s;
}
.theme-switcher {
display: flex;
align-items: center;
gap: 6px;
}
.theme-switcher button {
background: var(--accent-dim);
border: 1px solid var(--border);
border-radius: 6px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
color: var(--text-secondary);
transition: all 0.2s;
}
.theme-switcher button:hover {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.theme-label {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
}
/* EasyMDE dark theme overrides */
[data-theme="dark"] .EasyMDEContainer .editor-toolbar {
background: var(--bg-card);
border-color: var(--border);
}
[data-theme="dark"] .EasyMDEContainer .CodeMirror {
background: var(--bg-input);
color: var(--text-primary);
border-color: var(--border);
}
[data-theme="dark"] .editor-toolbar button {
color: var(--text-secondary);
border-color: transparent;
}
[data-theme="dark"] .editor-toolbar button:hover {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--text-primary);
}
[data-theme="dark"] .editor-toolbar button.active {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
}
[data-theme="dark"] .CodeMirror-cursor {
border-color: var(--accent) !important;
}
[data-theme="dark"] .CodeMirror-selected {
background: var(--accent-dim) !important;
}
[data-theme="dark"] .editor-preview {
background: var(--bg-card);
color: var(--text-primary);
}
[data-theme="dark"] .editor-preview-side {
background: var(--bg-card);
color: var(--text-primary);
border-color: var(--border);
}
[data-theme="dark"] .editor-statusbar {
color: var(--text-muted);
}
/* Sticky editor toolbar */
.EasyMDEContainer .editor-toolbar {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg-card);
border-radius: var(--radius) var(--radius) 0 0;
}
/* Sticky bottom actions on edit page */
.edit-form-actions {
position: sticky;
bottom: 0;
z-index: 10;
background: var(--bg-card);
padding: 12px 0;
border-top: 1px solid var(--border);
margin-top: 8px;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 56">
<text font-family="Montserrat,sans-serif" font-weight="800" font-size="36" y="42" fill="#00ADEF">Aegis<text fill="#e8edf5">One</text></text>
</svg>

After

Width:  |  Height:  |  Size: 212 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="4" fill="#0A1628"/><text x="16" y="22" font-size="18" font-weight="bold" fill="#00ADEF" text-anchor="middle" font-family="sans-serif">AO</text></svg>

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

@@ -0,0 +1 @@
{"name":"AegisOne Service Portal","short_name":"AegisOne","icons":[{"src":"/service/static/img/favicons/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/service/static/img/favicons/apple-touch-icon.png","sizes":"180x180","type":"image/png"},{"src":"/service/static/img/favicons/favicon-16x16.png","sizes":"16x16","type":"image/png"},{"src":"/service/static/img/favicons/favicon-32x32.png","sizes":"32x32","type":"image/png"},{"src":"/service/static/img/favicons/favicon.ico","sizes":"48x48","type":"image/x-icon"}]}
+47
View File
@@ -0,0 +1,47 @@
(function() {
var STORAGE_KEY = 'aegisone_service_theme';
var themes = ['system', 'dark', 'light'];
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
function applyTheme(theme) {
var resolved = theme === 'system' ? getSystemTheme() : theme;
document.documentElement.setAttribute('data-theme', resolved);
var label = document.getElementById('theme-label');
if (label) {
var names = { system: 'Авто', dark: 'Тёмная', light: 'Светлая' };
label.textContent = names[theme] || 'Авто';
}
var btn = document.getElementById('theme-btn');
if (btn) {
var icons = { system: '🖥', dark: '🌙', light: '☀️' };
btn.textContent = icons[theme] || '🖥';
}
}
function getNextTheme(current) {
var idx = themes.indexOf(current);
return themes[(idx + 1) % themes.length];
}
function toggleTheme() {
var current = localStorage.getItem(STORAGE_KEY) || 'system';
var next = getNextTheme(current);
localStorage.setItem(STORAGE_KEY, next);
applyTheme(next);
}
var saved = localStorage.getItem(STORAGE_KEY) || 'system';
applyTheme(saved);
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', function() {
var current = localStorage.getItem(STORAGE_KEY) || 'system';
if (current === 'system') {
applyTheme('system');
}
});
window.toggleTheme = toggleTheme;
})();
+22 -2
View File
@@ -5,12 +5,32 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>{% block title %}Service Portal — AegisOne Engineering{% endblock %}</title>
<link rel="stylesheet" href="/service-style.php">
<link rel="icon" type="image/x-icon" href="/assets/img/favicon.ico">
<link rel="stylesheet" href="/service/static/css/service.css">
<link rel="stylesheet" href="/service/static/css/service-theme.css">
<link rel="icon" type="image/x-icon" href="/service/static/img/favicons/favicon.ico">
<link rel="icon" type="image/svg+xml" href="/service/static/img/favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="/service/static/img/favicons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/service/static/img/favicons/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/service/static/img/favicons/apple-touch-icon.png">
<link rel="manifest" href="/service/static/img/favicons/site.webmanifest">
{% block extra_head %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
{% if user %}
<nav class="navbar">
<div class="navbar__brand">
<a href="/service/dashboard">AegisOne Service</a>
</div>
<ul class="navbar__menu">
<li class="theme-switcher" data-tooltip="Переключить тему">
<button id="theme-btn" onclick="toggleTheme()">🖥</button>
<span class="theme-label" id="theme-label">Авто</span>
</li>
</ul>
</nav>
{% endif %}
{% block content %}{% endblock %}
<script src="/service/static/js/theme.js"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>
+138 -8
View File
@@ -5,8 +5,8 @@
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">Aegis<span>One</span></div>
<div style="font-size:10px;color:var(--text-muted);">Service Portal</div>
<div class="logo sidebar-logo-text">Aegis<span>One</span></div>
<div style="font-size:12px;color:var(--text-muted);letter-spacing:0.05em;">Service Portal</div>
</div>
<nav class="sidebar-nav">
<div class="sidebar-section">Панель</div>
@@ -16,15 +16,18 @@
<a href="/service/users">Сотрудники</a>
<a href="/service/customers">Клиенты</a>
<a href="/service/objects">Объекты</a>
<a href="/service/assignments">Назначения</a>
<a href="/service/assignments">Назначение сотрудников</a>
<a href="/service/sla">SLA контракты</a>
<a href="/service/ceo">CEO дашборд</a>
<a href="/service/coefficients">Коэффициенты</a>
<a href="/service/charts">Графики</a>
<a href="/service/ideas">Идеи</a>
{% elif user.role == 'engineer' %}
<div class="sidebar-section">Управление</div>
<a href="/service/customers">Клиенты</a>
<a href="/service/objects">Объекты</a>
<a href="/service/sla">SLA контракты</a>
<a href="/service/documents/tech-access">Доступ техников к документам</a>
{% endif %}
<div class="sidebar-section">Работа</div>
{% if user.role in ['engineer', 'technician'] %}
@@ -44,6 +47,11 @@
{% if user.role == 'technician' %}
<a href="/service/checklist">Чек-лист</a>
{% endif %}
{% if user.role == 'owner' %}
<div class="sidebar-section">Внешние системы</div>
<a href="https://git.aegisone.ru" target="_blank" rel="noopener">Gitea</a>
<a href="{{ PORTAINER_URL }}" target="_blank" rel="noopener">Portainer</a>
{% endif %}
<div class="sidebar-section">Блог</div>
{% if user.role == 'owner' %}
<a href="/service/blog">Управление блогом</a>
@@ -55,23 +63,23 @@
{% endif %}
<div class="sidebar-section">Документация</div>
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/docs">Просмотр документации</a>
<a href="/service/documents/">Просмотр документации</a>
{% endif %}
{% if user.role == 'owner' %}
<a href="/service/docs-manage">Управление документами</a>
<a href="/service/documents/admin/permissions">Управление документами</a>
{% endif %}
</nav>
<div class="sidebar-footer">
<div class="user-name">{{ user.full_name }}</div>
<div class="user-role">{{ {'owner': 'Владелец', 'engineer': 'Инженер', 'technician': 'Техник'}[user.role] }}</div>
<a href="/service/logout" style="display:inline-block;margin-top:6px;font-size:12px;">Выйти</a>
<a href="/service/logout" style="display:inline-block;margin:6px 0;font-size:12px;">Выйти</a>
<div class="version-pill" onclick="openChangelog()" title="История изменений">v{{ app_version }}</div>
</div>
</aside>
<main class="main">
<div class="main-header">
<div>
<h1>Дашборд</h1>
<div class="breadcrumb"><a href="/service/dashboard">Главная</a></div>
<div class="breadcrumb"></div>
</div>
<div class="actions">
<span style="font-size:12px;color:var(--text-muted);">{{ now }}</span>
@@ -117,10 +125,132 @@
</a>
{% endif %}
</div>
<div style="text-align:right;margin-top:12px;">
<button class="btn btn-secondary btn-sm" onclick="loadDashSettings();document.getElementById('modal-dash-settings').classList.add('open')">Настроить</button>
</div>
</main>
</div>
<div class="modal-overlay" id="modal-dash-settings"><div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2>Настройка дашборда</h2>
<p style="color:var(--text-muted);font-size:13px;">Выберите показатели для отображения</p>
<div id="dash-settings-list"></div>
<div class="form-actions">
<button type="button" class="btn btn-primary" onclick="saveDashSettings()">Сохранить</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button>
</div>
</div></div>
<script>
function getDashCards(){
return document.querySelectorAll('.card-grid > .card-link');
}
function loadDashSettings(){
var hidden = JSON.parse(localStorage.getItem('dash_hidden_cards') || '[]');
var cards = getDashCards();
var list = document.getElementById('dash-settings-list');
list.innerHTML = '';
cards.forEach(function(card){
var label = card.querySelector('.card-label')?.textContent || 'Показатель';
var key = label.trim();
var checked = hidden.indexOf(key) === -1;
var div = document.createElement('div');
div.style.cssText = 'padding:6px 0;';
div.innerHTML = '<label><input type="checkbox" class="dash-card-toggle" data-key="' + key.replace(/"/g,'&quot;') + '" ' + (checked?'checked':'') + '> ' + key + '</label>';
list.appendChild(div);
});
}
function saveDashSettings(){
var hidden = [];
document.querySelectorAll('.dash-card-toggle').forEach(function(cb){
if(!cb.checked) hidden.push(cb.dataset.key);
});
localStorage.setItem('dash_hidden_cards', JSON.stringify(hidden));
applyDashSettings();
document.getElementById('modal-dash-settings').classList.remove('open');
}
function applyDashSettings(){
var hidden = JSON.parse(localStorage.getItem('dash_hidden_cards') || '[]');
var cards = getDashCards();
cards.forEach(function(card){
var label = card.querySelector('.card-label')?.textContent || '';
card.style.display = hidden.indexOf(label.trim()) !== -1 ? 'none' : '';
});
}
document.addEventListener('DOMContentLoaded', applyDashSettings);
</script>
<style>
.card-link { text-decoration:none; color:inherit; display:block; transition:transform 0.2s, box-shadow 0.2s; }
.card-link:hover { transform:translateY(-2px); box-shadow:var(--shadow); cursor:pointer; }
</style>
<div class="modal-overlay" id="changelog-modal"><div class="modal">
<button type="button" class="modal-close" onclick="closeChangelog()"></button>
<h2>История изменений</h2>
<div id="changelog-content" class="doc-content" style="padding:0;background:transparent;border:none;"></div>
</div></div>
<script>
function openChangelog(){
var modal = document.getElementById('changelog-modal');
modal.classList.add('open');
var content = document.getElementById('changelog-content');
if(!content.dataset.loaded){
content.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
fetch('/service/api/changelog').then(function(r){ return r.json() }).then(function(data){
content.innerHTML = data.html || '<p>Нет данных</p>';
content.dataset.loaded = '1';
});
}
}
function closeChangelog(){
document.getElementById('changelog-modal').classList.remove('open');
}
document.addEventListener('DOMContentLoaded', function(){
var nav = document.querySelector('.navbar__menu');
if(!nav) return;
var sidebarLinks = document.querySelectorAll('.sidebar-nav a[href]');
var saved = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
sidebarLinks.forEach(function(link){
var star = document.createElement('span');
star.className = 'sidebar-star';
var url = link.getAttribute('href');
star.textContent = saved.indexOf(url) !== -1 ? '\u2605' : '\u2606';
star.style.cssText = 'cursor:pointer;margin-right:6px;font-size:14px;user-select:none;';
star.addEventListener('click', function(e){
e.preventDefault(); e.stopPropagation();
var favs = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
var idx = favs.indexOf(url);
if(idx !== -1){ favs.splice(idx,1); star.textContent = '\u2606'; }
else { favs.push(url); star.textContent = '\u2605'; }
localStorage.setItem('sidebar_favorites', JSON.stringify(favs));
renderFavorites();
});
link.insertBefore(star, link.firstChild);
});
function renderFavorites(){
var existing = nav.querySelectorAll('.nav-fav');
existing.forEach(function(e){ e.remove(); });
var favs = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
favs.forEach(function(url){
var orig = document.querySelector('.sidebar-nav a[href="' + url + '"]');
if(!orig) return;
var li = document.createElement('li');
li.className = 'nav-fav';
var a = document.createElement('a');
a.href = url; a.textContent = orig.textContent.replace(/^[☆★]/, '').trim();
li.appendChild(a);
nav.insertBefore(li, nav.lastElementChild);
});
}
renderFavorites();
// Restore sidebar scroll position
var sidebarNav = document.querySelector('.sidebar-nav');
var savedScroll = sessionStorage.getItem('sidebar_scroll');
if (sidebarNav && savedScroll) sidebarNav.scrollTop = parseInt(savedScroll, 10);
// Save scroll position before navigating
sidebarNav && sidebarNav.addEventListener('click', function(){
sessionStorage.setItem('sidebar_scroll', this.scrollTop);
});
});
</script>
{% endblock %}
@@ -0,0 +1,29 @@
{% extends "page.html" %}
{% block breadcrumb %}<a href="/service/documents/">Документация</a> / Редактирование{% endblock %}
{% block page_actions %}<a href="/service/documents/{{ doc.filename | replace('.md', '') }}" class="btn btn-secondary btn-sm">← Назад</a>{% endblock %}
{% block page_content %}
<div class="card" style="padding-bottom:0">
<form method="post" action="/service/documents/{{ doc.filename | replace('.md', '') }}/edit">
<div class="form-group" style="margin-bottom:0">
<textarea id="editor" name="content">{{ content }}</textarea>
</div>
<div class="form-actions edit-form-actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="/service/documents/{{ doc.filename | replace('.md', '') }}" class="btn btn-secondary">Отмена</a>
</div>
</form>
</div>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
<script>
const easyMDE = new EasyMDE({
element: document.getElementById('editor'),
spellChecker: false,
autosave: {
enabled: true,
uniqueId: "{{ doc.filename }}",
delay: 1000,
},
});
</script>
{% endblock %}
@@ -0,0 +1,30 @@
{% extends "page.html" %}
{% block breadcrumb %}Документация{% endblock %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Документация</h2></div>
<div class="docs-list">
{% for doc in docs %}
<div class="doc-item">
<a href="/service/documents/{{ doc.filename | replace('.md', '') }}">{{ doc.title }}</a>
</div>
{% else %}
<p>Нет доступных документов</p>
{% endfor %}
</div>
{% if user.role == "owner" %}
<div class="docs-actions" style="margin-top:12px;">
<a href="/service/documents/admin/permissions" class="btn btn-secondary btn-sm">Управление доступами</a>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('create-form').style.display='block'">Создать документ</button>
</div>
<form id="create-form" method="post" action="/service/documents/admin/create" style="display:none; margin-top: 1rem;">
<div class="form-group">
<label for="title">Название документа</label>
<input type="text" id="title" name="title" required>
</div>
<button type="submit" class="btn btn-primary btn-sm">Создать</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="document.getElementById('create-form').style.display='none'">Отмена</button>
</form>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,39 @@
{% extends "page.html" %}
{% block breadcrumb %}<a href="/service/documents/">Документация</a> / Управление доступами{% endblock %}
{% block page_actions %}<a href="/service/documents/" class="btn btn-secondary btn-sm">← Назад</a>{% endblock %}
{% block page_content %}
<div class="card">
<form method="post" action="/service/documents/admin/permissions">
<table class="permissions-table">
<thead>
<tr>
<th>Документ</th>
<th>Engineer: Просмотр</th>
<th>Engineer: Редактирование</th>
<th>Technician: Просмотр</th>
</tr>
</thead>
<tbody>
{% for slug, doc in perms.items()|sort(attribute='value.sort_order') %}
<tr>
<td>{{ doc.title }}</td>
<td>
<input type="checkbox" name="{{ slug }}_engineer_view" {% if doc.permissions.engineer.view %}checked{% endif %}>
</td>
<td>
<input type="checkbox" name="{{ slug }}_engineer_edit" {% if doc.permissions.engineer.edit %}checked{% endif %}>
</td>
<td>
<input type="checkbox" name="{{ slug }}_technician_view" {% if doc.permissions.technician and doc.permissions.technician.view %}checked{% endif %}>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="/service/documents/" class="btn btn-secondary">Отмена</a>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,36 @@
{% extends "page.html" %}
{% block breadcrumb %}<a href="/service/documents/">Документация</a> / Доступ техников{% endblock %}
{% block page_actions %}<a href="/service/documents/" class="btn btn-secondary btn-sm">← Назад</a>{% endblock %}
{% block page_content %}
<div class="card">
<p style="color:var(--text-muted);margin-bottom:20px;">Вы можете открыть техникам просмотр документов, к которым у вас есть доступ на редактирование.</p>
<form method="post" action="/service/documents/tech-access">
<table class="permissions-table">
<thead>
<tr>
<th>Документ</th>
<th>Technician: Просмотр</th>
</tr>
</thead>
<tbody>
{% for slug, doc in perms.items() %}
{% if doc.permissions.engineer.edit %}
<tr>
<td>{{ doc.title }}</td>
<td>
<input type="checkbox" name="{{ slug }}_technician_view" {% if doc.permissions.technician and doc.permissions.technician.view %}checked{% endif %}>
</td>
</tr>
{% endif %}
{% else %}
<tr><td colspan="2">Нет документов с правом редактирования</td></tr>
{% endfor %}
</tbody>
</table>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="/service/documents/" class="btn btn-secondary">Отмена</a>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,16 @@
{% extends "page.html" %}
{% block breadcrumb %}<a href="/service/documents/">Документация</a>{% endblock %}
{% block page_actions %}
{% if can_edit %}
<a href="/service/documents/{{ doc.filename | replace('.md', '') }}/edit" class="btn btn-secondary btn-sm">Редактировать</a>
{% endif %}
{% if can_download %}
<a href="/service/documents/{{ doc.filename | replace('.md', '') }}/download" class="btn btn-primary btn-sm">Скачать</a>
{% endif %}
<a href="/service/documents/" class="btn btn-secondary btn-sm">← Назад</a>
{% endblock %}
{% block page_content %}
<div class="card">
<div class="doc-content">{{ content | safe }}</div>
</div>
{% endblock %}
+2 -2
View File
@@ -3,8 +3,8 @@
{% block content %}
<div class="login-box">
<div class="login-brand">
<img src="/logo.php" alt="AegisOne" class="login-brand__img">
<div class="login-brand__title">AegisOne Engineering</div>
<div class="login-brand__img"><img src="/service/static/img/favicons/logo_service.png" alt="AegisOne" style="height:48px;width:auto"></div>
<div class="login-brand__title"><span style="color:#e8edf5">Aegis</span><span style="color:#00ADEF">One</span><span style="color:#e8edf5"> Engineering</span></div>
<div class="login-brand__subtitle">Service Portal</div>
</div>
<div class="login-divider"></div>
+165
View File
@@ -0,0 +1,165 @@
{% extends "base.html" %}
{% block body_class %}{{ page_name|default('dashboard-page') }}{% endblock %}
{% block content %}
<button class="sidebar-toggle" onclick="document.querySelector('.sidebar').classList.toggle('open')"></button>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo sidebar-logo-text">Aegis<span>One</span></div>
<div style="font-size:12px;color:var(--text-muted);letter-spacing:0.05em;">Service Portal</div>
</div>
<nav class="sidebar-nav">
<div class="sidebar-section">Панель</div>
<a href="/service/dashboard" class="{% if active_page == 'dashboard' %}active{% endif %}">Дашборд</a>
{% if user.role == 'owner' %}
<div class="sidebar-section">Управление</div>
<a href="/service/users" class="{% if active_page == 'users' %}active{% endif %}">Сотрудники</a>
<a href="/service/customers" class="{% if active_page == 'customers' %}active{% endif %}">Клиенты</a>
<a href="/service/objects" class="{% if active_page == 'objects' %}active{% endif %}">Объекты</a>
<a href="/service/assignments" class="{% if active_page == 'assignments' %}active{% endif %}">Назначение сотрудников</a>
<a href="/service/sla" class="{% if active_page == 'sla' %}active{% endif %}">SLA контракты</a>
<a href="/service/ceo" class="{% if active_page == 'ceo' %}active{% endif %}">CEO дашборд</a>
<a href="/service/coefficients" class="{% if active_page == 'coefficients' %}active{% endif %}">Коэффициенты</a>
<a href="/service/coefficients/test" class="{% if active_page == 'coefficients-test' %}active{% endif %}">Тест формул</a>
<a href="/service/charts" class="{% if active_page == 'charts' %}active{% endif %}">Графики</a>
<a href="/service/ideas" class="{% if active_page == 'ideas' %}active{% endif %}">Идеи</a>
{% elif user.role == 'engineer' %}
<div class="sidebar-section">Управление</div>
<a href="/service/customers" class="{% if active_page == 'customers' %}active{% endif %}">Клиенты</a>
<a href="/service/objects" class="{% if active_page == 'objects' %}active{% endif %}">Объекты</a>
<a href="/service/sla" class="{% if active_page == 'sla' %}active{% endif %}">SLA контракты</a>
<a href="/service/documents/tech-access" class="{% if active_page == 'tech-access' %}active{% endif %}">Доступ техников к документам</a>
{% endif %}
<div class="sidebar-section">Работа</div>
{% if user.role in ['engineer', 'technician'] %}
<a href="/service/tasks" class="{% if active_page == 'tasks' %}active{% endif %}">Задачи</a>
<a href="/service/reports" class="{% if active_page == 'reports' %}active{% endif %}">Отчёты</a>
<a href="/service/incidents" class="{% if active_page == 'incidents' %}active{% endif %}">Инциденты</a>
{% endif %}
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/questionnaire" class="{% if active_page == 'questionnaire' %}active{% endif %}">Опросник</a>
{% endif %}
{% if user.role == 'owner' %}
<a href="/service/questionnaire-config" class="{% if active_page == 'questionnaire-config' %}active{% endif %}">Настройка опросника</a>
{% endif %}
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/passports" class="{% if active_page == 'passports' %}active{% endif %}">Паспорта объектов</a>
{% endif %}
{% if user.role == 'technician' %}
<a href="/service/checklist" class="{% if active_page == 'checklist' %}active{% endif %}">Чек-лист</a>
{% endif %}
{% if user.role == 'owner' %}
<div class="sidebar-section">Внешние системы</div>
<a href="https://git.aegisone.ru" target="_blank" rel="noopener">Gitea</a>
<a href="{{ PORTAINER_URL }}" target="_blank" rel="noopener">Portainer</a>
{% endif %}
<div class="sidebar-section">Блог</div>
{% if user.role == 'owner' %}
<a href="/service/blog" class="{% if active_page == 'blog' %}active{% endif %}">Управление блогом</a>
{% endif %}
<div class="sidebar-section">Контент</div>
{% if user.role == 'owner' %}
<a href="/service/cases" class="{% if active_page == 'cases' %}active{% endif %}">Примеры из практики</a>
{% endif %}
<div class="sidebar-section">Документация</div>
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/documents/" class="{% if active_page == 'docs' %}active{% endif %}">Просмотр документации</a>
{% endif %}
{% if user.role == 'owner' %}
<a href="/service/documents/admin/permissions" class="{% if active_page == 'docs-manage' %}active{% endif %}">Управление документами</a>
{% endif %}
</nav>
<div class="sidebar-footer">
<div class="user-name">{{ user.full_name }}</div>
<a href="/service/logout" style="display:inline-block;margin:6px 0;font-size:12px;">Выйти</a>
<div class="version-pill" onclick="openChangelog()" title="История изменений">v{{ app_version }}</div>
</div>
</aside>
<main class="main">
<div class="main-header">
<div>
<h1>{% block page_title %}{{ title|default(page_name|capitalize) }}{% endblock %}</h1>
<div class="breadcrumb">
{% block breadcrumb %}{% endblock %}
</div>
</div>
<div class="actions">
{% block page_actions %}{% endblock %}
<span style="font-size:12px;color:var(--text-muted);">{{ now }}</span>
</div>
</div>
{% block page_content %}{% endblock %}
</main>
</div>
<div class="modal-overlay" id="changelog-modal"><div class="modal">
<button type="button" class="modal-close" onclick="closeChangelog()"></button>
<h2>История изменений</h2>
<div id="changelog-content" class="doc-content" style="padding:0;background:transparent;border:none;"></div>
</div></div>
<script>
function openChangelog(){
var modal = document.getElementById('changelog-modal');
modal.classList.add('open');
var content = document.getElementById('changelog-content');
if(!content.dataset.loaded){
content.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
fetch('/service/api/changelog').then(function(r){ return r.json() }).then(function(data){
content.innerHTML = data.html || '<p>Нет данных</p>';
content.dataset.loaded = '1';
});
}
}
function closeChangelog(){
document.getElementById('changelog-modal').classList.remove('open');
}
document.addEventListener('DOMContentLoaded', function(){
var nav = document.querySelector('.navbar__menu');
if(!nav) return;
var sidebarLinks = document.querySelectorAll('.sidebar-nav a[href]');
var saved = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
sidebarLinks.forEach(function(link){
var star = document.createElement('span');
star.className = 'sidebar-star';
var url = link.getAttribute('href');
star.textContent = saved.indexOf(url) !== -1 ? '\u2605' : '\u2606';
star.style.cssText = 'cursor:pointer;margin-right:6px;font-size:14px;user-select:none;';
star.addEventListener('click', function(e){
e.preventDefault(); e.stopPropagation();
var favs = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
var idx = favs.indexOf(url);
if(idx !== -1){ favs.splice(idx,1); star.textContent = '\u2606'; }
else { favs.push(url); star.textContent = '\u2605'; }
localStorage.setItem('sidebar_favorites', JSON.stringify(favs));
renderFavorites();
});
link.insertBefore(star, link.firstChild);
});
function renderFavorites(){
var existing = nav.querySelectorAll('.nav-fav');
existing.forEach(function(e){ e.remove(); });
var favs = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
favs.forEach(function(url){
var orig = document.querySelector('.sidebar-nav a[href="' + url + '"]');
if(!orig) return;
var li = document.createElement('li');
li.className = 'nav-fav';
var a = document.createElement('a');
a.href = url; a.textContent = orig.textContent.replace(/^[☆★]/, '').trim();
li.appendChild(a);
nav.insertBefore(li, nav.lastElementChild);
});
}
renderFavorites();
// Restore sidebar scroll position
var sidebarNav = document.querySelector('.sidebar-nav');
var savedScroll = sessionStorage.getItem('sidebar_scroll');
if (sidebarNav && savedScroll) sidebarNav.scrollTop = parseInt(savedScroll, 10);
// Save scroll position before navigating
sidebarNav && sidebarNav.addEventListener('click', function(){
sessionStorage.setItem('sidebar_scroll', this.scrollTop);
});
});
</script>
{% endblock %}
{% block extra_scripts %}{% endblock %}
@@ -0,0 +1,31 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Назначение сотрудников</h2>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-assignment').classList.add('open')">+ Назначить</button>
</div>
<div class="table-wrap"><table><thead><tr><th>Объект</th><th>Сотрудник</th><th>Роль</th><th>Назначен</th><th class="actions"></th></tr></thead>
<tbody>{% for a in assignments %}<tr>
<td>{{ a.object.name if a.object else '' }}</td><td>{{ a.user.full_name if a.user else '' }}</td>
<td><span class="badge badge-{{ a.role }}">{{ {'engineer':'Инженер','technician':'Техник'}[a.role] }}</span></td>
<td>{{ a.assigned_at.strftime('%d.%m.%Y %H:%M') if a.assigned_at else '' }}</td>
<td class="actions">
<form method="post" action="/service/assignments/unassign" style="display:inline">
<input type="hidden" name="id" value="{{ a.id }}">
<button class="btn btn-danger btn-sm">Отвязать</button>
</form>
</td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
<div class="modal-overlay" id="modal-assignment"><div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2>Новое назначение</h2>
<form method="post" action="/service/assignments/create">
<div class="form-group"><label>Объект</label><select name="object_id" required>{% for o in objects %}<option value="{{ o.id }}">{{ o.name }}</option>{% endfor %}</select></div>
<div class="form-group"><label>Сотрудник</label><select name="user_id" required>{% for u in users %}<option value="{{ u.id }}">{{ u.full_name }} ({{ u.role }})</option>{% endfor %}</select></div>
<div class="form-actions"><button type="submit" class="btn btn-primary">Назначить</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form>
</div></div>
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Управление блогом</h2>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-blog').classList.add('open')">+ Новая запись</button>
</div>
<div class="table-wrap"><table><thead><tr><th>Название</th><th>Категория</th><th>Автор</th><th>Статус</th><th>Дата</th><th class="actions"></th></tr></thead>
<tbody>{% for p, author in posts %}<tr>
<td>{{ p.title }}</td><td><span class="badge badge-{{ p.category }}">{{ p.category }}</span></td><td>{{ author }}</td>
<td><span class="badge badge-{{ p.status }}">{{ {'draft':'Черновик','published':'Опубликован','archived':'Архив'}[p.status] }}</span></td>
<td><small>{{ p.created_at.strftime('%d.%m.%Y') if p.created_at else '' }}</small></td>
<td class="actions">
<button class="btn btn-secondary btn-sm" onclick="editBlog({{ p.id }},'{{ p.title|e }}','{{ p.slug|e }}','{{ p.category|e }}','{{ p.status }}','{{ p.excerpt|e }}','{{ p.content|e }}')"></button>
<form method="post" action="/service/blog/delete" style="display:inline" onsubmit="return confirm('Удалить запись?')"><input type="hidden" name="id" value="{{ p.id }}"><button class="btn btn-danger btn-sm"></button></form>
</td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
<div class="modal-overlay" id="modal-blog"><div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2 id="modal-blog-title">Новая запись</h2>
<form method="post" id="modal-blog-form">
<input type="hidden" name="id" id="blog-id">
<div class="form-group"><label>Название</label><input type="text" name="title" id="blog-title" required></div>
<div class="form-row">
<div class="form-group"><label>Slug</label><input type="text" name="slug" id="blog-slug"></div>
<div class="form-group"><label>Категория</label><select name="category" id="blog-category"><option value=""></option><option value="news">Новости</option><option value="tech">Технологии</option><option value="case">Кейсы</option></select></div>
</div>
<div class="form-group"><label>Краткое описание</label><textarea name="excerpt" id="blog-excerpt"></textarea></div>
<div class="form-group"><label>Содержание (HTML)</label><textarea name="content" id="blog-content" rows="10"></textarea></div>
<div class="form-group"><label>Статус</label><select name="status" id="blog-status"><option value="draft">Черновик</option><option value="published">Опубликовать</option></select></div>
<div class="form-actions"><button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form>
</div></div>
<script>
function editBlog(id,title,slug,category,status,excerpt,content){
document.getElementById('modal-blog').classList.add('open');
document.getElementById('modal-blog-title').textContent = 'Редактировать запись';
document.getElementById('modal-blog-form').action = '/service/blog/edit';
document.getElementById('blog-id').value = id;
document.getElementById('blog-title').value = title;
document.getElementById('blog-slug').value = slug;
document.getElementById('blog-category').value = category;
document.getElementById('blog-status').value = status;
document.getElementById('blog-excerpt').value = excerpt;
document.getElementById('blog-content').value = content;
}
</script>
{% endblock %}
+47
View File
@@ -0,0 +1,47 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Примеры из практики</h2>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-case').classList.add('open')">+ Добавить</button>
</div>
<div class="table-wrap"><table><thead><tr><th>ID</th><th>Название</th><th>Порядок</th><th>Активен</th><th class="actions"></th></tr></thead>
<tbody>{% for c in cases %}<tr>
<td>{{ c.id }}</td><td>{{ c.title }}</td><td>{{ c.sort_order }}</td>
<td><span class="badge badge-{{ 'active' if c.is_active else 'inactive' }}">{{ 'Да' if c.is_active else 'Нет' }}</span></td>
<td class="actions">
<button class="btn btn-secondary btn-sm" onclick="editCase({{ c.id }},'{{ c.title|e }}','{{ c.text|e }}','{{ c.effect|e }}',{{ c.sort_order }},{{ 'true' if c.is_active else 'false' }})"></button>
<form method="post" action="/service/cases/delete" style="display:inline" onsubmit="return confirm('Удалить?')"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-danger btn-sm"></button></form>
</td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
<div class="modal-overlay" id="modal-case"><div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2 id="modal-case-title">Новый пример</h2>
<form method="post" id="modal-case-form" action="/service/cases/create">
<input type="hidden" name="id" id="case-id">
<div class="form-group"><label>Название</label><input type="text" name="title" id="case-title" required></div>
<div class="form-group"><label>Описание проблемы</label><textarea name="text" id="case-text"></textarea></div>
<div class="form-group"><label>Результат</label><textarea name="effect" id="case-effect"></textarea></div>
<div class="form-row">
<div class="form-group"><label>Порядок</label><input type="number" name="sort_order" id="case-sort_order" value="0"></div>
<div class="form-check" style="margin-top:24px"><input type="checkbox" name="is_active" id="case-is_active" value="1" checked><label for="case-is_active">Активен</label></div>
</div>
<div class="form-actions"><button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form>
</div></div>
<script>
function editCase(id,title,text,effect,order,active){
document.getElementById('modal-case').classList.add('open');
document.getElementById('modal-case-title').textContent = 'Редактировать';
document.getElementById('modal-case-form').action = '/service/cases/edit';
document.getElementById('case-id').value = id;
document.getElementById('case-title').value = title;
document.getElementById('case-text').value = text;
document.getElementById('case-effect').value = effect;
document.getElementById('case-sort_order').value = order;
document.getElementById('case-is_active').checked = active;
}
</script>
{% endblock %}
+45
View File
@@ -0,0 +1,45 @@
{% extends "page.html" %}
{% block page_content %}
{% if latest_shs %}
<div class="card-grid">
<div class="card"><div class="card-label">SHS Score</div><div class="card-value value-accent">{{ "%.2f"|format(latest_shs.shs_score) }}</div><div class="card-label"><span class="badge badge-{{ latest_shs.shs_status|lower }}">{{ latest_shs.shs_status }}</span></div></div>
<div class="card"><div class="card-label">MRR</div><div class="card-value value-green">{{ "%.2f"|format(mrr) }} ₽</div></div>
<div class="card"><div class="card-label">SLA Compliance</div><div class="card-value {% if sla_compliance >= 90 %}value-green{% elif sla_compliance >= 70 %}value-yellow{% else %}value-red{% endif %}">{{ sla_compliance }}%</div></div>
<div class="card"><div class="card-label">Активные объекты</div><div class="card-value value-info">{{ active_objects }}</div></div>
<div class="card"><div class="card-label">Сотрудники</div><div class="card-value">{{ active_engineers }}</div></div>
<div class="card"><div class="card-label">Активные SLA</div><div class="card-value">{{ active_contracts }}</div></div>
<div class="card"><div class="card-label">Инциденты (30д)</div><div class="card-value {% if incidents_30 > 5 %}value-red{% else %}value-green{% endif %}">{{ incidents_30 }}</div></div>
<div class="card"><div class="card-label">Avg Engineer Score</div><div class="card-value value-accent">{{ avg_es }}</div></div>
</div>
{% endif %}
<div class="card">
<div class="card-header"><h2>История SHS</h2>
<div class="actions"><form method="post" action="/service/ceo/calc" style="display:inline"><button class="btn btn-primary btn-sm">Рассчитать SHS</button></form>
<form method="post" action="/service/ceo/kpi" style="display:inline"><button class="btn btn-secondary btn-sm">Рассчитать KPI</button></form></div>
</div>
<div class="table-wrap"><table><thead><tr><th>Период</th><th>SHS</th><th>Статус</th><th>MRR</th><th>Retention</th><th>Сотрудники</th></tr></thead>
<tbody>{% for s in shs_history %}<tr>
<td><small>{{ s.period_start.strftime('%d.%m.%Y') if s.period_start else '' }} — {{ s.period_end.strftime('%d.%m.%Y') if s.period_end else '' }}</small></td>
<td><strong>{{ "%.2f"|format(s.shs_score) }}</strong></td>
<td><span class="badge badge-{{ s.shs_status|lower }}">{{ s.shs_status }}</span></td>
<td>{{ "%.2f"|format(s.mrr or 0) }} ₽</td>
<td>{{ "%.1f"|format(s.retention_rate or 0) }}%</td>
<td>{{ s.employee_count or 0 }}</td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
<div class="card">
<div class="card-header"><h2>Последние KPI инженеров</h2></div>
<div class="table-wrap"><table><thead><tr><th>Инженер</th><th>Период</th><th>SLA Compliance</th><th>Engineer Score</th><th>Grade</th></tr></thead>
<tbody>{% for kpi, name in kpi_data %}<tr>
<td>{{ name }}</td>
<td><small>{{ kpi.period_start.strftime('%d.%m.%Y') if kpi.period_start else '' }} — {{ kpi.period_end.strftime('%d.%m.%Y') if kpi.period_end else '' }}</small></td>
<td>{{ "%.1f"|format(kpi.sla_compliance or 0) }}%</td>
<td><strong>{{ "%.2f"|format(kpi.engineer_score or 0) }}</strong></td>
<td><span class="badge badge-{{ kpi.grade|lower if kpi.grade else 'default' }}">{{ kpi.grade or '—' }}</span></td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
{% endblock %}
+158
View File
@@ -0,0 +1,158 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Графики и диаграммы</h2></div>
<div class="filter-bar" style="flex-wrap:wrap;gap:8px;">
<select id="metric-select">
<option value="tasks">Задачи по месяцам</option>
<option value="incidents">Инциденты по severity</option>
<option value="shs">SHS история</option>
<option value="kpi">KPI инженеров</option>
<option value="mrr">MRR по статусам</option>
<option value="objects">Объекты по статусам</option>
</select>
<select id="chart-type">
<option value="bar">Столбчатая</option>
<option value="line">Линейная</option>
<option value="pie">Круговая</option>
<option value="doughnut">Кольцевая</option>
</select>
<select id="chart-group">
<option value="month">По месяцам</option>
<option value="week">По неделям</option>
<option value="day">По дням</option>
</select>
<label style="font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:4px;">
<input type="text" id="chart-title" placeholder="Название графика" style="width:160px;padding:4px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg-card);color:var(--text);">
</label>
<button class="btn btn-primary btn-sm" onclick="loadChart()">Обновить</button>
<button class="btn btn-secondary btn-sm" onclick="exportChart()">Экспорт PNG</button>
</div>
<div class="filter-bar" style="margin-top:8px;flex-wrap:wrap;gap:6px;">
<span style="font-size:12px;color:var(--text-muted);">Пользовательские графики:</span>
<button class="btn btn-secondary btn-xs" onclick="saveCustomChart()">Сохранить текущий</button>
<select id="custom-chart-select" onchange="loadCustomChart(this.value)">
<option value="">— Загрузить —</option>
</select>
<button class="btn btn-danger btn-xs" onclick="deleteCustomChart()">Удалить</button>
</div>
<div style="position:relative;height:400px;">
<canvas id="chart-canvas"></canvas>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script>
var chartInstance = null;
var currentChartConfig = null;
function loadChart(){
var metric = document.getElementById('metric-select').value;
var type = document.getElementById('chart-type').value;
var group = document.getElementById('chart-group').value;
var canvas = document.getElementById('chart-canvas');
var title = document.getElementById('chart-title').value || '';
fetch('/service/api/charts/data?metric=' + metric + '&group=' + group).then(function(r){ return r.json() }).then(function(data){
if(chartInstance) { chartInstance.destroy(); chartInstance = null; }
if(!data.labels || data.labels.length === 0){
return;
}
currentChartConfig = { metric: metric, type: type, group: group, title: title, labels: data.labels };
var datasets = data.datasets.map(function(ds){
var color = 'hsl(' + (Math.random() * 360) + ', 70%, 60%)';
return {
label: ds.label,
data: ds.data,
backgroundColor: type === 'line' ? color : (type === 'bar' ? color : [
'rgba(0,173,239,0.7)', 'rgba(34,197,94,0.7)', 'rgba(245,158,11,0.7)',
'rgba(239,68,68,0.7)', 'rgba(99,102,241,0.7)', 'rgba(236,72,153,0.7)'
]),
borderColor: color,
borderWidth: 2,
tension: 0.3,
};
});
chartInstance = new Chart(canvas, {
type: type === 'doughnut' ? 'doughnut' : (['pie', 'doughnut'].includes(type) ? type : type),
data: { labels: data.labels, datasets: datasets },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#8899bb' } },
title: title ? { display: true, text: title, color: '#e8edf5', font: { size: 14 } } : undefined
},
scales: (['pie', 'doughnut'].includes(type) ? undefined : {
x: { ticks: { color: '#8899bb' }, grid: { color: 'rgba(255,255,255,0.05)' } },
y: { ticks: { color: '#8899bb' }, grid: { color: 'rgba(255,255,255,0.05)' } }
})
}
});
});
}
function exportChart(){
var canvas = document.getElementById('chart-canvas');
if(!canvas) return;
var link = document.createElement('a');
link.download = 'chart_' + new Date().toISOString().slice(0,10) + '.png';
link.href = canvas.toDataURL('image/png');
link.click();
}
function saveCustomChart(){
if(!currentChartConfig) return;
var name = prompt('Название графика:');
if(!name) return;
var saved = JSON.parse(localStorage.getItem('custom_charts') || '[]');
saved.push({ name: name, config: currentChartConfig });
localStorage.setItem('custom_charts', JSON.stringify(saved));
updateCustomChartList();
}
function loadCustomChart(name){
if(!name) return;
var saved = JSON.parse(localStorage.getItem('custom_charts') || '[]');
var found = saved.find(function(c){ return c.name === name; });
if(!found) return;
document.getElementById('metric-select').value = found.config.metric;
document.getElementById('chart-type').value = found.config.type;
document.getElementById('chart-group').value = found.config.group;
document.getElementById('chart-title').value = found.config.title || '';
loadChart();
}
function deleteCustomChart(){
var sel = document.getElementById('custom-chart-select');
var name = sel.value;
if(!name) return;
if(!confirm('Удалить "' + name + '"?')) return;
var saved = JSON.parse(localStorage.getItem('custom_charts') || '[]');
saved = saved.filter(function(c){ return c.name !== name; });
localStorage.setItem('custom_charts', JSON.stringify(saved));
updateCustomChartList();
sel.value = '';
}
function updateCustomChartList(){
var sel = document.getElementById('custom-chart-select');
var saved = JSON.parse(localStorage.getItem('custom_charts') || '[]');
sel.innerHTML = '<option value="">— Загрузить —</option>';
saved.forEach(function(c){
var opt = document.createElement('option');
opt.value = c.name; opt.textContent = c.name;
sel.appendChild(opt);
});
}
document.addEventListener('DOMContentLoaded', function(){
loadChart();
updateCustomChartList();
});
</script>
{% endblock %}
@@ -0,0 +1,32 @@
{% extends "page.html" %}
{% block page_content %}
{% for t in tasks %}
<div class="card">
<h3>{{ t.title }}</h3>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;">
{{ t.object.name if t.object else '' }} — {{ t.object.address if t.object else '' }}
<span class="badge badge-{{ {'P1':'critical','P2':'high','P3':'medium','P4':'low'}[t.priority] }}" style="margin-left:8px;">{{ t.priority }}</span>
</div>
{% if t.description %}<p style="margin-bottom:12px;">{{ t.description }}</p>{% endif %}
<form method="post" action="/service/checklist/complete">
<input type="hidden" name="task_id" value="{{ t.id }}">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px;">
<label class="form-check"><input type="checkbox" name="arrival_fixed" value="1"> Прибытие на объект зафиксировано</label>
<label class="form-check"><input type="checkbox" name="photo_before" value="1"> Фото ДО выполнения работ</label>
<label class="form-check"><input type="checkbox" name="power_check" value="1"> Проверка электропитания</label>
<label class="form-check"><input type="checkbox" name="cameras_check" value="1"> Проверка камер</label>
<label class="form-check"><input type="checkbox" name="acs_check" value="1"> Проверка СКУД</label>
<label class="form-check"><input type="checkbox" name="fire_check" value="1"> Проверка пожарной сигнализации</label>
<label class="form-check"><input type="checkbox" name="fault_found" value="1"> Неисправность обнаружена</label>
<label class="form-check"><input type="checkbox" name="fault_fixed" value="1"> Неисправность устранена</label>
<label class="form-check"><input type="checkbox" name="system_test" value="1"> Система протестирована</label>
<label class="form-check"><input type="checkbox" name="photo_after" value="1"> Фото ПОСЛЕ выполнения работ</label>
</div>
<div class="form-group"><label>Заметки</label><textarea name="notes"></textarea></div>
<button type="submit" class="btn btn-primary">Завершить задачу</button>
</form>
</div>
{% else %}
<div class="empty-state"><h3>Нет активных задач</h3><p>У вас нет открытых задач для выполнения.</p></div>
{% endfor %}
{% endblock %}
@@ -0,0 +1,107 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header">
<h2>Коэффициенты формул</h2>
<div>
<a href="/service/coefficients/test" class="btn btn-secondary btn-sm" style="margin-right:6px">🧪 Тест формул</a>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-coeff').classList.add('open')">+ Добавить</button>
</div>
</div>
{% if groups|length == 0 %}
<div class="card-body"><p class="empty">Нет коэффициентов</p></div>
{% else %}
{% for gkey, gdata in groups.items() %}
<div class="accordion">
<div class="accordion-header" onclick="toggleAccordion(this)">
<div class="accordion-header-left">
<span class="accordion-arrow"></span>
<span class="accordion-title">{{ gdata.info.name }}</span>
<span class="accordion-badge">{{ gdata.coeffs|length }}</span>
</div>
<div class="accordion-header-right">
<span class="accordion-formula" title="Формула" onclick="event.stopPropagation(); showFormulaPopover(this, '{{ gkey }}')" data-formula="{{ gdata.info.formula }}" data-result="{{ gdata.info.result }}">📐</span>
<span class="accordion-action" onclick="event.stopPropagation(); window.location='/service/coefficients/test?group={{ gkey }}'">🧪</span>
</div>
</div>
<div class="accordion-body">
<div class="table-wrap"><table><thead><tr><th>Ключ</th><th>Значение</th><th>Описание</th><th>Формула</th><th>Активен</th><th class="actions"></th></tr></thead>
<tbody>{% for c in gdata.coeffs %}<tr>
<td><code>{{ c.key }}</code></td>
<td><strong>{{ c.value }}</strong></td>
<td><small>{{ c.description }}</small></td>
<td><small>{{ c.formula_ref }}</small></td>
<td><form method="post" action="/service/coefficients/toggle"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-{{ 'success' if c.is_active else 'secondary' }} btn-sm">{{ '✓' if c.is_active else '✕' }}</button></form></td>
<td class="actions"><button class="btn btn-secondary btn-sm" onclick="editCoeff({{ c.id }},'{{ c.key|e }}','{{ c.value }}','{{ c.description|e }}','{{ c.formula_ref|e }}')"></button>
<form method="post" action="/service/coefficients/delete" style="display:inline" onsubmit="return confirm('Удалить коэффициент?')"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-danger btn-sm"></button></form>
</td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
</div>
{% endfor %}
{% endif %}
</div>
<div class="modal-overlay" id="modal-coeff"><div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2 id="modal-coeff-title">Новый коэффициент</h2>
<form method="post" id="modal-coeff-form">
<input type="hidden" name="id" id="coeff-id">
<div class="form-row">
<div class="form-group"><label>Ключ</label><input type="text" name="key" id="coeff-key" required></div>
<div class="form-group"><label>Значение</label><input type="text" name="value" id="coeff-value" required></div>
</div>
<div class="form-group"><label>Описание</label><input type="text" name="description" id="coeff-description"></div>
<div class="form-group"><label>Формула</label><input type="text" name="formula_ref" id="coeff-formula_ref"></div>
<div class="form-actions"><button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form>
</div></div>
<div class="modal-overlay" id="modal-formula-info"><div class="modal modal-sm">
<button type="button" class="modal-close" onclick="document.getElementById('modal-formula-info').classList.remove('open')"></button>
<h2 id="formula-info-title">Формула</h2>
<div id="formula-info-content" style="margin-top:12px"></div>
</div></div>
<script>
function toggleAccordion(el) {
var arrow = el.querySelector('.accordion-arrow');
var body = el.nextElementSibling;
if (body.style.display === 'none' || body.style.display === '') {
body.style.display = 'block';
arrow.textContent = '▼';
} else {
body.style.display = 'none';
arrow.textContent = '▶';
}
}
function editCoeff(id,key,value,desc,ref){
document.getElementById('modal-coeff').classList.add('open');
document.getElementById('modal-coeff-title').textContent = 'Редактировать коэффициент';
document.getElementById('modal-coeff-form').action = '/service/coefficients/edit';
document.getElementById('coeff-id').value = id;
document.getElementById('coeff-key').value = key;
document.getElementById('coeff-value').value = value;
document.getElementById('coeff-description').value = desc;
document.getElementById('coeff-formula_ref').value = ref;
}
function showFormulaPopover(el, gkey) {
var formula = el.getAttribute('data-formula');
var result = el.getAttribute('data-result');
var title = el.closest('.accordion-header').querySelector('.accordion-title').textContent;
document.getElementById('formula-info-title').textContent = title;
document.getElementById('formula-info-content').innerHTML =
'<div style="margin-bottom:12px"><strong>Формула:</strong></div>' +
'<div style="background:var(--bg-input);padding:12px;border-radius:8px;font-family:monospace;font-size:13px;margin-bottom:12px;white-space:pre-wrap">' + formula + '</div>' +
'<div><strong>Результат:</strong> ' + result + '</div>';
document.getElementById('modal-formula-info').classList.add('open');
}
// Open first group by default
document.addEventListener('DOMContentLoaded', function() {
var first = document.querySelector('.accordion-body');
if (first) { first.style.display = 'block'; first.previousElementSibling.querySelector('.accordion-arrow').textContent = '▼'; }
});
</script>
{% endblock %}
@@ -0,0 +1,90 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Клиенты</h2>
{% if error %}<div class="alert alert-error" style="margin-bottom:12px;padding:8px 12px;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:6px;color:var(--danger);font-size:13px;">{{ error }}</div>{% endif %}
</div>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-customer').classList.add('open')">+ Добавить</button>
</div>
<form method="get" class="search-box"><input type="text" name="search" placeholder="Поиск по названию или ИНН..." value="{{ search }}"><button class="btn btn-primary btn-sm">Найти</button></form>
<div class="table-wrap">
<table><thead><tr><th>Название</th><th>ИНН</th><th>Контакты</th><th>Статус</th><th>Объекты</th><th>Активные SLA</th><th class="actions"></th></tr></thead>
<tbody>{% for d in customers %}<tr>
<td>{{ d.c.name }}</td><td>{{ d.c.inn }}</td>
<td><small>{{ d.c.contact_person }}<br>{{ d.c.contact_phone }}</small></td>
<td><span class="badge badge-{{ d.c.status }}">{{ {'active':'Активен','inactive':'Неактивен','prospect':'Перспективный'}[d.c.status] }}</span></td>
<td><a href="/service/objects?customer_id={{ d.c.id }}">{{ d.objects_count }}</a></td>
<td>{{ d.active_contracts }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" onclick="editCustomer({{ d.c.id }},'{{ d.c.name|e }}','{{ d.c.inn|e }}','{{ d.c.kpp|e }}','{{ d.c.legal_address|e }}','{{ d.c.contact_person|e }}','{{ d.c.contact_phone|e }}','{{ d.c.contact_email|e }}','{{ d.c.status }}','{{ d.c.notes|e }}')"></button>
{% if user.role == 'owner' %}
<form method="post" action="/service/customers/delete" style="display:inline" onsubmit="return confirm('Удалить клиента?')">
<input type="hidden" name="id" value="{{ d.c.id }}"><button class="btn btn-danger btn-sm"></button>
</form>{% endif %}
</td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
<div class="modal-overlay" id="modal-customer"><div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2 id="modal-customer-title">Новый клиент</h2>
<form method="post" id="modal-customer-form" action="/service/customers/create">
<input type="hidden" name="id" id="customer-id">
<div class="form-row">
<div class="form-group"><label>Название <span style="color:var(--danger)">*</span></label><input type="text" name="name" id="customer-name" required></div>
<div class="form-group"><label>ИНН <span style="color:var(--danger)">*</span></label><input type="text" name="inn" id="customer-inn" required pattern="\d{10,12}" title="ИНН: 10 или 12 цифр" inputmode="numeric"></div>
</div>
<div class="form-row">
<div class="form-group"><label>КПП</label><input type="text" name="kpp" id="customer-kpp" pattern="\d{9}" title="КПП: 9 цифр" maxlength="9" inputmode="numeric"></div>
<div class="form-group"><label>Юр. адрес</label><input type="text" name="legal_address" id="customer-legal_address"></div>
</div>
<div class="form-row">
<div class="form-group"><label>Контактное лицо <span style="color:var(--danger)">*</span></label><input type="text" name="contact_person" id="customer-contact_person" required></div>
<div class="form-group"><label>Телефон <span style="color:var(--danger)">*</span></label><input type="text" name="contact_phone" id="customer-contact_phone" required></div>
</div>
<div class="form-row">
<div class="form-group"><label>Email</label><input type="email" name="contact_email" id="customer-contact_email"></div>
<div class="form-group"><label>Статус</label><select name="status" id="customer-status"><option value="active">Активен</option><option value="inactive">Неактивен</option><option value="prospect">Перспективный</option></select></div>
</div>
<div class="form-group"><label>Заметки</label><textarea name="notes" id="customer-notes"></textarea></div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button>
</div>
</form>
</div></div>
<script>
function phoneMask(input){
var x = input.value.replace(/\D/g, '').match(/(\d{0,1})(\d{0,3})(\d{0,3})(\d{0,2})(\d{0,2})/);
if (!x) return;
var val = '8';
if (x[2]) val += ' (' + x[2];
if (x[3]) val += ') ' + x[3];
if (x[4]) val += '-' + x[4];
if (x[5]) val += '-' + x[5];
input.value = val;
}
document.addEventListener('DOMContentLoaded', function(){
var phoneInput = document.getElementById('customer-contact_phone');
phoneInput.addEventListener('focus', function(){
if (!this.value || this.value === '8') this.value = '8 (';
});
phoneInput.addEventListener('input', function(){ phoneMask(this); });
});
function editCustomer(id,name,inn,kpp,addr,person,phone,email,status,notes){
document.getElementById('modal-customer').classList.add('open');
document.getElementById('modal-customer-title').textContent = 'Редактировать клиента';
document.getElementById('modal-customer-form').action = '/service/customers/edit';
document.getElementById('customer-id').value = id;
document.getElementById('customer-name').value = name;
document.getElementById('customer-inn').value = inn;
document.getElementById('customer-kpp').value = kpp;
document.getElementById('customer-legal_address').value = addr;
document.getElementById('customer-contact_person').value = person;
document.getElementById('customer-contact_phone').value = phone;
document.getElementById('customer-contact_email').value = email;
document.getElementById('customer-status').value = status;
document.getElementById('customer-notes').value = notes;
}
</script>
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Документация</h2></div>
{% if error %}<div class="alert alert-error">{{ error }}</div>{% endif %}
{% if docs %}
<div class="docs-list">{% for d in docs %}
<div class="doc-item"><a href="/service/documents/{{ d.filename | replace('.md', '') }}">{{ d.title }}</a></div>
{% endfor %}</div>
{% else %}
<div class="empty-state"><h3>Нет доступных документов</h3></div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,17 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Управление документами</h2></div>
<p>Управление доступом к документам для инженеров. Отметьте документы, которые будут видны.</p>
<form method="get" action="/service/documents/admin/permissions">
<div class="table-wrap"><table><thead><tr><th>Документ</th><th>Доступен инженерам</th></tr></thead>
<tbody>{% for d in docs %}
<tr>
<td>{{ d.title }}</td>
<td><input type="checkbox" name="perm_{{ d.filename }}" value="1" {% for p in (perms.get('docs') or []) if p.filename == d.filename and p.view %}checked{% endfor %}></td>
</tr>{% endfor %}</tbody></table>
</div>
<div class="form-actions"><button type="submit" class="btn btn-primary">Сохранить</button></div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends "page.html" %}
{% block breadcrumb %}<a href="/service/documents/">Документация</a>{% endblock %}
{% block page_actions %}<a class="btn btn-secondary btn-sm" href="/service/documents/">← Назад</a>{% endblock %}
{% block page_content %}
<div class="card">
<div class="doc-content">{{ content|safe }}</div>
</div>
{% endblock %}
@@ -0,0 +1,145 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header">
<h2>🧪 Тест формул</h2>
<a href="/service/coefficients" class="btn btn-secondary btn-sm">← Коэффициенты</a>
</div>
<div class="card-body">
<div class="form-group">
<label>Выберите формулу для тестирования</label>
<select id="formula-select" onchange="switchFormula(this.value)" style="max-width:400px">
{% for f in formulas %}
<option value="{{ f.key }}" {% if f.key == selected %}selected{% endif %}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
{% for f in formulas %}
<div id="formula-{{ f.key }}" class="formula-test-panel" {% if f.key != selected %}style="display:none"{% endif %}>
<div class="formula-description">
<div style="margin-bottom:8px"><strong>Формула:</strong></div>
<div class="formula-code">{{ f.formula }}</div>
<div style="margin-top:8px"><strong>Результат:</strong> {{ f.result }}</div>
</div>
{% if f.sandbox %}
<form class="formula-sandbox-form" onsubmit="return testFormula(event, '{{ f.key }}')">
<h3 style="margin:16px 0 12px;font-size:14px;">Параметры расчёта</h3>
<div class="formula-inputs">
{% for inp in f.sandbox.inputs %}
<div class="form-group">
<label>{{ inp.label }}</label>
{% if inp.type == "checkbox" %}
<input type="checkbox" name="{{ inp.key }}" value="1" style="width:20px;height:20px;accent-color:var(--accent)">
{% elif inp.type == "select" %}
<select name="{{ inp.key }}">
{% for ok, ol in inp.options.items() %}
<option value="{{ ok }}" {% if ok == inp.default %}selected{% endif %}>{{ ol }}</option>
{% endfor %}
</select>
{% else %}
<input type="number" name="{{ inp.key }}" value="{{ inp.default|default(0) }}" step="any">
{% endif %}
</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary">Рассчитать</button>
<div class="formula-result" id="result-{{ f.key }}" style="display:none"></div>
</form>
{% else %}
<div class="empty-state" style="padding:24px 0">
<p>Для этой формулы пока нет тестовой песочницы. Используйте коэффициенты на странице <a href="/service/coefficients">управления коэффициентами</a>.</p>
</div>
{% endif %}
<h3 style="margin:20px 0 10px;font-size:13px;color:var(--text-muted)">Связанные коэффициенты</h3>
<div class="table-wrap"><table><thead><tr><th>Ключ</th><th>Значение</th><th>Описание</th></tr></thead>
<tbody>{% for c in f.coeffs %}<tr>
<td><code>{{ c.key }}</code></td>
<td><strong>{{ c.value }}</strong></td>
<td><small>{{ c.description }}</small></td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
{% endfor %}
</div>
</div>
<script>
function switchFormula(key) {
document.querySelectorAll('.formula-test-panel').forEach(function(el) {
el.style.display = 'none';
});
var panel = document.getElementById('formula-' + key);
if (panel) panel.style.display = 'block';
history.replaceState(null, '', '/service/coefficients/test?group=' + key);
}
function testFormula(event, key) {
event.preventDefault();
var form = event.target;
var data = new URLSearchParams(new FormData(form));
var resultBox = document.getElementById('result-' + key);
resultBox.style.display = 'block';
resultBox.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
resultBox.className = 'formula-result';
fetch('/service/coefficients/test/' + key, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: data.toString()
}).then(function(r) { return r.json() }).then(function(resp) {
resultBox.textContent = 'Результат: ' + (resp.result || 'Ошибка');
resultBox.className = 'formula-result formula-result-success';
}).catch(function(err) {
resultBox.textContent = 'Ошибка: ' + err.message;
resultBox.className = 'formula-result formula-result-error';
});
return false;
}
</script>
<style>
.formula-test-panel { margin-top: 16px; }
.formula-description {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 12px;
}
.formula-code {
background: rgba(0,0,0,0.3);
padding: 12px;
border-radius: 8px;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
color: var(--accent);
}
.formula-inputs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.formula-result {
margin-top: 12px;
padding: 12px 16px;
border-radius: var(--radius);
font-weight: 600;
font-size: 14px;
}
.formula-result-success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid var(--success);
color: var(--success);
}
.formula-result-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--danger);
color: var(--danger);
}
</style>
{% endblock %}
+55
View File
@@ -0,0 +1,55 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Система идей</h2>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-idea').classList.add('open')">+ Новая идея</button>
</div>
<div class="table-wrap"><table><thead><tr><th>ID</th><th>Название</th><th>Описание</th><th>Статус</th><th>Дата</th><th class="actions"></th></tr></thead>
<tbody>{% for idea in ideas %}<tr>
<td>{{ idea.id }}</td>
<td><strong>{{ idea.title }}</strong></td>
<td>{{ (idea.description or '')[:100] }}{% if (idea.description or '')|length > 100 %}…{% endif %}</td>
<td><span class="badge badge-{{ 'done' if idea.status == 'completed' else 'progress' if idea.status == 'in_progress' else 'default' }}">{{ {'normal':'Обычная','in_progress':'В работе','completed':'Выполнено'}.get(idea.status, idea.status) }}</span></td>
<td><small>{{ idea.updated_at.strftime('%d.%m.%Y') if idea.updated_at else '' }}</small></td>
<td class="actions">
<form method="post" action="/service/ideas/status" style="display:inline">
<input type="hidden" name="id" value="{{ idea.id }}">
<button class="btn btn-secondary btn-sm" title="Переключить статус"></button>
</form>
<button class="btn btn-secondary btn-sm" onclick="editIdea({{ idea.id }},{{ idea.title|tojson }},{{ idea.description|tojson }})"></button>
<form method="post" action="/service/ideas/delete" style="display:inline" onsubmit="return confirm('Удалить идею?')">
<input type="hidden" name="id" value="{{ idea.id }}">
<button class="btn btn-danger btn-sm"></button>
</form>
</td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
<div class="modal-overlay" id="modal-idea"><div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2 id="modal-idea-title">Новая идея</h2>
<form method="post" id="modal-idea-form">
<input type="hidden" name="id" id="idea-id">
<div class="form-group"><label>Название</label><input type="text" name="title" id="idea-title" required></div>
<div class="form-group"><label>Описание</label><textarea name="description" id="idea-description" rows="4"></textarea></div>
<div class="form-actions"><button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form>
</div></div>
<script>
function editIdea(id,title,description){
document.getElementById('modal-idea').classList.add('open');
document.getElementById('modal-idea-title').textContent = 'Редактировать идею';
document.getElementById('modal-idea-form').action = '/service/ideas/edit';
document.getElementById('idea-id').value = id;
document.getElementById('idea-title').value = title;
document.getElementById('idea-description').value = description;
}
document.addEventListener('DOMContentLoaded', function(){
var form = document.getElementById('modal-idea-form');
if(form) form.action = '/service/ideas/create';
});
</script>
{% endblock %}
@@ -0,0 +1,55 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Инциденты</h2>
{% if user.role in ['owner','engineer'] %}
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-incident').classList.add('open')">+ Создать</button>
{% endif %}
</div>
<form method="get" class="filter-bar">
<select name="severity" onchange="this.form.submit()"><option value="">Все важности</option><option value="P1" {% if severity == 'P1' %}selected{% endif %}>P1 — Критичный</option><option value="P2" {% if severity == 'P2' %}selected{% endif %}>P2 — Высокий</option><option value="P3" {% if severity == 'P3' %}selected{% endif %}>P3 — Средний</option></select>
<select name="status_filter" onchange="this.form.submit()"><option value="">Все статусы</option><option value="open" {% if status_filter == 'open' %}selected{% endif %}>Открыт</option><option value="in_progress" {% if status_filter == 'in_progress' %}selected{% endif %}>В работе</option><option value="resolved" {% if status_filter == 'resolved' %}selected{% endif %}>Решён</option></select>
</form>
<div class="table-wrap"><table><thead><tr><th>Важность</th><th>Название</th><th>Объект</th><th>Исполнитель</th><th>Статус</th><th>Создан</th><th class="actions"></th></tr></thead>
<tbody>{% for i in incidents %}<tr>
<td><span class="badge badge-{{ {'P1':'critical','P2':'high','P3':'medium'}[i.severity] }}">{{ i.severity }}</span></td>
<td>{{ i.title }}</td><td>{{ i.object.name if i.object else '' }}</td>
<td>{{ i.assignee.full_name if i.assignee else '' }}</td>
<td><span class="badge badge-{{ i.status }}">{{ {'open':'Открыт','in_progress':'В работе','resolved':'Решён','closed':'Закрыт'}[i.status] }}</span></td>
<td><small>{{ i.created_at.strftime('%d.%m.%Y') if i.created_at else '' }}</small></td>
<td class="actions">{% if i.status in ['open','in_progress'] %}
<button class="btn btn-success btn-sm" onclick="resolveIncident({{ i.id }})">✓ Решить</button>
{% endif %}
</td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
<div class="modal-overlay" id="modal-incident"><div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2>Новый инцидент</h2>
<form method="post" action="/service/incidents/create">
<div class="form-group"><label>Название</label><input type="text" name="title" required></div>
<div class="form-group"><label>Описание</label><textarea name="description"></textarea></div>
<div class="form-row">
<div class="form-group"><label>Объект</label><select name="object_id" required>{% for o in objects %}<option value="{{ o.id }}">{{ o.name }}</option>{% endfor %}</select></div>
<div class="form-group"><label>Исполнитель</label><select name="assigned_to"><option value=""></option>{% for u in users %}<option value="{{ u.id }}">{{ u.full_name }}</option>{% endfor %}</select></div>
</div>
<div class="form-group"><label>Важность</label><select name="severity"><option value="P3">P3 — Средний</option><option value="P1">P1 — Критичный</option><option value="P2">P2 — Высокий</option></select></div>
<div class="form-actions"><button type="submit" class="btn btn-primary">Создать</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form>
</div></div>
<div class="modal-overlay" id="modal-resolve"><div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2>Решение инцидента</h2>
<form method="post" action="/service/incidents/resolve">
<input type="hidden" name="id" id="resolve-id">
<div class="form-group"><label>Описание решения</label><textarea name="resolution" required></textarea></div>
<div class="form-actions"><button type="submit" class="btn btn-success">Подтвердить</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form>
</div></div>
<script>
function resolveIncident(id){ document.getElementById('resolve-id').value = id; document.getElementById('modal-resolve').classList.add('open'); }
</script>
{% endblock %}
@@ -0,0 +1,67 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Объекты</h2>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-object').classList.add('open')">+ Добавить</button>
</div>
<form method="get" class="filter-bar">
<input type="text" name="search" placeholder="Поиск..." value="{{ search }}">
<select name="customer_id" onchange="this.form.submit()"><option value="">Все клиенты</option>{% for c in customers_list %}<option value="{{ c.id }}" {% if customer_id == c.id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}</select>
<button class="btn btn-primary btn-sm">Найти</button>
</form>
<div class="table-wrap"><table><thead><tr><th>Название</th><th>Клиент</th><th>Тип</th><th>Адрес</th><th>Статус</th><th class="actions"></th></tr></thead>
<tbody>{% for o in objects %}<tr>
<td>{{ o.name }}</td><td>{{ o.customer.name if o.customer else '' }}</td>
<td>{{ o.object_type }}</td><td><small>{{ o.address }}</small></td>
<td><span class="badge badge-{{ o.status }}">{{ {'active':'Активен','inactive':'Неактивен','audit':'Аудит','prospective':'Перспективный'}[o.status] }}</span></td>
<td class="actions">
<button class="btn btn-secondary btn-sm" onclick="editObject({{ o.id }},'{{ o.name|e }}','{{ o.customer_id or '' }}','{{ o.object_type|e }}','{{ o.address|e }}','{{ o.contact_person|e }}','{{ o.contact_phone|e }}','{{ o.status }}','{{ o.notes|e }}')"></button>
{% if user.role == 'owner' %}
<form method="post" action="/service/objects/delete" style="display:inline" onsubmit="return confirm('Удалить объект?')"><input type="hidden" name="id" value="{{ o.id }}"><button class="btn btn-danger btn-sm"></button></form>
{% endif %}
</td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
<div class="modal-overlay" id="modal-object"><div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2 id="modal-object-title">Новый объект</h2>
<form method="post" id="modal-object-form" action="/service/objects/create">
<input type="hidden" name="id" id="object-id">
<div class="form-row">
<div class="form-group"><label>Название</label><input type="text" name="name" id="object-name" required></div>
<div class="form-group"><label>Клиент</label><select name="customer_id" id="object-customer_id"><option value=""></option>{% for c in customers_list %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}</select></div>
</div>
<div class="form-row">
<div class="form-group"><label>Тип объекта</label><select name="object_type" id="object-object_type"><option value="">— Выберите —</option><option value="office">Офис</option><option value="warehouse">Склад</option><option value="production">Производство</option><option value="retail">Торговое помещение</option><option value="residential">Жилой комплекс</option><option value="construction">Стройка</option><option value="other">Другое</option></select></div>
<div class="form-group"><label>Статус</label><select name="status" id="object-status"><option value="active">Активен</option><option value="inactive">Неактивен</option><option value="audit">Аудит</option><option value="prospective">Перспективный</option></select></div>
</div>
<div class="form-group"><label>Адрес</label><input type="text" name="address" id="object-address"></div>
<div class="form-row">
<div class="form-group"><label>Контактное лицо</label><input type="text" name="contact_person" id="object-contact_person"></div>
<div class="form-group"><label>Телефон</label><input type="text" name="contact_phone" id="object-contact_phone"></div>
</div>
<div class="form-group"><label>Заметки</label><textarea name="notes" id="object-notes"></textarea></div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button>
</div>
</form>
</div></div>
<script>
function editObject(id,name,cid,type,addr,person,phone,status,notes){
document.getElementById('modal-object').classList.add('open');
document.getElementById('modal-object-title').textContent = 'Редактировать объект';
document.getElementById('modal-object-form').action = '/service/objects/edit';
document.getElementById('object-id').value = id;
document.getElementById('object-name').value = name;
document.getElementById('object-customer_id').value = cid;
document.getElementById('object-object_type').value = type;
document.getElementById('object-address').value = addr;
document.getElementById('object-contact_person').value = person;
document.getElementById('object-contact_phone').value = phone;
document.getElementById('object-status').value = status;
document.getElementById('object-notes').value = notes;
}
</script>
{% endblock %}
@@ -0,0 +1,16 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Паспорта объектов</h2></div>
<div class="table-wrap"><table><thead><tr><th>Объект</th><th>Автор</th><th>Object Index</th><th>Risk Score</th><th>SLA Price</th><th>Дата</th></tr></thead>
<tbody>{% for p in passports %}<tr>
<td>{{ p.object.name if p.object else '' }}</td>
<td>{{ p.creator.full_name if p.creator else '' }}</td>
<td><strong>{{ "%.2f"|format(p.object_index) if p.object_index else '—' }}</strong></td>
<td><span class="badge badge-{{ 'critical' if (p.risk_score or 0) > 70 else 'medium' if (p.risk_score or 0) > 40 else 'low' }}">{{ "%.2f"|format(p.risk_score) if p.risk_score else '—' }}</span></td>
<td>{{ "%.2f"|format(p.sla_price_monthly) if p.sla_price_monthly else '—' }} ₽</td>
<td><small>{{ p.created_at.strftime('%d.%m.%Y') if p.created_at else '' }}</small></td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,16 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Опросники</h2>
<a class="btn btn-primary btn-sm" href="/service/questionnaire/step/1">+ Новый опросник</a>
</div>
<div class="table-wrap"><table><thead><tr><th>ID</th><th>Клиент</th><th>Автор</th><th>Статус</th><th>Шаг</th><th>Дата</th></tr></thead>
<tbody>{% for s, author in sessions %}<tr>
<td>#{{ s.id }}</td><td>{{ s.client_name }}</td><td>{{ author }}</td>
<td><span class="badge badge-{{ s.status }}">{{ {'draft':'Черновик','completed':'Завершён','cancelled':'Отменён'}[s.status] }}</span></td>
<td>{{ s.current_step }}/5</td>
<td><small>{{ s.created_at.strftime('%d.%m.%Y') if s.created_at else '' }}</small></td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,59 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Настройка опросника</h2>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-qitem').classList.add('open')">+ Добавить вопрос</button>
</div>
<div class="tabs"><a href="/service/questionnaire-config" class="{% if not sel_step %}active{% endif %}">Все</a>{% for s in range(1,6) %}<a href="/service/questionnaire-config?step={{ s }}" class="{% if sel_step == s %}active{% endif %}">Шаг {{ s }}</a>{% endfor %}</div>
<div class="table-wrap"><table><thead><tr><th>Шаг</th><th>Раздел</th><th>Ключ</th><th>Вопрос</th><th>Тип</th><th>Обязат.</th><th>Порядок</th><th>Активен</th><th class="actions"></th></tr></thead>
<tbody>{% for qi in items %}<tr>
<td>{{ qi.step }}</td><td>{{ qi.section }}</td><td><code>{{ qi.question_key }}</code></td><td>{{ qi.label }}</td>
<td>{{ qi.type }}</td><td>{{ '✓' if qi.required else '—' }}</td><td>{{ qi.sort_order }}</td>
<td><form method="post" action="/service/questionnaire-config/toggle"><input type="hidden" name="id" value="{{ qi.id }}"><button class="btn btn-{{ 'success' if qi.is_active else 'secondary' }} btn-sm">{{ '✓' if qi.is_active else '✕' }}</button></form></td>
<td class="actions">
<button class="btn btn-secondary btn-sm" onclick="editQItem({{ qi.id }},{{ qi.step }},'{{ qi.section|e }}','{{ qi.question_key|e }}','{{ qi.label|e }}','{{ qi.type }}','{{ qi.required|lower }}','{{ qi.sort_order }}','{{ qi.help_text|e }}')"></button>
<form method="post" action="/service/questionnaire-config/delete" style="display:inline" onsubmit="return confirm('Удалить вопрос?')"><input type="hidden" name="id" value="{{ qi.id }}"><button class="btn btn-danger btn-sm"></button></form>
</td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
<div class="modal-overlay" id="modal-qitem"><div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2 id="modal-qitem-title">Новый вопрос</h2>
<form method="post" id="modal-qitem-form">
<input type="hidden" name="id" id="qitem-id">
<div class="form-row">
<div class="form-group"><label>Шаг</label><select name="step" id="qitem-step"><option value="1">1</option><option value="2">2</option><option value="3">3</option><option value="4">4</option><option value="5">5</option></select></div>
<div class="form-group"><label>Раздел</label><input type="text" name="section" id="qitem-section"></div>
</div>
<div class="form-row">
<div class="form-group"><label>Ключ</label><input type="text" name="question_key" id="qitem-question_key" required></div>
<div class="form-group"><label>Тип</label><select name="type" id="qitem-type"><option value="text">text</option><option value="number">number</option><option value="select">select</option><option value="radio">radio</option><option value="checkbox">checkbox</option><option value="textarea">textarea</option></select></div>
</div>
<div class="form-group"><label>Вопрос</label><input type="text" name="label" id="qitem-label" required></div>
<div class="form-row">
<div class="form-group"><label>Порядок</label><input type="number" name="sort_order" id="qitem-sort_order" value="0"></div>
<div class="form-check" style="margin-top:24px"><input type="checkbox" name="required" id="qitem-required" value="1"><label for="qitem-required">Обязательный</label></div>
</div>
<div class="form-group"><label>Подсказка</label><input type="text" name="help_text" id="qitem-help_text"></div>
<div class="form-actions"><button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form>
</div></div>
<script>
function editQItem(id,step,section,key,label,type,required,order,help){
document.getElementById('modal-qitem').classList.add('open');
document.getElementById('modal-qitem-title').textContent = 'Редактировать вопрос';
document.getElementById('modal-qitem-form').action = '/service/questionnaire-config/edit';
document.getElementById('qitem-id').value = id;
document.getElementById('qitem-step').value = step;
document.getElementById('qitem-section').value = section;
document.getElementById('qitem-question_key').value = key;
document.getElementById('qitem-label').value = label;
document.getElementById('qitem-type').value = type;
document.getElementById('qitem-required').checked = required === 'true';
document.getElementById('qitem-sort_order').value = order;
document.getElementById('qitem-help_text').value = help;
}
</script>
{% endblock %}
@@ -0,0 +1,53 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Шаг {{ step }} из 5</h2>
<div class="step-indicators">{% for s in range(1,6) %}<span class="step-dot {% if s == step %}active{% elif s < step %}completed{% endif %}">Шаг {{ s }}</span>{% endfor %}</div>
</div>
<form method="post" action="/service/questionnaire/save/{{ step }}">
{% if session_id %}<input type="hidden" name="session_id" value="{{ session_id }}">{% endif %}
{% for item in items %}
<div class="form-group">
<label>{{ item.label }}{% if item.required %} <span style="color:var(--danger)">*</span>{% endif %}</label>
{% if item.type == 'text' %}<input type="text" name="{{ item.question_key }}" {% if item.required %}required{% endif %}>
{% elif item.type == 'number' %}<input type="number" name="{{ item.question_key }}" {% if item.required %}required{% endif %}>
{% elif item.type == 'textarea' %}<textarea name="{{ item.question_key }}" {% if item.required %}required{% endif %}></textarea>
{% elif item.type in ('select','radio') %}
<select name="{{ item.question_key }}" {% if item.required %}required{% endif %}>
<option value="">— Выберите —</option>
{% for opt in (item.options or {}).get('options', []) %}<option value="{{ opt }}">{{ opt }}</option>{% endfor %}
</select>
{% elif item.type == 'checkbox' %}<div class="form-check">{% for opt in (item.options or {}).get('options', []) %}
<label><input type="checkbox" name="{{ item.question_key }}" value="{{ opt }}"> {{ opt }}</label>{% endfor %}</div>
{% endif %}
{% if item.help_text %}<small style="color:var(--text-muted)">{{ item.help_text }}</small>{% endif %}
</div>
{% endfor %}
<div class="form-actions">
{% if step > 1 %}<a class="btn btn-secondary" href="/service/questionnaire/step/{{ step-1 }}">← Назад</a>{% endif %}
{% if step < 5 %}<button type="submit" class="btn btn-primary">Далее →</button>{% else %}<button type="submit" class="btn btn-success">Завершить</button>{% endif %}
<button type="button" class="btn btn-secondary" onclick="exitQuestionnaire()">Выйти</button>
</div>
</form>
</div>
<div class="modal-overlay" id="modal-exit-questionnaire"><div class="modal" style="max-width:480px">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2>Выйти из опросника?</h2>
<p style="color:var(--text-muted);font-size:14px;">Вы можете сохранить черновик и продолжить позже</p>
<div class="form-actions" style="margin-top:16px">
<button type="button" class="btn btn-primary" onclick="saveAndExit()">Сохранить черновик</button>
<button type="button" class="btn btn-secondary" onclick="exitWithoutSaving()">Выйти без сохранения</button>
</div>
</div></div>
<script>
function exitQuestionnaire(){
document.getElementById('modal-exit-questionnaire').classList.add('open');
}
function saveAndExit(){
document.querySelector('form').submit();
}
function exitWithoutSaving(){
window.location.href = '/service/questionnaire';
}
</script>
{% endblock %}
@@ -0,0 +1,38 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>{% if editing_tech %}Меню для техников{% else %}Быстрое меню{% endif %}</h2>
<span style="font-size:12px;color:var(--text-muted);">Настройте ссылки, отображаемые в верхней панели навигации</span>
</div>
<form method="post" action="{% if editing_tech %}/service/quick-menu/tech-save{% else %}/service/quick-menu/save{% endif %}">
<div class="table-wrap"><table><thead><tr><th style="width:40px;">#</th><th>Название</th><th>Ссылка</th><th class="actions"><button type="button" class="btn btn-primary btn-sm" onclick="addRow()">+ Добавить</button></th></tr></thead>
<tbody id="items-body">
{% for item in items %}
<tr>
<td>{{ loop.index }}</td>
<td><input type="text" name="label[]" value="{{ item.label }}" style="width:100%;" required></td>
<td><input type="text" name="url[]" value="{{ item.url }}" style="width:100%;" required></td>
<td class="actions"><button type="button" class="btn btn-danger btn-sm" onclick="this.closest('tr').remove()"></button></td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% if items|length == 0 %}
<div class="empty-state" id="empty-state"><h3>Нет элементов</h3><p>Добавьте ссылки для быстрого доступа</p></div>
{% endif %}
<div class="form-actions"><button type="submit" class="btn btn-primary">Сохранить</button>
<a href="/service/dashboard" class="btn btn-secondary">Отмена</a></div>
</form>
</div>
<script>
function addRow(){
var tbody = document.getElementById('items-body');
var empty = document.getElementById('empty-state');
if(empty) empty.style.display = 'none';
var tr = document.createElement('tr');
var count = tbody.children.length + 1;
tr.innerHTML = '<td>'+count+'</td><td><input type="text" name="label[]" style="width:100%;" required></td><td><input type="text" name="url[]" style="width:100%;" required></td><td class="actions"><button type="button" class="btn btn-danger btn-sm" onclick="this.closest(\'tr\').remove()"></button></td>';
tbody.appendChild(tr);
}
</script>
{% endblock %}
@@ -0,0 +1,37 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Отчёты</h2>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-report').classList.add('open')">+ Создать</button>
</div>
<form method="get" class="filter-bar">
<select name="type_filter" onchange="this.form.submit()"><option value="">Все типы</option><option value="inspection" {% if type_filter == 'inspection' %}selected{% endif %}>Осмотр</option><option value="service" {% if type_filter == 'service' %}selected{% endif %}>Обслуживание</option><option value="emergency" {% if type_filter == 'emergency' %}selected{% endif %}>Авария</option><option value="audit" {% if type_filter == 'audit' %}selected{% endif %}>Аудит</option><option value="monthly" {% if type_filter == 'monthly' %}selected{% endif %}>Ежемесячный</option></select>
</form>
<div class="table-wrap"><table><thead><tr><th>Тип</th><th>Название</th><th>Объект</th><th>Статус</th><th>Результат</th><th>Дата</th></tr></thead>
<tbody>{% for r in reports %}<tr>
<td><span class="badge badge-{{ r.report_type }}">{{ {'inspection':'Осмотр','service':'Обслуживание','emergency':'Авария','audit':'Аудит','monthly':'Ежемесячный'}[r.report_type] }}</span></td>
<td>{{ r.title }}</td><td>{{ r.object.name if r.object else '' }}</td>
<td><span class="badge badge-{{ r.status }}">{{ {'draft':'Черновик','final':'Готов','cancelled':'Отменён'}[r.status] }}</span></td>
<td><span class="badge badge-{{ r.result_status }}">{{ {'ok':'OK','fixed':'Исправлено','partial':'Частично','revisit':'Повторный'}[r.result_status] }}</span></td>
<td><small>{{ r.created_at.strftime('%d.%m.%Y') if r.created_at else '' }}</small></td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
<div class="modal-overlay" id="modal-report"><div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2>Новый отчёт</h2>
<form method="post" action="/service/reports/create">
<div class="form-row">
<div class="form-group"><label>Объект</label><select name="object_id" required>{% for o in objects %}<option value="{{ o.id }}">{{ o.name }}</option>{% endfor %}</select></div>
<div class="form-group"><label>Тип</label><select name="report_type"><option value="inspection">Осмотр</option><option value="service">Обслуживание</option><option value="emergency">Авария</option><option value="audit">Аудит</option><option value="monthly">Ежемесячный</option></select></div>
</div>
<div class="form-group"><label>Название</label><input type="text" name="title" required></div>
<div class="form-group"><label>Описание</label><textarea name="description"></textarea></div>
<div class="form-group"><label>Результаты</label><textarea name="findings"></textarea></div>
<div class="form-group"><label>Рекомендации</label><textarea name="recommendations"></textarea></div>
<div class="form-group"><label>Статус</label><select name="status"><option value="draft">Черновик</option><option value="final">Готов</option></select></div>
<div class="form-actions"><button type="submit" class="btn btn-primary">Создать</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form>
</div></div>
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>SLA контракты</h2>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-sla').classList.add('open')">+ Добавить</button>
</div>
<form method="get" class="filter-bar">
<select name="object_id" onchange="this.form.submit()"><option value="">Все объекты</option>{% for o in objects %}<option value="{{ o.id }}" {% if sel_object_id == o.id %}selected{% endif %}>{{ o.name }}</option>{% endfor %}</select>
</form>
<div class="table-wrap"><table><thead><tr><th>Объект</th><th>Уровень</th><th>Цена/мес</th><th>Период</th><th>Статус</th><th class="actions"></th></tr></thead>
<tbody>{% for sc in contracts %}<tr>
<td>{{ sc.object.name if sc.object else '' }}</td>
<td><span class="badge badge-{{ sc.service_level }}">{{ {'start':'Базовый','business':'Оптимальный','enterprise':'Максимальный'}.get(sc.service_level, sc.service_level) }}</span></td>
<td>{{ "%.2f"|format(sc.sla_price_monthly) }} ₽</td>
<td><small>{{ sc.start_date.strftime('%d.%m.%Y') if sc.start_date else '' }} — {{ sc.end_date.strftime('%d.%m.%Y') if sc.end_date else '∞' }}</small></td>
<td><span class="badge badge-{{ sc.status }}">{{ {'active':'Активен','expired':'Истёк','cancelled':'Отменён','negotiation':'Переговоры'}.get(sc.status, sc.status) }}</span></td>
<td class="actions"><button class="btn btn-secondary btn-sm" onclick="editSLA({{ sc.id }},{{ sc.object_id }},'{{ sc.service_level }}','{{ sc.sla_price_monthly if sc.sla_price_monthly is not none else '' }}','{{ sc.start_date if sc.start_date is not none else '' }}','{{ sc.end_date if sc.end_date is not none else '' }}','{{ sc.status }}')"></button></td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
<div class="modal-overlay" id="modal-sla"><div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2 id="modal-sla-title">Новый SLA контракт</h2>
<form method="post" id="modal-sla-form" action="/service/sla/create">
<input type="hidden" name="id" id="sla-id">
<div class="form-group"><label>Объект</label><select name="object_id" id="sla-object_id" required>{% for o in objects %}<option value="{{ o.id }}">{{ o.name }}</option>{% endfor %}</select></div>
<div class="form-row">
<div class="form-group"><label>Уровень</label><select name="service_level" id="sla-service_level"><option value="start">Базовый</option><option value="business">Оптимальный</option><option value="enterprise">Максимальный</option></select></div>
<div class="form-group"><label>Цена (₽/мес)</label><input type="number" step="0.01" name="sla_price_monthly" id="sla-sla_price_monthly"></div>
</div>
<div class="form-row">
<div class="form-group"><label>Начало</label><input type="date" name="start_date" id="sla-start_date"></div>
<div class="form-group"><label>Окончание</label><input type="date" name="end_date" id="sla-end_date"></div>
</div>
<div class="form-group"><label>Статус</label><select name="status" id="sla-status"><option value="negotiation" selected>Переговоры</option><option value="active">Активен</option><option value="expired">Истёк</option><option value="cancelled">Отменён</option></select></div>
<div class="form-actions"><button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form>
</div></div>
<script>
function editSLA(id,oid,sl,price,start,end,status){
document.getElementById('modal-sla').classList.add('open');
document.getElementById('modal-sla-title').textContent = 'Редактировать SLA';
document.getElementById('modal-sla-form').action = '/service/sla/edit';
document.getElementById('sla-id').value = id;
document.getElementById('sla-object_id').value = oid;
document.getElementById('sla-service_level').value = sl;
document.getElementById('sla-sla_price_monthly').value = price;
document.getElementById('sla-start_date').value = start;
document.getElementById('sla-end_date').value = end;
document.getElementById('sla-status').value = status;
}
</script>
{% endblock %}
+46
View File
@@ -0,0 +1,46 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Задачи</h2>
{% if user.role in ['owner','engineer'] %}
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-task').classList.add('open')">+ Создать</button>
{% endif %}
</div>
<form method="get" class="filter-bar">
<select name="status_filter" onchange="this.form.submit()"><option value="">Все статусы</option><option value="open" {% if status_filter == 'open' %}selected{% endif %}>Открытые</option><option value="in_progress" {% if status_filter == 'in_progress' %}selected{% endif %}>В работе</option><option value="completed" {% if status_filter == 'completed' %}selected{% endif %}>Завершённые</option></select>
<input type="text" name="search" placeholder="Поиск..." value="{{ search }}">
<button class="btn btn-primary btn-sm">Найти</button>
</form>
<div class="table-wrap"><table><thead><tr><th>Приоритет</th><th>Название</th><th>Объект</th><th>Исполнитель</th><th>Статус</th><th>Срок</th><th class="actions"></th></tr></thead>
<tbody>{% for t in tasks %}<tr>
<td><span class="badge badge-{{ {'P1':'critical','P2':'high','P3':'medium','P4':'low'}[t.priority] }}">{{ t.priority }}</span></td>
<td>{{ t.title }}</td><td>{{ t.object.name if t.object else '' }}</td>
<td>{{ t.assignee.full_name if t.assignee else '' }}</td>
<td><span class="badge badge-{{ t.status }}">{{ {'open':'Открыта','in_progress':'В работе','completed':'Завершена','cancelled':'Отменена'}[t.status] }}</span></td>
<td><small>{{ t.deadline.strftime('%d.%m.%Y') if t.deadline else '' }}</small></td>
<td class="actions">{% if t.status in ['open','in_progress'] and (user.role in ['owner','engineer'] or t.assigned_to == user.id) %}
<form method="post" action="/service/tasks/close" style="display:inline" onsubmit="return confirm('Завершить задачу?')"><input type="hidden" name="id" value="{{ t.id }}"><button class="btn btn-success btn-sm">✓ Закрыть</button></form>
{% endif %}
</td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
<div class="modal-overlay" id="modal-task"><div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2>Новая задача</h2>
<form method="post" action="/service/tasks/create">
<div class="form-group"><label>Название</label><input type="text" name="title" required></div>
<div class="form-group"><label>Описание</label><textarea name="description"></textarea></div>
<div class="form-row">
<div class="form-group"><label>Объект</label><select name="object_id" required>{% for o in objects %}<option value="{{ o.id }}">{{ o.name }}</option>{% endfor %}</select></div>
<div class="form-group"><label>Исполнитель</label><select name="assigned_to"><option value=""></option>{% for u in users %}<option value="{{ u.id }}">{{ u.full_name }}</option>{% endfor %}</select></div>
</div>
<div class="form-row">
<div class="form-group"><label>Приоритет</label><select name="priority"><option value="P3">P3 — Medium</option><option value="P1">P1 — Critical</option><option value="P2">P2 — High</option><option value="P4">P4 — Low</option></select></div>
<div class="form-group"><label>Срок</label><input type="date" name="deadline"></div>
</div>
<div class="form-actions"><button type="submit" class="btn btn-primary">Создать</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form>
</div></div>
{% endblock %}
+67
View File
@@ -0,0 +1,67 @@
{% extends "page.html" %}
{% block page_content %}
<div class="card">
<div class="card-header"><h2>Сотрудники</h2>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-user').classList.add('open')">+ Добавить</button>
</div>
<div class="table-wrap">
<table><thead><tr><th>Логин</th><th>ФИО</th><th>Роль</th><th>Телефон</th><th>Email</th><th>Статус</th><th>Создан</th><th class="actions"></th></tr></thead>
<tbody>{% for u in users %}<tr>
<td>{{ u.login }}</td><td>{{ u.full_name }}</td>
<td><span class="badge badge-{{ u.role }}">{{ {'owner':'Владелец','engineer':'Инженер','technician':'Техник'}[u.role] }}</span></td>
<td>{{ u.phone }}</td><td>{{ u.email }}</td>
<td><span class="badge badge-{{ 'active' if u.is_active else 'inactive' }}">{{ 'Активен' if u.is_active else 'Неактивен' }}</span></td>
<td>{{ u.created_at.strftime('%d.%m.%Y') if u.created_at else '' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" onclick="editUser({{ u.id }},'{{ u.login }}','{{ u.full_name }}','{{ u.role }}','{{ u.phone }}','{{ u.email }}',{{ 'true' if u.is_active else 'false' }})"></button>
{% if u.role != 'owner' %}
<form method="post" action="/service/users/delete" style="display:inline" onsubmit="return confirm('Удалить пользователя?')">
<input type="hidden" name="id" value="{{ u.id }}">
<button type="submit" class="btn btn-danger btn-sm"></button>
</form>{% endif %}
</td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
<div class="modal-overlay" id="modal-user">
<div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2 id="modal-user-title">Новый сотрудник</h2>
<form method="post" id="modal-user-form" action="/service/users/create">
<input type="hidden" name="id" id="user-id" value="">
<div class="form-row">
<div class="form-group"><label>Логин</label><input type="text" name="login" id="user-login" required></div>
<div class="form-group"><label>Пароль <small>(оставьте пустым при редактировании)</small></label><input type="password" name="password" id="user-password"></div>
</div>
<div class="form-row">
<div class="form-group"><label>ФИО</label><input type="text" name="full_name" id="user-full_name" required></div>
<div class="form-group"><label>Роль</label><select name="role" id="user-role"><option value="engineer">Инженер</option><option value="technician">Техник</option></select></div>
</div>
<div class="form-row">
<div class="form-group"><label>Телефон</label><input type="text" name="phone" id="user-phone"></div>
<div class="form-group"><label>Email</label><input type="email" name="email" id="user-email"></div>
</div>
<div class="form-check"><input type="checkbox" name="is_active" id="user-is_active" value="1" checked><label for="user-is_active">Активен</label></div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button>
</div>
</form>
</div>
</div>
<script>
function editUser(id,login,name,role,phone,email,active){
document.getElementById('modal-user').classList.add('open');
document.getElementById('modal-user-title').textContent = 'Редактировать сотрудника';
document.getElementById('modal-user-form').action = '/service/users/edit';
document.getElementById('user-id').value = id;
document.getElementById('user-login').value = login; document.getElementById('user-login').required = false;
document.getElementById('user-full_name').value = name;
document.getElementById('user-role').value = role;
document.getElementById('user-phone').value = phone; document.getElementById('user-email').value = email;
document.getElementById('user-is_active').checked = active;
document.getElementById('user-password').required = false;
}
document.querySelector('#modal-user .modal-close').addEventListener('click', function(){ this.closest('.modal-overlay').classList.remove('open'); });
</script>
{% endblock %}
+10
View File
@@ -0,0 +1,10 @@
from fastapi.templating import Jinja2Templates
from app.config import get_settings
from app.version import get_app_version
settings = get_settings()
templates = Jinja2Templates(directory="app/templates")
templates.env.globals["SITE_NAME"] = "AegisOne Engineering"
templates.env.globals["app_version"] = get_app_version()
templates.env.globals["PORTAINER_URL"] = settings.PORTAINER_URL
+15
View File
@@ -0,0 +1,15 @@
import os
from pathlib import Path
VERSION_FILE = Path(__file__).parent.parent / "version.txt"
def read_version() -> str:
try:
return VERSION_FILE.read_text().strip()
except (FileNotFoundError, OSError):
return "0.0.1"
def get_app_version() -> str:
return read_version()
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Auto-increment version and update changelog on deploy."""
import os
import sys
import json
from datetime import datetime
from pathlib import Path
BASE = Path(__file__).resolve().parent.parent
VERSION_FILE = BASE / "version.txt"
CHANGELOG_FILE = BASE / "CHANGELOG.md"
def read_version() -> tuple:
try:
parts = VERSION_FILE.read_text().strip().split(".")
return tuple(int(p) for p in parts[:3])
except (FileNotFoundError, ValueError):
return (0, 0, 1)
def write_version(major: int, minor: int, patch: int):
VERSION_FILE.write_text(f"{major}.{minor}.{patch}\n")
def bump_version(bump_type: str = "patch") -> str:
major, minor, patch = read_version()
if bump_type == "major":
major += 1; minor = 0; patch = 0
elif bump_type == "minor":
minor += 1; patch = 0
else:
patch += 1
write_version(major, minor, patch)
return f"{major}.{minor}.{patch}"
def add_changelog_entry(version: str, features: list = None, fixes: list = None):
date = datetime.now().strftime("%d.%m.%Y")
lines = [f"\n## {version} ({date})"]
if features:
lines.append("### Новые функции\n" + "\n".join(f"- {f}" for f in features))
if fixes:
lines.append("### Исправления и улучшения\n" + "\n".join(f"- {f}" for f in fixes))
entry = "\n".join(lines) + "\n"
changelog = CHANGELOG_FILE.read_text(encoding="utf-8") if CHANGELOG_FILE.exists() else "# Changelog — AegisOne Service Portal\n"
# Insert after the title line
if changelog.startswith("# Changelog"):
header_end = changelog.find("\n", changelog.find("\n", 2) + 1)
if header_end == -1:
changelog += entry
else:
changelog = changelog[:header_end] + entry + changelog[header_end:]
else:
changelog = f"# Changelog — AegisOne Service Portal\n{entry}\n" + changelog
CHANGELOG_FILE.write_text(changelog, encoding="utf-8")
if __name__ == "__main__":
bump = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] in ("major", "minor", "patch") else "patch"
features = []
fixes = []
if len(sys.argv) > 2:
try:
parsed = json.loads(sys.argv[2])
features = parsed.get("features", [])
fixes = parsed.get("fixes", [])
except (json.JSONDecodeError, TypeError):
pass
version = bump_version(bump)
add_changelog_entry(version, features, fixes)
print(f"Version bumped to {version}")
+1
View File
@@ -0,0 +1 @@
1.4.0