feat(ui): modern theme, dark/light toggle, polished templates
This commit is contained in:
9
.vscode/settings.json
vendored
Normal file
9
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"chatgpt.openOnStartup": true,
|
||||||
|
// Codex - OpenAI's coding agent: run without prompts in this workspace
|
||||||
|
"codex.approvalPolicy": "never",
|
||||||
|
// Filesystem access: use with care; consider "workspace-write" if you prefer safer mode
|
||||||
|
"codex.sandboxMode": "danger-full-access",
|
||||||
|
// Allow network calls (fetch deps/APIs)
|
||||||
|
"codex.networkAccess": "enabled"
|
||||||
|
}
|
||||||
24
README.md
Normal file
24
README.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
Zammad Reports (Flask)
|
||||||
|
|
||||||
|
Quickstart
|
||||||
|
|
||||||
|
- Create and activate venv (optional).
|
||||||
|
- Install deps: pip install -r requirements.txt
|
||||||
|
- Configure env variables (recommended):
|
||||||
|
- ZAMMAD_URL=https://task.4mont.ru
|
||||||
|
- ZAMMAD_TOKEN=your_token_here
|
||||||
|
- Run: python app.py
|
||||||
|
|
||||||
|
Environment
|
||||||
|
|
||||||
|
- FLASK_SECRET_KEY: optional, for sessions/flash.
|
||||||
|
- ZAMMAD_URL: base URL of Zammad (e.g. https://task.4mont.ru).
|
||||||
|
- ZAMMAD_TOKEN: personal access token with API rights.
|
||||||
|
|
||||||
|
Features
|
||||||
|
|
||||||
|
- Totals: total, open vs closed.
|
||||||
|
- Grouping: by agent (owner), by group, by state, by priority.
|
||||||
|
- Trend: tickets per day in range.
|
||||||
|
- HTML charts (bar/line) with Chart.js + Bootstrap UI.
|
||||||
|
- JSON API: `/api/report.json` aggregates everything for the period.
|
||||||
7
app.py
Normal file
7
app.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from app import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import os
|
||||||
|
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=True)
|
||||||
32
app/__init__.py
Normal file
32
app/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import os
|
||||||
|
import datetime as dt
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from .routes import web_bp
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> Flask:
|
||||||
|
app = Flask(__name__, template_folder="../templates", static_folder="../static")
|
||||||
|
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "dev-secret")
|
||||||
|
|
||||||
|
# Base config
|
||||||
|
app.config.setdefault("ZAMMAD_URL", os.environ.get("ZAMMAD_URL", "https://task.4mont.ru"))
|
||||||
|
app.config.setdefault(
|
||||||
|
"ZAMMAD_TOKEN",
|
||||||
|
os.environ.get(
|
||||||
|
"ZAMMAD_TOKEN",
|
||||||
|
"L5A9p_7Ge6YSR-VhAFysu6vqhu5qfWkri6ITdZLcKdZW7tes342KqHmNY6Ao2jY_",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Defaults for forms
|
||||||
|
app.config.setdefault("DEFAULT_DATE_TO", dt.date.today().isoformat())
|
||||||
|
app.config.setdefault(
|
||||||
|
"DEFAULT_DATE_FROM",
|
||||||
|
(dt.date.today() - dt.timedelta(days=30)).isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
app.register_blueprint(web_bp)
|
||||||
|
|
||||||
|
return app
|
||||||
BIN
app/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/routes.cpython-310.pyc
Normal file
BIN
app/__pycache__/routes.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/clients/__pycache__/zammad.cpython-310.pyc
Normal file
BIN
app/clients/__pycache__/zammad.cpython-310.pyc
Normal file
Binary file not shown.
124
app/clients/zammad.py
Normal file
124
app/clients/zammad.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ZammadError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ZammadClient:
|
||||||
|
def __init__(self, base_url: str, token: str) -> None:
|
||||||
|
self.base = base_url.rstrip("/")
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update(
|
||||||
|
{
|
||||||
|
"Authorization": f"Token token={token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simple in-memory caches
|
||||||
|
self._user_cache: Dict[int, str] = {}
|
||||||
|
self._group_cache: Dict[int, str] = {}
|
||||||
|
self._state_cache: Dict[int, Dict[str, Any]] = {}
|
||||||
|
self._priority_cache: Dict[int, str] = {}
|
||||||
|
|
||||||
|
# HTTP helper
|
||||||
|
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
||||||
|
url = f"{self.base}{path}"
|
||||||
|
try:
|
||||||
|
r = self.session.get(url, params=params, timeout=30)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise ZammadError(str(e))
|
||||||
|
if r.status_code >= 400:
|
||||||
|
raise ZammadError(f"{r.status_code} {r.text}")
|
||||||
|
try:
|
||||||
|
return r.json()
|
||||||
|
except ValueError:
|
||||||
|
raise ZammadError("Invalid JSON from Zammad")
|
||||||
|
|
||||||
|
# Tickets search by created range
|
||||||
|
def search_tickets(self, date_from: str, date_to: str, per_page: int = 200) -> List[Dict[str, Any]]:
|
||||||
|
start = date_from[:10]
|
||||||
|
end = date_to[:10]
|
||||||
|
query = f"created_at:[{start} TO {end}]"
|
||||||
|
|
||||||
|
tickets: List[Dict[str, Any]] = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
params = {"query": query, "per_page": per_page, "page": page}
|
||||||
|
data = self._get("/api/v1/tickets/search", params=params)
|
||||||
|
|
||||||
|
if isinstance(data, list):
|
||||||
|
batch = data
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
if isinstance(data.get("tickets"), list):
|
||||||
|
batch = data["tickets"]
|
||||||
|
elif isinstance(data.get("rows"), list):
|
||||||
|
batch = data["rows"]
|
||||||
|
else:
|
||||||
|
batch = data.get("data", []) if isinstance(data.get("data"), list) else []
|
||||||
|
else:
|
||||||
|
batch = []
|
||||||
|
|
||||||
|
tickets.extend(batch)
|
||||||
|
if len(batch) < per_page:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
return tickets
|
||||||
|
|
||||||
|
# Lookups
|
||||||
|
def user_name(self, user_id: Optional[int]) -> str:
|
||||||
|
if not user_id:
|
||||||
|
return "Без владельца"
|
||||||
|
if user_id in self._user_cache:
|
||||||
|
return self._user_cache[user_id]
|
||||||
|
data = self._get(f"/api/v1/users/{user_id}")
|
||||||
|
name = (data.get("fullname") or data.get("firstname") or data.get("login") or str(user_id)).strip()
|
||||||
|
self._user_cache[user_id] = name
|
||||||
|
return name
|
||||||
|
|
||||||
|
def group_name(self, group_id: Optional[int]) -> str:
|
||||||
|
if not group_id:
|
||||||
|
return "Без группы"
|
||||||
|
if group_id in self._group_cache:
|
||||||
|
return self._group_cache[group_id]
|
||||||
|
data = self._get(f"/api/v1/groups/{group_id}")
|
||||||
|
name = (data.get("name") or str(group_id)).strip()
|
||||||
|
self._group_cache[group_id] = name
|
||||||
|
return name
|
||||||
|
|
||||||
|
def state(self, state_id: Optional[int]) -> Dict[str, Any]:
|
||||||
|
if not state_id:
|
||||||
|
return {"name": "unknown", "state_type": "unknown"}
|
||||||
|
if state_id in self._state_cache:
|
||||||
|
return self._state_cache[state_id]
|
||||||
|
data = self._get(f"/api/v1/ticket_states/{state_id}")
|
||||||
|
# Try to resolve state_type name if available
|
||||||
|
stype = data.get("state_type") or data.get("state_type_id")
|
||||||
|
state_type_name = None
|
||||||
|
if isinstance(stype, dict):
|
||||||
|
state_type_name = stype.get("name")
|
||||||
|
elif isinstance(stype, int):
|
||||||
|
try:
|
||||||
|
tdata = self._get(f"/api/v1/ticket_state_types/{stype}")
|
||||||
|
state_type_name = tdata.get("name")
|
||||||
|
except ZammadError:
|
||||||
|
state_type_name = None
|
||||||
|
result = {"name": data.get("name", str(state_id)), "state_type": state_type_name or "unknown"}
|
||||||
|
self._state_cache[state_id] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
def priority_name(self, priority_id: Optional[int]) -> str:
|
||||||
|
if not priority_id:
|
||||||
|
return "Без приоритета"
|
||||||
|
if priority_id in self._priority_cache:
|
||||||
|
return self._priority_cache[priority_id]
|
||||||
|
data = self._get(f"/api/v1/ticket_priorities/{priority_id}")
|
||||||
|
name = (data.get("name") or str(priority_id)).strip()
|
||||||
|
self._priority_cache[priority_id] = name
|
||||||
|
return name
|
||||||
|
|
||||||
129
app/routes.py
Normal file
129
app/routes.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, render_template, request, redirect, url_for, jsonify, flash
|
||||||
|
|
||||||
|
from .clients.zammad import ZammadClient, ZammadError
|
||||||
|
from .services import aggregations as agg
|
||||||
|
|
||||||
|
|
||||||
|
web_bp = Blueprint("web", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _client() -> ZammadClient:
|
||||||
|
return ZammadClient(current_app.config["ZAMMAD_URL"], current_app.config["ZAMMAD_TOKEN"])
|
||||||
|
|
||||||
|
|
||||||
|
@web_bp.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template(
|
||||||
|
"index.html",
|
||||||
|
default_date_from=current_app.config["DEFAULT_DATE_FROM"],
|
||||||
|
default_date_to=current_app.config["DEFAULT_DATE_TO"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
AVAILABLE_GROUPS: Dict[str, Dict[str, Any]] = {
|
||||||
|
"overview": {"title": "Общее количество тикетов"},
|
||||||
|
"by_agent": {"title": "Тикеты по агентам"},
|
||||||
|
"by_group": {"title": "Тикеты по группам"},
|
||||||
|
"by_state": {"title": "Тикеты по статусам"},
|
||||||
|
"open_closed": {"title": "Открытые vs Закрытые"},
|
||||||
|
"by_priority": {"title": "Тикеты по приоритетам"},
|
||||||
|
"by_day": {"title": "Динамика: тикеты по дням"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@web_bp.get("/report")
|
||||||
|
def report():
|
||||||
|
date_from = request.args.get("date_from")
|
||||||
|
date_to = request.args.get("date_to")
|
||||||
|
group_by = request.args.get("group_by", "overview")
|
||||||
|
|
||||||
|
if not date_from or not date_to:
|
||||||
|
flash("Укажите период дат.", "warning")
|
||||||
|
return redirect(url_for("web:index"))
|
||||||
|
|
||||||
|
if group_by not in AVAILABLE_GROUPS:
|
||||||
|
group_by = "overview"
|
||||||
|
|
||||||
|
z = _client()
|
||||||
|
try:
|
||||||
|
tickets = z.search_tickets(date_from, date_to)
|
||||||
|
except ZammadError as e:
|
||||||
|
flash(f"Ошибка Zammad API: {e}", "danger")
|
||||||
|
return redirect(url_for("web:index"))
|
||||||
|
|
||||||
|
overview = agg.summarize_overview(tickets, z)
|
||||||
|
|
||||||
|
# Determine main chart
|
||||||
|
if group_by == "by_agent":
|
||||||
|
data = agg.by_agent(tickets, z)
|
||||||
|
elif group_by == "by_group":
|
||||||
|
data = agg.by_group(tickets, z)
|
||||||
|
elif group_by == "by_state":
|
||||||
|
data = agg.by_state(tickets, z)
|
||||||
|
elif group_by == "open_closed":
|
||||||
|
data = agg.by_open_closed(tickets, z)
|
||||||
|
elif group_by == "by_priority":
|
||||||
|
data = agg.by_priority(tickets, z)
|
||||||
|
elif group_by == "by_day":
|
||||||
|
data = agg.by_day_created(tickets)
|
||||||
|
else:
|
||||||
|
data = {"Всего": overview["total"]}
|
||||||
|
|
||||||
|
labels, values = agg.dict_to_series(data)
|
||||||
|
title = AVAILABLE_GROUPS[group_by]["title"]
|
||||||
|
|
||||||
|
# Supplemental tables
|
||||||
|
by_agent = sorted(agg.by_agent(tickets, z).items(), key=lambda x: x[1], reverse=True)
|
||||||
|
by_group = sorted(agg.by_group(tickets, z).items(), key=lambda x: x[1], reverse=True)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"report.html",
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
group_by=group_by,
|
||||||
|
title=title,
|
||||||
|
chart_labels=labels,
|
||||||
|
chart_values=values,
|
||||||
|
overview=overview,
|
||||||
|
by_agent=by_agent,
|
||||||
|
by_group=by_group,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@web_bp.get("/api/report.json")
|
||||||
|
def report_json():
|
||||||
|
date_from = request.args.get("date_from")
|
||||||
|
date_to = request.args.get("date_to")
|
||||||
|
group_by = request.args.get("group_by", "overview")
|
||||||
|
if not date_from or not date_to:
|
||||||
|
return jsonify({"error": "date_from and date_to are required"}), 400
|
||||||
|
|
||||||
|
z = _client()
|
||||||
|
tickets = z.search_tickets(date_from, date_to)
|
||||||
|
|
||||||
|
overview = agg.summarize_overview(tickets, z)
|
||||||
|
payload: Dict[str, Any] = {"overview": overview}
|
||||||
|
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"by_agent": agg.by_agent(tickets, z),
|
||||||
|
"by_group": agg.by_group(tickets, z),
|
||||||
|
"by_state": agg.by_state(tickets, z),
|
||||||
|
"open_closed": agg.by_open_closed(tickets, z),
|
||||||
|
"by_priority": agg.by_priority(tickets, z),
|
||||||
|
"by_day": agg.by_day_created(tickets),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optional: main_group result
|
||||||
|
if group_by in payload:
|
||||||
|
labels, values = agg.dict_to_series(payload[group_by])
|
||||||
|
payload["chart"] = {"labels": labels, "values": values}
|
||||||
|
|
||||||
|
return jsonify(payload)
|
||||||
|
|
||||||
BIN
app/services/__pycache__/aggregations.cpython-310.pyc
Normal file
BIN
app/services/__pycache__/aggregations.cpython-310.pyc
Normal file
Binary file not shown.
77
app/services/aggregations.py
Normal file
77
app/services/aggregations.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from typing import Any, Dict, Iterable, List, Tuple
|
||||||
|
|
||||||
|
from ..clients.zammad import ZammadClient
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_overview(tickets: List[Dict[str, Any]], z: ZammadClient) -> Dict[str, Any]:
|
||||||
|
total = len(tickets)
|
||||||
|
open_count = 0
|
||||||
|
closed_count = 0
|
||||||
|
for t in tickets:
|
||||||
|
st = z.state(t.get("state_id"))
|
||||||
|
stype = (st.get("state_type") or "").lower()
|
||||||
|
if "close" in stype or stype == "closed":
|
||||||
|
closed_count += 1
|
||||||
|
else:
|
||||||
|
open_count += 1
|
||||||
|
return {"total": total, "open": open_count, "closed": closed_count}
|
||||||
|
|
||||||
|
|
||||||
|
def by_agent(tickets: List[Dict[str, Any]], z: ZammadClient) -> Dict[str, int]:
|
||||||
|
counts = Counter([t.get("owner_id") or 0 for t in tickets])
|
||||||
|
return {z.user_name(uid if uid != 0 else None): c for uid, c in counts.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def by_group(tickets: List[Dict[str, Any]], z: ZammadClient) -> Dict[str, int]:
|
||||||
|
counts = Counter([t.get("group_id") or 0 for t in tickets])
|
||||||
|
return {z.group_name(gid if gid != 0 else None): c for gid, c in counts.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def by_state(tickets: List[Dict[str, Any]], z: ZammadClient) -> Dict[str, int]:
|
||||||
|
counts: Dict[str, int] = {}
|
||||||
|
for t in tickets:
|
||||||
|
st = z.state(t.get("state_id"))
|
||||||
|
name = st.get("name", "unknown")
|
||||||
|
counts[name] = counts.get(name, 0) + 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def by_open_closed(tickets: List[Dict[str, Any]], z: ZammadClient) -> Dict[str, int]:
|
||||||
|
opened = 0
|
||||||
|
closed = 0
|
||||||
|
for t in tickets:
|
||||||
|
st = z.state(t.get("state_id"))
|
||||||
|
stype = (st.get("state_type") or "").lower()
|
||||||
|
if "close" in stype or stype == "closed":
|
||||||
|
closed += 1
|
||||||
|
else:
|
||||||
|
opened += 1
|
||||||
|
return {"Открытые": opened, "Закрытые": closed}
|
||||||
|
|
||||||
|
|
||||||
|
def by_priority(tickets: List[Dict[str, Any]], z: ZammadClient) -> Dict[str, int]:
|
||||||
|
counts = Counter([t.get("priority_id") or 0 for t in tickets])
|
||||||
|
return {z.priority_name(pid if pid != 0 else None): c for pid, c in counts.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def by_day_created(tickets: List[Dict[str, Any]]) -> Dict[str, int]:
|
||||||
|
counts: Dict[str, int] = defaultdict(int)
|
||||||
|
for t in tickets:
|
||||||
|
created = t.get("created_at") or t.get("created")
|
||||||
|
if not created:
|
||||||
|
continue
|
||||||
|
day = str(created)[:10]
|
||||||
|
counts[day] += 1
|
||||||
|
# sort by date
|
||||||
|
return dict(sorted(counts.items(), key=lambda kv: kv[0]))
|
||||||
|
|
||||||
|
|
||||||
|
def dict_to_series(d: Dict[str, int]) -> Tuple[List[str], List[int]]:
|
||||||
|
labels = list(d.keys())
|
||||||
|
values = list(d.values())
|
||||||
|
return labels, values
|
||||||
|
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Flask>=2.3
|
||||||
|
requests>=2.31
|
||||||
126
static/css/styles.css
Normal file
126
static/css/styles.css
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/* Base Theme Variables */
|
||||||
|
:root {
|
||||||
|
--bg: #0b1020;
|
||||||
|
--bg-soft: #0f152b;
|
||||||
|
--text: #e6e9ef;
|
||||||
|
--muted: #aab0bc;
|
||||||
|
--card: rgba(255, 255, 255, 0.06);
|
||||||
|
--card-border: rgba(255, 255, 255, 0.12);
|
||||||
|
--brand: #4f8cff;
|
||||||
|
--brand-2: #8a5cff;
|
||||||
|
--accent: #22c55e;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme overrides */
|
||||||
|
html[data-theme="light"] {
|
||||||
|
--bg: #f6f7fb;
|
||||||
|
--bg-soft: #ffffff;
|
||||||
|
--text: #1b2430;
|
||||||
|
--muted: #5b6573;
|
||||||
|
--card: rgba(0, 0, 0, 0.04);
|
||||||
|
--card-border: rgba(0, 0, 0, 0.08);
|
||||||
|
--brand: #0d6efd;
|
||||||
|
--brand-2: #6f42c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global */
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
color: var(--text);
|
||||||
|
background: radial-gradient(1200px 600px at 10% -10%, rgba(79, 140, 255, .25), transparent 60%),
|
||||||
|
radial-gradient(900px 500px at 100% 0%, rgba(138, 92, 255, .18), transparent 60%),
|
||||||
|
var(--bg);
|
||||||
|
background-attachment: fixed;
|
||||||
|
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji", "Segoe UI Emoji";
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navbar */
|
||||||
|
.navbar {
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,0));
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.navbar .navbar-brand {
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card / Surfaces */
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,0));
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary {
|
||||||
|
--bs-btn-color: #fff;
|
||||||
|
--bs-btn-bg: var(--brand);
|
||||||
|
--bs-btn-border-color: var(--brand);
|
||||||
|
--bs-btn-hover-bg: color-mix(in oklab, var(--brand), #000 15%);
|
||||||
|
--bs-btn-hover-border-color: color-mix(in oklab, var(--brand), #000 20%);
|
||||||
|
--bs-btn-focus-shadow-rgb: 79, 140, 255;
|
||||||
|
}
|
||||||
|
.btn-outline-secondary {
|
||||||
|
--bs-btn-color: var(--text);
|
||||||
|
--bs-btn-border-color: var(--card-border);
|
||||||
|
--bs-btn-hover-bg: var(--card);
|
||||||
|
--bs-btn-hover-border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-label { color: var(--muted); font-weight: 600; }
|
||||||
|
.form-control, .form-select {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
}
|
||||||
|
.form-control:focus, .form-select:focus {
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border-color: var(--brand);
|
||||||
|
box-shadow: 0 0 0 .25rem rgba(79,140,255,.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table { color: var(--text); }
|
||||||
|
.table > :not(caption) > * > * { background-color: transparent; }
|
||||||
|
.table-light { background: rgba(255,255,255, .06) !important; color: var(--text); }
|
||||||
|
.table-hover > tbody > tr:hover > * { background: rgba(79, 140, 255, 0.08); }
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert { border: 1px solid var(--card-border); border-radius: 12px; }
|
||||||
|
|
||||||
|
/* Chart container */
|
||||||
|
.chart-wrap { position: relative; height: 380px; }
|
||||||
|
|
||||||
|
/* Subtle animations */
|
||||||
|
.fade-in { animation: fade-in .3s ease-out; }
|
||||||
|
@keyframes fade-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||||
|
|
||||||
|
/* Theme toggle */
|
||||||
|
.theme-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
70
templates/base.html
Normal file
70
templates/base.html
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#0b1020">
|
||||||
|
<title>Zammad Reports by Ruslan</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link href="{{ url_for('static', filename='css/styles.css') }}" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/">Zammad Reports</a>
|
||||||
|
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||||
|
<button id="themeToggle" class="btn btn-sm theme-toggle" type="button" aria-label="Toggle theme">
|
||||||
|
<span class="icon">🌙</span>
|
||||||
|
<span class="label">Тёмная</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="container my-4 fade-in">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }}" role="alert">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Theme persistence
|
||||||
|
const key = 'zammad.theme';
|
||||||
|
const root = document.documentElement;
|
||||||
|
const btn = () => document.getElementById('themeToggle');
|
||||||
|
const label = () => btn()?.querySelector('.label');
|
||||||
|
const icon = () => btn()?.querySelector('.icon');
|
||||||
|
function applyTheme(t) {
|
||||||
|
root.setAttribute('data-theme', t);
|
||||||
|
if (label()) label().textContent = t === 'dark' ? 'Тёмная' : 'Светлая';
|
||||||
|
if (icon()) icon().textContent = t === 'dark' ? '🌙' : '☀️';
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (meta) meta.setAttribute('content', t === 'dark' ? '#0b1020' : '#f6f7fb');
|
||||||
|
}
|
||||||
|
(function initTheme(){
|
||||||
|
const saved = localStorage.getItem(key);
|
||||||
|
const preferred = saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||||
|
applyTheme(preferred);
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const toggle = btn();
|
||||||
|
if (toggle) toggle.addEventListener('click', () => {
|
||||||
|
const current = root.getAttribute('data-theme') || 'dark';
|
||||||
|
const next = current === 'dark' ? 'light' : 'dark';
|
||||||
|
localStorage.setItem(key, next);
|
||||||
|
applyTheme(next);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
46
templates/index.html
Normal file
46
templates/index.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">Параметры отчёта</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" action="{{ url_for('web.report') }}" class="row gy-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Дата с</label>
|
||||||
|
<input type="date" name="date_from" class="form-control" value="{{ default_date_from }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Дата по</label>
|
||||||
|
<input type="date" name="date_to" class="form-control" value="{{ default_date_to }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Группировка</label>
|
||||||
|
<select class="form-select" name="group_by">
|
||||||
|
<option value="overview">Общая сводка</option>
|
||||||
|
<option value="by_agent">По агентам</option>
|
||||||
|
<option value="by_group">По группам</option>
|
||||||
|
<option value="by_state">По статусу</option>
|
||||||
|
<option value="open_closed">Открытые vs Закрытые</option>
|
||||||
|
<option value="by_priority">По приоритету</option>
|
||||||
|
<option value="by_day">По дням (таймлайн)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-primary">Построить отчёт</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header">Подсказка</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted mb-2">Выберите диапазон дат и тип группировки, затем нажмите «Построить отчёт».</p>
|
||||||
|
<p class="text-muted m-0">Переключайте тёмную/светлую тему в шапке справа.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
107
templates/report.html
Normal file
107
templates/report.html
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12 d-flex justify-content-between align-items-center">
|
||||||
|
<h4 class="m-0">{{ title }}</h4>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('web.index') }}">На главную</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<canvas id="chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">Сводка</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item d-flex justify-content-between"><span>Всего тикетов</span><strong>{{ overview.total }}</strong></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-4 g-3">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">По агентам</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-sm table-hover align-middle m-0">
|
||||||
|
<thead class="table-light"><tr><th>Агент</th><th class="text-end">Количество</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for name, count in by_agent %}
|
||||||
|
<tr><td>{{ name }}</td><td class="text-end">{{ count }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">По группам</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-sm table-hover align-middle m-0">
|
||||||
|
<thead class="table-light"><tr><th>Группа</th><th class="text-end">Количество</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for name, count in by_group %}
|
||||||
|
<tr><td>{{ name }}</td><td class="text-end">{{ count }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const labels = {{ chart_labels | tojson }};
|
||||||
|
const data = {{ chart_values | tojson }};
|
||||||
|
const ctx = document.getElementById('chart');
|
||||||
|
|
||||||
|
const css = getComputedStyle(document.documentElement);
|
||||||
|
const primary = css.getPropertyValue('--brand').trim() || '#0d6efd';
|
||||||
|
const primaryFill = 'rgba(79, 140, 255, .25)';
|
||||||
|
|
||||||
|
const isDate = (v) => /^\d{4}-\d{2}-\d{2}$/.test(v);
|
||||||
|
const mode = labels.every(isDate) ? 'line' : 'bar';
|
||||||
|
|
||||||
|
const chart = new Chart(ctx, {
|
||||||
|
type: mode,
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Значения',
|
||||||
|
data,
|
||||||
|
borderColor: primary,
|
||||||
|
backgroundColor: mode === 'bar' ? primaryFill : primaryFill,
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.25,
|
||||||
|
fill: mode === 'line',
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true, ticks: { precision: 0 } }
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
Reference in New Issue
Block a user