@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,6 +105,7 @@ class RoleGuardMiddleware(BaseHTTPMiddleware):
|
||||
if path.startswith("/service/") and not any(
|
||||
path.startswith(p) for p in ["/service/login", "/service/static", "/service/api/"]
|
||||
):
|
||||
if "session" in request.scope:
|
||||
user = request.session.get("user")
|
||||
if not user:
|
||||
return RedirectResponse(url="/service/login", status_code=302)
|
||||
@@ -46,20 +114,19 @@ class RoleGuardMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
|
||||
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:
|
||||
@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)
|
||||
if roles and user["role"] not in roles:
|
||||
return HTMLResponse(content="Доступ запрещён", status_code=403)
|
||||
return user
|
||||
return dependency
|
||||
|
||||
|
||||
@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"}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -36,9 +36,28 @@ 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},
|
||||
},
|
||||
}
|
||||
# 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
|
||||
if role == "engineer":
|
||||
perms = load_permissions()
|
||||
doc = perms.get(slug, {})
|
||||
if role == "engineer":
|
||||
return doc.get("permissions", {}).get("engineer", {}).get(action, False)
|
||||
if role == "technician":
|
||||
return doc.get("permissions", {}).get("technician", {}).get("view", False)
|
||||
return False
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
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"}]}
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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,'"') + '" ' + (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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -0,0 +1 @@
|
||||
1.4.0
|
||||