7436 lines
347 KiB
Markdown
7436 lines
347 KiB
Markdown
# 🌐 WEB API & FRONTEND CODE SNAPSHOT (AUTO-DISCOVERY)
|
||
|
||
## File: `./modules/web_api/llm/agent.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/llm/agent.py
|
||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||
MODULE: web_api / llm (Core Agent Coordinator)
|
||
ROLE: Нативный оркестратор диалога, диспетчер Function Calling,
|
||
передача активных срезов в контекст модели и терминальные вызовы.
|
||
===============================================================================
|
||
"""
|
||
|
||
import sys
|
||
import json
|
||
import logging
|
||
from typing import List, Dict, Any, Tuple, Optional
|
||
|
||
from .db.connection import get_db_connection
|
||
from .db_tools import (
|
||
db_get_active_system_prompt,
|
||
db_apply_prompt_node_action,
|
||
db_get_tool_action,
|
||
db_get_tasks,
|
||
db_update_task_details,
|
||
db_delete_task,
|
||
db_add_task,
|
||
db_tasks_edit,
|
||
db_export_tasks_markdown,
|
||
db_get_rules,
|
||
db_set_session_state,
|
||
db_get_session_state,
|
||
db_clear_session_state,
|
||
db_get_snapshots,
|
||
db_delete_snapshots,
|
||
db_get_current_server_time,
|
||
db_save_chat_message,
|
||
db_get_chat_history,
|
||
db_purge_ephemeral_messages,
|
||
db_get_stats,
|
||
db_get_anomalies,
|
||
db_get_reference
|
||
)
|
||
|
||
from .schemas import TOOLS_SCHEMA
|
||
from .core.calendar_utils import get_dynamic_calendar_context
|
||
from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed
|
||
from .core.ollama_client import call_ollama_chat
|
||
from .core.fast_path import handle_fast_path_intercept
|
||
from .core.context_manager import (
|
||
save_tool_interaction,
|
||
save_dialog_interaction,
|
||
mark_last_user_message_ephemeral,
|
||
close_tool_session_and_cleanup
|
||
)
|
||
|
||
logger = logging.getLogger("SCUD_AGENT")
|
||
logger.setLevel(logging.INFO)
|
||
logger.propagate = False
|
||
|
||
if not logger.handlers:
|
||
handler = logging.StreamHandler(sys.stdout)
|
||
formatter = logging.Formatter("%(asctime)s [%(levelname)s] [%(name)s] %(message)s")
|
||
handler.setFormatter(formatter)
|
||
logger.addHandler(handler)
|
||
|
||
|
||
def process_chat_message(
|
||
user_id: int,
|
||
user_message: str,
|
||
file_context: str = "",
|
||
image_b64: Optional[str] = None,
|
||
chat_history: List[Dict[str, Any]] = None,
|
||
session_id: str = "web_session_main"
|
||
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||
"""
|
||
Главный конвейер обработки входящего сообщения чата на базе нативного Function Calling.
|
||
"""
|
||
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
|
||
|
||
session_state = db_get_session_state(session_id)
|
||
if not session_state:
|
||
db_purge_ephemeral_messages(session_id)
|
||
|
||
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
|
||
|
||
# 1. Быстрый перехват системных кнопок UI и терминальных действий без задержек LLM
|
||
fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
|
||
if fast_path_res:
|
||
return fast_path_res
|
||
|
||
# 2. Фиксация сообщения пользователя
|
||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||
db_history = db_get_chat_history(session_id, limit=20)
|
||
|
||
calendar_context = get_dynamic_calendar_context()
|
||
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
|
||
|
||
current_state_type = session_state.get("state_type") if session_state else None
|
||
state_data = session_state.get("data_json") or {} if session_state else {}
|
||
if not isinstance(state_data, dict):
|
||
state_data = {}
|
||
idle_turns = state_data.get("idle_turns", 0)
|
||
|
||
active_state_context = ""
|
||
if current_state_type == "PROMPT_PREVIEW":
|
||
active_state_context = "\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n- Открыт предпросмотр изменений промпта.\n"
|
||
elif current_state_type == "SNAPSHOTS_VIEW":
|
||
active_date = state_data.get("query_date", "выбранную дату")
|
||
active_state_context = f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n- Отображаются срезы за {active_date}.\n"
|
||
elif current_state_type == "SNAPSHOT_INSPECT":
|
||
# ⭐️ Защита от залипания: если вопрос бытовой или отвлеченный, выходим из жесткого режима инспекции
|
||
msg_l = user_message.lower().strip()
|
||
scud_terms = ["срез", "скуд", "вход", "выход", "здани", "присутств", "отсутств", "кто в", "кто сейчас", "1с", "зуп", "турникет", "карточк", "инспекци"]
|
||
if not any(t in msg_l for t in scud_terms) and len(msg_l.split()) <= 12:
|
||
db_clear_session_state(session_id)
|
||
current_state_type = None
|
||
active_state_context = ""
|
||
else:
|
||
snap_id = state_data.get("snapshot_id", "")
|
||
snap_date = state_data.get("log_date", "")
|
||
records = state_data.get("records", [])
|
||
|
||
lines = []
|
||
for r in records:
|
||
st = "Присутствовал" if r.get("is_present") else "Отсутствовал"
|
||
lines.append(f"- {r.get('fio')}: Отдел={r.get('department')}, Вход={r.get('time_in')}, Активность={r.get('first_activity')}, Выход={r.get('time_out')}, ВремяВЗдании={r.get('in_building')}, Статус={st}")
|
||
|
||
dump_str = "\n".join(lines)
|
||
active_state_context = (
|
||
f"\n[ТЕКУЩИЙ РЕЖИМ: АКТИВНА ИНСПЕКЦИЯ СРЕЗА СКУД #{snap_id} ЗА {snap_date}]\n"
|
||
f"Оператор сейчас изучает срез #{snap_id}. При любых вопросах о сотрудниках, фильтрации по входам, выходам, времени или отделам:\n"
|
||
f"1. ТЫ ОБЯЗАН ответить обычным текстом, проанализировав список ниже.\n"
|
||
f"2. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать инструменты (tools), такие как db_get_snapshots!\n"
|
||
f"Список сотрудников в активном срезе:\n{dump_str}\n"
|
||
)
|
||
|
||
# ⭐️ Промпт с поддержкой Topic Drift и защитой от переспросов по задачам
|
||
system_prompt_content = (
|
||
f"Ты — интеллектуальный ассистент SCUD Orion AI.\n"
|
||
f"Твоя основная роль — помощь оператору в кадровом аудите СКУД/1С, управлении задачами и настройками системы.\n\n"
|
||
f"СТРОГИЕ ПРАВИЛА:\n"
|
||
f"1. К оператору всегда обращайся на Вы.\n"
|
||
f"2. Для работы с данными системы ВСЕГДА вызывай соответствующие инструменты (tools):\n"
|
||
f" - Снапшоты и срезы СКУД -> db_get_snapshots\n"
|
||
f" - Удаление срезов -> db_delete_snapshots\n"
|
||
f" - Задачи и бэклог -> db_get_tasks, db_tasks_edit\n"
|
||
f" - Системный промпт -> db_get_system_prompt, db_prompt_node_edit\n"
|
||
f" - База знаний и регламенты -> db_get_rules\n"
|
||
f" - Аномалии СКУД/1С -> db_get_anomalies\n"
|
||
f" - Справка -> db_get_reference\n"
|
||
f"3. ЗАДАЧИ:\n"
|
||
f" - При любых запросах на просмотр задач (включая опечатки вроде 'змдачи', 'таски', 'дела', 'покажи задачи') — "
|
||
f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_tasks без лишних вопросов!\n"
|
||
f" - КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО переспрашивать у оператора фильтры, статус или категорию задач текстом! "
|
||
f"Интерактивная карточка в интерфейсе содержит все нужные фильтры.\n"
|
||
f"4. ПРАВИЛА КОМПАНИИ: Инструмент db_get_rules вызывай СТРОГО при вопросах о внутренних регламентах, "
|
||
"политиках или локальных правилах НАШЕЙ компании (например, 'покажи правила компании', 'какие у нас регламенты'). "
|
||
"При вопросах по Трудовому кодексу РФ (ТК РФ), законам РФ или общим юридическим нормам — отвечай подробно "
|
||
"из своих профессиональных знаний, не вызывая db_get_rules!"
|
||
f"5. Запрещено выдумывать факты и цифры по системе СКУД/1С без вызова инструментов.\n"
|
||
f"6. ОБЩИЙ ДИАЛОГ: На любые отвлечённые, познавательные, научные или бытовые вопросы "
|
||
f"(расстояние между планетами или городами, программирование, кругозор) отвечай полно, доброжелательно и интересно, не отказывая пользователю.\n\n"
|
||
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
|
||
f"- Пользователь: {user_info}\n"
|
||
f"- {calendar_context}\n"
|
||
f"{active_state_context}"
|
||
)
|
||
|
||
user_msg_object = {"role": "user", "content": full_user_content}
|
||
|
||
try:
|
||
if image_b64:
|
||
user_msg_object["images"] = [image_b64]
|
||
messages = [
|
||
{"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву."},
|
||
user_msg_object
|
||
]
|
||
msg = call_ollama_chat(messages, is_vision=True)
|
||
else:
|
||
clean_db_history = [dict(m) for m in db_history]
|
||
for m in clean_db_history:
|
||
m.pop("images", None)
|
||
messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object]
|
||
msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False)
|
||
|
||
raw_text_reply = msg.get("content", "")
|
||
tool_calls = msg.get("tool_calls", [])
|
||
|
||
if not tool_calls:
|
||
tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls)
|
||
|
||
if tool_calls:
|
||
logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})")
|
||
|
||
tool = tool_calls[0]
|
||
fn_name = tool["function"]["name"]
|
||
fn_args = tool["function"].get("arguments", {})
|
||
if isinstance(fn_args, str):
|
||
try:
|
||
fn_args = json.loads(fn_args)
|
||
except Exception:
|
||
fn_args = {}
|
||
|
||
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
|
||
|
||
# Ротация контекста
|
||
close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
|
||
session_state = None
|
||
mark_last_user_message_ephemeral(session_id)
|
||
|
||
# 1. Задачи (Просмотр)
|
||
if fn_name == "db_get_tasks":
|
||
raw_tasks = db_get_tasks(user_id, status=fn_args.get("status"))
|
||
reply_text = "Вот интерактивный список ваших текущих задач:"
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), {
|
||
"type": "TASK_INTERACTIVE_CARD",
|
||
"tasks": raw_tasks
|
||
}
|
||
|
||
# 2. Единый диспетчер задач
|
||
elif fn_name in ["db_tasks_edit", "db_add_task", "db_update_task_details", "db_delete_task"]:
|
||
action = fn_args.get("action", "UPDATE").upper()
|
||
if fn_name == "db_add_task": action = "ADD"
|
||
elif fn_name == "db_delete_task": action = "DELETE"
|
||
elif fn_name == "db_update_task_status": action = "UPDATE"
|
||
|
||
if action == "DELETE":
|
||
task_id_raw = str(fn_args.get("task_id", "")).replace("#", "").replace("TASK-", "").strip()
|
||
db_set_session_state(session_id, "TASK_DELETE_CONFIRM", {"task_id": task_id_raw, "idle_turns": 0})
|
||
reply_text = f"Вы действительно хотите удалить задачу #{task_id_raw}?"
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), {
|
||
"type": "TASK_DELETE_CONFIRM",
|
||
"buttons": [
|
||
{"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"},
|
||
{"label": "Отмена", "value": "отмена", "style": "secondary"}
|
||
]
|
||
}
|
||
|
||
elif action == "EXPORT":
|
||
export_res = db_export_tasks_markdown(
|
||
user_id=user_id,
|
||
filename=fn_args.get("filename") or "ROADMAP.md",
|
||
status_filter=fn_args.get("status")
|
||
)
|
||
reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.")
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
|
||
|
||
action_payload = None
|
||
if export_res.get("status") == "success":
|
||
action_payload = {
|
||
"type": "FILE_DOWNLOAD_CARD",
|
||
"filename": export_res.get("filename"),
|
||
"download_url": export_res.get("download_url"),
|
||
"tasks_count": export_res.get("tasks_count")
|
||
}
|
||
return reply_text, db_get_chat_history(session_id), action_payload
|
||
|
||
else:
|
||
res = db_tasks_edit(
|
||
user_id=user_id,
|
||
action=action,
|
||
task_id=fn_args.get("task_id"),
|
||
title=fn_args.get("title"),
|
||
priority=fn_args.get("priority", "MEDIUM"),
|
||
status=fn_args.get("status"),
|
||
module=fn_args.get("module", "general"),
|
||
due_date=fn_args.get("due_date")
|
||
)
|
||
raw_tasks = db_get_tasks(user_id)
|
||
reply_text = res.get("message", "Операция над задачами выполнена.")
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), {
|
||
"type": "TASK_INTERACTIVE_CARD",
|
||
"tasks": raw_tasks
|
||
}
|
||
|
||
# 3. Системный промпт
|
||
elif fn_name == "db_get_system_prompt":
|
||
active_prompt = db_get_active_system_prompt()
|
||
reply_text = (
|
||
"📋 **АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ:**\n\n"
|
||
f"```text\n{active_prompt}\n```\n\n"
|
||
"Вы можете добавить, отредактировать или удалить любой пункт."
|
||
)
|
||
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), {
|
||
"type": "PROMPT_VIEW",
|
||
"buttons": [
|
||
{"label": "✏️ Редактировать промпт", "value": "action:open_editor", "style": "primary"},
|
||
{"label": "База знаний", "value": "покажи правила компании", "style": "secondary"}
|
||
]
|
||
}
|
||
|
||
# 4. База знаний и правила компании (терминальный возврат)
|
||
elif fn_name == "db_get_rules":
|
||
rules_data = db_get_rules()
|
||
if isinstance(rules_data, dict) and "rules" in rules_data:
|
||
rules_list = rules_data["rules"]
|
||
elif isinstance(rules_data, list):
|
||
rules_list = rules_data
|
||
else:
|
||
rules_list = [str(rules_data)]
|
||
|
||
formatted_rules = "\n\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
|
||
reply_text = (
|
||
"📖 **БАЗА ЗНАНИЙ И ПРАВИЛА КОМПАНИИ (КАДРОВЫЙ АРБИТРАЖ):**\n\n"
|
||
f"```text\n{formatted_rules}\n```"
|
||
)
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), {
|
||
"type": "RULES_VIEW",
|
||
"buttons": [
|
||
{"label": "✏️ Редактировать правила", "value": "action:open_rules_editor", "style": "primary"},
|
||
{"label": "📋 Системный промпт", "value": "покажи системный промпт", "style": "secondary"}
|
||
]
|
||
}
|
||
|
||
# 5. Снапшоты СКУД
|
||
elif fn_name == "db_get_snapshots":
|
||
snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message)
|
||
query_date = snapshots_res.get("query_date", "выбранную дату")
|
||
reply_text = f"Реестр срезов СКУД за {query_date}:"
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), {
|
||
"type": "SNAPSHOTS_CARD",
|
||
"data": snapshots_res
|
||
}
|
||
|
||
elif fn_name == "db_delete_snapshots":
|
||
raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str")
|
||
raw_ids = fn_args.get("snapshot_ids") or []
|
||
is_confirmed = fn_args.get("confirmed", False)
|
||
|
||
if raw_id and not raw_ids:
|
||
if isinstance(raw_id, str) and "," in raw_id:
|
||
raw_ids = [s.strip() for s in raw_id.split(",")]
|
||
else:
|
||
raw_ids = [raw_id]
|
||
|
||
safe_ids = [s.strip() for s in raw_ids if s and not str(s).strip().startswith("Y")]
|
||
if not safe_ids:
|
||
reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), None
|
||
|
||
if not is_confirmed:
|
||
query_date = state_data.get("query_date", "")
|
||
db_set_session_state(session_id, "SNAPSHOT_DELETE_CONFIRM", {
|
||
"snapshot_ids": safe_ids,
|
||
"query_date": query_date,
|
||
"idle_turns": 0
|
||
})
|
||
ids_str = ", ".join(safe_ids)
|
||
reply_text = f"Вы действительно хотите удалить дневные снапшоты: {ids_str}?"
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), {
|
||
"type": "SNAPSHOT_DELETE_CONFIRM",
|
||
"buttons": [
|
||
{"label": f"Удалить ({len(safe_ids)} шт.)", "value": f"подтверждаю удаление снапшотов {ids_str}", "style": "danger"},
|
||
{"label": "Отмена", "value": "отмена", "style": "secondary"}
|
||
]
|
||
}
|
||
else:
|
||
db_delete_snapshots(snapshot_ids=safe_ids)
|
||
query_date = state_data.get("query_date", "")
|
||
updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date)
|
||
|
||
reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт."
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), {
|
||
"type": "SNAPSHOTS_CARD",
|
||
"data": updated_snapshots_res
|
||
}
|
||
|
||
# 6. Прочие сервисные инструменты
|
||
elif fn_name == "db_get_current_server_time":
|
||
tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False)
|
||
elif fn_name == "db_get_stats":
|
||
tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False)
|
||
elif fn_name == "db_get_anomalies":
|
||
tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
|
||
elif fn_name == "db_get_reference":
|
||
tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
|
||
else:
|
||
tool_result_content = "{}"
|
||
|
||
messages.append(msg)
|
||
messages.append({"role": "tool", "content": tool_result_content})
|
||
sec_msg = call_ollama_chat(messages, is_vision=False)
|
||
raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "")
|
||
final_content = clean_raw_tool_tags(clean_output(raw_content)) or "Запрос выполнен."
|
||
|
||
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0)
|
||
return final_content, db_get_chat_history(session_id), None
|
||
|
||
# Свободный диалог (Topic Drift)
|
||
raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "")
|
||
final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан."
|
||
|
||
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
|
||
return final_reply, db_get_chat_history(session_id), None
|
||
|
||
except Exception as ex:
|
||
logger.exception(f"Непредвиденная ошибка агента: {ex}")
|
||
error_reply = f"Внутренняя ошибка сервера: {ex}"
|
||
return error_reply, db_get_chat_history(session_id), None
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/core/calendar_utils.py`
|
||
```py
|
||
import re
|
||
from datetime import datetime, timedelta
|
||
|
||
DAYS_RU = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
|
||
|
||
def parse_relative_date_ru(text: str) -> str:
|
||
now = datetime.now()
|
||
text_lower = text.lower() if text else ""
|
||
match = re.search(r'(\d{2}\.\d{2}\.\d{4})', text)
|
||
if match:
|
||
return match.group(1)
|
||
if "вчера" in text_lower:
|
||
return (now - timedelta(days=1)).strftime("%d.%m.%Y")
|
||
elif "позавчера" in text_lower:
|
||
return (now - timedelta(days=2)).strftime("%d.%m.%Y")
|
||
elif "сегодня" in text_lower:
|
||
return now.strftime("%d.%m.%Y")
|
||
return (now - timedelta(days=1)).strftime("%d.%m.%Y")
|
||
|
||
def get_dynamic_calendar_context() -> str:
|
||
now = datetime.now()
|
||
current_wd = now.weekday()
|
||
lines = [
|
||
f"СЕГОДНЯ: {DAYS_RU[current_wd].upper()}, {now.strftime('%d.%m.%Y')} (время сервера: {now.strftime('%H:%M:%S')}).",
|
||
"\nСПРАВОЧНИК ДАТ ДЛЯ ОТВЕТОВ (БЕРИ ДАТЫ СТРОГО ОТСЮДА):",
|
||
f"• Сегодня: {now.strftime('%d.%m.%Y')} ({DAYS_RU[current_wd]})",
|
||
f"• Вчера: {(now - timedelta(days=1)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 1) % 7]})",
|
||
f"• Позавчера: {(now - timedelta(days=2)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 2) % 7]})",
|
||
"\nПрошедшие дни недели:"
|
||
]
|
||
for days_back in range(1, 8):
|
||
dt = now - timedelta(days=days_back)
|
||
day_name = DAYS_RU[dt.weekday()]
|
||
label = f"Прошлый {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Прошлая {day_name}"
|
||
lines.append(f"• {label}: {dt.strftime('%d.%m.%Y')}")
|
||
return "\n".join(lines)
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/core/context_manager.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/llm/core/context_manager.py
|
||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||
MODULE: web_api / llm / core
|
||
ROLE: Интеллектуальный менеджер контекста: разделение служебных транзакций
|
||
инструментов и содержательного диалога с сохранением Topic Drift.
|
||
===============================================================================
|
||
"""
|
||
|
||
import logging
|
||
from typing import Dict, Any, Optional, List
|
||
|
||
from ..db.connection import get_db_connection
|
||
from ..db_tools import (
|
||
db_save_chat_message,
|
||
db_get_chat_history,
|
||
db_purge_ephemeral_messages,
|
||
db_clear_session_state,
|
||
db_set_session_state
|
||
)
|
||
|
||
logger = logging.getLogger("CONTEXT_MANAGER")
|
||
|
||
|
||
def save_tool_interaction(session_id: str, user_content: str, assistant_reply: str) -> None:
|
||
"""
|
||
Сохраняет синхронную служебную пару инструмента (и вопрос, и ответ = 1).
|
||
При очистке удалятся оба сообщения, не оставляя сирот.
|
||
"""
|
||
db_save_chat_message(session_id, "user", user_content, is_ephemeral=1)
|
||
db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=1)
|
||
|
||
|
||
def save_dialog_interaction(session_id: str, user_content: str, assistant_reply: str) -> None:
|
||
"""
|
||
Сохраняет содержательный диалог пользователя и ассистента (и вопрос, и ответ = 0).
|
||
Эти сообщения остаются в истории навсегда (включая Topic Drift).
|
||
"""
|
||
db_save_chat_message(session_id, "user", user_content, is_ephemeral=0)
|
||
db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=0)
|
||
|
||
|
||
def mark_last_user_message_ephemeral(session_id: str) -> None:
|
||
"""Помечает последнее сообщение пользователя как эфемерное при активации инструмента."""
|
||
with get_db_connection() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
UPDATE chat_messages
|
||
SET is_ephemeral = 1
|
||
WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user')
|
||
""", (session_id,))
|
||
conn.commit()
|
||
|
||
|
||
def close_tool_session_and_cleanup(session_id: str, close_reason: str = "COMPLETED") -> int:
|
||
"""
|
||
Закрывает сессию инструмента и удаляет ТОЛЬКО служебные карточки/команды.
|
||
Весь содержательный диалог сохраняется.
|
||
"""
|
||
db_clear_session_state(session_id)
|
||
deleted_count = db_purge_ephemeral_messages(session_id)
|
||
logger.info(f"[ContextManager] Сессия инструмента закрыта ({close_reason}). Очищено служебных сообщений: {deleted_count}")
|
||
return deleted_count
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/core/fast_path.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/llm/core/fast_path.py
|
||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||
MODULE: web_api / llm / core
|
||
ROLE: Мгновенный перехват UI-действий, инспекции срезов, экспорта и Diff-превью.
|
||
===============================================================================
|
||
"""
|
||
|
||
import difflib
|
||
import logging
|
||
from typing import Dict, Any, Tuple, Optional
|
||
|
||
from services.prompts.service import save_full_prompt_draft, apply_prompt_action, get_active_system_prompt
|
||
from services.knowledge.service import get_rules, add_rule
|
||
from services.tasks.service import get_tasks, delete_task, execute_task_action
|
||
from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
|
||
|
||
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages
|
||
from modules.web_api.llm.db.db_prompts import db_set_session_state, db_clear_session_state
|
||
from modules.web_api.llm.core.context_manager import close_tool_session_and_cleanup
|
||
|
||
logger = logging.getLogger("FAST_PATH")
|
||
|
||
|
||
def _generate_prompt_diff_html(baseline_text: str, draft_text: str) -> str:
|
||
base_lines = [line.rstrip() for line in baseline_text.strip().splitlines()]
|
||
draft_lines = [line.rstrip() for line in draft_text.strip().splitlines()]
|
||
|
||
matcher = difflib.SequenceMatcher(None, base_lines, draft_lines)
|
||
diff_html_lines = []
|
||
|
||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||
if tag == 'equal':
|
||
for line in base_lines[i1:i2]:
|
||
diff_html_lines.append(line)
|
||
elif tag == 'delete':
|
||
for line in base_lines[i1:i2]:
|
||
diff_html_lines.append(
|
||
f'<span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{line} [УДАЛЕНИЕ]</span>'
|
||
)
|
||
elif tag == 'insert':
|
||
for line in draft_lines[j1:j2]:
|
||
diff_html_lines.append(
|
||
f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{line}</span>'
|
||
)
|
||
elif tag == 'replace':
|
||
for line in base_lines[i1:i2]:
|
||
diff_html_lines.append(
|
||
f'<span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{line} [УДАЛЕНИЕ]</span>'
|
||
)
|
||
for line in draft_lines[j1:j2]:
|
||
diff_html_lines.append(
|
||
f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{line}</span>'
|
||
)
|
||
|
||
return "\n".join(diff_html_lines)
|
||
|
||
|
||
def handle_fast_path_intercept(
|
||
session_id: str,
|
||
user_message: str,
|
||
full_user_content: str,
|
||
session_state: Optional[Dict[str, Any]]
|
||
) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]:
|
||
msg_raw = user_message.strip()
|
||
msg_lower = msg_raw.lower()
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 0.0 ЭКСПОРТ ЗАДАЧ В ФАЙЛ (МГНОВЕННО, БЕЗ ЛИШНИХ ДИАЛОГОВ)
|
||
# -------------------------------------------------------------------------
|
||
if any(msg_lower.startswith(p) for p in ["экспортируй задачи", "экспорт задач", "выгрузи задачи", "скачать задачи"]):
|
||
filename = "ROADMAP.md"
|
||
if " в " in msg_lower:
|
||
parts = msg_raw.split(" в ", 1)[1].strip().split()
|
||
if parts and ("." in parts[0] or parts[0].endswith("md")):
|
||
filename = parts[0]
|
||
|
||
res = execute_task_action(user_id=1, action="EXPORT", filename=filename)
|
||
reply = res.get("message", f"Отчет по задачам сформирован в `{filename}`.")
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
|
||
|
||
action_payload = {
|
||
"type": "FILE_DOWNLOAD_CARD",
|
||
"filename": res.get("filename", filename),
|
||
"download_url": res.get("download_url", "#"),
|
||
"tasks_count": res.get("tasks_count", 0)
|
||
}
|
||
return reply, db_get_chat_history(session_id), action_payload
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 0.1 ИНСПЕКЦИЯ КОНКРЕТНОГО СРЕЗА СКУД (ПО ID СНАПШОТА)
|
||
# -------------------------------------------------------------------------
|
||
if msg_lower.startswith("покажи срез ") or msg_lower.startswith("инспекция среза "):
|
||
target_snap_id = msg_raw.split()[-1].replace("#", "").strip()
|
||
from core.database import load_scud_from_db_by_snapshot
|
||
|
||
df_snap = load_scud_from_db_by_snapshot(date_str="", snapshot_param=target_snap_id)
|
||
if df_snap is None or df_snap.empty:
|
||
reply = f"⚠️ Срез СКУД `#{target_snap_id}` не найден в базе данных."
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
|
||
return reply, db_get_chat_history(session_id), None
|
||
|
||
total_cnt = len(df_snap)
|
||
present_cnt = len(df_snap[df_snap['Пришел'] == True]) if 'Пришел' in df_snap.columns else 0
|
||
absent_cnt = total_cnt - present_cnt
|
||
|
||
snap_time = df_snap['snapshot_time'].iloc[0] if 'snapshot_time' in df_snap.columns else '—'
|
||
log_date = df_snap['log_date'].iloc[0] if 'log_date' in df_snap.columns else '—'
|
||
|
||
rows_list = []
|
||
for _, r in df_snap.iterrows():
|
||
rows_list.append({
|
||
"fio": r.get('Сотрудник', r.get('fio', '')),
|
||
"department": r.get('Подразделение', r.get('department_scud', '—')),
|
||
"time_in": r.get('Начало_дня', 'Нет входа'),
|
||
"first_activity": r.get('Первая_активность', '—'),
|
||
"time_out": r.get('Конец_дня', 'Нет выхода'),
|
||
"in_building": r.get('Находился_в_здании', '00:00'),
|
||
"is_present": bool(r.get('Пришел', False)),
|
||
"anomaly": r.get('anomaly_flag', 'NONE')
|
||
})
|
||
|
||
# Фиксация среза в памяти сессии для последующих вопросов к LLM
|
||
db_set_session_state(session_id, "SNAPSHOT_INSPECT", {
|
||
"snapshot_id": target_snap_id,
|
||
"log_date": log_date,
|
||
"snapshot_time": snap_time,
|
||
"records": rows_list,
|
||
"idle_turns": 0
|
||
})
|
||
|
||
reply = f"🔍 **Инспекция среза #{target_snap_id}** (Дата: {log_date}, Время: {snap_time}). Всего записей: {total_cnt} (Присутствовали: {present_cnt}, Отсутствовали: {absent_cnt})."
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
|
||
|
||
return reply, db_get_chat_history(session_id), {
|
||
"type": "SNAPSHOT_INSPECT_CARD",
|
||
"snapshot_id": target_snap_id,
|
||
"log_date": log_date,
|
||
"snapshot_time": snap_time,
|
||
"total_count": total_cnt,
|
||
"present_count": present_cnt,
|
||
"absent_count": absent_cnt,
|
||
"records": rows_list
|
||
}
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 0.2 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРОМПТА
|
||
# -------------------------------------------------------------------------
|
||
if msg_lower in ["action:open_editor", "редактировать промпт", "открыть редактор"]:
|
||
active_prompt = get_active_system_prompt()
|
||
state_data = session_state.get("data_json") if session_state else {}
|
||
draft_text = state_data.get("draft_text") if isinstance(state_data, dict) and state_data.get("draft_text") else active_prompt
|
||
|
||
db_set_session_state(session_id, "PROMPT_PREVIEW", {
|
||
"draft_text": draft_text,
|
||
"action": "MANUAL_EDIT",
|
||
"idle_turns": 0
|
||
})
|
||
reply = "✏️ Внесите необходимые изменения в текст промпта и нажмите «Показать превью изменений»:"
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
|
||
return reply, db_get_chat_history(session_id), {
|
||
"type": "PROMPT_EDITOR",
|
||
"raw_draft": draft_text,
|
||
"baseline_prompt": active_prompt
|
||
}
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 0.3 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРАВИЛ
|
||
# -------------------------------------------------------------------------
|
||
if msg_lower in ["action:open_rules_editor", "редактировать правила", "изменить правила"]:
|
||
rules_data = get_rules()
|
||
rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)])
|
||
raw_rules_text = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
|
||
|
||
db_set_session_state(session_id, "RULES_PREVIEW", {
|
||
"draft_text": raw_rules_text,
|
||
"action": "MANUAL_EDIT_RULES",
|
||
"idle_turns": 0
|
||
})
|
||
reply = "✏️ Редактор базы знаний и правил компании. Внесите изменения и нажмите «Показать превью изменений»:"
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
|
||
return reply, db_get_chat_history(session_id), {
|
||
"type": "RULES_EDITOR",
|
||
"raw_draft": raw_rules_text,
|
||
"baseline_prompt": raw_rules_text
|
||
}
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 0.4 ПОСТУПЛЕНИЕ ДРАФТА ПРОМПТА -> DIFF-ПРЕВЬЮ
|
||
# -------------------------------------------------------------------------
|
||
if msg_raw.startswith("action:save_draft_prompt:::"):
|
||
new_draft_content = msg_raw.replace("action:save_draft_prompt:::", "").strip()
|
||
active_prompt = get_active_system_prompt()
|
||
diff_html = _generate_prompt_diff_html(active_prompt, new_draft_content)
|
||
|
||
db_set_session_state(session_id, "PROMPT_PREVIEW", {
|
||
"draft_text": new_draft_content,
|
||
"action": "MANUAL_EDIT",
|
||
"idle_turns": 0
|
||
})
|
||
|
||
preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените."
|
||
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
|
||
return preview_reply, db_get_chat_history(session_id), {
|
||
"type": "PROMPT_PREVIEW",
|
||
"raw_draft": new_draft_content,
|
||
"baseline_prompt": active_prompt,
|
||
"diff_html": diff_html,
|
||
"buttons": [
|
||
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
|
||
{"label": "Отменить", "value": "отмена", "style": "danger"},
|
||
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
|
||
]
|
||
}
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 0.5 ПОСТУПЛЕНИЕ ДРАФТА ПРАВИЛ -> DIFF-ПРЕВЬЮ
|
||
# -------------------------------------------------------------------------
|
||
if msg_raw.startswith("action:save_draft_rules:::"):
|
||
new_draft_content = msg_raw.replace("action:save_draft_rules:::", "").strip()
|
||
rules_data = get_rules()
|
||
rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)])
|
||
baseline_rules = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
|
||
diff_html = _generate_prompt_diff_html(baseline_rules, new_draft_content)
|
||
|
||
db_set_session_state(session_id, "RULES_PREVIEW", {
|
||
"draft_text": new_draft_content,
|
||
"action": "MANUAL_EDIT_RULES",
|
||
"idle_turns": 0
|
||
})
|
||
|
||
preview_reply = f"Предпросмотр изменений базы знаний компании:\n\n{diff_html}\n\nПодтвердите сохранение или отмените действие."
|
||
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
|
||
return preview_reply, db_get_chat_history(session_id), {
|
||
"type": "PROMPT_PREVIEW",
|
||
"raw_draft": new_draft_content,
|
||
"baseline_prompt": baseline_rules,
|
||
"diff_html": diff_html,
|
||
"buttons": [
|
||
{"label": "Подтвердить", "value": "подтверждаю сохранение правил", "style": "primary"},
|
||
{"label": "Отменить", "value": "отмена", "style": "danger"},
|
||
{"label": "✏️ Редактировать", "value": "action:open_rules_editor", "style": "secondary"}
|
||
]
|
||
}
|
||
|
||
if not session_state:
|
||
return None
|
||
|
||
state_type = session_state.get("state_type")
|
||
state_data = session_state.get("data_json") or {}
|
||
if not isinstance(state_data, dict):
|
||
state_data = {}
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 1. ПОДТВЕРЖДЕНИЕ ПРОМПТА
|
||
# -------------------------------------------------------------------------
|
||
if state_type == "PROMPT_PREVIEW":
|
||
is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"]
|
||
is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"]
|
||
|
||
if is_confirm:
|
||
draft_text = state_data.get("draft_text", "")
|
||
if draft_text:
|
||
save_full_prompt_draft(draft_text, prompt_name="main_agent")
|
||
|
||
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY")
|
||
db_clear_session_state(session_id)
|
||
|
||
reply = "✅ Системный промпт успешно сохранен и применен в базе данных."
|
||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
|
||
return reply, db_get_chat_history(session_id), None
|
||
|
||
elif is_cancel:
|
||
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_EDIT_CANCELLED")
|
||
db_clear_session_state(session_id)
|
||
|
||
reply = "❌ Изменения системного промпта отменены."
|
||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
|
||
return reply, db_get_chat_history(session_id), None
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 2. ПОДТВЕРЖДЕНИЕ ПРАВИЛ
|
||
# -------------------------------------------------------------------------
|
||
elif state_type == "RULES_PREVIEW":
|
||
is_confirm = "сохранение правил" in msg_lower or msg_lower in ["подтверждаю", "да", "сохраняй", "применить"]
|
||
is_cancel = msg_lower in ["отмена", "отменить", "нет"]
|
||
|
||
if is_confirm:
|
||
draft_text = state_data.get("draft_text", "")
|
||
lines = [l.strip() for l in draft_text.splitlines() if l.strip()]
|
||
for line in lines:
|
||
clean_line = line
|
||
if line[0].isdigit() and "." in line[:5]:
|
||
clean_line = line.split(".", 1)[1].strip()
|
||
add_rule(clean_line)
|
||
|
||
close_tool_session_and_cleanup(session_id, close_reason="RULES_SAVED_SUCCESS")
|
||
db_clear_session_state(session_id)
|
||
|
||
reply = "✅ База знаний и правила компании успешно сохранены в базе данных."
|
||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
|
||
return reply, db_get_chat_history(session_id), None
|
||
|
||
elif is_cancel:
|
||
close_tool_session_and_cleanup(session_id, close_reason="RULES_EDIT_CANCELLED")
|
||
db_clear_session_state(session_id)
|
||
|
||
reply = "❌ Изменение правил компании отменено."
|
||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
|
||
return reply, db_get_chat_history(session_id), None
|
||
|
||
return None
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/core/ollama_client.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/llm/core/ollama_client.py
|
||
ROLE: Транспортный клиент HTTP взаимодействия с локальным API Ollama.
|
||
===============================================================================
|
||
"""
|
||
|
||
# ANCHOR[OLLAMA_CLIENT_IMPORTS]
|
||
import json
|
||
import urllib.request
|
||
import urllib.error
|
||
import logging
|
||
from typing import Dict, Any, Optional
|
||
|
||
logger = logging.getLogger("OLLAMA_CLIENT")
|
||
|
||
OLLAMA_URL = "http://10.121.17.227:11434/api/chat"
|
||
TEXT_MODEL = "qwen2.5:14b"
|
||
VISION_MODEL = "qwen2.5vl:7b-q8_0"
|
||
|
||
LLM_OPTIONS = {
|
||
"num_predict": 8192,
|
||
"num_ctx": 8192,
|
||
"temperature": 0.0,
|
||
"repeat_penalty": 1.0,
|
||
"presence_penalty": 0.0,
|
||
"top_p": 0.9
|
||
}
|
||
|
||
|
||
# ANCHOR[OLLAMA_REQUEST_DISPATCHER]
|
||
def call_ollama_chat(
|
||
messages: list,
|
||
tools: Optional[list] = None,
|
||
is_vision: bool = False,
|
||
timeout: int = 120
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Отправляет подготовленный массив сообщений в API Ollama.
|
||
Возвращает разобранный словарь сообщения ответа или генерирует исключение.
|
||
"""
|
||
model_name = VISION_MODEL if is_vision else TEXT_MODEL
|
||
payload = {
|
||
"model": model_name,
|
||
"messages": messages,
|
||
"stream": False,
|
||
"options": LLM_OPTIONS
|
||
}
|
||
|
||
if tools and not is_vision:
|
||
payload["tools"] = tools
|
||
|
||
req = urllib.request.Request(
|
||
OLLAMA_URL,
|
||
data=json.dumps(payload).encode("utf-8"),
|
||
headers={"Content-Type": "application/json"}
|
||
)
|
||
|
||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||
res_data = json.loads(response.read().decode("utf-8"))
|
||
return res_data.get("message", {})
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/core/tool_injector.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/llm/core/tool_injector.py
|
||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||
MODULE: web_api / llm / core
|
||
ROLE: Базовая санитарная очистка вывода и тегов инструментов.
|
||
===============================================================================
|
||
"""
|
||
|
||
import re
|
||
import logging
|
||
from typing import List, Dict, Any
|
||
|
||
logger = logging.getLogger("TOOL_INJECTOR")
|
||
|
||
|
||
def clean_raw_tool_tags(text: str) -> str:
|
||
"""Удаляет сырые теги вызова инструментов и системный шум."""
|
||
if not text:
|
||
return ""
|
||
cleaned = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
|
||
cleaned = re.sub(r'<\|.*?\|>', '', cleaned)
|
||
return cleaned.strip()
|
||
|
||
|
||
def clean_output(text: str) -> str:
|
||
"""Очищает маркеры форматирования."""
|
||
return text.strip() if text else ""
|
||
|
||
|
||
def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""
|
||
Модель управляется через TOOLS_SCHEMA и системный контекст.
|
||
Любые синтетические перехваты текста регулярными выражениями отключены согласно ROADMAP.
|
||
"""
|
||
return existing_tool_calls
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/db/connection.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/llm/db/connection.py
|
||
ROLE: Реэкспорт единого подключения к БД из core.connection.
|
||
===============================================================================
|
||
"""
|
||
|
||
from core.connection import get_connection as get_db_connection, DB_PATH
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/db/db_chat.py`
|
||
```py
|
||
"""
|
||
FILE: modules/web_api/llm/db/db_chat.py
|
||
ROLE: Управление историей сообщений и очисткой эфемерного контекста.
|
||
"""
|
||
from typing import List, Dict, Any
|
||
from .connection import get_db_connection
|
||
|
||
def db_save_chat_message(session_id: str, role: str, content: str, is_ephemeral: int = 0) -> None:
|
||
"""Сохраняет сообщение в БД (is_ephemeral=1 для временных служебных шагов, 0 для постоянных)."""
|
||
conn = get_db_connection(row_factory=True)
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
INSERT INTO chat_messages (session_id, role, content, is_ephemeral)
|
||
VALUES (?, ?, ?, ?)
|
||
""", (session_id, role, content, is_ephemeral))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
|
||
def db_get_chat_history(session_id: str, limit: int = 50) -> list:
|
||
"""Возвращает историю сообщений диалога для сессии."""
|
||
with get_db_connection(row_factory=True) as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
SELECT role, content, is_ephemeral, created_at
|
||
FROM chat_messages
|
||
WHERE session_id = ?
|
||
ORDER BY id DESC
|
||
LIMIT ?
|
||
""", (session_id, limit))
|
||
rows = cursor.fetchall()
|
||
return [{"role": r["role"], "content": r["content"], "is_ephemeral": r["is_ephemeral"]} for r in reversed(rows)]
|
||
|
||
|
||
def db_purge_ephemeral_messages(session_id: str) -> int:
|
||
"""
|
||
Физически удаляет все временные служебные сообщения выбранной сессии
|
||
после завершения сценария работы с инструментом.
|
||
"""
|
||
conn = get_db_connection(row_factory=True)
|
||
cursor = conn.cursor()
|
||
cursor.execute("DELETE FROM chat_messages WHERE session_id = ? AND is_ephemeral = 1", (session_id,))
|
||
deleted = cursor.rowcount
|
||
conn.commit()
|
||
conn.close()
|
||
return deleted
|
||
|
||
|
||
def db_clear_chat_history(session_id: str) -> None:
|
||
"""Полная очистка всех сообщений сессии."""
|
||
conn = get_db_connection(row_factory=True)
|
||
cursor = conn.cursor()
|
||
cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
def db_clear_all_chat_context(session_id: str = None, purge_all: bool = False) -> int:
|
||
"""Удаляет сообщения чата и стейты сессий."""
|
||
with get_db_connection(row_factory=True) as conn:
|
||
cursor = conn.cursor()
|
||
if purge_all:
|
||
if session_id:
|
||
cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,))
|
||
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
|
||
else:
|
||
cursor.execute("DELETE FROM chat_messages")
|
||
cursor.execute("DELETE FROM session_states")
|
||
cnt = cursor.rowcount
|
||
conn.commit()
|
||
return cnt
|
||
|
||
query = """
|
||
DELETE FROM chat_messages
|
||
WHERE is_ephemeral = 1
|
||
OR content LIKE '%Предпросмотр изменений%'
|
||
OR content LIKE '%Удален пункт:%'
|
||
OR content LIKE '%добавлен пункт:%'
|
||
"""
|
||
if session_id:
|
||
cursor.execute(query + " AND session_id = ?", (session_id,))
|
||
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
|
||
else:
|
||
cursor.execute(query)
|
||
cursor.execute("DELETE FROM session_states")
|
||
cnt = cursor.rowcount
|
||
conn.commit()
|
||
return cnt
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/db/db_prompts.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/llm/db/db_prompts.py
|
||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||
MODULE: web_api / llm / db
|
||
ROLE: Реляционное управление системным промптом (таблица system_prompt_nodes),
|
||
базой знаний, реестром действий и сессионными стейтами.
|
||
===============================================================================
|
||
"""
|
||
import re
|
||
import json
|
||
import logging
|
||
from typing import List, Dict, Any, Optional
|
||
from .connection import get_db_connection
|
||
|
||
logger = logging.getLogger("DB_PROMPTS")
|
||
|
||
|
||
def init_prompt_nodes_table():
|
||
"""Создает реляционную таблицу узлов промпта и заполняет базовыми данными."""
|
||
with get_db_connection(row_factory=True) as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
CREATE TABLE IF NOT EXISTS system_prompt_nodes (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
prompt_name TEXT DEFAULT 'main_agent',
|
||
section_id INTEGER NOT NULL,
|
||
item_id INTEGER NOT NULL,
|
||
content TEXT NOT NULL,
|
||
is_active INTEGER DEFAULT 1,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
UNIQUE(prompt_name, section_id, item_id)
|
||
);
|
||
""")
|
||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);")
|
||
|
||
cursor.execute("SELECT COUNT(*) FROM system_prompt_nodes WHERE prompt_name = 'main_agent'")
|
||
if cursor.fetchone()[0] == 0:
|
||
seed_nodes = [
|
||
# Раздел 1
|
||
(1, 0, "РОЛЬ И ЗАДАЧИ АССИСТЕНТА"),
|
||
(1, 1, "Управление бэклогом задач проекта (через db_get_tasks, db_add_task, db_update_task_status, db_delete_task)."),
|
||
(1, 2, "Консультация по внутренним регламентам компании (через db_get_rules). Вопросы по ТК РФ и законам поясняй напрямую."),
|
||
(1, 3, "Предоставление справки о возможностях и примерах команд (СТРОГО через db_get_reference)."),
|
||
(1, 4, "Просмотр аномалий СКУД ⟷ 1С (СТРОГО через db_get_anomalies)."),
|
||
(1, 5, "Поддержка диалога с операторами и администраторами системы."),
|
||
(1, 7, "Работа с логами, снапшотами и срезами СКУД (через db_get_snapshots)."),
|
||
|
||
# Раздел 2
|
||
(2, 0, "ПРАВИЛА ВЫЗОВА ИНСТРУМЕНТОВ И ДАТ"),
|
||
(2, 1, "ОБЯЗАТЕЛЬНЫЙ ПРЕВЬЮ-МЕРДЖ: Категорически ЗАПРЕЩЕНО изменять промпт напрямую! При ЛЮБОМ запросе пользователя на изменение системного промпта Ты ОБЯЗАН вызвать инструмент db_prompt_node_edit."),
|
||
(2, 2, "БЕЗУСЛОВНОЕ ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ: Инструмент db_confirm_prompt_preview вызывается СТРОГО после того, как пользователь напишет 'подтверждаю', 'да', 'сохраняй'."),
|
||
(2, 3, "ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧ: При запросе на удаление задач КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО сразу вызывать db_delete_task! Ты ОБЯЗАН сначала спросить пользователя подтверждение."),
|
||
(2, 4, "ИСПОЛЬЗОВАНИЕ КАЛЕНДАРЯ: При любых вопросах про даты БЕРИ ТОЧНУЮ ДАТУ ИЗ [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА] В НАЧАЛЕ КОНТЕКСТА."),
|
||
(2, 5, "СТРОГИЙ ВЫЗОВ АНОМАЛИЙ: При запросах аномалий или нарушений Ты ОБЯЗАН вызвать инструмент db_get_anomalies."),
|
||
(2, 6, "СТРОГИЙ ВЫЗОВ СПРАВОЧНИКА: При запросах о возможностях, примерах запросов или командах Ты ОБЯЗАН вызывать db_get_reference."),
|
||
(2, 7, "Запрещено писать названия функций или код вызова текстом на экран."),
|
||
(2, 8, "СТРОГИЙ ВЫЗОВ СНАПШОТОВ: При ЛЮБЫХ запросах про снапшоты, срезы или логи СКУД за дату/день недели Ты ОБЯЗАН вызывать инструмент db_get_snapshots."),
|
||
|
||
# Раздел 3
|
||
(3, 0, "ПРАВИЛА СТИЛЯ"),
|
||
(3, 1, "Никогда не начинай ответ со склеек или слов-паразитов."),
|
||
(3, 2, "Отвечай в чистом текстовом формате (plain text) без спецсимволов или ###, если прямо не попросили."),
|
||
(3, 3, "Сохраняй инженерный, лаконичный и профессиональный стиль.")
|
||
]
|
||
cursor.executemany("""
|
||
INSERT OR IGNORE INTO system_prompt_nodes (prompt_name, section_id, item_id, content)
|
||
VALUES ('main_agent', ?, ?, ?)
|
||
""", seed_nodes)
|
||
conn.commit()
|
||
|
||
|
||
def db_get_active_system_prompt(prompt_name: str = "main_agent") -> str:
|
||
"""Собирает структурированный текст промпта из реляционной таблицы узлов."""
|
||
init_prompt_nodes_table()
|
||
with get_db_connection(row_factory=True) as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
SELECT section_id, item_id, content
|
||
FROM system_prompt_nodes
|
||
WHERE prompt_name = ? AND is_active = 1
|
||
ORDER BY section_id ASC, item_id ASC
|
||
""", (prompt_name,))
|
||
rows = cursor.fetchall()
|
||
|
||
if not rows:
|
||
return "Ты — ИИ-ассистент SCUD Orion AI."
|
||
|
||
lines = []
|
||
current_section = None
|
||
|
||
for sec_id, itm_id, content in rows:
|
||
if itm_id == 0:
|
||
if current_section is not None:
|
||
lines.append("")
|
||
lines.append(f"{sec_id}. {content}")
|
||
current_section = sec_id
|
||
else:
|
||
lines.append(f" {sec_id}.{itm_id}. {content}")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def db_apply_prompt_node_action(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent"):
|
||
"""Прямое добавление, изменение или удаление узла в БД."""
|
||
init_prompt_nodes_table()
|
||
with get_db_connection(row_factory=True) as conn:
|
||
cursor = conn.cursor()
|
||
action_clean = action.upper()
|
||
if action_clean in ["ADD", "UPDATE"]:
|
||
cursor.execute("""
|
||
INSERT INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active, updated_at)
|
||
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
|
||
ON CONFLICT(prompt_name, section_id, item_id) DO UPDATE SET
|
||
content = excluded.content,
|
||
is_active = 1,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
""", (prompt_name, section_id, item_id, content))
|
||
elif action_clean == "DELETE":
|
||
cursor.execute("""
|
||
DELETE FROM system_prompt_nodes
|
||
WHERE prompt_name = ? AND section_id = ? AND item_id = ?
|
||
""", (prompt_name, section_id, item_id))
|
||
conn.commit()
|
||
|
||
|
||
def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]:
|
||
conn = get_db_connection(row_factory=True)
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
SELECT tool_name, category, bypass_llm, success_template,
|
||
follow_up_question, action_type, buttons_json, is_ephemeral
|
||
FROM tool_action_registry
|
||
WHERE tool_name = ? AND is_active = 1
|
||
""", (tool_name,))
|
||
row = cursor.fetchone()
|
||
conn.close()
|
||
|
||
if row:
|
||
res = dict(row)
|
||
res["buttons"] = json.loads(res["buttons_json"]) if res.get("buttons_json") else []
|
||
return res
|
||
return None
|
||
|
||
|
||
def db_get_rules() -> List[Dict[str, Any]]:
|
||
conn = get_db_connection(row_factory=True)
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC")
|
||
rows = cursor.fetchall()
|
||
conn.close()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
def db_set_session_state(session_id: str, state_type: str, data: Any) -> None:
|
||
"""Сохраняет состояние сессии в SQLite."""
|
||
conn = get_db_connection(row_factory=True)
|
||
cursor = conn.cursor()
|
||
payload_str = json.dumps(data, ensure_ascii=False) if isinstance(data, (dict, list)) else (str(data) if data is not None else "")
|
||
cursor.execute("""
|
||
INSERT INTO session_states (session_id, state_type, pending_data, updated_at)
|
||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||
ON CONFLICT(session_id) DO UPDATE SET
|
||
state_type = excluded.state_type,
|
||
pending_data = excluded.pending_data,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
""", (session_id, state_type, payload_str))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
|
||
def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
|
||
conn = get_db_connection(row_factory=True)
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?", (session_id,))
|
||
row = cursor.fetchone()
|
||
conn.close()
|
||
if not row:
|
||
return None
|
||
res = dict(row)
|
||
raw_data = res.get("pending_data") or ""
|
||
try:
|
||
res["data_json"] = json.loads(raw_data) if raw_data.strip().startswith(("{", "[")) else None
|
||
except Exception:
|
||
res["data_json"] = None
|
||
return res
|
||
|
||
|
||
def db_clear_session_state(session_id: str) -> None:
|
||
conn = get_db_connection(row_factory=True)
|
||
cursor = conn.cursor()
|
||
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
|
||
def db_get_stats() -> Dict[str, Any]:
|
||
conn = get_db_connection(row_factory=True)
|
||
cursor = conn.cursor()
|
||
tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompt_nodes', 'session_states', 'tasks']
|
||
stats = {}
|
||
for t in tables:
|
||
try:
|
||
cursor.execute(f"SELECT COUNT(*) FROM {t}")
|
||
stats[t] = cursor.fetchone()[0]
|
||
except Exception:
|
||
stats[t] = 0
|
||
conn.close()
|
||
return {"status": "success", "tables_stats": stats}
|
||
|
||
|
||
def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]:
|
||
conn = get_db_connection(row_factory=True)
|
||
cursor = conn.cursor()
|
||
query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history"
|
||
params = []
|
||
if date_str:
|
||
query += " WHERE anomaly_date = ?"
|
||
params.append(date_str)
|
||
query += " ORDER BY id DESC LIMIT ?"
|
||
params.append(limit)
|
||
cursor.execute(query, params)
|
||
rows = cursor.fetchall()
|
||
conn.close()
|
||
return {"status": "success", "count": len(rows), "anomalies": [dict(r) for r in rows]}
|
||
|
||
|
||
def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
|
||
conn = get_db_connection(row_factory=True)
|
||
cursor = conn.cursor()
|
||
query = "SELECT category, title, example_prompt, description FROM system_reference"
|
||
params = []
|
||
if category:
|
||
query += " WHERE category = ?"
|
||
params.append(category)
|
||
query += " ORDER BY id ASC"
|
||
cursor.execute(query, params)
|
||
rows = cursor.fetchall()
|
||
conn.close()
|
||
return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]}
|
||
|
||
def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
|
||
"""Надежный парсер: восстанавливает разделы и пункты с авто-выравниванием отступов."""
|
||
try:
|
||
init_prompt_nodes_table()
|
||
with get_db_connection() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute("DELETE FROM system_prompt_nodes WHERE prompt_name = ?", (name,))
|
||
|
||
current_sec = 1
|
||
current_itm = 0
|
||
|
||
for raw_line in prompt_text.splitlines():
|
||
clean_line = re.sub(r'<[^>]+>', '', raw_line).strip()
|
||
if not clean_line:
|
||
continue
|
||
|
||
# 1. Проверяем подпункт (например: "1.8. Текст", "1.8 Текст", "1. 8 Текст")
|
||
sub_match = re.match(r'^(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)$', clean_line)
|
||
# 2. Проверяем заголовок раздела (например: "1. РОЛЬ И ЗАДАЧИ", "1 РОЛЬ И ЗАДАЧИ")
|
||
sec_match = re.match(r'^(\d+)[\.\s\:\-]+(.*)$', clean_line)
|
||
|
||
if sub_match:
|
||
current_sec = int(sub_match.group(1))
|
||
current_itm = int(sub_match.group(2))
|
||
content = sub_match.group(3).strip()
|
||
elif sec_match and not any(c.islower() for c in sec_match.group(2)[:15]):
|
||
# Заголовок раздела (обычно капсом)
|
||
current_sec = int(sec_match.group(1))
|
||
current_itm = 0
|
||
content = sec_match.group(2).strip()
|
||
else:
|
||
current_itm += 1
|
||
content = clean_line
|
||
|
||
cursor.execute("""
|
||
INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active)
|
||
VALUES (?, ?, ?, ?, 1)
|
||
""", (name, current_sec, current_itm, content))
|
||
|
||
conn.commit()
|
||
return {"status": "success", "message": "Системный промпт успешно сохранен по узлам"}
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при разборе промпта в узлы: {e}")
|
||
return {"status": "error", "error": str(e)}
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/db_tools.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/llm/db_tools.py
|
||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||
MODULE: web_api / llm
|
||
ROLE: Фасадная точка доступа к доменным сервисам (Domain Facade).
|
||
|
||
AI-CONTEXT-ANCHORS:
|
||
- ANCHOR[FACADE_EXPORTS]: Экспорт методов предметных сервисов для LLM и API.
|
||
===============================================================================
|
||
"""
|
||
|
||
# ANCHOR[FACADE_EXPORTS]
|
||
from typing import Optional, List, Dict, Any
|
||
from core.connection import DB_PATH, get_connection as get_db_connection
|
||
|
||
# Домен: Задачи
|
||
from services.tasks.service import (
|
||
get_tasks as db_get_tasks,
|
||
add_task as db_add_task,
|
||
update_task_details as db_update_task_details,
|
||
delete_task as db_delete_task,
|
||
execute_task_action as db_tasks_edit
|
||
)
|
||
# Защитный алиас для обратной совместимости
|
||
db_update_task_status = db_update_task_details
|
||
|
||
from services.tasks.exporter import export_tasks_to_markdown as db_export_tasks_markdown
|
||
from services.tasks.repository import normalize_task_id
|
||
|
||
# Домен: Системный промпт
|
||
from services.prompts.service import (
|
||
get_active_system_prompt as db_get_active_system_prompt,
|
||
apply_prompt_action as db_apply_prompt_node_action,
|
||
save_full_prompt_draft,
|
||
create_prompt_preview
|
||
)
|
||
|
||
# Домен: База знаний
|
||
from services.knowledge.service import (
|
||
get_rules as db_get_rules,
|
||
add_rule as db_add_rule
|
||
)
|
||
|
||
# Чат, сессии, статистика
|
||
from .db.db_chat import (
|
||
db_save_chat_message,
|
||
db_get_chat_history,
|
||
db_purge_ephemeral_messages,
|
||
db_clear_chat_history
|
||
)
|
||
from .db.db_prompts import (
|
||
db_get_tool_action,
|
||
db_set_session_state,
|
||
db_get_session_state,
|
||
db_clear_session_state,
|
||
db_get_stats,
|
||
db_get_anomalies,
|
||
db_get_reference
|
||
)
|
||
from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time
|
||
|
||
|
||
def db_add_system_prompt(name_or_text: str, draft_text: str = None) -> None:
|
||
"""Совместимая обертка для сохранения системного промпта."""
|
||
if draft_text is not None:
|
||
save_full_prompt_draft(draft_text, prompt_name=name_or_text)
|
||
else:
|
||
save_full_prompt_draft(name_or_text, prompt_name="main_agent")
|
||
|
||
|
||
def db_get_snapshots(session_id: str = "web_session_main", date_str: str = None, original_user_message: str = "") -> Dict[str, Any]:
|
||
"""Совместимый фасад выборки снапшотов с сохранением стейта сессии."""
|
||
from services.snapshots.service import get_snapshots_registry
|
||
from .core.calendar_utils import parse_relative_date_ru
|
||
|
||
clean_date = (date_str or "").strip()
|
||
if not clean_date and original_user_message:
|
||
clean_date = parse_relative_date_ru(original_user_message)
|
||
|
||
res = get_snapshots_registry(date_str=clean_date if clean_date else None)
|
||
db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=res)
|
||
return res
|
||
|
||
|
||
def db_delete_snapshots(snapshot_id: str = None, snapshot_ids: List[str] = None, day_str: str = None) -> Dict[str, Any]:
|
||
"""Совместимый фасад безопасного удаления снапшотов."""
|
||
from services.snapshots.service import delete_snapshots_safely
|
||
|
||
target_ids = []
|
||
if snapshot_ids:
|
||
target_ids.extend(snapshot_ids)
|
||
if snapshot_id:
|
||
if isinstance(snapshot_id, str) and "," in snapshot_id:
|
||
target_ids.extend([s.strip() for s in snapshot_id.split(",")])
|
||
else:
|
||
target_ids.append(snapshot_id)
|
||
|
||
return delete_snapshots_safely(snapshot_ids=target_ids)
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/file_parser.py`
|
||
```py
|
||
import base64
|
||
import os
|
||
import subprocess
|
||
import logging
|
||
import pandas as pd
|
||
|
||
logger = logging.getLogger("FILE_PARSER")
|
||
|
||
def extract_text_from_file(file_bytes: bytes, filename: str) -> dict:
|
||
ext = os.path.splitext(filename)[1].lower()
|
||
temp_filepath = f"/tmp/upload_{os.getpid()}_{filename}"
|
||
|
||
with open(temp_filepath, "wb") as f:
|
||
f.write(file_bytes)
|
||
|
||
try:
|
||
# 1. ИЗОБРАЖЕНИЯ (.png, .jpg, .jpeg, .bmp, .webp) -> Кодируем в Base64 для Vision LLM
|
||
if ext in ['.png', '.jpg', '.jpeg', '.bmp', '.webp']:
|
||
b64_str = base64.b64encode(file_bytes).decode('utf-8')
|
||
return {
|
||
"text": f"[ПРИКРЕПЛЕНО ИЗОБРАЖЕНИЕ: {filename}]",
|
||
"image_b64": b64_str
|
||
}
|
||
|
||
# 2. PDF ДОКУМЕНТЫ (Конвертируем 1-ю страницу в картинку для Vision LLM)
|
||
elif ext == '.pdf':
|
||
cmd = ['pdftotext', temp_filepath, '-']
|
||
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||
pdf_text = res.stdout.strip()
|
||
|
||
img_prefix = f"/tmp/pdf_preview_{os.getpid()}"
|
||
subprocess.run(['pdftoppm', '-png', '-r', '200', '-f', '1', '-l', '1', temp_filepath, img_prefix], check=True)
|
||
|
||
page_png = f"{img_prefix}-1.png"
|
||
b64_str = None
|
||
if os.path.exists(page_png):
|
||
with open(page_png, "rb") as pf:
|
||
b64_str = base64.b64encode(pf.read()).decode('utf-8')
|
||
os.remove(page_png)
|
||
|
||
context_text = f"[ПРИКРЕПЛЕН ДОКУМЕНТ PDF: {filename}]"
|
||
if pdf_text:
|
||
context_text += f"\n\n[ЭЛЕКТРОННЫЙ ТЕКСТОВЫЙ СЛОЙ PDF]:\n{pdf_text}"
|
||
|
||
return {
|
||
"text": context_text,
|
||
"image_b64": b64_str
|
||
}
|
||
|
||
# 3. ЭЛЕКТРОННЫЕ ТАБЛИЦЫ (.xlsx, .xls, .csv)
|
||
elif ext in ['.xlsx', '.xls', '.csv']:
|
||
if ext == '.csv':
|
||
df = pd.read_csv(temp_filepath)
|
||
else:
|
||
df = pd.read_excel(temp_filepath)
|
||
|
||
total_rows = len(df)
|
||
df_preview = df.head(100)
|
||
table_str = df_preview.to_string(index=False)
|
||
note = f"\n(Показано первых 100 строк из {total_rows})" if total_rows > 100 else ""
|
||
return {
|
||
"text": f"[СОДЕРЖИМОЕ ТАБЛИЦЫ {filename}]:\n{table_str}{note}",
|
||
"image_b64": None
|
||
}
|
||
|
||
# 4. ТЕКСТОВЫЕ ФАЙЛЫ
|
||
elif ext in ['.txt', '.log', '.json', '.xml', '.md']:
|
||
with open(temp_filepath, 'r', encoding='utf-8', errors='replace') as tf:
|
||
return {
|
||
"text": tf.read().strip(),
|
||
"image_b64": None
|
||
}
|
||
|
||
else:
|
||
return {
|
||
"text": f"[ОШИБКА: Формат {ext} не поддерживается]",
|
||
"image_b64": None
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при анализе файла {filename}: {e}")
|
||
return {
|
||
"text": f"[ОШИБКА ОБРАБОТКИ ФАЙЛА: {str(e)}]",
|
||
"image_b64": None
|
||
}
|
||
finally:
|
||
if os.path.exists(temp_filepath):
|
||
os.remove(temp_filepath)
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/schemas.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/llm/schemas.py
|
||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||
MODULE: web_api / llm
|
||
ROLE: Декларативная схема нативных инструментов (Function Calling) для Ollama.
|
||
|
||
AI-CONTEXT-ANCHORS:
|
||
- ANCHOR[SCHEMA_PROMPT_EDIT]: Схема управления узлами системного промпта.
|
||
- ANCHOR[SCHEMA_TASKS_EDIT]: Консолидированная схема управления задачами.
|
||
- ANCHOR[SCHEMA_SNAPSHOTS]: Схема доступа к логам и срезам СКУД.
|
||
===============================================================================
|
||
"""
|
||
|
||
# ANCHOR[SCHEMA_PROMPT_EDIT]
|
||
TOOLS_SCHEMA = [
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_prompt_node_edit",
|
||
"description": (
|
||
"Управление элементами системного промпта (добавление, изменение, удаление пунктов).\n"
|
||
"Поддерживает как одиночные пункты (section_id, item_id), так и список пунктов для удаления (nodes_list=['1.8', '3.4']).\n"
|
||
"При любом запросе на удаление или добавление пунктов системного промпта ТЫ ОБЯЗАН вызвать этот инструмент."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"action": {
|
||
"type": "string",
|
||
"enum": ["ADD", "EDIT", "DELETE", "BATCH_DELETE"],
|
||
"description": "Тип действия"
|
||
},
|
||
"section_id": {
|
||
"type": "integer",
|
||
"description": "Номер раздела (например 1)"
|
||
},
|
||
"item_id": {
|
||
"type": "integer",
|
||
"description": "Номер пункта (например 8)"
|
||
},
|
||
"nodes_list": {
|
||
"type": "array",
|
||
"items": {"type": "string"},
|
||
"description": "Список пунктов для удаления/изменения, например ['1.8', '3.4']"
|
||
},
|
||
"content": {
|
||
"type": "string",
|
||
"description": "Текст пункта"
|
||
}
|
||
},
|
||
"required": ["action"]
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_get_system_prompt",
|
||
"description": "Просмотр текущего активного системного промпта ассистента из базы данных.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {}
|
||
}
|
||
}
|
||
},
|
||
# ANCHOR[SCHEMA_TASKS_EDIT]
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_get_tasks",
|
||
"description": (
|
||
"Просмотр реестра задач и бэклога текущего пользователя.\n"
|
||
"Вызывай этот инструмент ВСЕГДА при любых запросах просмотра задач ('покажи задачи', 'мои задачи', опечатки 'змдачи').\n"
|
||
"Запрещено переспрашивать статус или параметры текстом: просто вызывай функцию с аргументами {}."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"status": {
|
||
"type": "string",
|
||
"enum": ["ALL", "IN_PROGRESS", "PLANNED", "COMPLETED"],
|
||
"description": "Опциональный фильтр статуса задач (по умолчанию ALL)"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_tasks_edit",
|
||
"description": (
|
||
"Единый инструмент управления задачами: создание (ADD), изменение статуса/срока/названия (UPDATE), удаление (DELETE) и экспорт (EXPORT).\n"
|
||
"СТРОГИЕ ПРАВИЛА ВЫЗОВА:\n"
|
||
"- На любые фразы вида 'удали задачу N', 'удалить N', 'убери задачу N' ТЫ ОБЯЗАН СРАЗУ вызвать инструмент с action='DELETE', task_id='N'.\n"
|
||
"- На фразы 'возьми в работу N' вызывай action='UPDATE', task_id='N', status='IN_PROGRESS'.\n"
|
||
"- На фразы 'заверши N', 'готово N' вызывай action='UPDATE', task_id='N', status='COMPLETED'.\n"
|
||
"- КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать текстовые вопросы или запрашивать подтверждения словами! ТЫ ОБЯЗАН СРАЗУ вызвать инструмент."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"action": {
|
||
"type": "string",
|
||
"enum": ["ADD", "UPDATE", "DELETE", "EXPORT"],
|
||
"description": "Тип действия: ADD, UPDATE, DELETE или EXPORT"
|
||
},
|
||
"task_id": {
|
||
"type": "string",
|
||
"description": "Номер задачи (например: '37')"
|
||
},
|
||
"title": {
|
||
"type": "string",
|
||
"description": "Описание или текст задачи"
|
||
},
|
||
"status": {
|
||
"type": "string",
|
||
"enum": ["IN_PROGRESS", "COMPLETED", "BACKLOG"],
|
||
"description": "Новый статус задачи: IN_PROGRESS (В работу), COMPLETED (Завершено), BACKLOG (В планы)"
|
||
},
|
||
"priority": {
|
||
"type": "string",
|
||
"enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"],
|
||
"description": "Приоритет задачи"
|
||
},
|
||
"module": {
|
||
"type": "string",
|
||
"description": "Модуль проекта"
|
||
},
|
||
"due_date": {
|
||
"type": "string",
|
||
"description": "Срок в формате ГГГГ-ММ-ДД"
|
||
},
|
||
"filename": {
|
||
"type": "string",
|
||
"description": "Имя файла для экспорта"
|
||
}
|
||
},
|
||
"required": ["action"]
|
||
}
|
||
}
|
||
},
|
||
# ANCHOR[SCHEMA_SNAPSHOTS]
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_get_snapshots",
|
||
"description": (
|
||
"Получение реестра/списка доступных снапшотов (файлов срезов) СКУД.\n"
|
||
"ВЫЗЫВАТЬ ТОЛЬКО при прямом запросе на список срезов ('покажи срезы', 'какие есть снапшоты', 'срезы за дату').\n"
|
||
"КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать эту функцию, если пользователь спрашивает о людях, сотрудниках, входах или выходах внутри уже открытого среза!"
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"date_str": {
|
||
"type": "string",
|
||
"description": "Дата в формате ДД.ММ.ГГГГ или относительное слово"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_delete_snapshots",
|
||
"description": "Удаление дневных снапшотов СКУД по идентификатору или дате.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"snapshot_id": {
|
||
"type": "string",
|
||
"description": "Идентификатор или список идентификаторов через запятую"
|
||
},
|
||
"day_str": {
|
||
"type": "string",
|
||
"description": "Дата всех снапшотов за день"
|
||
},
|
||
"confirmed": {
|
||
"type": "boolean",
|
||
"description": "Флаг окончательного подтверждения удаления пользователем"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_get_anomalies",
|
||
"description": "Просмотр истории аномалий и расхождений между СКУД и 1С.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"date_str": {
|
||
"type": "string",
|
||
"description": "Опциональная дата в формате ДД.ММ.ГГГГ"
|
||
},
|
||
"limit": {
|
||
"type": "integer",
|
||
"description": "Лимит записей"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_get_rules",
|
||
"description": "Просмотр базы знаний и правил кадрового арбитража компании.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_get_stats",
|
||
"description": "Получение статистики количества записей в таблицах базы данных.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_get_current_server_time",
|
||
"description": "Получение текущего точного времени сервера.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_get_reference",
|
||
"description": "Справка о возможностях ассистента и примеры доступных команд.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"category": {
|
||
"type": "string",
|
||
"description": "Категория справки"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
]
|
||
```
|
||
|
||
## File: `./modules/web_api/main.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/main.py
|
||
PROJECT: SCUD Orion AI (Unified Repository)
|
||
MODULE: web_api (Main Application Entry Point)
|
||
ROLE: Инициализация FastAPI приложения, подключение роутеров и статики.
|
||
===============================================================================
|
||
"""
|
||
|
||
# ANCHOR[APP_INIT_IMPORTS]
|
||
import os
|
||
import sys
|
||
import logging
|
||
|
||
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
if CURRENT_DIR not in sys.path:
|
||
sys.path.insert(0, CURRENT_DIR)
|
||
|
||
ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, "../../"))
|
||
|
||
for p in [ROOT_DIR, CURRENT_DIR]:
|
||
if p not in sys.path:
|
||
sys.path.insert(0, p)
|
||
|
||
from fastapi import FastAPI, HTTPException
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.responses import FileResponse, JSONResponse
|
||
from fastapi.exceptions import RequestValidationError
|
||
from routers.manual_absences import router as manual_absences_router
|
||
|
||
from routers.auth import router as auth_router
|
||
from routers.admin import router as admin_router
|
||
from routers.tasks import router as tasks_router
|
||
from routers.chat import router as chat_router
|
||
from routers.files import router as files_router
|
||
from routers.exceptions import router as exceptions_router
|
||
from routers.snapshots import router as snapshots_router
|
||
from routers.remote_workers import router as remote_workers_router
|
||
from routers.context import router as context_router
|
||
from routers.presence import router as presence_router
|
||
from routers.reports import router as reports_router
|
||
|
||
# ANCHOR[APP_CONFIG]
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||
handlers=[logging.StreamHandler()]
|
||
)
|
||
|
||
STATIC_DIR = os.path.join(CURRENT_DIR, "static")
|
||
|
||
app = FastAPI(title="SCUD Orion AI Context API", version="2.5")
|
||
|
||
if os.path.exists(STATIC_DIR):
|
||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||
|
||
@app.exception_handler(RequestValidationError)
|
||
async def validation_exception_handler(request, exc):
|
||
logging.error(f"❌ ОШИБКА ВАЛИДАЦИИ 422 НА {request.url}: {exc.errors()}")
|
||
return JSONResponse(
|
||
status_code=422,
|
||
content={"detail": exc.errors(), "body": str(exc)}
|
||
)
|
||
|
||
# ANCHOR[ROUTER_REGISTRATION]
|
||
app.include_router(auth_router)
|
||
app.include_router(admin_router)
|
||
app.include_router(tasks_router)
|
||
app.include_router(chat_router)
|
||
app.include_router(files_router)
|
||
app.include_router(exceptions_router)
|
||
app.include_router(snapshots_router)
|
||
app.include_router(remote_workers_router)
|
||
app.include_router(context_router)
|
||
app.include_router(manual_absences_router)
|
||
app.include_router(presence_router)
|
||
app.include_router(reports_router)
|
||
|
||
# ANCHOR[ROOT_STATIC_ROUTES]
|
||
@app.get("/")
|
||
def read_root():
|
||
index_path = os.path.join(STATIC_DIR, "index.html")
|
||
if os.path.exists(index_path):
|
||
return FileResponse(index_path)
|
||
raise HTTPException(status_code=404, detail="Frontend index.html not found")
|
||
|
||
@app.get("/favicon.ico")
|
||
async def favicon():
|
||
file_path = os.path.join(STATIC_DIR, "favicon.ico")
|
||
if os.path.exists(file_path):
|
||
return FileResponse(file_path)
|
||
raise HTTPException(status_code=404)
|
||
|
||
@app.get("/{file_path:path}")
|
||
def serve_static_fallback(file_path: str):
|
||
clean_path = file_path.lstrip("/")
|
||
|
||
# Жесткая блокировка скрытых файлов (.env, .git) и служебных форматов
|
||
forbidden_patterns = [".env", ".git", ".yml", ".yaml", ".json", ".sql", ".php", ".bak"]
|
||
if clean_path.startswith(".") or any(p in clean_path.lower() for p in forbidden_patterns):
|
||
raise HTTPException(status_code=403, detail="Access denied")
|
||
|
||
target = os.path.join(STATIC_DIR, clean_path)
|
||
|
||
if os.path.isfile(target):
|
||
if clean_path.endswith(".js"):
|
||
return FileResponse(target, media_type="application/javascript")
|
||
elif clean_path.endswith(".css"):
|
||
return FileResponse(target, media_type="text/css")
|
||
return FileResponse(target)
|
||
|
||
filename = os.path.basename(clean_path)
|
||
for root, _, files in os.walk(STATIC_DIR):
|
||
if filename in files:
|
||
full_path = os.path.join(root, filename)
|
||
media = "application/javascript" if filename.endswith(".js") else "text/css"
|
||
return FileResponse(full_path, media_type=media)
|
||
|
||
raise HTTPException(status_code=404, detail="File not found")
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/admin.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/admin.py
|
||
ROLE: Администрирование пользователей и прав доступа.
|
||
===============================================================================
|
||
"""
|
||
|
||
# ANCHOR[ADMIN_ROUTER_IMPORTS]
|
||
import logging
|
||
from typing import Dict, Any, Optional
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel
|
||
|
||
from .auth import get_current_user, get_db, pwd_context
|
||
|
||
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
|
||
|
||
# ANCHOR[ADMIN_SCHEMAS]
|
||
class CreateUserRequest(BaseModel):
|
||
username: str
|
||
password: str
|
||
full_name: Optional[str] = None
|
||
is_admin: Optional[bool] = False
|
||
|
||
# ANCHOR[ADMIN_ENDPOINTS]
|
||
@router.get("/users")
|
||
def list_users(current_user: Dict[str, Any] = Depends(get_current_user)):
|
||
if not current_user["is_admin"]:
|
||
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
||
|
||
conn = get_db()
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC")
|
||
users = [dict(r) for r in cursor.fetchall()]
|
||
conn.close()
|
||
return users
|
||
|
||
@router.post("/users")
|
||
def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||
if not current_user["is_admin"]:
|
||
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
||
|
||
username = req.username.strip().lower()
|
||
if not username or not req.password:
|
||
raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль")
|
||
|
||
conn = get_db()
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT id FROM users WHERE username = ?", (username,))
|
||
if cursor.fetchone():
|
||
conn.close()
|
||
raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует")
|
||
|
||
pwd_hash = pwd_context.hash(req.password)
|
||
full_name = req.full_name.strip() if req.full_name else None
|
||
is_admin = 1 if req.is_admin else 0
|
||
|
||
cursor.execute(
|
||
"INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)",
|
||
(username, pwd_hash, full_name, is_admin)
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}")
|
||
return {"status": "success", "message": f"Пользователь {username} создан"}
|
||
|
||
@router.delete("/users/{user_id}")
|
||
def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||
if not current_user["is_admin"]:
|
||
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
||
|
||
if user_id == current_user["id"]:
|
||
raise HTTPException(status_code=400, detail="Нельзя удалить самого себя")
|
||
|
||
conn = get_db()
|
||
cursor = conn.cursor()
|
||
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
logging.info(f"Удален пользователь ID: {user_id}")
|
||
return {"status": "success", "message": "Пользователь удален"}
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/auth.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/auth.py
|
||
ROLE: Аутентификация, валидация JWT-токенов и управление паролями.
|
||
===============================================================================
|
||
"""
|
||
|
||
import logging
|
||
from datetime import datetime, timedelta
|
||
from typing import Dict, Any, Optional
|
||
|
||
import jwt
|
||
from passlib.context import CryptContext
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||
from pydantic import BaseModel
|
||
|
||
from core.connection import get_connection
|
||
|
||
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
|
||
ALGORITHM = "HS256"
|
||
|
||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||
security = HTTPBearer()
|
||
|
||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||
|
||
|
||
def get_db():
|
||
return get_connection(row_factory=True)
|
||
|
||
|
||
def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
|
||
payload = {
|
||
"sub": str(user_id),
|
||
"username": username,
|
||
"is_admin": is_admin,
|
||
"exp": datetime.utcnow() + timedelta(days=30)
|
||
}
|
||
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
|
||
|
||
|
||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
|
||
try:
|
||
token = credentials.credentials
|
||
payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
|
||
user_id = int(payload.get("sub"))
|
||
username = payload.get("username")
|
||
is_admin = bool(payload.get("is_admin", False))
|
||
return {"id": user_id, "username": username, "is_admin": is_admin}
|
||
except Exception as e:
|
||
logging.warning(f"Auth error: {e}")
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="Недействительный или просроченный токен авторизации",
|
||
headers={"WWW-Authenticate": "Bearer"},
|
||
)
|
||
|
||
|
||
class AuthRequest(BaseModel):
|
||
username: str
|
||
password: str
|
||
|
||
|
||
class ChangePasswordRequest(BaseModel):
|
||
old_password: str
|
||
new_password: str
|
||
|
||
|
||
@router.post("/login")
|
||
def login(req: AuthRequest):
|
||
username = req.username.strip().lower()
|
||
conn = get_db()
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT id, username, password_hash, full_name, is_admin FROM users WHERE username = ?", (username,))
|
||
user = cursor.fetchone()
|
||
conn.close()
|
||
|
||
if not user or not pwd_context.verify(req.password, user["password_hash"]):
|
||
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
|
||
|
||
is_admin = bool(user["is_admin"]) or (user["username"] == "puh")
|
||
token = create_access_token(user["id"], user["username"], is_admin)
|
||
|
||
# Возвращаем full_name (если не задано — отдаем username)
|
||
full_name = user["full_name"] if user["full_name"] else user["username"]
|
||
|
||
return {
|
||
"status": "success",
|
||
"token": token,
|
||
"username": user["username"],
|
||
"full_name": full_name,
|
||
"user_id": user["id"],
|
||
"is_admin": is_admin
|
||
}
|
||
|
||
|
||
@router.post("/change-password")
|
||
def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||
if not req.new_password or len(req.new_password) < 4:
|
||
raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа")
|
||
|
||
conn = get_db()
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],))
|
||
user = cursor.fetchone()
|
||
|
||
if not user or not pwd_context.verify(req.old_password, user["password_hash"]):
|
||
conn.close()
|
||
raise HTTPException(status_code=400, detail="Неверный старый пароль")
|
||
|
||
new_hash = pwd_context.hash(req.new_password)
|
||
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"]))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return {"status": "success", "message": "Пароль успешно изменен"}
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/chat.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/chat.py
|
||
ROLE: Полнофункциональный роутер чата с извлечением текста из PDF и сканов,
|
||
поддержкой Function Calling, Fast-Path и оптического распознавания OCR.
|
||
===============================================================================
|
||
"""
|
||
|
||
import os
|
||
import shutil
|
||
import base64
|
||
import logging
|
||
from typing import Optional, List, Dict, Any
|
||
|
||
from fastapi import APIRouter, Header, HTTPException, UploadFile, File, Form
|
||
from pydantic import BaseModel
|
||
|
||
from llm.agent import process_chat_message
|
||
from config import BASE_DIR
|
||
|
||
logger = logging.getLogger("CHAT_API")
|
||
router = APIRouter(prefix="/api/v1", tags=["Chat"])
|
||
|
||
UPLOAD_TMP_DIR = os.path.join(BASE_DIR, "data", "uploads")
|
||
os.makedirs(UPLOAD_TMP_DIR, exist_ok=True)
|
||
|
||
|
||
class ChatMessageRequest(BaseModel):
|
||
message: str
|
||
session_id: Optional[str] = "web_session_main"
|
||
user_id: Optional[int] = 1
|
||
|
||
|
||
def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optional[int] = None) -> int:
|
||
if explicit_user_id and explicit_user_id > 0:
|
||
return explicit_user_id
|
||
|
||
if authorization and authorization.startswith("Bearer "):
|
||
token = authorization.replace("Bearer ", "").strip()
|
||
if token.isdigit():
|
||
return int(token)
|
||
elif token.startswith("dev_token_"):
|
||
try:
|
||
return int(token.replace("dev_token_", ""))
|
||
except ValueError:
|
||
pass
|
||
return 1
|
||
|
||
|
||
@router.post("/chat")
|
||
async def chat_endpoint(payload: ChatMessageRequest, authorization: Optional[str] = Header(None)):
|
||
user_id = resolve_user_id(authorization, payload.user_id)
|
||
session_id = payload.session_id or "web_session_main"
|
||
user_msg = payload.message.strip()
|
||
|
||
if not user_msg:
|
||
raise HTTPException(status_code=400, detail="Пустое сообщение")
|
||
|
||
logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_msg}")
|
||
|
||
reply_text, history, action_payload = process_chat_message(
|
||
user_id=user_id,
|
||
user_message=user_msg,
|
||
session_id=session_id
|
||
)
|
||
|
||
return {
|
||
"status": "success",
|
||
"user_id": user_id,
|
||
"session_id": session_id,
|
||
"response": reply_text,
|
||
"action_payload": action_payload
|
||
}
|
||
|
||
|
||
@router.post("/chat/upload")
|
||
async def chat_upload_endpoint(
|
||
file: UploadFile = File(...),
|
||
message: Optional[str] = Form(""),
|
||
session_id: Optional[str] = Form("web_session_main"),
|
||
authorization: Optional[str] = Header(None)
|
||
):
|
||
user_id = resolve_user_id(authorization, 1)
|
||
file_path = os.path.join(UPLOAD_TMP_DIR, file.filename)
|
||
|
||
with open(file_path, "wb") as buffer:
|
||
shutil.copyfileobj(file.file, buffer)
|
||
|
||
file_context = ""
|
||
image_b64 = None
|
||
fn_lower = file.filename.lower()
|
||
|
||
# 1. Текстовые форматы
|
||
if fn_lower.endswith((".txt", ".csv", ".log", ".md")):
|
||
try:
|
||
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
||
file_context = f.read(6000)
|
||
except Exception as e:
|
||
logger.warning(f"Не удалось прочитать текст: {e}")
|
||
|
||
# 2. Изображения (прямой OCR)
|
||
elif fn_lower.endswith((".png", ".jpg", ".jpeg", ".webp")):
|
||
try:
|
||
with open(file_path, "rb") as f:
|
||
image_b64 = base64.b64encode(f.read()).decode("utf-8")
|
||
except Exception as e:
|
||
logger.warning(f"Ошибка кодирования картинки в base64: {e}")
|
||
|
||
# 3. PDF документы (текстовый слой + рендеринг скана при необходимости)
|
||
elif fn_lower.endswith(".pdf"):
|
||
# Попытка извлечь встроенный текстовый слой
|
||
try:
|
||
import pypdf
|
||
reader = pypdf.PdfReader(file_path)
|
||
extracted = []
|
||
for page in reader.pages:
|
||
t = page.extract_text()
|
||
if t:
|
||
extracted.append(t)
|
||
file_context = "\n".join(extracted).strip()
|
||
except Exception:
|
||
pass
|
||
|
||
# Если текстового слоя мало (скан или фото документа), рендерим страницу в картинку для Vision OCR
|
||
if len(file_context) < 40:
|
||
try:
|
||
import fitz # PyMuPDF
|
||
doc = fitz.open(file_path)
|
||
if len(doc) > 0:
|
||
page = doc[0]
|
||
pix = page.get_pixmap(dpi=150)
|
||
img_bytes = pix.tobytes("png")
|
||
image_b64 = base64.b64encode(img_bytes).decode("utf-8")
|
||
file_context = ""
|
||
except Exception as e:
|
||
logger.warning(f"PyMuPDF не установлен или сбой рендеринга PDF: {e}")
|
||
|
||
user_msg = message.strip() or f"Распознай и проанализируй прикрепленный документ {file.filename}"
|
||
|
||
reply_text, history, action_payload = process_chat_message(
|
||
user_id=user_id,
|
||
user_message=user_msg,
|
||
file_context=file_context,
|
||
image_b64=image_b64,
|
||
session_id=session_id
|
||
)
|
||
|
||
return {
|
||
"status": "success",
|
||
"user_id": user_id,
|
||
"session_id": session_id,
|
||
"response": reply_text,
|
||
"action_payload": action_payload
|
||
}
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/context.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/context.py
|
||
ROLE: REST API мониторинга состояния сессии и очистки памяти диалога.
|
||
===============================================================================
|
||
"""
|
||
|
||
from fastapi import APIRouter, Depends
|
||
from pydantic import BaseModel
|
||
from typing import Optional
|
||
|
||
from routers.auth import get_current_user
|
||
from modules.web_api.llm.db.db_prompts import db_get_session_state, db_clear_session_state
|
||
from modules.web_api.llm.db.db_chat import db_get_chat_history, db_purge_ephemeral_messages, db_clear_chat_history
|
||
|
||
router = APIRouter(prefix="/api/v1/context", tags=["Context"])
|
||
|
||
|
||
class SessionActionRequest(BaseModel):
|
||
session_id: Optional[str] = "web_session_main"
|
||
|
||
|
||
@router.get("/state")
|
||
def api_get_context_state(session_id: str = "web_session_main", current_user = Depends(get_current_user)):
|
||
state = db_get_session_state(session_id) or {}
|
||
history = db_get_chat_history(session_id, limit=50)
|
||
|
||
ephemeral_count = sum(1 for m in history if dict(m).get("is_ephemeral") == 1)
|
||
total_messages = len(history)
|
||
|
||
return {
|
||
"session_id": session_id,
|
||
"active_state": state.get("state_type", "IDLE"),
|
||
"state_data": state.get("data_json", {}),
|
||
"total_messages": total_messages,
|
||
"ephemeral_messages": ephemeral_count
|
||
}
|
||
|
||
|
||
@router.post("/purge-ephemeral")
|
||
def api_purge_ephemeral(req: SessionActionRequest, current_user = Depends(get_current_user)):
|
||
purged = db_purge_ephemeral_messages(req.session_id)
|
||
return {"status": "success", "purged_count": purged}
|
||
|
||
|
||
@router.post("/clear-all")
|
||
def api_clear_all_context(req: SessionActionRequest, current_user = Depends(get_current_user)):
|
||
db_clear_session_state(req.session_id)
|
||
db_clear_chat_history(req.session_id)
|
||
return {"status": "success", "message": "Контекст сессии полностью очищен"}
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/exceptions.py`
|
||
```py
|
||
from fastapi import APIRouter, HTTPException
|
||
from pydantic import BaseModel
|
||
from typing import Optional, Dict, List
|
||
from services.exceptions_repo import get_all_exceptions_from_db, add_exception_to_db, remove_exception_from_db
|
||
|
||
router = APIRouter(prefix="/api/v1/exceptions", tags=["Exceptions"])
|
||
|
||
|
||
class ExceptionItem(BaseModel):
|
||
category: str
|
||
value: str
|
||
comment: Optional[str] = ""
|
||
|
||
|
||
@router.get("")
|
||
@router.get("/")
|
||
def api_get_exceptions():
|
||
return get_all_exceptions_from_db()
|
||
|
||
|
||
@router.post("")
|
||
@router.post("/")
|
||
def api_add_exception(item: ExceptionItem):
|
||
if not add_exception_to_db(item.category, item.value, item.comment):
|
||
raise HTTPException(status_code=400, detail="Ошибка добавления исключения")
|
||
return {"status": "success", "data": item}
|
||
|
||
|
||
@router.delete("")
|
||
@router.delete("/")
|
||
def api_delete_exception(category: str, value: str):
|
||
if not remove_exception_from_db(category, value):
|
||
raise HTTPException(status_code=404, detail="Исключение не найдено")
|
||
return {"status": "success"}
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/files.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/files.py
|
||
ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен
|
||
через изолированные UUID-директории инструментов.
|
||
===============================================================================
|
||
"""
|
||
|
||
import os
|
||
import time
|
||
import shutil
|
||
import urllib.parse
|
||
from fastapi import APIRouter, HTTPException
|
||
from fastapi.responses import FileResponse
|
||
|
||
router = APIRouter(prefix="/api/v1/files", tags=["Files"])
|
||
|
||
BASE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
|
||
WEB_OUTPUT_DIR = os.path.join(BASE_ROOT, "output", "web")
|
||
os.makedirs(WEB_OUTPUT_DIR, exist_ok=True)
|
||
|
||
SESSION_TTL_HOURS = 24 # Срок жизни временных сессионных выгрузок
|
||
|
||
|
||
def purge_old_tool_sessions(tool_dir_path: str):
|
||
"""Удаляет временные UUID-папки старше SESSION_TTL_HOURS внутри инструмента."""
|
||
if not os.path.exists(tool_dir_path):
|
||
return
|
||
now = time.time()
|
||
cutoff = now - (SESSION_TTL_HOURS * 3600)
|
||
try:
|
||
for entry in os.listdir(tool_dir_path):
|
||
subpath = os.path.join(tool_dir_path, entry)
|
||
if os.path.isdir(subpath):
|
||
if os.path.getmtime(subpath) < cutoff:
|
||
shutil.rmtree(subpath, ignore_errors=True)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
@router.get("/download/{tool_name}/{session_uuid}/{filename}")
|
||
async def download_file(tool_name: str, session_uuid: str, filename: str):
|
||
"""
|
||
Безопасная отдача файла с каноническим именем из изолированной директории.
|
||
"""
|
||
safe_tool = os.path.basename(tool_name)
|
||
safe_uuid = os.path.basename(session_uuid)
|
||
safe_filename = os.path.basename(filename)
|
||
|
||
file_path = os.path.join(WEB_OUTPUT_DIR, safe_tool, safe_uuid, safe_filename)
|
||
|
||
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
||
raise HTTPException(status_code=404, detail="Файл не найден или срок его действия истек")
|
||
|
||
# Определение MIME-типа
|
||
media_type = "application/octet-stream"
|
||
if safe_filename.endswith(".md") or safe_filename.endswith(".txt"):
|
||
media_type = "text/markdown; charset=utf-8"
|
||
elif safe_filename.endswith(".xlsx"):
|
||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||
elif safe_filename.endswith(".pdf"):
|
||
media_type = "application/pdf"
|
||
|
||
# Корректная кодировка для кириллических имен файлов
|
||
encoded_filename = urllib.parse.quote(safe_filename)
|
||
|
||
return FileResponse(
|
||
path=file_path,
|
||
media_type=media_type,
|
||
headers={
|
||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
|
||
}
|
||
)
|
||
|
||
@router.get("/download/reports/{filename}")
|
||
async def download_report_direct(filename: str):
|
||
"""Прямое скачивание отчетов из output/reports или /tmp/scud_reports."""
|
||
safe_filename = os.path.basename(filename)
|
||
|
||
# 1. Проверяем сетевую шару / постоянный каталог
|
||
target_path = os.path.join(BASE_ROOT, "output", "reports", safe_filename)
|
||
|
||
# 2. Если нет — проверяем временный буфер сборки
|
||
if not os.path.exists(target_path):
|
||
target_path = os.path.join("/tmp/scud_reports", safe_filename)
|
||
|
||
if not os.path.exists(target_path):
|
||
raise HTTPException(status_code=404, detail="Отчет не найден")
|
||
|
||
encoded_filename = urllib.parse.quote(safe_filename)
|
||
return FileResponse(
|
||
path=target_path,
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}
|
||
)
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/manual_absences.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/manual_absences.py
|
||
ROLE: REST API эндпоинты для реестров "Мест. командир.", "Иное" и автокомплита.
|
||
===============================================================================
|
||
"""
|
||
|
||
from fastapi import APIRouter, HTTPException, Query
|
||
from pydantic import BaseModel
|
||
from typing import Optional, List, Dict, Any
|
||
|
||
from services.manual_absences_repo import (
|
||
search_staff_suggestions,
|
||
get_static_reasons,
|
||
add_manual_absence,
|
||
delete_manual_absence,
|
||
get_manual_absences_list
|
||
)
|
||
|
||
router = APIRouter(prefix="/api/v1/manual-absences", tags=["Manual Absences"])
|
||
|
||
|
||
class AddAbsenceRequest(BaseModel):
|
||
absence_type: str # 'LOCAL_TRIP' или 'OTHER'
|
||
fio: str
|
||
reason: Optional[str] = ""
|
||
department: Optional[str] = ""
|
||
position: Optional[str] = ""
|
||
date_start: Optional[str] = None
|
||
date_end: Optional[str] = None
|
||
comment: Optional[str] = ""
|
||
|
||
|
||
@router.get("/staff-autocomplete")
|
||
def api_staff_autocomplete(q: str = Query(..., min_length=2)):
|
||
return search_staff_suggestions(q)
|
||
|
||
|
||
@router.get("/reasons")
|
||
def api_get_reasons():
|
||
return {"reasons": get_static_reasons()}
|
||
|
||
|
||
@router.get("/")
|
||
def api_list_manual_absences(type: Optional[str] = None):
|
||
return {"items": get_manual_absences_list(type)}
|
||
|
||
|
||
@router.post("/")
|
||
def api_add_manual_absence(req: AddAbsenceRequest):
|
||
reason = req.reason or ("Местная командировка" if req.absence_type == "LOCAL_TRIP" else "Иное")
|
||
res_id = add_manual_absence(
|
||
absence_type=req.absence_type,
|
||
fio=req.fio,
|
||
reason=reason,
|
||
department=req.department,
|
||
position=req.position,
|
||
date_start=req.date_start,
|
||
date_end=req.date_end,
|
||
comment=req.comment
|
||
)
|
||
if not res_id:
|
||
raise HTTPException(status_code=400, detail="Не удалось добавить запись")
|
||
return {"status": "success", "id": res_id}
|
||
|
||
|
||
@router.delete("/{item_id}")
|
||
def api_delete_manual_absence(item_id: int):
|
||
if not delete_manual_absence(item_id):
|
||
raise HTTPException(status_code=404, detail="Запись не найдена")
|
||
return {"status": "success"}
|
||
|
||
class UpdateAbsenceDatesRequest(BaseModel):
|
||
id: int
|
||
date_start: Optional[str] = None
|
||
date_end: Optional[str] = None
|
||
|
||
@router.put("/{item_id}")
|
||
def api_update_manual_absence(item_id: int, req: UpdateAbsenceDatesRequest):
|
||
with get_connection() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
UPDATE manual_absences
|
||
SET date_start = ?, date_end = ?
|
||
WHERE id = ?
|
||
""", (req.date_start, req.date_end, item_id))
|
||
conn.commit()
|
||
return {"status": "success"}
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/presence.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/presence.py
|
||
ROLE: REST API оперативного статуса присутствия сотрудников в здании.
|
||
===============================================================================
|
||
"""
|
||
|
||
from fastapi import APIRouter, Query
|
||
from typing import Optional, Dict, Any
|
||
|
||
from services.presence_service import get_live_presence
|
||
|
||
router = APIRouter(prefix="/api/v1/presence", tags=["Presence"])
|
||
|
||
|
||
@router.get("/live")
|
||
def api_get_live_presence(
|
||
date_str: Optional[str] = Query(None, description="Дата в формате ДД.ММ.ГГГГ"),
|
||
force_refresh: bool = Query(False, description="Принудительный опрос MS SQL Орион")
|
||
):
|
||
"""
|
||
Возвращает оперативный статус сотрудников («Кто в здании»).
|
||
По умолчанию возвращает срез моментально из локальной базы SQLite.
|
||
При force_refresh=true выполняет опрос турникетов в MS SQL Орион.
|
||
"""
|
||
return get_live_presence(date_str=date_str, force_refresh=force_refresh)
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/remote_workers.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/remote_workers.py
|
||
ROLE: REST API реестра удаленщиков (CRUD, редактирование сроков, автоочистка).
|
||
===============================================================================
|
||
"""
|
||
|
||
import os
|
||
from datetime import datetime
|
||
import pandas as pd
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel
|
||
from typing import Optional, List, Dict, Any
|
||
|
||
from routers.auth import get_current_user
|
||
from config import BASE_DIR, DATA_DIR
|
||
|
||
router = APIRouter(prefix="/api/v1/remote-workers", tags=["RemoteWorkers"])
|
||
CSV_PATH = os.path.join(DATA_DIR, "static_reason_workers.csv")
|
||
|
||
|
||
def _load_workers() -> List[Dict[str, Any]]:
|
||
"""Читает CSV, гарантирует структуру колонок и удаляет просроченные записи."""
|
||
if not os.path.exists(CSV_PATH):
|
||
return []
|
||
try:
|
||
df = pd.read_csv(CSV_PATH, dtype=str, on_bad_lines='skip').fillna("")
|
||
for col in ["fio", "department", "reason", "date_from", "date_to"]:
|
||
if col not in df.columns:
|
||
df[col] = ""
|
||
|
||
today_date = datetime.now().date()
|
||
valid_workers = []
|
||
has_expired = False
|
||
|
||
for row in df.to_dict(orient="records"):
|
||
fio = str(row.get("fio", "")).strip()
|
||
if not fio:
|
||
continue
|
||
|
||
d_to_str = str(row.get("date_to", "")).strip()
|
||
if d_to_str and d_to_str.lower() not in ["nan", "none", ""]:
|
||
try:
|
||
d_to = datetime.strptime(d_to_str.replace('_', '.'), "%d.%m.%Y").date()
|
||
# Если срок завершился вчера или ранее — запись удаляется из файла
|
||
if today_date > d_to:
|
||
has_expired = True
|
||
continue
|
||
except ValueError:
|
||
pass
|
||
|
||
valid_workers.append(row)
|
||
|
||
# Синхронная перезапись файла при обнаружении истекших сроков
|
||
if has_expired:
|
||
_save_workers(valid_workers)
|
||
|
||
return valid_workers
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def _save_workers(workers: List[Dict[str, Any]]):
|
||
"""Сохраняет актуальный список в CSV с сохранением колонок."""
|
||
df = pd.DataFrame(workers)
|
||
for col in ["fio", "department", "reason", "date_from", "date_to"]:
|
||
if col not in df.columns:
|
||
df[col] = ""
|
||
os.makedirs(os.path.dirname(CSV_PATH), exist_ok=True)
|
||
df.to_csv(CSV_PATH, index=False, encoding="utf-8")
|
||
|
||
|
||
class RemoteWorkerItem(BaseModel):
|
||
fio: str
|
||
department: Optional[str] = "Все"
|
||
reason: Optional[str] = "Удаленная работа"
|
||
date_from: Optional[str] = ""
|
||
date_to: Optional[str] = ""
|
||
|
||
|
||
class UpdateDatesRequest(BaseModel):
|
||
fio: str
|
||
date_from: Optional[str] = ""
|
||
date_to: Optional[str] = ""
|
||
|
||
|
||
@router.get("")
|
||
def api_get_remote_workers(current_user = Depends(get_current_user)):
|
||
return {"workers": _load_workers()}
|
||
|
||
|
||
@router.post("")
|
||
def api_add_remote_worker(item: RemoteWorkerItem, current_user = Depends(get_current_user)):
|
||
workers = _load_workers()
|
||
fio_clean = item.fio.strip()
|
||
if not fio_clean:
|
||
raise HTTPException(status_code=400, detail="ФИО не может быть пустым")
|
||
|
||
# Если начало не указано — берем сегодня
|
||
today_str = datetime.now().strftime("%d.%m.%Y")
|
||
date_from = item.date_from.strip() if item.date_from and item.date_from.strip() else today_str
|
||
date_to = item.date_to.strip() if item.date_to else ""
|
||
|
||
# Проверка на совпадение ФИО (обновление существующей записи)
|
||
for w in workers:
|
||
if str(w.get("fio", "")).strip().lower() == fio_clean.lower():
|
||
w["department"] = item.department.strip() if item.department else (w.get("department") or "Все")
|
||
w["reason"] = item.reason.strip() if item.reason else (w.get("reason") or "Удаленная работа")
|
||
w["date_from"] = date_from
|
||
w["date_to"] = date_to
|
||
_save_workers(workers)
|
||
return {"status": "success", "message": "Срок удаленки обновлен", "workers": workers}
|
||
|
||
workers.append({
|
||
"fio": fio_clean,
|
||
"department": item.department.strip() if item.department else "Все",
|
||
"reason": item.reason.strip() if item.reason else "Удаленная работа",
|
||
"date_from": date_from,
|
||
"date_to": date_to
|
||
})
|
||
_save_workers(workers)
|
||
return {"status": "success", "workers": workers}
|
||
|
||
|
||
@router.put("")
|
||
def api_update_worker_dates(req: UpdateDatesRequest, current_user = Depends(get_current_user)):
|
||
"""Редактирование срока удаленки (продление или сокращение)."""
|
||
workers = _load_workers()
|
||
target_fio = req.fio.strip().lower()
|
||
found = False
|
||
|
||
for w in workers:
|
||
if str(w.get("fio", "")).strip().lower() == target_fio:
|
||
w["date_from"] = req.date_from.strip() if req.date_from is not None else w.get("date_from", "")
|
||
w["date_to"] = req.date_to.strip() if req.date_to is not None else w.get("date_to", "")
|
||
found = True
|
||
break
|
||
|
||
if not found:
|
||
raise HTTPException(status_code=404, detail="Сотрудник не найден в списке")
|
||
|
||
_save_workers(workers)
|
||
return {"status": "success", "message": "Сроки успешно изменены", "workers": workers}
|
||
|
||
|
||
@router.delete("")
|
||
def api_delete_remote_worker(fio: str, current_user = Depends(get_current_user)):
|
||
workers = _load_workers()
|
||
fio_clean = fio.strip().lower()
|
||
initial_len = len(workers)
|
||
workers = [w for w in workers if str(w.get("fio", "")).strip().lower() != fio_clean]
|
||
|
||
if len(workers) == initial_len:
|
||
raise HTTPException(status_code=404, detail="Сотрудник не найден")
|
||
|
||
_save_workers(workers)
|
||
return {"status": "success", "workers": workers}
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/reports.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/reports.py
|
||
ROLE: REST API On-Demand генерации отчетов (Сводка, Детальный, Упрощенный)
|
||
с атомарной сборкой в /tmp и безопасной публикацией на сетевую шару.
|
||
===============================================================================
|
||
"""
|
||
|
||
import os
|
||
import shutil
|
||
import logging
|
||
from datetime import datetime
|
||
from typing import Optional
|
||
from pydantic import BaseModel
|
||
from fastapi import APIRouter, HTTPException, Query
|
||
|
||
from config import DATE_TODAY, DATE_YESTERDAY
|
||
from services.scud_etl.svodka_generator import generate_svodka_service
|
||
from services.scud_etl.otchet_generator import generate_otchet_service
|
||
from services.reports.simplified_builder import generate_simplified_excel
|
||
from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date
|
||
from services.scud_etl.merger import merge_scud_and_1c
|
||
|
||
logger = logging.getLogger("REPORTS_API")
|
||
router = APIRouter(prefix="/api/v1/reports", tags=["Reports"])
|
||
|
||
TEMP_REPORTS_DIR = "/tmp/scud_reports"
|
||
os.makedirs(TEMP_REPORTS_DIR, exist_ok=True)
|
||
|
||
|
||
class GenerateReportRequest(BaseModel):
|
||
date: Optional[str] = None # ДД.ММ.ГГГГ
|
||
time: Optional[str] = None # ЧЧ:ММ (для сводки)
|
||
report_type: str # 'SVODKA', 'DETAILED', 'SIMPLIFIED', 'ALL'
|
||
|
||
|
||
@router.post("/generate")
|
||
def api_generate_report(req: GenerateReportRequest):
|
||
"""
|
||
Генерирует выбранный отчет на указанную дату/время и возвращает ссылки на скачивание.
|
||
"""
|
||
target_date = (req.date or DATE_TODAY).replace('_', '.')
|
||
r_type = req.report_type.upper()
|
||
results = []
|
||
|
||
# 1. Ежедневная сводка
|
||
if r_type in ["SVODKA", "ALL"]:
|
||
res_svodka = generate_svodka_service(
|
||
target_date=target_date,
|
||
target_time=req.time
|
||
)
|
||
if res_svodka.get("status") == "success":
|
||
results.append({
|
||
"type": "Сводка",
|
||
"filename": res_svodka.get("filename"),
|
||
"download_url": res_svodka.get("download_url"),
|
||
"status": "success"
|
||
})
|
||
else:
|
||
results.append({
|
||
"type": "Сводка",
|
||
"error": res_svodka.get("message"),
|
||
"status": "error"
|
||
})
|
||
|
||
# 2. Детальный отчет
|
||
if r_type in ["DETAILED", "ALL"]:
|
||
res_det = generate_otchet_service(target_date=target_date)
|
||
if res_det.get("status") == "success":
|
||
results.append({
|
||
"type": "Детальный отчет",
|
||
"filename": res_det.get("filename"),
|
||
"download_url": res_det.get("download_url"),
|
||
"status": "success"
|
||
})
|
||
else:
|
||
results.append({
|
||
"type": "Детальный отчет",
|
||
"error": res_det.get("message"),
|
||
"status": "error"
|
||
})
|
||
|
||
# 3. Упрощенный отчет
|
||
if r_type in ["SIMPLIFIED", "ALL"]:
|
||
try:
|
||
df_scud = load_best_snapshot_for_date(target_date, prefer_final_y=True)
|
||
if df_scud is not None and not df_scud.empty:
|
||
df_staff, df_abs = load_1c_files_for_date(target_date)
|
||
df_merged = merge_scud_and_1c(df_scud, df_staff, df_abs)
|
||
out_path = generate_simplified_excel(df_merged, date_str=target_date)
|
||
filename = os.path.basename(out_path)
|
||
results.append({
|
||
"type": "Упрощенный отчет",
|
||
"filename": filename,
|
||
"download_url": f"/api/v1/files/download/reports/{filename}",
|
||
"status": "success"
|
||
})
|
||
else:
|
||
results.append({
|
||
"type": "Упрощенный отчет",
|
||
"error": f"Срез СКУД за {target_date} не найден.",
|
||
"status": "error"
|
||
})
|
||
except Exception as e:
|
||
results.append({
|
||
"type": "Упрощенный отчет",
|
||
"error": str(e),
|
||
"status": "error"
|
||
})
|
||
|
||
return {
|
||
"date": target_date,
|
||
"requested_type": r_type,
|
||
"reports": results
|
||
}
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/snapshots.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/snapshots.py
|
||
ROLE: Роутер срезов СКУД:
|
||
- Список срезов за период дат (/api/v1/snapshots)
|
||
- Инспекция среза (/api/v1/snapshots/{snapshot_id}/details и /inspect)
|
||
- Экспорт среза в Excel (.xlsx) и CSV (UTF-8 с BOM)
|
||
- Ручное создание среза (/create)
|
||
- Удаление срезов (пакетное через snapshot_ids)
|
||
===============================================================================
|
||
"""
|
||
|
||
import os
|
||
import io
|
||
import urllib.parse
|
||
import logging
|
||
from typing import Optional, List, Dict, Any
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, HTTPException, Query
|
||
from fastapi.responses import StreamingResponse, FileResponse
|
||
from pydantic import BaseModel
|
||
import pandas as pd
|
||
|
||
from core.connection import get_connection
|
||
import config
|
||
|
||
logger = logging.getLogger("SNAPSHOTS_ROUTER")
|
||
|
||
router = APIRouter(prefix="/api/v1/snapshots", tags=["Snapshots"])
|
||
|
||
BASE_DIR = getattr(config, "BASE_DIR", Path(__file__).resolve().parent.parent.parent.parent)
|
||
SNAPSHOTS_DIR = getattr(config, "SNAPSHOTS_DIR", os.path.join(str(BASE_DIR), "exports", "snapshots"))
|
||
DATE_TODAY = getattr(config, "DATE_TODAY", datetime.now().strftime("%d.%m.%Y"))
|
||
|
||
|
||
class CreateSnapshotRequest(BaseModel):
|
||
date_str: Optional[str] = None
|
||
time_str: Optional[str] = None
|
||
|
||
|
||
class DeleteSnapshotsRequest(BaseModel):
|
||
snapshot_ids: List[str]
|
||
|
||
|
||
# =============================================================================
|
||
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ РАСЧЕТА СТАТУСА И ВРЕМЕНИ
|
||
# =============================================================================
|
||
|
||
def parse_time_str(t_str: str) -> Optional[datetime]:
|
||
"""Парсит строку времени HH:MM[:SS] в datetime объект."""
|
||
if not t_str or str(t_str).strip() in ("Нет входа", "Нет выхода", "—", "-", "None", "nan"):
|
||
return None
|
||
for fmt in ("%H:%M:%S", "%H:%M"):
|
||
try:
|
||
return datetime.strptime(str(t_str).strip(), fmt)
|
||
except ValueError:
|
||
pass
|
||
return None
|
||
|
||
|
||
def calculate_row_presence(d: dict, snapshot_time_str: str) -> dict:
|
||
"""
|
||
Определяет реальный статус сотрудника и время нахождения в здании.
|
||
"""
|
||
fio = d.get("fio") or d.get("Сотрудник") or "—"
|
||
dept = d.get("department") or d.get("Подразделение") or "—"
|
||
|
||
t_in = d.get("time_in") or d.get("Начало_дня") or d.get("first_in") or "Нет входа"
|
||
first_act = d.get("first_activity") or d.get("Первая_активность") or "—"
|
||
t_out = d.get("time_out") or d.get("Конец_дня") or d.get("last_out") or "Нет выхода"
|
||
|
||
db_in_bld = d.get("in_building") or d.get("Находился_в_здании")
|
||
db_status = d.get("status") or d.get("Статус") or d.get("Пришел")
|
||
|
||
has_in = t_in not in ("Нет входа", "—", "-", "", None, "None")
|
||
has_act = first_act not in ("—", "-", "", None, "None")
|
||
has_out = t_out not in ("Нет выхода", "—", "-", "", None, "None")
|
||
|
||
# 1. Если нет отметок прохода — сотрудник отсутствовал
|
||
if not has_in and not has_act and not has_out:
|
||
final_status = "Отсутствовал (Нет событий)"
|
||
final_in_bld = "00:00"
|
||
else:
|
||
# Сотрудник присутствовал
|
||
final_status = "Присутствовал"
|
||
|
||
# Расчет времени нахождения в здании
|
||
dt_in = parse_time_str(t_in) or parse_time_str(first_act)
|
||
dt_out = parse_time_str(t_out)
|
||
|
||
if db_in_bld and db_in_bld not in ("00:00", "—", "", "None"):
|
||
final_in_bld = db_in_bld
|
||
elif dt_in:
|
||
if dt_out and dt_out >= dt_in:
|
||
diff = dt_out - dt_in
|
||
else:
|
||
# Если выхода еще нет — считаем до момента фиксации среза
|
||
dt_snap = parse_time_str(snapshot_time_str) or datetime.now()
|
||
if dt_snap >= dt_in:
|
||
diff = dt_snap - dt_in
|
||
else:
|
||
diff = timedelta(0)
|
||
|
||
total_minutes = int(diff.total_seconds() // 60)
|
||
hh = total_minutes // 60
|
||
mm = total_minutes % 60
|
||
final_in_bld = f"{hh:02d}:{mm:02d}"
|
||
else:
|
||
final_in_bld = "00:00"
|
||
|
||
# Сохраняем специальные статусы, если они зафиксированы в БД
|
||
if db_status and "Отсутств" in str(db_status):
|
||
final_status = db_status
|
||
|
||
return {
|
||
"fio": fio,
|
||
"department": dept,
|
||
"time_in": t_in,
|
||
"first_activity": first_act,
|
||
"time_out": t_out,
|
||
"in_building": final_in_bld,
|
||
"status": final_status
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 1. СПИСОК СРЕЗОВ (С ПОДДЕРЖКОЙ ДИАПАЗОНА ДАТ)
|
||
# =============================================================================
|
||
|
||
@router.get("", include_in_schema=False)
|
||
@router.get("/")
|
||
def list_snapshots(
|
||
date: Optional[str] = None,
|
||
date_from: Optional[str] = None,
|
||
date_to: Optional[str] = None
|
||
):
|
||
"""
|
||
Возвращает список срезов за диапазон дат со всеми полями для интерфейса.
|
||
"""
|
||
d_from = (date_from or date or DATE_TODAY).replace('_', '.')
|
||
d_to = (date_to or date or DATE_TODAY).replace('_', '.')
|
||
|
||
with get_connection(row_factory=True) as conn:
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute("""
|
||
SELECT snapshot_id, COUNT(*) as cnt, MAX(created_at) as created_at, log_date
|
||
FROM scud_logs
|
||
WHERE (
|
||
substr(log_date, 7, 4) || '-' || substr(log_date, 4, 2) || '-' || substr(log_date, 1, 2)
|
||
BETWEEN
|
||
substr(?, 7, 4) || '-' || substr(?, 4, 2) || '-' || substr(?, 1, 2)
|
||
AND
|
||
substr(?, 7, 4) || '-' || substr(?, 4, 2) || '-' || substr(?, 1, 2)
|
||
)
|
||
AND snapshot_id IS NOT NULL AND snapshot_id != ''
|
||
GROUP BY snapshot_id
|
||
ORDER BY snapshot_id DESC
|
||
""", (d_from, d_from, d_from, d_to, d_to, d_to))
|
||
rows = cursor.fetchall()
|
||
|
||
items = []
|
||
for r in rows:
|
||
raw_id = r["snapshot_id"]
|
||
clean_id = str(raw_id).lstrip("#").strip()
|
||
total_cnt = r["cnt"]
|
||
|
||
time_str = "—"
|
||
if "_" in clean_id:
|
||
parts = clean_id.split("_")
|
||
if len(parts) > 1 and len(parts[1]) >= 4 and parts[1][:4].isdigit():
|
||
time_str = f"{parts[1][:2]}:{parts[1][2:4]}"
|
||
|
||
is_final = "FINAL" in clean_id.upper()
|
||
|
||
items.append({
|
||
"id": clean_id,
|
||
"snapshot_id": clean_id,
|
||
"label": clean_id,
|
||
"snapshot_time": time_str,
|
||
"time": time_str,
|
||
"record_count": total_cnt,
|
||
"count": total_cnt,
|
||
"records_count": total_cnt,
|
||
"is_final": is_final,
|
||
"date": r["log_date"],
|
||
"created_at": r["created_at"] or r["log_date"]
|
||
})
|
||
|
||
return {
|
||
"date_from": d_from,
|
||
"date_to": d_to,
|
||
"total_snapshots": len(items),
|
||
"snapshots": items
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 2. ИНСПЕКЦИЯ СРЕЗА (ДЛЯ МОДАЛЬНОГО ОКНА)
|
||
# Поддерживает оба пути: /details и /inspect
|
||
# =============================================================================
|
||
|
||
@router.get("/{snapshot_id}/details")
|
||
@router.get("/{snapshot_id}/inspect")
|
||
def inspect_snapshot(snapshot_id: str):
|
||
clean_id = snapshot_id.lstrip("#").strip()
|
||
|
||
target_date = ""
|
||
snapshot_time = "23:59:59"
|
||
if "_" in clean_id:
|
||
parts = clean_id.split("_")
|
||
if len(parts[0]) == 8 and parts[0].isdigit():
|
||
target_date = f"{parts[0][6:8]}.{parts[0][4:6]}.{parts[0][:4]}"
|
||
if len(parts) > 1 and len(parts[1]) >= 4 and parts[1][:4].isdigit():
|
||
snapshot_time = f"{parts[1][:2]}:{parts[1][2:4]}:00"
|
||
|
||
rows = []
|
||
with get_connection(row_factory=True) as conn:
|
||
cursor = conn.cursor()
|
||
|
||
# Безопасная выборка всех полей строки среза
|
||
cursor.execute("""
|
||
SELECT * FROM scud_logs
|
||
WHERE snapshot_id IN (?, ?, ?, ?)
|
||
ORDER BY id ASC
|
||
""", (clean_id, f"#{clean_id}", f"#{snapshot_id}", snapshot_id))
|
||
rows = cursor.fetchall()
|
||
|
||
# Резервный сбор на лету из scud_events_raw, если среза нет в scud_logs
|
||
if not rows and target_date:
|
||
cursor.execute("""
|
||
SELECT
|
||
fio,
|
||
department,
|
||
MIN(CASE WHEN direction = 'IN' THEN time_val END) as time_in,
|
||
NULL as first_activity,
|
||
MAX(CASE WHEN direction = 'OUT' THEN time_val END) as time_out,
|
||
'00:00' as in_building,
|
||
'Присутствовал' as status
|
||
FROM scud_events_raw
|
||
WHERE log_date = ?
|
||
GROUP BY fio_clean
|
||
ORDER BY fio ASC
|
||
""", (target_date,))
|
||
rows = cursor.fetchall()
|
||
|
||
if not rows:
|
||
raise HTTPException(status_code=404, detail=f"Срез '{clean_id}' не найден")
|
||
|
||
records = []
|
||
for r in rows:
|
||
calc = calculate_row_presence(dict(r), snapshot_time)
|
||
records.append({
|
||
"hoz_organ": dict(r).get("hoz_organ") or dict(r).get("tab_num") or "",
|
||
"fio": calc["fio"],
|
||
"Сотрудник": calc["fio"],
|
||
"department": calc["department"],
|
||
"Подразделение": calc["department"],
|
||
"time_in": calc["time_in"],
|
||
"Вход": calc["time_in"],
|
||
"first_activity": calc["first_activity"],
|
||
"time_out": calc["time_out"],
|
||
"Выход": calc["time_out"],
|
||
"in_building": calc["in_building"],
|
||
"Находился_в_здании": calc["in_building"],
|
||
"status": calc["status"],
|
||
"Пришел": calc["status"]
|
||
})
|
||
|
||
return {
|
||
"status": "ok",
|
||
"snapshot_id": clean_id,
|
||
"date": target_date or DATE_TODAY,
|
||
"total_records": len(records),
|
||
"count": len(records),
|
||
"records": records,
|
||
"data": records
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 3. ЭКСПОРТ ДАННЫХ ИНСПЕКЦИИ СРЕЗА (EXCEL / CSV)
|
||
# =============================================================================
|
||
|
||
@router.get("/{snapshot_id}/export")
|
||
def api_export_snapshot(snapshot_id: str, format: str = Query("xlsx")):
|
||
clean_id = snapshot_id.lstrip("#").strip()
|
||
|
||
target_date = ""
|
||
snapshot_time = "23:59:59"
|
||
if "_" in clean_id:
|
||
parts = clean_id.split("_")
|
||
if len(parts[0]) == 8 and parts[0].isdigit():
|
||
target_date = f"{parts[0][6:8]}.{parts[0][4:6]}.{parts[0][:4]}"
|
||
if len(parts) > 1 and len(parts[1]) >= 4 and parts[1][:4].isdigit():
|
||
snapshot_time = f"{parts[1][:2]}:{parts[1][2:4]}:00"
|
||
|
||
rows = []
|
||
with get_connection(row_factory=True) as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
SELECT * FROM scud_logs
|
||
WHERE snapshot_id IN (?, ?, ?, ?)
|
||
ORDER BY id ASC
|
||
""", (clean_id, f"#{clean_id}", f"#{snapshot_id}", snapshot_id))
|
||
rows = cursor.fetchall()
|
||
|
||
if not rows:
|
||
raise HTTPException(status_code=404, detail=f"Срез '{clean_id}' не найден")
|
||
|
||
export_list = []
|
||
for r in rows:
|
||
calc = calculate_row_presence(dict(r), snapshot_time)
|
||
export_list.append({
|
||
"Сотрудник": calc["fio"],
|
||
"Подразделение": calc["department"],
|
||
"Вход": calc["time_in"],
|
||
"Первая активность": calc["first_activity"],
|
||
"Выход": calc["time_out"],
|
||
"В здании": calc["in_building"],
|
||
"Статус": calc["status"]
|
||
})
|
||
|
||
out_df = pd.DataFrame(export_list)
|
||
filename_base = f"Инспекция_{clean_id}"
|
||
|
||
if format.lower() == "csv":
|
||
csv_bytes = out_df.to_csv(index=False, sep=";", encoding="utf-8-sig").encode("utf-8-sig")
|
||
filename = f"{filename_base}.csv"
|
||
encoded = urllib.parse.quote(filename)
|
||
return StreamingResponse(
|
||
io.BytesIO(csv_bytes),
|
||
media_type="text/csv; charset=utf-8",
|
||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"}
|
||
)
|
||
else:
|
||
output = io.BytesIO()
|
||
with pd.ExcelWriter(output, engine="openpyxl") as writer:
|
||
out_df.to_excel(writer, index=False, sheet_name="Срез")
|
||
output.seek(0)
|
||
filename = f"{filename_base}.xlsx"
|
||
encoded = urllib.parse.quote(filename)
|
||
return StreamingResponse(
|
||
output,
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"}
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# 4. РУЧНОЕ СОЗДАНИЕ И ПАКЕТНОЕ УДАЛЕНИЕ СРЕЗОВ
|
||
# =============================================================================
|
||
|
||
@router.post("/create")
|
||
def create_snapshot(req: CreateSnapshotRequest):
|
||
from services.scud_export import run_export
|
||
target_date = (req.date_str or DATE_TODAY).replace('_', '.')
|
||
try:
|
||
run_export(input_date=target_date, save_xlsx=True, debug=False)
|
||
return {"status": "ok", "message": f"Срез за {target_date} успешно создан"}
|
||
except Exception as e:
|
||
logger.error(f"Ошибка создания среза: {e}")
|
||
raise HTTPException(status_code=500, detail=f"Не удалось создать срез: {e}")
|
||
|
||
|
||
@router.delete("", include_in_schema=False)
|
||
@router.delete("/")
|
||
def delete_snapshots(req: DeleteSnapshotsRequest):
|
||
if not req.snapshot_ids:
|
||
return {"status": "ok", "deleted": 0}
|
||
|
||
with get_connection() as conn:
|
||
cursor = conn.cursor()
|
||
for sid in req.snapshot_ids:
|
||
clean_id = sid.lstrip("#").strip()
|
||
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id IN (?, ?)", (clean_id, f"#{clean_id}"))
|
||
conn.commit()
|
||
|
||
return {"status": "ok", "deleted": len(req.snapshot_ids)}
|
||
|
||
|
||
# =============================================================================
|
||
# 5. СКАЧИВАНИЕ ФИЗИЧЕСКИХ ФАЙЛОВ .XLSX (FALLBACK)
|
||
# =============================================================================
|
||
|
||
@router.get("/{filename}")
|
||
def download_snapshot_file(filename: str):
|
||
safe_filename = os.path.basename(filename)
|
||
file_path = os.path.join(SNAPSHOTS_DIR, safe_filename)
|
||
|
||
if not os.path.exists(file_path):
|
||
raise HTTPException(status_code=404, detail="Файл среза не найден на диске")
|
||
|
||
return FileResponse(
|
||
path=file_path,
|
||
filename=safe_filename,
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||
)
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/tasks.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/tasks.py
|
||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||
MODULE: web_api / routers
|
||
ROLE: REST API эндпоинты реестра задач.
|
||
===============================================================================
|
||
"""
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel
|
||
from typing import Optional, Dict, Any
|
||
|
||
from routers.auth import get_current_user
|
||
from services.tasks.service import get_tasks, add_task, update_task_details
|
||
|
||
router = APIRouter(prefix="/api/v1/tasks", tags=["Tasks"])
|
||
|
||
|
||
class TaskCreateRequest(BaseModel):
|
||
title: str
|
||
priority: Optional[str] = "MEDIUM"
|
||
module: Optional[str] = "general"
|
||
due_date: Optional[str] = None
|
||
status: Optional[str] = "BACKLOG"
|
||
|
||
|
||
class TaskUpdateRequest(BaseModel):
|
||
title: Optional[str] = None
|
||
priority: Optional[str] = None
|
||
due_date: Optional[str] = None
|
||
status: Optional[str] = None
|
||
|
||
|
||
def resolve_user_id(current_user: Dict[str, Any]) -> int:
|
||
if not current_user:
|
||
return 1
|
||
return current_user.get("id") or current_user.get("user_id") or 1
|
||
|
||
|
||
@router.get("")
|
||
async def get_tasks_endpoint(status: Optional[str] = None, current_user = Depends(get_current_user)):
|
||
user_id = resolve_user_id(current_user)
|
||
return {"tasks": get_tasks(user_id=user_id, status=status)}
|
||
|
||
|
||
@router.post("")
|
||
async def create_task_endpoint(req: TaskCreateRequest, current_user = Depends(get_current_user)):
|
||
user_id = resolve_user_id(current_user)
|
||
res = add_task(
|
||
user_id=user_id,
|
||
module=req.module,
|
||
title=req.title,
|
||
priority=req.priority,
|
||
due_date=req.due_date,
|
||
status=req.status
|
||
)
|
||
if "error" in res:
|
||
raise HTTPException(status_code=400, detail=res["error"])
|
||
return res
|
||
|
||
|
||
@router.patch("/{task_id}")
|
||
async def update_task_endpoint(task_id: str, req: TaskUpdateRequest, current_user = Depends(get_current_user)):
|
||
user_id = resolve_user_id(current_user)
|
||
res = update_task_details(
|
||
user_id=user_id,
|
||
task_id=task_id,
|
||
title=req.title,
|
||
priority=req.priority,
|
||
status=req.status,
|
||
due_date=req.due_date
|
||
)
|
||
if "error" in res:
|
||
raise HTTPException(status_code=404, detail=res["error"])
|
||
return res
|
||
```
|
||
|
||
## File: `./modules/web_api/scripts/clear_history.py`
|
||
```py
|
||
"""
|
||
Скрипт полной очистки истории диалогов и сессионных состояний SQLite.
|
||
"""
|
||
import os
|
||
import sqlite3
|
||
|
||
# Рассчитываем путь к общей БД data/scud_orion_ai.db в корне проекта
|
||
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
|
||
DB_PATH = os.path.join(BASE_DIR, "data", "scud_orion_ai.db")
|
||
|
||
def clear_chat_history():
|
||
if not os.path.exists(DB_PATH):
|
||
print(f"❌ База данных не найдена по адресу: {DB_PATH}")
|
||
return
|
||
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cursor = conn.cursor()
|
||
|
||
# Очищаем таблицы сообщений и стейтов
|
||
try:
|
||
cursor.execute("DELETE FROM chat_messages;")
|
||
except sqlite3.OperationalError:
|
||
pass
|
||
|
||
try:
|
||
cursor.execute("DELETE FROM session_states;")
|
||
except sqlite3.OperationalError:
|
||
pass
|
||
|
||
try:
|
||
cursor.execute("DELETE FROM chat_sessions;")
|
||
except sqlite3.OperationalError:
|
||
pass
|
||
|
||
conn.commit()
|
||
conn.close()
|
||
print("✓ [SUCCESS] История сообщений чата и сессионные состояния успешно очищены!")
|
||
|
||
if __name__ == "__main__":
|
||
clear_chat_history()
|
||
```
|
||
|
||
## File: `./modules/web_api/scripts/diagnostics/show_tree.py`
|
||
```py
|
||
import os
|
||
|
||
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
|
||
|
||
def print_tree(startpath):
|
||
print("=" * 60)
|
||
print("📂 ДЕРЕВО АРХИТЕКТУРЫ ПРОЕКТА")
|
||
print("=" * 60)
|
||
for root, dirs, files in os.walk(startpath):
|
||
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
|
||
level = root.replace(startpath, '').count(os.sep)
|
||
indent = ' ' * 4 * (level)
|
||
print(f'{indent}📁 {os.path.basename(root)}/')
|
||
subindent = ' ' * 4 * (level + 1)
|
||
for f in sorted(files):
|
||
if not f.endswith('.pyc'):
|
||
print(f'{subindent}📄 {f}')
|
||
print("=" * 60)
|
||
|
||
if __name__ == "__main__":
|
||
print_tree('.')
|
||
```
|
||
|
||
## File: `./modules/web_api/static/css/styles.css`
|
||
```css
|
||
/* Плавное исчезновение текста сверху при скролле */
|
||
.fade-scroll-top {
|
||
mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
|
||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
|
||
}
|
||
|
||
/* Скрытие стандартного скроллбара */
|
||
.no-scrollbar::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
.no-scrollbar {
|
||
-ms-overflow-style: none;
|
||
scrollbar-width: none;
|
||
}
|
||
|
||
/* Оптимизация под мобильный viewport (борьба со скачками клавиатуры на iOS/Android) */
|
||
body {
|
||
min-height: 100vh;
|
||
min-height: -webkit-fill-available;
|
||
}
|
||
|
||
```
|
||
|
||
## File: `./modules/web_api/static/index.html`
|
||
```html
|
||
<!DOCTYPE html>
|
||
<html lang="ru" class="h-full bg-slate-100">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>SCUD Orion AI Assistant</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||
<style>
|
||
* { overflow-anchor: none !important; }
|
||
#chat-messages-container, main { overflow-anchor: none !important; }
|
||
#chat-messages-container { padding-bottom: clamp(400px, 85vh, 900px) !important; }
|
||
#chat-messages-container .message-content,
|
||
#chat-messages-container .text-xs,
|
||
#chat-messages-container .text-sm {
|
||
font-size: 14.5px !important;
|
||
line-height: 1.6 !important;
|
||
}
|
||
#chat-messages-container pre,
|
||
#chat-messages-container code {
|
||
font-size: 13.5px !important;
|
||
}
|
||
.user-chat-bubble { scroll-margin-top: 24px !important; }
|
||
</style>
|
||
</head>
|
||
<body class="h-full flex flex-col font-sans antialiased text-slate-800 bg-slate-100 selection:bg-indigo-500 selection:text-white">
|
||
|
||
<div id="app-container" class="flex-1 flex overflow-hidden w-full h-full">
|
||
|
||
<!-- ЛЕВАЯ КОЛОНКА (САЙДБАР) -->
|
||
<aside class="w-[420px] md:w-[500px] bg-white border-r border-slate-200 flex flex-col shrink-0 h-full shadow-sm z-10 select-none">
|
||
|
||
<!-- НАВИГАЦИОННЫЙ ТАБ-БАР -->
|
||
<div class="flex items-center justify-between border-b border-slate-200 bg-white px-1 py-1 shrink-0">
|
||
<button data-tab="tasks" class="sidebar-tab-btn flex-1 py-2 text-center text-xs border-b-2 border-transparent text-slate-500 hover:text-indigo-600 transition">
|
||
<i class="fa-solid fa-list-check block text-sm mb-0.5"></i> Задачи
|
||
</button>
|
||
<button data-tab="snapshots" class="sidebar-tab-btn flex-1 py-2 text-center text-xs border-b-2 border-transparent text-slate-500 hover:text-indigo-600 transition">
|
||
<i class="fa-solid fa-camera block text-sm mb-0.5"></i> Срезы
|
||
</button>
|
||
<button data-tab="registries" class="sidebar-tab-btn flex-1 py-2 text-center text-xs border-b-2 border-transparent text-slate-500 hover:text-indigo-600 transition">
|
||
<i class="fa-solid fa-address-book block text-sm mb-0.5"></i> Реестры
|
||
</button>
|
||
<button data-tab="prompts" class="sidebar-tab-btn flex-1 py-2 text-center text-xs border-b-2 border-transparent text-slate-500 hover:text-indigo-600 transition">
|
||
<i class="fa-solid fa-terminal block text-sm mb-0.5"></i> Промпт
|
||
</button>
|
||
<button data-tab="context" class="sidebar-tab-btn flex-1 py-2 text-center text-xs border-b-2 border-transparent text-slate-500 hover:text-indigo-600 transition">
|
||
<i class="fa-solid fa-comments block text-sm mb-0.5"></i> Контекст
|
||
</button>
|
||
</div>
|
||
|
||
<!-- РАБОЧИЕ ОБЛАСТИ ВКЛАДОК -->
|
||
<div class="flex-1 overflow-y-auto p-2 flex flex-col gap-2">
|
||
<!-- 1. ЗАДАЧИ -->
|
||
<div id="sidebar-view-tasks" class="sidebar-view w-full h-full flex flex-col">
|
||
<div id="tasks-list" class="space-y-2 flex-1"></div>
|
||
</div>
|
||
|
||
<!-- 2. СРЕЗЫ И ОТЧЕТЫ -->
|
||
<div id="sidebar-view-snapshots" class="sidebar-view w-full space-y-3 hidden">
|
||
|
||
<!-- ПЕРИОД ВЫБОРКИ СРЕЗОВ -->
|
||
<div class="p-2.5 bg-slate-50 rounded-xl border border-slate-200 shadow-xs space-y-2">
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-[11px] font-bold text-slate-600 uppercase flex items-center gap-1.5">
|
||
<i class="fa-regular fa-calendar text-indigo-500"></i> Период срезов:
|
||
</span>
|
||
<button onclick="loadSnapshotsView()" class="text-xs text-indigo-600 hover:text-indigo-800 font-semibold flex items-center gap-1 transition">
|
||
<i class="fa-solid fa-rotate-right text-[10px]"></i> Найти
|
||
</button>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label class="text-[10px] text-slate-400 block mb-0.5">С даты:</label>
|
||
<input type="date" id="snapshots-date-from" onchange="loadSnapshotsView()"
|
||
class="w-full text-xs px-2 py-1.5 bg-white border border-slate-200 rounded-lg text-slate-700 font-mono focus:outline-none focus:border-indigo-500 cursor-pointer">
|
||
</div>
|
||
<div>
|
||
<label class="text-[10px] text-slate-400 block mb-0.5">По дату:</label>
|
||
<input type="date" id="snapshots-date-to" onchange="loadSnapshotsView()"
|
||
class="w-full text-xs px-2 py-1.5 bg-white border border-slate-200 rounded-lg text-slate-700 font-mono focus:outline-none focus:border-indigo-500 cursor-pointer">
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 pt-1 border-t border-slate-200/60">
|
||
<button onclick="setSnapshotDatePreset('today')" class="px-2 py-0.5 text-[11px] bg-white border border-slate-200 hover:bg-slate-100 rounded-md text-slate-600 font-medium transition shadow-2xs">
|
||
Сегодня
|
||
</button>
|
||
<button onclick="setSnapshotDatePreset('yesterday')" class="px-2 py-0.5 text-[11px] bg-white border border-slate-200 hover:bg-slate-100 rounded-md text-slate-600 font-medium transition shadow-2xs">
|
||
Вчера
|
||
</button>
|
||
<button onclick="setSnapshotDatePreset('days3')" class="px-2 py-0.5 text-[11px] bg-white border border-slate-200 hover:bg-slate-100 rounded-md text-slate-600 font-medium transition shadow-2xs">
|
||
3 дня
|
||
</button>
|
||
<button onclick="setSnapshotDatePreset('days7')" class="px-2 py-0.5 text-[11px] bg-white border border-slate-200 hover:bg-slate-100 rounded-md text-slate-600 font-medium transition shadow-2xs">
|
||
7 дней
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Панель создания срезов -->
|
||
<div class="p-3 bg-white rounded-xl border border-slate-200 shadow-xs space-y-2">
|
||
<div class="text-[11px] font-bold text-slate-600 uppercase flex items-center gap-1.5">
|
||
<i class="fa-regular fa-clock text-indigo-500"></i> Создать срез на дату/время:
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<input type="text" id="manual-snapshot-date" placeholder="ДД.ММ.ГГГГ" class="text-xs px-2.5 py-1.5 border border-slate-200 rounded-lg text-center font-mono">
|
||
<input type="text" id="manual-snapshot-time" placeholder="ЧЧ:ММ" class="text-xs px-2.5 py-1.5 border border-slate-200 rounded-lg text-center font-mono">
|
||
</div>
|
||
<button id="btn-create-snapshot" onclick="createSnapshotManual()" class="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold transition flex items-center justify-center gap-1.5">
|
||
<i class="fa-solid fa-camera"></i> Сделать срез
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Панель On-Demand генерации отчетов -->
|
||
<div class="p-3 bg-white rounded-xl border border-slate-200 shadow-xs space-y-2">
|
||
<div class="text-[11px] font-bold text-slate-700 uppercase flex items-center gap-1.5">
|
||
<i class="fa-solid fa-file-excel text-emerald-600"></i> Сформировать отчет:
|
||
</div>
|
||
<div class="grid grid-cols-3 gap-1.5">
|
||
<button onclick="generateReportDirect('SVODKA')" class="py-2 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border border-emerald-200 rounded-lg text-[10px] font-bold transition flex items-center justify-center gap-1">
|
||
<i class="fa-solid fa-table-list"></i> Сводка
|
||
</button>
|
||
<button onclick="generateReportDirect('SIMPLIFIED')" class="py-2 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border border-indigo-200 rounded-lg text-[10px] font-bold transition flex items-center justify-center gap-1">
|
||
<i class="fa-solid fa-file-lines"></i> Упрощ.
|
||
</button>
|
||
<button onclick="generateReportDirect('DETAILED')" class="py-2 bg-purple-50 hover:bg-purple-100 text-purple-700 border border-purple-200 rounded-lg text-[10px] font-bold transition flex items-center justify-center gap-1">
|
||
<i class="fa-solid fa-chart-column"></i> Детальн.
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center justify-between pt-1">
|
||
<span id="snapshots-count-badge" class="text-xs font-bold text-slate-700">Срезы в базе: 0</span>
|
||
<button onclick="loadSnapshotsView()" class="text-slate-400 hover:text-indigo-600 text-xs transition" title="Обновить список">
|
||
<i class="fa-solid fa-rotate-right"></i>
|
||
</button>
|
||
</div>
|
||
<div id="snapshots-list" class="space-y-2"></div>
|
||
</div>
|
||
|
||
<!-- 3. РЕЕСТРЫ -->
|
||
<div id="sidebar-view-registries" class="sidebar-view w-full space-y-3 hidden">
|
||
<div class="grid grid-cols-2 gap-1 p-1 bg-slate-200/60 rounded-xl">
|
||
<button data-subtab="exceptions" onclick="switchRegistrySubTab('exceptions')" class="registry-subtab-btn py-1 text-[11px] rounded-lg transition text-center flex items-center justify-center gap-1">
|
||
<i class="fa-solid fa-user-shield text-[10px]"></i> Исключения
|
||
</button>
|
||
<button data-subtab="remote" onclick="switchRegistrySubTab('remote')" class="registry-subtab-btn py-1 text-[11px] rounded-lg transition text-center flex items-center justify-center gap-1">
|
||
<i class="fa-solid fa-house-laptop text-[10px]"></i> Удаленщики
|
||
</button>
|
||
<button data-subtab="local_trip" onclick="switchRegistrySubTab('local_trip')" class="registry-subtab-btn py-1 text-[11px] rounded-lg transition text-center flex items-center justify-center gap-1">
|
||
<i class="fa-solid fa-location-dot text-[10px]"></i> Мест. командир.
|
||
</button>
|
||
<button data-subtab="other" onclick="switchRegistrySubTab('other')" class="registry-subtab-btn py-1 text-[11px] rounded-lg transition text-center flex items-center justify-center gap-1">
|
||
<i class="fa-solid fa-clipboard-question text-[10px]"></i> Иное
|
||
</button>
|
||
</div>
|
||
<div id="registry-content-container" class="space-y-3"></div>
|
||
</div>
|
||
|
||
<!-- 4. ПРОМПТ -->
|
||
<div id="sidebar-view-prompts" class="sidebar-view w-full space-y-3 hidden">
|
||
<div class="flex items-center justify-between pb-1 border-b border-slate-200">
|
||
<span class="text-xs font-bold text-slate-800">Системный промпт (Ollama)</span>
|
||
<button onclick="loadPromptsView()" class="text-slate-400 hover:text-indigo-600 text-xs transition" title="Обновить">
|
||
<i class="fa-solid fa-rotate-right"></i>
|
||
</button>
|
||
</div>
|
||
<div id="prompts-content-container" class="space-y-2"></div>
|
||
</div>
|
||
|
||
<!-- 5. КОНТЕКСТ -->
|
||
<div id="sidebar-view-context" class="sidebar-view w-full space-y-3 hidden">
|
||
<div class="flex items-center justify-between pb-1 border-b border-slate-200">
|
||
<span class="text-xs font-bold text-slate-800">Мониторинг сессии</span>
|
||
<button onclick="loadContextView()" class="text-slate-400 hover:text-indigo-600 text-xs transition" title="Обновить">
|
||
<i class="fa-solid fa-rotate-right"></i>
|
||
</button>
|
||
</div>
|
||
<div id="context-content-container" class="space-y-2"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ПОДВАЛ -->
|
||
<div class="p-3 border-t border-slate-200 bg-slate-50 flex items-center justify-between shrink-0">
|
||
<div class="flex items-center gap-2.5 min-w-0">
|
||
<div class="w-8 h-8 rounded-full bg-indigo-600 text-white flex items-center justify-center font-bold text-xs shadow-sm shrink-0">
|
||
<i class="fa-solid fa-user"></i>
|
||
</div>
|
||
<div class="min-w-0 flex flex-col">
|
||
<span id="user-display-name" class="text-xs font-bold text-slate-800 truncate">Пользователь</span>
|
||
<span id="user-display-role" class="text-[10px] text-slate-400">Оператор</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-1">
|
||
<button id="admin-panel-btn" type="button" onclick="openAdminModal()" class="hidden p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-slate-200 rounded-lg transition" title="Управление пользователями">
|
||
<i class="fa-solid fa-users-gear text-sm"></i>
|
||
</button>
|
||
<button type="button" onclick="openProfileModal()" class="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-200 rounded-lg transition" title="Сменить пароль">
|
||
<i class="fa-solid fa-gear text-sm"></i>
|
||
</button>
|
||
<button type="button" onclick="AuthManager.logout()" class="p-1.5 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition" title="Выйти из системы">
|
||
<i class="fa-solid fa-arrow-right-from-bracket text-sm"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- ПРАВАЯ ОБЛАСТЬ (ЧАТ И ИИ-АССИСТЕНТ) -->
|
||
<main class="flex-1 flex flex-col h-full min-w-0 bg-slate-50 relative">
|
||
<header class="h-14 bg-white border-b border-slate-200 px-4 flex items-center justify-between shrink-0 shadow-sm z-10">
|
||
<div class="flex items-center gap-2.5">
|
||
<div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shadow-sm">
|
||
<i class="fa-solid fa-robot text-xs"></i>
|
||
</div>
|
||
<div>
|
||
<div class="flex items-center gap-2">
|
||
<h1 class="text-sm font-bold text-slate-800">SCUD Orion AI Assistant</h1>
|
||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">Online</span>
|
||
</div>
|
||
<p class="text-[10px] text-slate-400">Система интеллектуального аудита и контроля СКУД / 1С</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<button onclick="window.openPresenceModal()" class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-emerald-50 hover:bg-emerald-100 text-emerald-700 text-xs font-bold border border-emerald-200 transition shadow-xs">
|
||
<i class="fa-solid fa-building-user text-xs"></i>
|
||
<span>Кто в здании</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- ЛЕНТА ЧАТА -->
|
||
<div id="chat-messages-container" class="flex-1 overflow-y-auto p-4 md:p-6 flex flex-col gap-4">
|
||
<div class="flex gap-3 max-w-4xl mx-auto w-full">
|
||
<div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
|
||
<i class="fa-solid fa-robot text-xs"></i>
|
||
</div>
|
||
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
|
||
<div class="text-[10px] font-bold text-indigo-600 uppercase tracking-wider mb-1">ИИ-ассистент SCUD Orion AI</div>
|
||
<div class="text-xs text-slate-700 leading-relaxed">
|
||
Привет! Вы можете задавать вопросы ассистенту, управлять системным промптом, сверять кадровые нестыковки СКУД и 1С или формировать срезы и отчеты.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- БЕЙДЖ ПРИКРЕПЛЕННОГО ФАЙЛА -->
|
||
<div id="file-attachment-preview" class="hidden max-w-4xl mx-auto w-full px-4 pt-2">
|
||
<div class="inline-flex items-center gap-2 px-3 py-1 bg-indigo-50 border border-indigo-200 rounded-xl text-xs text-indigo-700 shadow-sm">
|
||
<i class="fa-solid fa-file text-indigo-600"></i>
|
||
<span id="file-attachment-name" class="font-medium truncate max-w-xs"></span>
|
||
<button type="button" onclick="clearAttachedFile()" class="text-indigo-400 hover:text-rose-600 ml-1">
|
||
<i class="fa-solid fa-xmark"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- СТРОКА ВВОДА -->
|
||
<div class="px-4 py-2 bg-white border-t border-slate-200">
|
||
<div id="chat-input-box" class="max-w-4xl mx-auto w-full flex items-center gap-2 bg-slate-50 border border-slate-300 rounded-xl px-2.5 py-1 transition focus-within:border-indigo-500 focus-within:bg-white focus-within:ring-1 focus-within:ring-indigo-100">
|
||
<button type="button" onclick="document.getElementById('file-upload-input').click()" class="text-slate-400 hover:text-indigo-600 p-1 transition shrink-0" title="Прикрепить файл">
|
||
<i class="fa-solid fa-paperclip text-xs"></i>
|
||
</button>
|
||
<input type="file" id="file-upload-input" class="hidden" />
|
||
|
||
<textarea id="user-input" rows="1" placeholder="Команда, вопрос (Enter - отправить, Shift+Enter - перенос строки)..."
|
||
class="flex-1 bg-transparent border-0 focus:outline-none text-xs text-slate-800 resize-none py-0 leading-5" style="height: 24px; line-height: 24px;"></textarea>
|
||
|
||
<button type="button" onclick="window.sendMessage()" class="w-6 h-6 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white flex items-center justify-center shrink-0 shadow-sm transition">
|
||
<i class="fa-solid fa-paper-plane text-[10px]"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- КОНТЕЙНЕР ДИНАМИЧЕСКИХ МОДАЛЬНЫХ ОКОН -->
|
||
<div id="modals-container"></div>
|
||
|
||
<script src="/static/js/auth.js?v=2.6.0"></script>
|
||
<script src="/static/js/tasks.js?v=2.6.0"></script>
|
||
<script src="/static/js/manual_absences.js?v=2.6.0"></script>
|
||
<script src="/static/js/snapshot_inspector.js?v=2.6.0"></script>
|
||
|
||
<script src="/static/js/sidebar/core.js?v=2.6.0"></script>
|
||
<script src="/static/js/sidebar/registries.js?v=2.6.0"></script>
|
||
<script src="/static/js/sidebar/snapshots.js?v=2.6.0"></script>
|
||
<script src="/static/js/sidebar/prompts_context.js?v=2.6.0"></script>
|
||
|
||
<script src="/static/js/presence.js?v=2.6.0"></script>
|
||
|
||
<script src="/static/js/chat/task_widget.js?v=2.6.0"></script>
|
||
<script src="/static/js/chat/core.js?v=2.6.0"></script>
|
||
<script src="/static/js/app.js?v=2.6.0"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/app.js`
|
||
```js
|
||
/**
|
||
* ===============================================================================
|
||
* FILE: modules/web_api/static/js/app.js
|
||
* ROLE: Главная точка входа UI: динамическая загрузка модальных окон,
|
||
* маршрутизация авторизации, управление профилем и глобальный стейт.
|
||
* ===============================================================================
|
||
*/
|
||
|
||
const AUTH_TOKEN_KEY = "scud_api_auth_token";
|
||
const SESSION_ID = "web_session_main";
|
||
const STORAGE_KEY = "scud_chat_input_history";
|
||
|
||
let API_TOKEN = localStorage.getItem(AUTH_TOKEN_KEY) || "";
|
||
let CURRENT_USERNAME = localStorage.getItem("scud_username") || "";
|
||
let IS_ADMIN = localStorage.getItem("scud_is_admin") === "true";
|
||
let IS_GUEST = localStorage.getItem("scud_is_guest") === "true";
|
||
|
||
let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
|
||
let historyIndex = -1;
|
||
|
||
// ============================================================================
|
||
// 1. ДИНАМИЧЕСКАЯ ЗАГРУЗКА МОДАЛЬНЫХ ОКОН
|
||
// ============================================================================
|
||
async function loadModals() {
|
||
const modalFiles = [
|
||
'remote_worker_modal.html',
|
||
'manual_absence_modal.html',
|
||
'exception_modal.html',
|
||
'auth_modal.html',
|
||
'profile_modal.html',
|
||
'admin_modal.html',
|
||
'snapshot_inspector_modal.html',
|
||
'presence_modal.html'
|
||
];
|
||
|
||
const container = document.getElementById('modals-container');
|
||
if (!container) return;
|
||
|
||
for (const file of modalFiles) {
|
||
try {
|
||
const res = await fetch(`/static/modals/${file}?v=2.6.0`);
|
||
if (res.ok) {
|
||
const html = await res.text();
|
||
container.insertAdjacentHTML('beforeend', html);
|
||
}
|
||
} catch (e) {
|
||
console.error(`[Modals] Ошибка загрузки ${file}:`, e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 2. УПРАВЛЕНИЕ АВТОРИЗАЦИЕЙ И ПРОФИЛЕМ В UI
|
||
// ============================================================================
|
||
function showAuthModal() {
|
||
const modal = document.getElementById("auth-modal");
|
||
if (modal) modal.classList.remove("hidden");
|
||
}
|
||
|
||
function hideAuthModal() {
|
||
const modal = document.getElementById("auth-modal");
|
||
if (modal) modal.classList.add("hidden");
|
||
}
|
||
|
||
function updateUIState() {
|
||
const nameEl = document.getElementById("user-display-name");
|
||
const roleEl = document.getElementById("user-display-role");
|
||
const adminBtn = document.getElementById("admin-panel-btn");
|
||
|
||
if (nameEl) nameEl.innerText = AuthManager.getFullName();
|
||
if (roleEl) roleEl.innerText = AuthManager.isAdmin() ? "Администратор" : "Оператор";
|
||
|
||
if (adminBtn) {
|
||
if (AuthManager.isAdmin()) {
|
||
adminBtn.classList.remove("hidden");
|
||
} else {
|
||
adminBtn.classList.add("hidden");
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handleLoginSubmit(e) {
|
||
e.preventDefault();
|
||
const uInput = document.getElementById("auth-username");
|
||
const pInput = document.getElementById("auth-password");
|
||
const errEl = document.getElementById("auth-error");
|
||
if (errEl) errEl.classList.add("hidden");
|
||
|
||
try {
|
||
const res = await fetch("/api/v1/auth/login", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
username: uInput.value.trim(),
|
||
password: pInput.value
|
||
})
|
||
});
|
||
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
AuthManager.setSession(data.token, data.username, data.full_name, data.is_admin, data.user_id);
|
||
hideAuthModal();
|
||
updateUIState();
|
||
if (window.SidebarManager) SidebarManager.switchTab('tasks');
|
||
} else {
|
||
const err = await res.json();
|
||
if (errEl) {
|
||
errEl.innerText = err.detail || "Неверный логин или пароль";
|
||
errEl.classList.remove("hidden");
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (errEl) {
|
||
errEl.innerText = "Ошибка соединения с сервером";
|
||
errEl.classList.remove("hidden");
|
||
}
|
||
}
|
||
}
|
||
|
||
function openProfileModal() {
|
||
const modal = document.getElementById("profile-modal");
|
||
if (modal) modal.classList.remove("hidden");
|
||
}
|
||
|
||
function closeProfileModal() {
|
||
const modal = document.getElementById("profile-modal");
|
||
if (modal) modal.classList.add("hidden");
|
||
}
|
||
|
||
async function handleChangePassword(e) {
|
||
e.preventDefault();
|
||
const oldP = document.getElementById("old-pass").value;
|
||
const newP = document.getElementById("new-pass").value;
|
||
const errEl = document.getElementById("pass-error");
|
||
if (errEl) errEl.classList.add("hidden");
|
||
|
||
try {
|
||
const res = await fetch("/api/v1/auth/change-password", {
|
||
method: "POST",
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify({ old_password: oldP, new_password: newP })
|
||
});
|
||
if (res.ok) {
|
||
alert("Пароль успешно изменен");
|
||
closeProfileModal();
|
||
} else {
|
||
const err = await res.json();
|
||
if (errEl) {
|
||
errEl.innerText = err.detail || "Ошибка изменения пароля";
|
||
errEl.classList.remove("hidden");
|
||
}
|
||
}
|
||
} catch (e) {
|
||
if (errEl) {
|
||
errEl.innerText = "Ошибка сети";
|
||
errEl.classList.remove("hidden");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 3. АДМИНИСТРИРОВАНИЕ ПОЛЬЗОВАТЕЛЕЙ
|
||
// ============================================================================
|
||
function openAdminModal() {
|
||
const modal = document.getElementById("admin-modal");
|
||
if (modal) {
|
||
modal.classList.remove("hidden");
|
||
loadAdminUsers();
|
||
}
|
||
}
|
||
|
||
function closeAdminModal() {
|
||
const modal = document.getElementById("admin-modal");
|
||
if (modal) modal.classList.add("hidden");
|
||
}
|
||
|
||
async function loadAdminUsers() {
|
||
const listEl = document.getElementById("admin-users-list");
|
||
if (!listEl) return;
|
||
listEl.innerHTML = `<div class="text-center py-4 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...</div>`;
|
||
|
||
try {
|
||
const res = await fetch("/api/v1/admin/users", { headers: AuthManager.getAuthHeaders() });
|
||
if (res.ok) {
|
||
const users = await res.json();
|
||
listEl.innerHTML = users.map(u => `
|
||
<div class="flex items-center justify-between p-2.5 bg-slate-50 border border-slate-200 rounded-xl text-xs">
|
||
<div>
|
||
<div class="font-bold text-slate-800">${escapeHtml(u.full_name || u.username)} <span class="text-slate-400 font-mono text-[10px]">(${escapeHtml(u.username)})</span></div>
|
||
<div class="text-[10px] ${u.is_admin ? 'text-indigo-600 font-bold' : 'text-slate-400'}">${u.is_admin ? 'Администратор' : 'Оператор'}</div>
|
||
</div>
|
||
<button onclick="deleteAdminUser(${u.id}, '${escapeHtml(u.username)}')" class="text-slate-400 hover:text-rose-600 p-1.5" title="Удалить">
|
||
<i class="fa-solid fa-trash-can"></i>
|
||
</button>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
listEl.innerHTML = `<div class="text-center py-4 text-xs text-rose-500">Ошибка загрузки пользователей</div>`;
|
||
}
|
||
} catch (e) {
|
||
listEl.innerHTML = `<div class="text-center py-4 text-xs text-rose-500">Ошибка сети</div>`;
|
||
}
|
||
}
|
||
|
||
async function handleCreateUser(e) {
|
||
e.preventDefault();
|
||
const u = document.getElementById("new-user-username").value;
|
||
const p = document.getElementById("new-user-password").value;
|
||
const f = document.getElementById("new-user-fullname").value;
|
||
const a = document.getElementById("new-user-admin").checked;
|
||
|
||
try {
|
||
const res = await fetch("/api/v1/admin/users", {
|
||
method: "POST",
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify({ username: u, password: p, full_name: f, is_admin: a })
|
||
});
|
||
if (res.ok) {
|
||
document.getElementById("new-user-username").value = "";
|
||
document.getElementById("new-user-password").value = "";
|
||
document.getElementById("new-user-fullname").value = "";
|
||
document.getElementById("new-user-admin").checked = false;
|
||
loadAdminUsers();
|
||
} else {
|
||
const err = await res.json();
|
||
alert(err.detail || "Ошибка создания пользователя");
|
||
}
|
||
} catch (e) {
|
||
alert("Ошибка сети");
|
||
}
|
||
}
|
||
|
||
async function deleteAdminUser(id, username) {
|
||
if (!confirm(`Удалить пользователя ${username}?`)) return;
|
||
try {
|
||
const res = await fetch(`/api/v1/admin/users/${id}`, {
|
||
method: "DELETE",
|
||
headers: AuthManager.getAuthHeaders()
|
||
});
|
||
if (res.ok) {
|
||
loadAdminUsers();
|
||
} else {
|
||
const err = await res.json();
|
||
alert(err.detail || "Ошибка удаления");
|
||
}
|
||
} catch (e) {
|
||
alert("Ошибка сети");
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 4. ВСПОМОГАТЕЛЬНЫЕ ФОРМАТЕРЫ И МОДАЛКА УДАЛЕНЩИКОВ
|
||
// ============================================================================
|
||
function dmyToYmd(str) {
|
||
if (!str) return "";
|
||
const parts = str.replace(/_/g, '.').split('.');
|
||
if (parts.length === 3) return `${parts[2]}-${parts[1].padStart(2, '0')}-${parts[0].padStart(2, '0')}`;
|
||
return "";
|
||
}
|
||
|
||
function ymdToDmy(str) {
|
||
if (!str) return "";
|
||
const parts = str.split('-');
|
||
if (parts.length === 3) return `${parts[2]}.${parts[1]}.${parts[0]}`;
|
||
return "";
|
||
}
|
||
|
||
function openRemoteWorkerModal(mode = 'ADD', fio = '', dept = 'Все', dateFrom = '', dateTo = '') {
|
||
const modal = document.getElementById("remote-worker-modal");
|
||
const titleEl = document.getElementById("remote-modal-title");
|
||
const modeInput = document.getElementById("rw-mode");
|
||
const fioInput = document.getElementById("rw-fio");
|
||
const deptInput = document.getElementById("rw-dept");
|
||
const fromInput = document.getElementById("rw-date-from");
|
||
const toInput = document.getElementById("rw-date-to");
|
||
const errEl = document.getElementById("rw-error");
|
||
const suggBox = document.getElementById("rw-fio-suggestions");
|
||
|
||
if (!modal) return;
|
||
if (errEl) errEl.classList.add("hidden");
|
||
if (suggBox) {
|
||
suggBox.classList.add("hidden");
|
||
suggBox.innerHTML = "";
|
||
}
|
||
if (modeInput) modeInput.value = mode;
|
||
|
||
if (mode === 'EDIT') {
|
||
if (titleEl) titleEl.innerHTML = `<i class="fa-solid fa-pen-to-square text-emerald-600"></i><span>Изменение сроков удаленки</span>`;
|
||
if (fioInput) {
|
||
fioInput.value = fio;
|
||
fioInput.readOnly = true;
|
||
fioInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
|
||
}
|
||
if (deptInput) {
|
||
deptInput.value = dept || "Все";
|
||
deptInput.readOnly = true;
|
||
deptInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
|
||
}
|
||
if (fromInput) fromInput.value = dmyToYmd(dateFrom);
|
||
if (toInput) toInput.value = dmyToYmd(dateTo);
|
||
} else {
|
||
if (titleEl) titleEl.innerHTML = `<i class="fa-solid fa-house-laptop text-emerald-600"></i><span>Добавление удаленщика</span>`;
|
||
if (fioInput) {
|
||
fioInput.value = "";
|
||
fioInput.readOnly = false;
|
||
fioInput.classList.remove("bg-slate-100", "text-slate-500", "cursor-not-allowed");
|
||
}
|
||
if (deptInput) {
|
||
deptInput.value = "Все";
|
||
deptInput.readOnly = false;
|
||
deptInput.classList.remove("bg-slate-100", "text-slate-500", "cursor-not-allowed");
|
||
}
|
||
const today = new Date().toISOString().split('T')[0];
|
||
if (fromInput) fromInput.value = today;
|
||
if (toInput) toInput.value = "";
|
||
}
|
||
|
||
modal.classList.remove("hidden");
|
||
}
|
||
|
||
function closeRemoteWorkerModal() {
|
||
const modal = document.getElementById("remote-worker-modal");
|
||
if (modal) modal.classList.add("hidden");
|
||
}
|
||
|
||
async function handleRemoteWorkerSubmit(e) {
|
||
e.preventDefault();
|
||
const mode = document.getElementById("rw-mode").value;
|
||
const fio = document.getElementById("rw-fio").value.trim();
|
||
const dept = document.getElementById("rw-dept").value.trim() || "Все";
|
||
const fromVal = ymdToDmy(document.getElementById("rw-date-from").value);
|
||
const toVal = ymdToDmy(document.getElementById("rw-date-to").value);
|
||
const errEl = document.getElementById("rw-error");
|
||
if (errEl) errEl.classList.add("hidden");
|
||
|
||
const method = (mode === 'EDIT') ? "PUT" : "POST";
|
||
const payload = (mode === 'EDIT')
|
||
? { fio: fio, date_from: fromVal, date_to: toVal }
|
||
: { fio: fio, department: dept, reason: "Удаленная работа", date_from: fromVal, date_to: toVal };
|
||
|
||
try {
|
||
const res = await fetch("/api/v1/remote-workers", {
|
||
method: method,
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
if (res.ok) {
|
||
closeRemoteWorkerModal();
|
||
if (window.SidebarManager) SidebarManager.switchRegistrySubTab('remote');
|
||
} else {
|
||
const err = await res.json();
|
||
if (errEl) {
|
||
errEl.innerText = err.detail || "Ошибка сохранения";
|
||
errEl.classList.remove("hidden");
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (errEl) {
|
||
errEl.innerText = "Ошибка соединения с сервером";
|
||
errEl.classList.remove("hidden");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 5. ИНИЦИАЛИЗАЦИЯ ПРИЛОЖЕНИЯ
|
||
// ============================================================================
|
||
document.addEventListener("DOMContentLoaded", async () => {
|
||
// 1. Асинхронная подгрузка модальных окон
|
||
await loadModals();
|
||
|
||
// 2. Инициализация автокомплита исключений после монтирования разметки
|
||
const excInput = document.getElementById("exception-value-input");
|
||
const excBox = document.getElementById("exception-suggestions");
|
||
if (excInput && excBox && typeof setupStaffAutocomplete === "function") {
|
||
setupStaffAutocomplete(excInput, "exception-suggestions");
|
||
}
|
||
|
||
// 3. Инициализация автокомплита для модалки удаленщиков
|
||
const rwFioInput = document.getElementById("rw-fio");
|
||
if (rwFioInput && typeof setupStaffAutocomplete === "function") {
|
||
setupStaffAutocomplete(rwFioInput, "rw-fio-suggestions");
|
||
}
|
||
|
||
//3.1 Автокомплит для местных командировок и иного
|
||
const maFioInput = document.getElementById("manual-absence-fio-input");
|
||
if (maFioInput && typeof setupStaffAutocomplete === "function") {
|
||
setupStaffAutocomplete(maFioInput, "manual-absence-suggestions");
|
||
}
|
||
|
||
// 4. Авто-высота поля ввода команд
|
||
const userInputEl = document.getElementById("user-input");
|
||
if (userInputEl) {
|
||
userInputEl.addEventListener("input", function() {
|
||
this.style.height = "24px";
|
||
const newHeight = Math.min(this.scrollHeight, 120);
|
||
this.style.height = newHeight + "px";
|
||
});
|
||
}
|
||
|
||
// 5. Проверка сессии пользователя
|
||
if (IS_GUEST) {
|
||
hideAuthModal();
|
||
updateUIState();
|
||
} else if (AuthManager && AuthManager.isAuthenticated()) {
|
||
hideAuthModal();
|
||
updateUIState();
|
||
if (typeof loadTasks === "function") {
|
||
loadTasks();
|
||
}
|
||
if (window.SidebarManager) {
|
||
SidebarManager.init();
|
||
}
|
||
} else {
|
||
showAuthModal();
|
||
}
|
||
});
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/auth.js`
|
||
```js
|
||
/**
|
||
* ===============================================================================
|
||
* FILE: modules/web_api/static/js/auth.js
|
||
* ROLE: Менеджер сессий, токенов, ФИО и авторизационных заголовков.
|
||
* ===============================================================================
|
||
*/
|
||
|
||
const AUTH_STORAGE_KEY = "scud_auth_token";
|
||
const USERNAME_STORAGE_KEY = "scud_username";
|
||
const FULLNAME_STORAGE_KEY = "scud_full_name";
|
||
const IS_ADMIN_STORAGE_KEY = "scud_is_admin";
|
||
const USER_ID_STORAGE_KEY = "scud_user_id";
|
||
|
||
const AuthManager = {
|
||
getToken() {
|
||
return localStorage.getItem(AUTH_STORAGE_KEY) || "";
|
||
},
|
||
|
||
getUserId() {
|
||
const uid = localStorage.getItem(USER_ID_STORAGE_KEY);
|
||
return uid ? parseInt(uid, 10) : 1;
|
||
},
|
||
|
||
getUsername() {
|
||
return localStorage.getItem(USERNAME_STORAGE_KEY) || "";
|
||
},
|
||
|
||
getFullName() {
|
||
return localStorage.getItem(FULLNAME_STORAGE_KEY) || this.getUsername() || "Пользователь";
|
||
},
|
||
|
||
isAdmin() {
|
||
return localStorage.getItem(IS_ADMIN_STORAGE_KEY) === "true";
|
||
},
|
||
|
||
isAuthenticated() {
|
||
return Boolean(this.getToken());
|
||
},
|
||
|
||
setSession(token, username, fullName, isAdmin, userId = 1) {
|
||
localStorage.setItem(AUTH_STORAGE_KEY, token);
|
||
localStorage.setItem(USERNAME_STORAGE_KEY, username);
|
||
localStorage.setItem(FULLNAME_STORAGE_KEY, fullName || username);
|
||
localStorage.setItem(IS_ADMIN_STORAGE_KEY, String(isAdmin));
|
||
localStorage.setItem(USER_ID_STORAGE_KEY, String(userId));
|
||
localStorage.setItem("scud_api_auth_token", token);
|
||
localStorage.setItem("auth_token", token);
|
||
},
|
||
|
||
getAuthHeaders() {
|
||
const token = this.getToken();
|
||
const headers = { "Content-Type": "application/json" };
|
||
if (token) {
|
||
headers["Authorization"] = `Bearer ${token}`;
|
||
}
|
||
return headers;
|
||
},
|
||
|
||
logout() {
|
||
console.log("[Auth] Полный выход из системы...");
|
||
localStorage.clear();
|
||
sessionStorage.clear();
|
||
|
||
document.cookie.split(";").forEach((cookie) => {
|
||
const eqPos = cookie.indexOf("=");
|
||
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
|
||
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
|
||
});
|
||
|
||
window.location.href = "/";
|
||
}
|
||
};
|
||
|
||
window.AuthManager = AuthManager;
|
||
window.logout = () => AuthManager.logout();
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/chat/core.js`
|
||
```js
|
||
/**
|
||
* ===============================================================================
|
||
* FILE: modules/web_api/static/js/chat/core.js
|
||
* ROLE: Ядро чата: полноэкранный Drag-and-Drop оверлей, авто-высота инпута (24px),
|
||
* надежный расчет скролла вопроса к верху окна, крупный шрифт text-sm.
|
||
* ===============================================================================
|
||
*/
|
||
|
||
let currentAttachedFile = null;
|
||
|
||
const CHAT_INPUT_STORAGE_KEY = "scud_chat_input_history";
|
||
let chatInputHistory = JSON.parse(localStorage.getItem(CHAT_INPUT_STORAGE_KEY) || "[]");
|
||
let chatHistoryIndex = -1;
|
||
let temporaryCurrentInput = "";
|
||
|
||
function saveCommandToHistory(commandText) {
|
||
if (!commandText || !commandText.trim()) return;
|
||
const cleanCmd = commandText.trim();
|
||
if (cleanCmd.startsWith("action:save_draft_")) return;
|
||
|
||
chatInputHistory = chatInputHistory.filter(item => item !== cleanCmd);
|
||
chatInputHistory.push(cleanCmd);
|
||
if (chatInputHistory.length > 50) chatInputHistory.shift();
|
||
localStorage.setItem(CHAT_INPUT_STORAGE_KEY, JSON.stringify(chatInputHistory));
|
||
chatHistoryIndex = -1;
|
||
}
|
||
|
||
// ⭐️ ЕДИНАЯ ФУНКЦИЯ СКРОЛЛА: выравнивание вопроса к верхней границе
|
||
function scrollToUserMessageTop() {
|
||
const container = document.getElementById("chat-messages-container");
|
||
if (!container) return;
|
||
|
||
const userBubbles = container.querySelectorAll(".user-chat-bubble");
|
||
const targetEl = userBubbles[userBubbles.length - 1];
|
||
if (!targetEl) return;
|
||
|
||
requestAnimationFrame(() => {
|
||
const targetScroll = targetEl.offsetTop - container.offsetTop - 12;
|
||
|
||
container.scrollTo({
|
||
top: Math.max(0, targetScroll),
|
||
behavior: 'smooth'
|
||
});
|
||
});
|
||
}
|
||
|
||
function updateInputHeightAndFade(textarea) {
|
||
if (!textarea) return;
|
||
|
||
if (!textarea.value || textarea.value.trim() === '') {
|
||
textarea.style.height = '24px';
|
||
textarea.style.overflowY = 'hidden';
|
||
textarea.style.maskImage = 'none';
|
||
textarea.style.webkitMaskImage = 'none';
|
||
return;
|
||
}
|
||
|
||
textarea.style.height = 'auto';
|
||
const minHeight = 24;
|
||
const maxHeight = 120;
|
||
const currentScrollHeight = textarea.scrollHeight;
|
||
|
||
if (currentScrollHeight <= minHeight + 2) {
|
||
textarea.style.height = minHeight + 'px';
|
||
textarea.style.overflowY = 'hidden';
|
||
textarea.style.maskImage = 'none';
|
||
textarea.style.webkitMaskImage = 'none';
|
||
} else if (currentScrollHeight > maxHeight) {
|
||
textarea.style.height = maxHeight + 'px';
|
||
textarea.style.overflowY = 'auto';
|
||
textarea.style.maskImage = 'linear-gradient(to bottom, transparent 0%, black 14px, black 100%)';
|
||
textarea.style.webkitMaskImage = 'linear-gradient(to bottom, transparent 0%, black 14px, black 100%)';
|
||
} else {
|
||
textarea.style.height = currentScrollHeight + 'px';
|
||
textarea.style.overflowY = 'hidden';
|
||
textarea.style.maskImage = 'none';
|
||
textarea.style.webkitMaskImage = 'none';
|
||
}
|
||
}
|
||
|
||
function appendUserMessage(text, filename = null) {
|
||
const container = document.getElementById("chat-messages-container");
|
||
if (!container) return;
|
||
|
||
const msgId = 'user-msg-' + Date.now();
|
||
let fileBadge = '';
|
||
if (filename) {
|
||
fileBadge = `
|
||
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 mb-2 bg-indigo-700/80 rounded-lg text-xs font-semibold text-white shadow-xs">
|
||
<i class="fa-solid fa-paperclip text-xs"></i>
|
||
<span class="truncate max-w-xs">${escapeHtml(filename)}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const msgHtml = `
|
||
<div id="${msgId}" class="user-chat-bubble relative flex gap-3 max-w-4xl mx-auto w-full justify-end pt-3 scroll-mt-4">
|
||
<div class="flex-1 max-w-2xl bg-indigo-600 text-white rounded-2xl rounded-tr-none p-4 shadow-sm">
|
||
${fileBadge}
|
||
<div class="text-sm leading-relaxed whitespace-pre-wrap">${escapeHtml(text)}</div>
|
||
</div>
|
||
<div class="w-8 h-8 rounded-lg bg-slate-200 text-slate-600 flex items-center justify-center shrink-0 shadow-sm mt-0.5 font-bold text-sm">
|
||
<i class="fa-solid fa-user"></i>
|
||
</div>
|
||
</div>
|
||
`;
|
||
container.insertAdjacentHTML("beforeend", msgHtml);
|
||
}
|
||
|
||
function appendAssistantLoading() {
|
||
const container = document.getElementById("chat-messages-container");
|
||
if (!container) return null;
|
||
|
||
const loadingId = 'loading-' + Date.now();
|
||
const html = `
|
||
<div id="${loadingId}" class="flex gap-3 max-w-4xl mx-auto w-full pt-1">
|
||
<div class="w-8 h-8 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
|
||
<i class="fa-solid fa-robot text-sm"></i>
|
||
</div>
|
||
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
|
||
<div class="text-sm text-slate-500 flex items-center gap-2">
|
||
<i class="fa-solid fa-spinner fa-spin text-indigo-600"></i>
|
||
<span>ИИ обрабатывает запрос...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
container.insertAdjacentHTML("beforeend", html);
|
||
return loadingId;
|
||
}
|
||
|
||
function appendAssistantMessage(text, buttons = [], actionPayload = null) {
|
||
const container = document.getElementById("chat-messages-container");
|
||
if (!container) return;
|
||
|
||
let payloadHtml = '';
|
||
let isHtmlBody = false;
|
||
|
||
if (actionPayload) {
|
||
if (actionPayload.type === 'SNAPSHOTS_CARD' && typeof renderSnapshotsCard === 'function') {
|
||
payloadHtml = renderSnapshotsCard(actionPayload.data);
|
||
} else if (actionPayload.type === 'TASK_INTERACTIVE_CARD' && typeof renderInteractiveTaskCard === 'function') {
|
||
payloadHtml = renderInteractiveTaskCard(actionPayload.tasks);
|
||
} else if (actionPayload.type === 'FILE_DOWNLOAD_CARD') {
|
||
const dlUrl = actionPayload.download_url || '#';
|
||
const fName = actionPayload.filename || 'ROADMAP.md';
|
||
const count = actionPayload.tasks_count || '';
|
||
payloadHtml = `
|
||
<div class="mt-3 p-3.5 bg-indigo-50/80 border border-indigo-200 rounded-xl flex items-center justify-between gap-3 shadow-sm">
|
||
<div class="flex items-center gap-3 min-w-0">
|
||
<div class="w-9 h-9 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm">
|
||
<i class="fa-solid fa-file-lines text-base"></i>
|
||
</div>
|
||
<div class="min-w-0">
|
||
<div class="text-sm font-bold text-slate-800 truncate">${escapeHtml(fName)}</div>
|
||
<div class="text-xs text-slate-500">Задач выгружено: ${count} шт. · Markdown</div>
|
||
</div>
|
||
</div>
|
||
<a href="${dlUrl}" download="${escapeHtml(fName)}" target="_blank"
|
||
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center gap-1.5 shrink-0">
|
||
<i class="fa-solid fa-download text-xs"></i>
|
||
<span>Скачать файл</span>
|
||
</a>
|
||
</div>
|
||
`;
|
||
} else if (actionPayload.type === 'SNAPSHOT_INSPECT_CARD') {
|
||
const records = actionPayload.records || [];
|
||
const inspectTableId = 'inspect-table-' + Date.now();
|
||
const searchInputId = 'inspect-search-' + Date.now();
|
||
|
||
const rowsHtml = records.map((r, idx) => `
|
||
<tr class="inspect-row border-b border-slate-100 text-xs ${r.is_present ? 'bg-white' : 'bg-slate-50/60'} hover:bg-indigo-50/40"
|
||
data-fio="${escapeHtml(r.fio).toLowerCase()}" data-dept="${escapeHtml(r.department).toLowerCase()}">
|
||
<td class="p-2.5 text-slate-400 text-center font-mono w-10">${idx + 1}</td>
|
||
<td class="p-2.5 font-medium text-slate-800">${escapeHtml(r.fio)}</td>
|
||
<td class="p-2.5 text-slate-500 text-center">${escapeHtml(r.department)}</td>
|
||
<td class="p-2.5 text-center ${r.time_in !== 'Нет входа' ? 'font-bold text-emerald-700' : 'text-slate-400'}">${escapeHtml(r.time_in)}</td>
|
||
<td class="p-2.5 text-center text-slate-500">${escapeHtml(r.first_activity)}</td>
|
||
<td class="p-2.5 text-center ${r.time_out !== 'Нет выхода' ? 'font-bold text-slate-800' : 'text-slate-400'}">${escapeHtml(r.time_out)}</td>
|
||
<td class="p-2.5 text-center font-mono text-slate-600">${escapeHtml(r.in_building)}</td>
|
||
<td class="p-2.5 text-center font-bold">${r.is_present ? '<span class="text-emerald-600">✓ Да</span>' : '<span class="text-slate-400">Нет</span>'}</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
payloadHtml = `
|
||
<div class="mt-3 bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm flex flex-col">
|
||
<div class="px-4 py-3 bg-slate-100/90 border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-sm font-bold text-slate-800">Срез #${escapeHtml(actionPayload.snapshot_id)}</span>
|
||
<span class="text-xs text-slate-500">· Всего: ${records.length} чел. (Присутствуют: ${actionPayload.present_count || 0})</span>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<input type="text" id="${searchInputId}" placeholder="Поиск в срезе (ФИО / отдел)..."
|
||
oninput="window.filterInspectTable('${inspectTableId}', this.value)"
|
||
class="text-xs px-3 py-1.5 bg-white border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 w-60" />
|
||
<button onclick="window.sendChatAction('покажи срезы')" class="text-xs text-indigo-600 hover:underline font-semibold">Все срезы</button>
|
||
</div>
|
||
</div>
|
||
<div class="max-h-96 overflow-y-auto">
|
||
<table id="${inspectTableId}" class="w-full text-left border-collapse">
|
||
<thead class="bg-slate-50 text-[11px] uppercase text-slate-500 sticky top-0 border-b border-slate-200 shadow-xs">
|
||
<tr>
|
||
<th class="p-2.5 text-center w-10">№</th>
|
||
<th class="p-2.5">Сотрудник</th>
|
||
<th class="p-2.5 text-center">Отдел</th>
|
||
<th class="p-2.5 text-center">Вход</th>
|
||
<th class="p-2.5 text-center">Активность</th>
|
||
<th class="p-2.5 text-center">Выход</th>
|
||
<th class="p-2.5 text-center">В здании</th>
|
||
<th class="p-2.5 text-center">Статус</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rowsHtml}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (actionPayload.type === 'PROMPT_EDITOR') {
|
||
const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || '';
|
||
const editorId = 'prompt-editor-' + Date.now();
|
||
payloadHtml = `
|
||
<div class="mt-3 p-3.5 bg-slate-50 border border-slate-300 rounded-xl flex flex-col gap-2 shadow-inner">
|
||
<div class="flex items-center justify-between text-xs font-bold text-slate-700">
|
||
<span><i class="fa-solid fa-pen-to-square text-indigo-600 mr-1"></i> Инлайн-редактор системного промпта:</span>
|
||
<span class="text-[11px] text-slate-400 font-normal">Прямое редактирование текста</span>
|
||
</div>
|
||
<textarea id="${editorId}" rows="14"
|
||
class="w-full text-xs font-mono p-3 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-white leading-relaxed resize-y">${escapeHtml(draftText)}</textarea>
|
||
<div class="flex items-center justify-end gap-2 pt-1">
|
||
<button type="button" onclick="window.sendChatAction('отмена')" class="px-3.5 py-1.5 text-xs text-slate-600 hover:bg-slate-200 rounded-lg transition font-medium">Отменить</button>
|
||
<button type="button" onclick="window.submitPromptDraftToDiff('${editorId}')" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition flex items-center gap-1.5">
|
||
<i class="fa-solid fa-eye text-xs"></i>
|
||
<span>Показать превью изменений</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (actionPayload.type === 'RULES_EDITOR') {
|
||
const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || '';
|
||
const editorId = 'rules-editor-' + Date.now();
|
||
payloadHtml = `
|
||
<div class="mt-3 p-3.5 bg-slate-50 border border-slate-300 rounded-xl flex flex-col gap-2 shadow-inner">
|
||
<div class="flex items-center justify-between text-xs font-bold text-slate-700">
|
||
<span><i class="fa-solid fa-book-bookmark text-emerald-600 mr-1"></i> Редактор базы знаний и правил компании:</span>
|
||
<span class="text-[11px] text-slate-400 font-normal">Прямое изменение правил кадрового арбитража</span>
|
||
</div>
|
||
<textarea id="${editorId}" rows="12"
|
||
class="w-full text-xs font-mono p-3 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-white leading-relaxed resize-y">${escapeHtml(draftText)}</textarea>
|
||
<div class="flex items-center justify-end gap-2 pt-1">
|
||
<button type="button" onclick="window.sendChatAction('отмена')" class="px-3.5 py-1.5 text-xs text-slate-600 hover:bg-slate-200 rounded-lg transition font-medium">Отменить</button>
|
||
<button type="button" onclick="window.submitRulesDraftToDiff('${editorId}')" class="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow transition flex items-center gap-1.5">
|
||
<i class="fa-solid fa-eye text-xs"></i>
|
||
<span>Показать превью изменений</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (actionPayload.type === 'PROMPT_PREVIEW') {
|
||
isHtmlBody = true;
|
||
}
|
||
}
|
||
|
||
let buttonsHtml = '';
|
||
if (!actionPayload || (actionPayload.type !== 'PROMPT_EDITOR' && actionPayload.type !== 'RULES_EDITOR')) {
|
||
const allButtons = (buttons && buttons.length > 0) ? buttons : (actionPayload && actionPayload.buttons ? actionPayload.buttons : []);
|
||
if (allButtons && Array.isArray(allButtons) && allButtons.length > 0) {
|
||
buttonsHtml = `
|
||
<div class="flex flex-wrap gap-2 mt-3.5 pt-2.5 border-t border-slate-100">
|
||
${allButtons.map(b => `
|
||
<button onclick="window.sendChatAction('${escapeHtml(b.value || b.action || b.title || '')}')"
|
||
class="px-3 py-1.5 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 rounded-lg text-xs font-semibold border border-indigo-200 transition">
|
||
${escapeHtml(b.label || b.title || b.action)}
|
||
</button>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
const bodyContent = isHtmlBody ? text : escapeHtml(text);
|
||
|
||
const html = `
|
||
<div class="flex gap-3 max-w-4xl mx-auto w-full pt-1">
|
||
<div class="w-8 h-8 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
|
||
<i class="fa-solid fa-robot text-sm"></i>
|
||
</div>
|
||
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm min-w-0">
|
||
<div class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1.5">ИИ-ассистент SCUD Orion AI</div>
|
||
<div class="text-sm text-slate-800 leading-relaxed whitespace-pre-wrap">${bodyContent}</div>
|
||
${payloadHtml}
|
||
${buttonsHtml}
|
||
</div>
|
||
</div>
|
||
`;
|
||
container.insertAdjacentHTML("beforeend", html);
|
||
}
|
||
|
||
window.filterInspectTable = function(tableId, query) {
|
||
const table = document.getElementById(tableId);
|
||
if (!table) return;
|
||
const q = (query || '').trim().toLowerCase();
|
||
const rows = table.querySelectorAll('.inspect-row');
|
||
rows.forEach(r => {
|
||
const fio = r.getAttribute('data-fio') || '';
|
||
const dept = r.getAttribute('data-dept') || '';
|
||
if (!q || fio.includes(q) || dept.includes(q)) {
|
||
r.classList.remove('hidden');
|
||
} else {
|
||
r.classList.add('hidden');
|
||
}
|
||
});
|
||
};
|
||
|
||
window.sendChatAction = function(actionText) {
|
||
if (!actionText || !actionText.trim()) return;
|
||
const input = document.getElementById("user-input");
|
||
if (input) {
|
||
input.value = actionText.trim();
|
||
updateInputHeightAndFade(input);
|
||
}
|
||
window.sendMessage();
|
||
};
|
||
|
||
window.submitPromptDraftToDiff = function(editorId) {
|
||
const textarea = document.getElementById(editorId);
|
||
if (!textarea) return;
|
||
const newText = textarea.value.trim();
|
||
if (!newText) {
|
||
alert("Текст системного промпта не может быть пустым");
|
||
return;
|
||
}
|
||
|
||
const command = `action:save_draft_prompt:::${newText}`;
|
||
const input = document.getElementById("user-input");
|
||
if (input) input.value = command;
|
||
window.sendMessage();
|
||
};
|
||
|
||
window.submitRulesDraftToDiff = function(editorId) {
|
||
const textarea = document.getElementById(editorId);
|
||
if (!textarea) return;
|
||
const newText = textarea.value.trim();
|
||
if (!newText) {
|
||
alert("Правила не могут быть пустыми");
|
||
return;
|
||
}
|
||
|
||
const command = `action:save_draft_rules:::${newText}`;
|
||
const input = document.getElementById("user-input");
|
||
if (input) input.value = command;
|
||
window.sendMessage();
|
||
};
|
||
|
||
window.sendMessage = async function() {
|
||
const input = document.getElementById("user-input");
|
||
if (!input) return;
|
||
|
||
const messageText = input.value.trim();
|
||
const fileToSend = currentAttachedFile;
|
||
|
||
if (!messageText && !fileToSend) return;
|
||
|
||
saveCommandToHistory(messageText);
|
||
|
||
input.value = "";
|
||
input.style.height = '24px';
|
||
input.style.overflowY = 'hidden';
|
||
input.style.maskImage = 'none';
|
||
input.style.webkitMaskImage = 'none';
|
||
window.clearAttachedFile();
|
||
|
||
// 1. Отрисовка сообщения пользователя в ленте
|
||
if (!messageText.startsWith("action:save_draft_prompt:::") && !messageText.startsWith("action:save_draft_rules:::")) {
|
||
appendUserMessage(
|
||
messageText || (fileToSend ? `Прикреплен файл: ${fileToSend.name}` : ''),
|
||
fileToSend ? fileToSend.name : null
|
||
);
|
||
} else {
|
||
appendUserMessage("Сформировать предпросмотр изменений");
|
||
}
|
||
|
||
// 2. Вставка лоадера
|
||
const loadingId = appendAssistantLoading();
|
||
|
||
// 3. Вызов точного скролла
|
||
scrollToUserMessageTop();
|
||
|
||
try {
|
||
let res;
|
||
if (fileToSend) {
|
||
const formData = new FormData();
|
||
formData.append("file", fileToSend);
|
||
formData.append("message", messageText);
|
||
formData.append("session_id", "web_session_main");
|
||
|
||
res = await fetch("/api/v1/chat/upload", {
|
||
method: "POST",
|
||
headers: {
|
||
"Authorization": AuthManager.getAuthHeaders()["Authorization"]
|
||
},
|
||
body: formData
|
||
});
|
||
|
||
if (res.status === 404) {
|
||
res = await fetch("/api/v1/chat", {
|
||
method: "POST",
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
message: messageText || `Загружен файл: ${fileToSend.name}`,
|
||
session_id: "web_session_main"
|
||
})
|
||
});
|
||
}
|
||
} else {
|
||
res = await fetch("/api/v1/chat", {
|
||
method: "POST",
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
message: messageText,
|
||
session_id: "web_session_main"
|
||
})
|
||
});
|
||
}
|
||
|
||
const loaderEl = document.getElementById(loadingId);
|
||
if (loaderEl) loaderEl.remove();
|
||
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
const replyText = data.response || data.text || data.message || "Запрос выполнен.";
|
||
const buttons = data.buttons || [];
|
||
const actionPayload = data.action_payload || null;
|
||
|
||
appendAssistantMessage(replyText, buttons, actionPayload);
|
||
|
||
if (window.SidebarManager) {
|
||
SidebarManager.renderContent();
|
||
}
|
||
} else {
|
||
const err = await res.json().catch(() => ({}));
|
||
appendAssistantMessage(`⚠️ Ошибка сервера (${res.status}): ${err.detail || "Не удалось получить ответ"}`);
|
||
}
|
||
} catch (err) {
|
||
console.error("Ошибка отправки сообщения:", err);
|
||
const loaderEl = document.getElementById(loadingId);
|
||
if (loaderEl) loaderEl.remove();
|
||
appendAssistantMessage("⚠️ Ошибка соединения с сервером при отправке сообщения.");
|
||
}
|
||
};
|
||
|
||
window.clearAttachedFile = function() {
|
||
currentAttachedFile = null;
|
||
const preview = document.getElementById("file-attachment-preview");
|
||
const nameEl = document.getElementById("file-attachment-name");
|
||
const fileInput = document.getElementById("file-upload-input");
|
||
|
||
if (preview) preview.classList.add("hidden");
|
||
if (nameEl) nameEl.innerText = "";
|
||
if (fileInput) fileInput.value = "";
|
||
};
|
||
|
||
function handleFileSelected(file) {
|
||
if (!file) return;
|
||
currentAttachedFile = file;
|
||
const preview = document.getElementById("file-attachment-preview");
|
||
const nameEl = document.getElementById("file-attachment-name");
|
||
|
||
if (preview && nameEl) {
|
||
nameEl.innerText = file.name;
|
||
preview.classList.remove("hidden");
|
||
}
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
const input = document.getElementById("user-input");
|
||
const fileInput = document.getElementById("file-upload-input");
|
||
const overlay = document.getElementById("global-drag-overlay");
|
||
|
||
if (input) {
|
||
input.style.lineHeight = '24px';
|
||
input.style.height = '24px';
|
||
|
||
input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
if (e.shiftKey) return;
|
||
e.preventDefault();
|
||
window.sendMessage();
|
||
return;
|
||
}
|
||
|
||
if (e.key === "ArrowUp") {
|
||
const isSingleLine = !input.value.includes("\n");
|
||
const isAtBeginning = input.selectionStart === 0 && input.selectionEnd === 0;
|
||
|
||
if (isSingleLine || isAtBeginning) {
|
||
if (chatInputHistory.length > 0) {
|
||
e.preventDefault();
|
||
if (chatHistoryIndex === -1) {
|
||
temporaryCurrentInput = input.value;
|
||
chatHistoryIndex = chatInputHistory.length - 1;
|
||
} else if (chatHistoryIndex > 0) {
|
||
chatHistoryIndex--;
|
||
}
|
||
input.value = chatInputHistory[chatHistoryIndex];
|
||
updateInputHeightAndFade(input);
|
||
input.setSelectionRange(input.value.length, input.value.length);
|
||
}
|
||
}
|
||
} else if (e.key === "ArrowDown") {
|
||
const isSingleLine = !input.value.includes("\n");
|
||
const isAtEnd = input.selectionStart === input.value.length;
|
||
|
||
if (isSingleLine || isAtEnd) {
|
||
if (chatHistoryIndex !== -1) {
|
||
e.preventDefault();
|
||
if (chatHistoryIndex < chatInputHistory.length - 1) {
|
||
chatHistoryIndex++;
|
||
input.value = chatInputHistory[chatHistoryIndex];
|
||
} else {
|
||
chatHistoryIndex = -1;
|
||
input.value = temporaryCurrentInput;
|
||
}
|
||
updateInputHeightAndFade(input);
|
||
input.setSelectionRange(input.value.length, input.value.length);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
input.addEventListener("input", function() {
|
||
updateInputHeightAndFade(this);
|
||
chatHistoryIndex = -1;
|
||
});
|
||
}
|
||
|
||
if (fileInput) {
|
||
fileInput.addEventListener("change", (e) => {
|
||
if (e.target.files && e.target.files[0]) {
|
||
handleFileSelected(e.target.files[0]);
|
||
}
|
||
});
|
||
}
|
||
|
||
let dragCounter = 0;
|
||
|
||
window.addEventListener('dragenter', (e) => {
|
||
e.preventDefault();
|
||
dragCounter++;
|
||
if (overlay) {
|
||
overlay.classList.remove('hidden');
|
||
}
|
||
}, false);
|
||
|
||
window.addEventListener('dragleave', (e) => {
|
||
e.preventDefault();
|
||
dragCounter--;
|
||
if (dragCounter <= 0) {
|
||
dragCounter = 0;
|
||
if (overlay) {
|
||
overlay.classList.add('hidden');
|
||
}
|
||
}
|
||
}, false);
|
||
|
||
window.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
}, false);
|
||
|
||
window.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
dragCounter = 0;
|
||
if (overlay) {
|
||
overlay.classList.add('hidden');
|
||
}
|
||
|
||
const dt = e.dataTransfer;
|
||
if (dt && dt.files && dt.files[0]) {
|
||
handleFileSelected(dt.files[0]);
|
||
}
|
||
}, false);
|
||
});
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/chat/task_widget.js`
|
||
```js
|
||
/*
|
||
===============================================================================
|
||
FILE: modules/web_api/static/js/chat/task_widget.js
|
||
ROLE: Интерактивные виджеты задач и срезов СКУД (SNAPSHOTS_CARD) с чекбоксами.
|
||
===============================================================================
|
||
*/
|
||
|
||
window.activeTaskFilter = window.activeTaskFilter || 'IN_PROGRESS';
|
||
window.currentTasksCache = window.currentTasksCache || [];
|
||
|
||
function getAuthHeaders() {
|
||
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||
const headers = { 'Content-Type': 'application/json' };
|
||
if (token) {
|
||
headers['Authorization'] = 'Bearer ' + token;
|
||
}
|
||
return headers;
|
||
}
|
||
|
||
window.sendChatAction = function(actionText) {
|
||
const input = document.getElementById("user-input");
|
||
if (input && typeof sendMessage === "function") {
|
||
input.value = actionText;
|
||
if (typeof setInputLocked === "function") setInputLocked(false);
|
||
sendMessage();
|
||
}
|
||
};
|
||
|
||
// ============================================================================
|
||
// РЕНДЕРИНГ КАРТОЧКИ СРЕЗОВ СКУД (SNAPSHOTS_CARD) С ЧЕКБОКСАМИ
|
||
// ============================================================================
|
||
function renderSnapshotsCard(data) {
|
||
if (!data || !data.snapshots || !Array.isArray(data.snapshots)) return '';
|
||
const queryDate = data.query_date || 'выбранную дату';
|
||
const snapshots = data.snapshots;
|
||
|
||
if (snapshots.length === 0) {
|
||
return `
|
||
<div class="p-6 bg-slate-50 border border-slate-200 rounded-xl text-center text-xs text-slate-500 my-2">
|
||
📸 За дату <b>${queryDate}</b> сохраненных снапшотов не найдено.
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const rowsHtml = snapshots.map((s, idx) => {
|
||
const snapId = s.snapshot_id || `ID-${idx}`;
|
||
const snapTime = s.snapshot_time ? s.snapshot_time.split(' ')[1] || s.snapshot_time : '—';
|
||
const count = s.record_count || 0;
|
||
const isFinal = snapId.startsWith('Y');
|
||
|
||
if (isFinal) {
|
||
return `
|
||
<div class="p-2.5 bg-purple-50/60 rounded-lg border border-purple-200 shadow-sm flex items-center justify-between gap-3">
|
||
<div class="flex items-center gap-2.5 min-w-0">
|
||
<span class="w-4 flex justify-center text-purple-400" title="Итоговый срез защищен">
|
||
<i class="fa-solid fa-lock text-[11px]"></i>
|
||
</span>
|
||
<span class="font-mono text-xs font-bold px-2 py-0.5 rounded bg-purple-100 text-purple-800 border border-purple-300">
|
||
#${snapId}
|
||
</span>
|
||
<div class="flex items-center gap-3 text-xs text-slate-600">
|
||
<span class="flex items-center gap-1 font-semibold text-purple-900">
|
||
<i class="fa-regular fa-clock text-purple-600 text-[11px]"></i> ${snapTime}
|
||
</span>
|
||
<span class="flex items-center gap-1 text-slate-500">
|
||
<i class="fa-solid fa-users text-slate-400 text-[11px]"></i> ${count} записей
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<span class="text-[10px] font-bold text-purple-700 bg-purple-100 border border-purple-200 px-2 py-0.5 rounded">
|
||
Итоговый Y-срез
|
||
</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
return `
|
||
<div class="p-2.5 bg-white rounded-lg border border-slate-200 shadow-sm hover:border-slate-300 transition flex items-center justify-between gap-3">
|
||
<div class="flex items-center gap-2.5 min-w-0">
|
||
<input type="checkbox" value="${snapId}" onchange="window.updateSelectedSnapshots(this)"
|
||
class="snapshot-item-checkbox rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 w-4 h-4 cursor-pointer">
|
||
<span class="font-mono text-xs font-bold px-2 py-0.5 rounded bg-slate-100 text-slate-700 border border-slate-200">
|
||
#${snapId}
|
||
</span>
|
||
<div class="flex items-center gap-3 text-xs text-slate-600">
|
||
<span class="flex items-center gap-1 font-semibold text-slate-800">
|
||
<i class="fa-regular fa-clock text-indigo-500 text-[11px]"></i> ${snapTime}
|
||
</span>
|
||
<span class="flex items-center gap-1 text-slate-500">
|
||
<i class="fa-solid fa-users text-slate-400 text-[11px]"></i> ${count} записей
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<button type="button" onclick="window.sendChatAction('удали снапшот ${snapId}')"
|
||
class="p-1 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded border border-transparent hover:border-rose-200 transition"
|
||
title="Удалить срез">
|
||
<i class="fa-solid fa-trash-can text-xs"></i>
|
||
</button>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
return `
|
||
<div class="snapshots-widget-root w-full max-w-4xl mx-auto my-2 bg-slate-50 border border-slate-300 rounded-xl shadow-md overflow-hidden flex flex-col">
|
||
<div class="px-4 py-2.5 bg-white border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
|
||
<div class="flex items-center gap-2">
|
||
<div class="bg-indigo-600 text-white p-1.5 rounded-lg flex items-center justify-center">
|
||
<i class="fa-solid fa-camera text-xs"></i>
|
||
</div>
|
||
<div>
|
||
<span class="text-xs font-bold text-slate-800">Реестр срезов СКУД</span>
|
||
<span class="text-xs text-slate-500 ml-1">за ${queryDate}</span>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
<label class="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer select-none">
|
||
<input type="checkbox" onchange="window.toggleSelectAllSnapshots(this)" class="select-all-snapshots-cb rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 w-3.5 h-3.5">
|
||
<span>Выбрать все</span>
|
||
</label>
|
||
<span class="text-[11px] font-semibold bg-indigo-50 text-indigo-700 border border-indigo-200 px-2 py-0.5 rounded-full">
|
||
Срезов: ${snapshots.length}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="p-3 flex flex-col gap-2">
|
||
${rowsHtml}
|
||
</div>
|
||
|
||
<!-- ПОДВАЛ С КНОПКОЙ УДАЛЕНИЯ ВЫБРАННЫХ -->
|
||
<div class="snapshot-bulk-actions-footer hidden px-4 py-2 bg-rose-50/70 border-t border-rose-200 flex items-center justify-between">
|
||
<span class="text-xs text-rose-800 font-medium bulk-selected-counter">Выбрано: 0</span>
|
||
<button type="button" onclick="window.submitBulkDeleteSnapshots(this)"
|
||
class="px-3 py-1.5 bg-rose-600 hover:bg-rose-700 active:bg-rose-800 text-white font-bold rounded-lg text-xs flex items-center gap-1.5 transition shadow-sm">
|
||
<i class="fa-solid fa-trash-can"></i>
|
||
<span>Удалить выбранные</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ⭐️ Обработчики чекбоксов
|
||
window.toggleSelectAllSnapshots = function(masterCb) {
|
||
const root = masterCb.closest('.snapshots-widget-root');
|
||
if (!root) return;
|
||
const checkboxes = root.querySelectorAll('.snapshot-item-checkbox');
|
||
checkboxes.forEach(cb => cb.checked = masterCb.checked);
|
||
window.syncBulkDeleteFooter(root);
|
||
};
|
||
|
||
window.updateSelectedSnapshots = function(itemCb) {
|
||
const root = itemCb.closest('.snapshots-widget-root');
|
||
if (!root) return;
|
||
window.syncBulkDeleteFooter(root);
|
||
};
|
||
|
||
window.syncBulkDeleteFooter = function(root) {
|
||
const checkboxes = root.querySelectorAll('.snapshot-item-checkbox:checked');
|
||
const footer = root.querySelector('.snapshot-bulk-actions-footer');
|
||
const counter = root.querySelector('.bulk-selected-counter');
|
||
const masterCb = root.querySelector('.select-all-snapshots-cb');
|
||
const allCheckboxes = root.querySelectorAll('.snapshot-item-checkbox');
|
||
|
||
if (masterCb) {
|
||
masterCb.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
|
||
}
|
||
|
||
if (checkboxes.length > 0) {
|
||
if (footer) footer.classList.remove('hidden');
|
||
if (counter) counter.innerText = `Выбрано дневных срезов: ${checkboxes.length}`;
|
||
} else {
|
||
if (footer) footer.classList.add('hidden');
|
||
}
|
||
};
|
||
|
||
// Одиночная корзина в строке снапшота:
|
||
// onclick="window.sendChatAction('удали снапшот ${snapId}')"
|
||
|
||
// Кнопка пакетного удаления в подвале карточки:
|
||
window.submitBulkDeleteSnapshots = function(btnEl) {
|
||
const root = btnEl.closest('.snapshots-widget-root');
|
||
if (!root) return;
|
||
const selected = Array.from(root.querySelectorAll('.snapshot-item-checkbox:checked')).map(cb => cb.value);
|
||
if (selected.length === 0) return;
|
||
window.sendChatAction(`удали снапшоты ${selected.join(', ')}`);
|
||
};
|
||
|
||
// ============================================================================
|
||
// РЕНДЕРИНГ КАРТОЧКИ ЗАДАЧ (TASK_INTERACTIVE_CARD)
|
||
// ============================================================================
|
||
function renderInteractiveTaskCard(tasks) {
|
||
if (!tasks || !Array.isArray(tasks)) return '';
|
||
window.currentTasksCache = tasks;
|
||
|
||
const counts = {
|
||
ALL: tasks.length,
|
||
IN_PROGRESS: tasks.filter(t => t.status === 'IN_PROGRESS' || t.status === 'PROGRESS').length,
|
||
PLANNED: tasks.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED').length,
|
||
COMPLETED: tasks.filter(t => t.status === 'COMPLETED' || t.status === 'DONE').length
|
||
};
|
||
|
||
const currentFilter = window.activeTaskFilter || 'IN_PROGRESS';
|
||
|
||
const filteredTasks = tasks.filter(t => {
|
||
const s = (t.status || 'BACKLOG').toUpperCase();
|
||
if (currentFilter === 'ALL') return true;
|
||
if (currentFilter === 'IN_PROGRESS') return s === 'IN_PROGRESS' || s === 'PROGRESS';
|
||
if (currentFilter === 'PLANNED') return s === 'BACKLOG' || s === 'PLANNED';
|
||
if (currentFilter === 'COMPLETED') return s === 'COMPLETED' || s === 'DONE';
|
||
return true;
|
||
});
|
||
|
||
const getPrioBadge = (prio) => {
|
||
const p = (prio || 'MEDIUM').toUpperCase();
|
||
if (p === 'HIGH' || p === 'CRITICAL') return '<span class="text-[10px] px-2 py-0.5 rounded font-bold bg-rose-100 text-rose-700 border border-rose-200">🔥 HIGH</span>';
|
||
if (p === 'LOW') return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-slate-100 text-slate-600 border border-slate-200">☕ LOW</span>';
|
||
return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-amber-100 text-amber-700 border border-amber-200">⚡ MEDIUM</span>';
|
||
};
|
||
|
||
const getStatusBadge = (status) => {
|
||
const s = (status || 'BACKLOG').toUpperCase();
|
||
if (s === 'IN_PROGRESS' || s === 'PROGRESS') return '<span class="text-[10px] px-2 py-0.5 rounded font-bold bg-blue-50 text-blue-700 border border-blue-200">⚙️ В работе</span>';
|
||
if (s === 'COMPLETED' || s === 'DONE') return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">✓ Готово</span>';
|
||
return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-slate-50 text-slate-600 border border-slate-200">📋 В планах</span>';
|
||
};
|
||
|
||
const taskRows = filteredTasks.map(t => {
|
||
const id = t.id;
|
||
const title = t.title || 'Без названия';
|
||
const isDone = t.status === 'COMPLETED' || t.status === 'DONE';
|
||
const isInProgress = t.status === 'IN_PROGRESS' || t.status === 'PROGRESS';
|
||
|
||
const actionBtn = isInProgress
|
||
? `<button type="button" onclick="window.sendChatAction('заверши задачу ${id}')" class="px-2.5 py-1 text-xs font-semibold rounded bg-emerald-50 text-emerald-700 border border-emerald-300 hover:bg-emerald-100 transition-colors shadow-sm" title="Завершить задачу">✓ Готово</button>`
|
||
: (!isDone
|
||
? `<button type="button" onclick="window.sendChatAction('возьми в работу задачу ${id}')" class="px-2.5 py-1 text-xs font-semibold rounded bg-blue-50 text-blue-700 border border-blue-300 hover:bg-blue-100 transition-colors shadow-sm" title="Взять в работу">⚙️ В работу</button>`
|
||
: '');
|
||
|
||
return `
|
||
<div id="task-card-${id}" class="p-3 bg-white rounded-lg border border-slate-200 shadow-sm hover:border-slate-300 transition-all flex flex-col gap-2">
|
||
<div class="task-view-mode flex items-center justify-between gap-3">
|
||
<div class="flex items-center gap-2 flex-wrap flex-1 min-w-0">
|
||
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-slate-100 text-slate-700 border border-slate-200">#${id}</span>
|
||
<span class="text-sm font-medium text-slate-900 truncate" title="${title}">${title}</span>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 shrink-0">
|
||
${actionBtn}
|
||
<button type="button" onclick="window.openTaskInlineEditor(${id})" class="p-1 text-xs text-slate-500 hover:text-indigo-600 hover:bg-slate-50 rounded border border-slate-200 transition-colors" title="Редактировать">✏️</button>
|
||
<button type="button" onclick="window.sendChatAction('удали задачу ${id}')" class="p-1 text-xs text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded border border-slate-200 transition-colors" title="Удалить">🗑️</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="task-view-mode flex items-center gap-2 text-xs text-slate-500 flex-wrap">
|
||
${getPrioBadge(t.priority)}
|
||
${getStatusBadge(t.status)}
|
||
<span class="px-1.5 py-0.5 rounded bg-slate-50 border border-slate-200 text-slate-600 font-mono text-[11px]">${t.module || 'general'}</span>
|
||
${t.due_date ? `<span class="text-slate-500">📅 срок: <b>${t.due_date}</b></span>` : ''}
|
||
${t.created_at ? `<span class="text-slate-400">создана: ${t.created_at.split(' ')[0]}</span>` : ''}
|
||
</div>
|
||
|
||
<!-- ФОРМА ИНЛАЙН РЕДАКТИРОВАНИЯ -->
|
||
<div id="task-editor-${id}" class="hidden flex flex-col gap-2 pt-2 border-t border-slate-100">
|
||
<input type="text" id="task-edit-title-${id}" value="${title.replace(/"/g, '"')}" class="w-full text-xs px-2.5 py-1.5 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50" placeholder="Описание задачи..." />
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
<input type="date" id="task-edit-date-${id}" value="${t.due_date || ''}" class="text-xs px-2 py-1 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50" />
|
||
<select id="task-edit-prio-${id}" class="text-xs px-2 py-1 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50">
|
||
<option value="LOW" ${t.priority === 'LOW' ? 'selected' : ''}>☕ LOW</option>
|
||
<option value="MEDIUM" ${t.priority === 'MEDIUM' || !t.priority ? 'selected' : ''}>⚡ MEDIUM</option>
|
||
<option value="HIGH" ${t.priority === 'HIGH' ? 'selected' : ''}>🔥 HIGH</option>
|
||
<option value="CRITICAL" ${t.priority === 'CRITICAL' ? 'selected' : ''}>🚨 CRITICAL</option>
|
||
</select>
|
||
<button type="button" onclick="window.saveTaskInlineEdit(${id})" class="px-2.5 py-1 text-xs font-semibold rounded bg-indigo-600 text-white hover:bg-indigo-700 transition-colors shadow-sm">Сохранить</button>
|
||
<button type="button" onclick="window.closeTaskInlineEditor(${id})" class="px-2 py-1 text-xs font-medium rounded text-slate-500 hover:bg-slate-100 transition-colors">Отмена</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
const emptyState = `
|
||
<div class="p-8 text-center text-slate-400">
|
||
<div class="text-3xl mb-2">📭</div>
|
||
<div class="text-sm font-medium">Нет задач в категории «${currentFilter}»</div>
|
||
</div>
|
||
`;
|
||
|
||
return `
|
||
<div class="task-widget-root w-full max-w-4xl mx-auto my-2 bg-slate-50 border border-slate-300 rounded-xl shadow-md overflow-hidden flex flex-col">
|
||
<div class="px-4 py-3 bg-white border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
|
||
<div class="flex items-center gap-1.5 flex-wrap">
|
||
<button type="button" onclick="window.switchTaskFilter('IN_PROGRESS', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'IN_PROGRESS' ? 'bg-blue-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
|
||
⚙️ В работе (${counts.IN_PROGRESS})
|
||
</button>
|
||
<button type="button" onclick="window.switchTaskFilter('PLANNED', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'PLANNED' ? 'bg-indigo-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
|
||
📋 В планах (${counts.PLANNED})
|
||
</button>
|
||
<button type="button" onclick="window.switchTaskFilter('COMPLETED', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'COMPLETED' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
|
||
✓ Готово (${counts.COMPLETED})
|
||
</button>
|
||
<button type="button" onclick="window.switchTaskFilter('ALL', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'ALL' ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
|
||
Все (${counts.ALL})
|
||
</button>
|
||
</div>
|
||
|
||
<button type="button" onclick="window.toggleCreateTaskForm(this)" class="px-3 py-1.5 text-xs font-bold rounded-md bg-indigo-600 hover:bg-indigo-700 text-white transition-all shadow-sm flex items-center gap-1">
|
||
➕ Добавить задачу
|
||
</button>
|
||
</div>
|
||
|
||
<div class="new-task-creation-form hidden p-3 bg-indigo-50/70 border-b border-indigo-100 flex flex-col gap-2">
|
||
<div class="text-xs font-bold text-indigo-900">Новая задача:</div>
|
||
<input type="text" class="new-task-title w-full text-xs px-2.5 py-1.5 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white" placeholder="Что необходимо сделать?..." />
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
<input type="date" class="new-task-date text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white" />
|
||
<select class="new-task-prio text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white">
|
||
<option value="LOW">☕ LOW (Низкий)</option>
|
||
<option value="MEDIUM" selected>⚡ MEDIUM (Средний)</option>
|
||
<option value="HIGH">🔥 HIGH (Высокий)</option>
|
||
<option value="CRITICAL">🚨 CRITICAL (Критический)</option>
|
||
</select>
|
||
<select class="new-task-status text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white">
|
||
<option value="BACKLOG" selected>📋 В планы (Бэклог)</option>
|
||
<option value="IN_PROGRESS">⚙️ Сразу в работу</option>
|
||
</select>
|
||
<button type="button" onclick="window.submitCreateTask(this)" class="px-3 py-1 text-xs font-bold rounded bg-indigo-600 text-white hover:bg-indigo-700 transition-colors shadow-sm">Создать</button>
|
||
<button type="button" onclick="window.toggleCreateTaskForm(this)" class="px-2 py-1 text-xs font-medium rounded text-slate-500 hover:bg-slate-200 transition-colors">Отмена</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="p-3 overflow-y-auto max-h-[70vh] flex flex-col gap-2 task-rows-container">
|
||
${filteredTasks.length > 0 ? taskRows : emptyState}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
window.switchTaskFilter = function(filterName, btnEl) {
|
||
window.activeTaskFilter = filterName;
|
||
const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
|
||
if (root && window.currentTasksCache && window.currentTasksCache.length > 0) {
|
||
root.outerHTML = renderInteractiveTaskCard(window.currentTasksCache);
|
||
}
|
||
};
|
||
|
||
window.toggleCreateTaskForm = function(btnEl) {
|
||
const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
|
||
if (!root) return;
|
||
const form = root.querySelector('.new-task-creation-form');
|
||
if (form) {
|
||
form.classList.toggle('hidden');
|
||
if (!form.classList.contains('hidden')) {
|
||
const input = form.querySelector('.new-task-title');
|
||
if (input) input.focus();
|
||
}
|
||
}
|
||
};
|
||
|
||
window.submitCreateTask = async function(btnEl) {
|
||
const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
|
||
if (!root) return;
|
||
|
||
const titleInput = root.querySelector('.new-task-title');
|
||
const dateInput = root.querySelector('.new-task-date');
|
||
const prioInput = root.querySelector('.new-task-prio');
|
||
const statusInput = root.querySelector('.new-task-status');
|
||
|
||
if (!titleInput || !titleInput.value.trim()) {
|
||
alert('Введите описание задачи');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/api/v1/tasks', {
|
||
method: 'POST',
|
||
headers: getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
title: titleInput.value.trim(),
|
||
due_date: dateInput ? (dateInput.value || null) : null,
|
||
priority: prioInput ? prioInput.value : 'MEDIUM',
|
||
status: statusInput ? statusInput.value : 'BACKLOG'
|
||
})
|
||
});
|
||
|
||
if (res.ok) {
|
||
window.activeTaskFilter = (statusInput && statusInput.value === 'IN_PROGRESS') ? 'IN_PROGRESS' : 'PLANNED';
|
||
window.sendChatAction('покажи задачи');
|
||
} else {
|
||
const err = await res.json();
|
||
alert('Ошибка создания задачи: ' + (err.detail || 'Неизвестная ошибка'));
|
||
}
|
||
} catch (e) {
|
||
console.error('Ошибка создания задачи:', e);
|
||
alert('Сетевая ошибка при создании задачи');
|
||
}
|
||
};
|
||
|
||
window.openTaskInlineEditor = function(id) {
|
||
const card = document.getElementById(`task-card-${id}`);
|
||
if (!card) return;
|
||
card.querySelectorAll('.task-view-mode').forEach(el => el.classList.add('hidden'));
|
||
const editor = document.getElementById(`task-editor-${id}`);
|
||
if (editor) editor.classList.remove('hidden');
|
||
};
|
||
|
||
window.closeTaskInlineEditor = function(id) {
|
||
const card = document.getElementById(`task-card-${id}`);
|
||
if (!card) return;
|
||
card.querySelectorAll('.task-view-mode').forEach(el => el.classList.remove('hidden'));
|
||
const editor = document.getElementById(`task-editor-${id}`);
|
||
if (editor) editor.classList.add('hidden');
|
||
};
|
||
|
||
window.saveTaskInlineEdit = async function(id) {
|
||
const titleInput = document.getElementById(`task-edit-title-${id}`);
|
||
const dateInput = document.getElementById(`task-edit-date-${id}`);
|
||
const prioInput = document.getElementById(`task-edit-prio-${id}`);
|
||
|
||
if (!titleInput || !titleInput.value.trim()) {
|
||
alert('Описание задачи не может быть пустым');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(`/api/v1/tasks/${id}`, {
|
||
method: 'PATCH',
|
||
headers: getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
title: titleInput.value.trim(),
|
||
due_date: dateInput ? (dateInput.value || null) : null,
|
||
priority: prioInput ? prioInput.value : 'MEDIUM'
|
||
})
|
||
});
|
||
|
||
if (res.ok) {
|
||
window.sendChatAction('покажи задачи');
|
||
} else {
|
||
const err = await res.json();
|
||
alert('Ошибка обновления задачи: ' + (err.detail || 'Неизвестная ошибка'));
|
||
}
|
||
} catch (e) {
|
||
console.error('Ошибка сохранения задачи:', e);
|
||
alert('Сетевая ошибка при обновлении задачи');
|
||
}
|
||
};
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/manual_absences.js`
|
||
```js
|
||
/**
|
||
* ===============================================================================
|
||
* FILE: modules/web_api/static/js/manual_absences.js
|
||
* ROLE: Модальные окна "Мест. командир.", "Иное", универсальный автокомплит ФИО
|
||
* и синхронизация с боковой панелью SidebarManager.
|
||
* ===============================================================================
|
||
*/
|
||
|
||
let activeAbsenceType = 'LOCAL_TRIP'; // 'LOCAL_TRIP' или 'OTHER'
|
||
let reasonsCache = [];
|
||
|
||
async function loadAbsenceReasons() {
|
||
try {
|
||
const res = await fetch('/api/v1/manual-absences/reasons');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
reasonsCache = data.reasons || [];
|
||
}
|
||
} catch (e) {
|
||
console.error('Ошибка загрузки причин:', e);
|
||
}
|
||
}
|
||
|
||
// ⭐️ Открытие окна: режим создания (item = null) или редактирования (item = {...})
|
||
function openManualAbsenceModal(type, editItem = null) {
|
||
activeAbsenceType = type;
|
||
const isTrip = (type === 'LOCAL_TRIP');
|
||
const modal = document.getElementById('manual-absence-modal');
|
||
const titleEl = document.getElementById('manual-absence-modal-title-text');
|
||
const reasonBlock = document.getElementById('manual-absence-reason-block');
|
||
const reasonSelect = document.getElementById('manual-absence-reason-select');
|
||
|
||
const idInput = document.getElementById('manual-absence-id');
|
||
const fioInput = document.getElementById('manual-absence-fio-input');
|
||
const deptInput = document.getElementById('manual-absence-dept');
|
||
const posInput = document.getElementById('manual-absence-pos');
|
||
const startDateInput = document.getElementById('manual-absence-start-date');
|
||
const endDateInput = document.getElementById('manual-absence-end-date');
|
||
const errEl = document.getElementById('manual-absence-error');
|
||
|
||
if (errEl) errEl.classList.add('hidden');
|
||
|
||
if (reasonBlock && reasonSelect) {
|
||
if (isTrip) {
|
||
reasonBlock.classList.add('hidden');
|
||
} else {
|
||
reasonBlock.classList.remove('hidden');
|
||
reasonSelect.innerHTML = reasonsCache.map(r => `<option value="${r}">${r}</option>`).join('');
|
||
}
|
||
}
|
||
|
||
const today = new Date().toISOString().split('T')[0];
|
||
|
||
if (editItem) {
|
||
// Режим РЕДАКТИРОВАНИЯ
|
||
if (titleEl) titleEl.innerText = isTrip ? 'Изменение сроков командировки' : 'Изменение сроков отсутствия';
|
||
if (idInput) idInput.value = editItem.id;
|
||
if (fioInput) {
|
||
fioInput.value = editItem.fio;
|
||
fioInput.readOnly = true;
|
||
fioInput.classList.add('bg-slate-100', 'text-slate-500', 'cursor-not-allowed');
|
||
}
|
||
if (deptInput) deptInput.value = editItem.department || '';
|
||
if (posInput) posInput.value = editItem.position || '';
|
||
if (startDateInput) startDateInput.value = editItem.date_start ? editItem.date_start.replace(/\./g, '-') : today;
|
||
if (endDateInput) endDateInput.value = editItem.date_end ? editItem.date_end.replace(/\./g, '-') : today;
|
||
if (reasonSelect && editItem.reason) reasonSelect.value = editItem.reason;
|
||
} else {
|
||
// Режим СОЗДАНИЯ
|
||
if (titleEl) titleEl.innerText = isTrip ? 'Добавить в командировки' : 'Добавить отсутствие';
|
||
if (idInput) idInput.value = '';
|
||
if (fioInput) {
|
||
fioInput.value = '';
|
||
fioInput.readOnly = false;
|
||
fioInput.classList.remove('bg-slate-100', 'text-slate-500', 'cursor-not-allowed');
|
||
}
|
||
if (deptInput) deptInput.value = '';
|
||
if (posInput) posInput.value = '';
|
||
if (startDateInput) startDateInput.value = today;
|
||
if (endDateInput) endDateInput.value = today;
|
||
}
|
||
|
||
if (modal) modal.classList.remove('hidden');
|
||
}
|
||
|
||
function closeManualAbsenceModal() {
|
||
const modal = document.getElementById('manual-absence-modal');
|
||
if (modal) modal.classList.add('hidden');
|
||
}
|
||
|
||
async function submitManualAbsence(event) {
|
||
if (event) event.preventDefault();
|
||
const idVal = document.getElementById('manual-absence-id')?.value.trim();
|
||
const fio = document.getElementById('manual-absence-fio-input')?.value.trim();
|
||
const deptVal = document.getElementById('manual-absence-dept')?.value.trim() || '';
|
||
const posVal = document.getElementById('manual-absence-pos')?.value.trim() || '';
|
||
const startDateVal = document.getElementById('manual-absence-start-date')?.value || null;
|
||
const endDateVal = document.getElementById('manual-absence-end-date')?.value || null;
|
||
const reasonSelect = document.getElementById('manual-absence-reason-select');
|
||
const errEl = document.getElementById('manual-absence-error');
|
||
|
||
if (!fio) {
|
||
if (errEl) { errEl.innerText = 'Укажите ФИО сотрудника'; errEl.classList.remove('hidden'); }
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
absence_type: activeAbsenceType,
|
||
fio: fio,
|
||
department: deptVal,
|
||
position: posVal,
|
||
date_start: startDateVal,
|
||
date_end: endDateVal,
|
||
reason: activeAbsenceType === 'LOCAL_TRIP' ? 'Местная командировка' : (reasonSelect ? reasonSelect.value : 'Иное')
|
||
};
|
||
|
||
try {
|
||
let res;
|
||
if (idVal) {
|
||
// Редактирование
|
||
res = await fetch(`/api/v1/manual-absences/${idVal}`, {
|
||
method: 'PUT',
|
||
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: parseInt(idVal), date_start: startDateVal, date_end: endDateVal })
|
||
});
|
||
} else {
|
||
// Создание
|
||
res = await fetch('/api/v1/manual-absences/', {
|
||
method: 'POST',
|
||
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
}
|
||
|
||
if (res.ok) {
|
||
closeManualAbsenceModal();
|
||
if (typeof loadManualAbsencesView === 'function') {
|
||
loadManualAbsencesView(activeAbsenceType);
|
||
}
|
||
} else {
|
||
const data = await res.json();
|
||
if (errEl) { errEl.innerText = data.detail || 'Ошибка сохранения'; errEl.classList.remove('hidden'); }
|
||
}
|
||
} catch (e) {
|
||
if (errEl) { errEl.innerText = 'Сетевая ошибка при сохранении'; errEl.classList.remove('hidden'); }
|
||
}
|
||
}
|
||
|
||
// Универсальный автокомплит сотрудников
|
||
let searchTimeout = null;
|
||
function setupStaffAutocomplete(inputEl, suggestionsBoxId) {
|
||
const box = document.getElementById(suggestionsBoxId);
|
||
if (!inputEl || !box) return;
|
||
|
||
inputEl.addEventListener('input', function() {
|
||
const val = this.value.trim();
|
||
clearTimeout(searchTimeout);
|
||
if (val.length < 2) {
|
||
box.classList.add('hidden');
|
||
box.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
searchTimeout = setTimeout(async () => {
|
||
try {
|
||
const res = await fetch(`/api/v1/manual-absences/staff-autocomplete?q=${encodeURIComponent(val)}`);
|
||
if (!res.ok) return;
|
||
const items = await res.json();
|
||
if (items.length === 0) {
|
||
box.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
box.innerHTML = items.map(it => `
|
||
<div class="p-2 hover:bg-indigo-50 cursor-pointer border-b border-slate-100 flex flex-col text-xs"
|
||
onmousedown="selectStaffSuggestion('${escapeHtml(it.fio)}', '${escapeHtml(it.department)}', '${escapeHtml(it.position)}', '${inputEl.id}', '${suggestionsBoxId}')">
|
||
<span class="font-bold text-slate-800">${escapeHtml(it.fio)}</span>
|
||
<span class="text-[10px] text-slate-500">${escapeHtml(it.department)} · ${escapeHtml(it.position)}</span>
|
||
</div>
|
||
`).join('');
|
||
box.classList.remove('hidden');
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
}, 200);
|
||
});
|
||
|
||
document.addEventListener('click', (e) => {
|
||
if (!inputEl.contains(e.target) && !box.contains(e.target)) {
|
||
box.classList.add('hidden');
|
||
}
|
||
});
|
||
}
|
||
|
||
function selectStaffSuggestion(fio, dept, pos, targetInputId, boxId) {
|
||
const targetFioEl = document.getElementById(targetInputId);
|
||
if (targetFioEl) targetFioEl.value = fio;
|
||
|
||
if (targetInputId === 'rw-fio') {
|
||
const rwDept = document.getElementById('rw-dept');
|
||
if (rwDept && dept && dept !== '—') rwDept.value = dept;
|
||
} else if (targetInputId === 'manual-absence-fio-input') {
|
||
const deptInput = document.getElementById('manual-absence-dept');
|
||
const posInput = document.getElementById('manual-absence-pos');
|
||
if (deptInput) deptInput.value = dept;
|
||
if (posInput) posInput.value = pos;
|
||
}
|
||
|
||
const box = document.getElementById(boxId);
|
||
if (box) box.classList.add('hidden');
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
loadAbsenceReasons();
|
||
});
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/presence.js`
|
||
```js
|
||
/**
|
||
* ===============================================================================
|
||
* FILE: modules/web_api/static/js/presence.js
|
||
* ROLE: Контроллер экрана оперативного мониторинга («Кто в здании прямо сейчас»).
|
||
* ===============================================================================
|
||
*/
|
||
|
||
window.PresenceManager = {
|
||
records: [],
|
||
activeTab: 'ALL',
|
||
|
||
open() {
|
||
const modal = document.getElementById('presence-modal');
|
||
if (modal) {
|
||
modal.classList.remove('hidden');
|
||
this.bindEventsOnce();
|
||
this.loadData(false);
|
||
}
|
||
},
|
||
|
||
close() {
|
||
const modal = document.getElementById('presence-modal');
|
||
if (modal) modal.classList.add('hidden');
|
||
},
|
||
|
||
bindEventsOnce() {
|
||
if (this._eventsBound) return;
|
||
this._eventsBound = true;
|
||
|
||
document.getElementById('btn-close-presence-modal')?.addEventListener('click', () => this.close());
|
||
document.getElementById('btn-presence-force-refresh')?.addEventListener('click', () => this.loadData(true));
|
||
|
||
document.querySelectorAll('.presence-tab-btn').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
document.querySelectorAll('.presence-tab-btn').forEach(b => {
|
||
b.classList.remove('bg-white', 'shadow-xs', 'text-slate-800');
|
||
b.classList.add('text-slate-600');
|
||
});
|
||
const target = e.currentTarget;
|
||
target.classList.add('bg-white', 'shadow-xs', 'text-slate-800');
|
||
target.classList.remove('text-slate-600');
|
||
this.activeTab = target.getAttribute('data-tab') || 'ALL';
|
||
this.applyFilters();
|
||
});
|
||
});
|
||
|
||
document.getElementById('presence-search-input')?.addEventListener('input', () => this.applyFilters());
|
||
document.getElementById('presence-dept-filter')?.addEventListener('change', () => this.applyFilters());
|
||
},
|
||
|
||
async loadData(forceRefresh = false) {
|
||
const subtitle = document.getElementById('presence-modal-subtitle');
|
||
const btnRefresh = document.getElementById('btn-presence-force-refresh');
|
||
|
||
if (btnRefresh && forceRefresh) {
|
||
btnRefresh.disabled = true;
|
||
btnRefresh.innerHTML = '<span>⏳</span><span>Опрос СКУД...</span>';
|
||
}
|
||
|
||
try {
|
||
const url = `/api/v1/presence/live?force_refresh=${forceRefresh}`;
|
||
const res = await fetch(url, { headers: AuthManager.getAuthHeaders() });
|
||
if (!res.ok) throw new Error('Ошибка ответа сервера');
|
||
const data = await res.json();
|
||
this.render(data);
|
||
} catch (err) {
|
||
console.error('[Presence] Ошибка загрузки:', err);
|
||
if (subtitle) subtitle.textContent = 'Ошибка загрузки данных присутствия';
|
||
} finally {
|
||
if (btnRefresh) {
|
||
btnRefresh.disabled = false;
|
||
btnRefresh.innerHTML = '<span>🔄</span><span>Запросить из СКУД</span>';
|
||
}
|
||
}
|
||
},
|
||
|
||
render(data) {
|
||
this.records = data.records || [];
|
||
const m = data.metrics || {};
|
||
|
||
// Заголовок
|
||
const subtitle = document.getElementById('presence-modal-subtitle');
|
||
const sourceLabel = data.data_source === 'LIVE_MSSQL' ? '🟢 Онлайн срез Орион' : '⏱️ Локальная база СКУД';
|
||
if (subtitle) {
|
||
subtitle.textContent = `${sourceLabel} на ${data.latest_event_time || data.timestamp} | Всего в штате 1С: ${m.total_staff || 0} чел.`;
|
||
}
|
||
|
||
// Плашки метрик (строго сбалансированы)
|
||
document.getElementById('metric-total').textContent = m.total_staff || 0;
|
||
document.getElementById('metric-inside').textContent = m.inside || 0;
|
||
document.getElementById('metric-outside').textContent = m.outside || 0;
|
||
document.getElementById('metric-remote').textContent = m.remote || 0;
|
||
document.getElementById('metric-absence').textContent = m.official_absence || 0;
|
||
document.getElementById('metric-not-entered').textContent = m.not_entered || 0;
|
||
document.getElementById('metric-excluded').textContent = m.excluded || 0;
|
||
|
||
// Счетчики на табах
|
||
document.getElementById('tab-cnt-all').textContent = m.total_staff || 0;
|
||
document.getElementById('tab-cnt-inside').textContent = m.inside || 0;
|
||
document.getElementById('tab-cnt-outside').textContent = m.outside || 0;
|
||
document.getElementById('tab-cnt-remote').textContent = m.remote || 0;
|
||
document.getElementById('tab-cnt-absence').textContent = m.official_absence || 0;
|
||
document.getElementById('tab-cnt-not-entered').textContent = m.not_entered || 0;
|
||
document.getElementById('tab-cnt-excluded').textContent = m.excluded || 0;
|
||
|
||
// Выпадающий список подразделений
|
||
const deptSelect = document.getElementById('presence-dept-filter');
|
||
if (deptSelect) {
|
||
const depts = Array.from(new Set(this.records.map(r => r.department).filter(Boolean))).sort();
|
||
deptSelect.innerHTML = '<option value="ALL">Все подразделения</option>' +
|
||
depts.map(d => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join('');
|
||
}
|
||
|
||
this.applyFilters();
|
||
},
|
||
|
||
applyFilters() {
|
||
const search = (document.getElementById('presence-search-input')?.value || '').toLowerCase().trim();
|
||
const dept = document.getElementById('presence-dept-filter')?.value || 'ALL';
|
||
|
||
const filtered = this.records.filter(r => {
|
||
if (this.activeTab === 'INSIDE' && r.status !== 'INSIDE') return false;
|
||
if (this.activeTab === 'OUTSIDE' && r.status !== 'OUTSIDE') return false;
|
||
if (this.activeTab === 'REMOTE' && r.status !== 'REMOTE') return false;
|
||
if (this.activeTab === 'ABSENCE' && r.status !== 'OFFICIAL_ABSENCE') return false;
|
||
if (this.activeTab === 'NOT_ENTERED' && r.status !== 'NOT_ENTERED') return false;
|
||
if (this.activeTab === 'EXCLUDED' && r.status !== 'EXCLUDED') return false;
|
||
|
||
if (dept !== 'ALL' && r.department !== dept) return false;
|
||
if (search && !r.fio.toLowerCase().includes(search) && !r.position.toLowerCase().includes(search)) return false;
|
||
|
||
return true;
|
||
});
|
||
|
||
const tbody = document.getElementById('presence-table-body');
|
||
if (!tbody) return;
|
||
|
||
if (!filtered.length) {
|
||
tbody.innerHTML = '<tr><td colspan="5" class="py-10 text-center text-slate-400">Сотрудники не найдены</td></tr>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = filtered.map(r => {
|
||
let badgeClass = 'bg-slate-100 text-slate-700';
|
||
if (r.status === 'INSIDE') {
|
||
badgeClass = r.is_fligel
|
||
? 'bg-emerald-100 text-emerald-900 border border-emerald-300 font-bold'
|
||
: 'bg-emerald-100 text-emerald-800 font-bold';
|
||
} else if (r.status === 'OUTSIDE') {
|
||
badgeClass = 'bg-amber-100 text-amber-800 font-bold';
|
||
} else if (r.status === 'REMOTE') {
|
||
badgeClass = 'bg-blue-100 text-blue-800 font-semibold';
|
||
} else if (r.status === 'OFFICIAL_ABSENCE') {
|
||
badgeClass = 'bg-purple-100 text-purple-800 font-medium';
|
||
} else if (r.status === 'NOT_ENTERED') {
|
||
badgeClass = 'bg-rose-100 text-rose-800 font-semibold';
|
||
} else if (r.status === 'EXCLUDED') {
|
||
badgeClass = 'bg-slate-200 text-slate-600 font-mono text-[10px]';
|
||
}
|
||
|
||
return `
|
||
<tr class="hover:bg-slate-50 transition-colors">
|
||
<td class="py-2.5 px-3 font-medium text-slate-800">${escapeHtml(r.fio)}</td>
|
||
<td class="py-2.5 px-3 text-slate-600 font-mono text-[11px]">${escapeHtml(r.department)}</td>
|
||
<td class="py-2.5 px-3 text-slate-500">${escapeHtml(r.position)}</td>
|
||
<td class="py-2.5 px-3 text-center">
|
||
<span class="px-2 py-0.5 rounded-md text-[11px] ${badgeClass}">${escapeHtml(r.status_label)}</span>
|
||
</td>
|
||
<td class="py-2.5 px-3 text-center text-slate-600 font-mono font-semibold">${escapeHtml(r.last_time || '—')}</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
};
|
||
|
||
window.openPresenceModal = () => window.PresenceManager.open();
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/sidebar/core.js`
|
||
```js
|
||
/**
|
||
* Роутер табов сайдбара и общие утилиты.
|
||
*/
|
||
window.escapeHtml = window.escapeHtml || function (str) {
|
||
if (str === null || str === undefined) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
};
|
||
|
||
const SidebarManager = {
|
||
currentTab: 'tasks',
|
||
currentRegistrySubTab: 'exceptions',
|
||
|
||
init() {
|
||
this.bindEvents();
|
||
this.switchTab('tasks');
|
||
},
|
||
|
||
bindEvents() {
|
||
document.querySelectorAll('.sidebar-tab-btn, [data-tab]').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
const tab = e.currentTarget.dataset.tab;
|
||
if (tab) this.switchTab(tab);
|
||
});
|
||
});
|
||
},
|
||
|
||
switchTab(tabName) {
|
||
this.currentTab = tabName;
|
||
|
||
// Переключение стилей кнопок табов
|
||
document.querySelectorAll('.sidebar-tab-btn, [data-tab]').forEach(btn => {
|
||
const isActive = btn.dataset.tab === tabName;
|
||
btn.classList.toggle('text-indigo-600', isActive);
|
||
btn.classList.toggle('border-indigo-600', isActive);
|
||
btn.classList.toggle('font-bold', isActive);
|
||
btn.classList.toggle('text-slate-500', !isActive);
|
||
btn.classList.toggle('border-transparent', !isActive);
|
||
});
|
||
|
||
// Переключение видимости вьюх
|
||
document.querySelectorAll('.sidebar-view').forEach(view => {
|
||
view.classList.add('hidden');
|
||
});
|
||
|
||
const targetView = document.getElementById(`sidebar-view-${tabName}`);
|
||
if (targetView) targetView.classList.remove('hidden');
|
||
|
||
// Вызов профильного загрузчика
|
||
if (tabName === 'tasks' && typeof loadTasks === 'function') {
|
||
loadTasks();
|
||
} else if (tabName === 'snapshots' && typeof loadSnapshotsView === 'function') {
|
||
loadSnapshotsView();
|
||
} else if (tabName === 'registries' && typeof switchRegistrySubTab === 'function') {
|
||
switchRegistrySubTab(this.currentRegistrySubTab);
|
||
} else if (tabName === 'prompts' && typeof loadPromptsView === 'function') {
|
||
loadPromptsView();
|
||
} else if (tabName === 'context' && typeof loadContextView === 'function') {
|
||
loadContextView();
|
||
}
|
||
}
|
||
};
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
SidebarManager.init();
|
||
});
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/sidebar/prompts_context.js`
|
||
```js
|
||
/**
|
||
* Модуль вкладок "Промпт" и "Контекст".
|
||
*/
|
||
|
||
async function loadPromptsView() {
|
||
const container = document.getElementById('prompts-content-container');
|
||
if (!container) return;
|
||
|
||
container.innerHTML = `<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка промпта...</div>`;
|
||
|
||
try {
|
||
const res = await fetch('/api/v1/chat', {
|
||
method: 'POST',
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify({ message: "покажи системный промпт" })
|
||
});
|
||
const data = await res.json();
|
||
const text = data.response || "Промпт не получен";
|
||
|
||
container.innerHTML = `
|
||
<div class="p-3 bg-white border border-slate-200 rounded-xl shadow-xs space-y-2">
|
||
<div class="text-[11px] font-bold text-slate-600 flex items-center justify-between">
|
||
<span>Текущий системный промпт</span>
|
||
<button onclick="window.sendChatAction && window.sendChatAction('action:open_editor')" class="text-indigo-600 hover:text-indigo-800 text-[10px]">
|
||
<i class="fa-solid fa-pen-to-square"></i> Редактор
|
||
</button>
|
||
</div>
|
||
<pre class="text-[11px] font-mono text-slate-700 bg-slate-50 p-2.5 rounded-lg overflow-x-auto whitespace-pre-wrap leading-relaxed max-h-[65vh] border border-slate-100">${escapeHtml(text)}</pre>
|
||
</div>
|
||
`;
|
||
} catch (e) {
|
||
container.innerHTML = `<div class="text-center py-8 text-rose-500 text-xs">Не удалось загрузить системный промпт</div>`;
|
||
}
|
||
}
|
||
|
||
async function loadContextView() {
|
||
const container = document.getElementById('context-content-container');
|
||
if (!container) return;
|
||
|
||
container.innerHTML = `<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка сессии...</div>`;
|
||
|
||
try {
|
||
const res = await fetch('/api/v1/context/state?session_id=web_session_main', { headers: AuthManager.getAuthHeaders() });
|
||
const data = await res.json();
|
||
|
||
container.innerHTML = `
|
||
<div class="p-3 bg-white border border-slate-200 rounded-xl shadow-xs space-y-3">
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-xs font-bold text-slate-800">Сессия: web_session_main</span>
|
||
<span class="px-2 py-0.5 text-[10px] font-bold rounded-full ${data.active_state !== 'IDLE' ? 'bg-amber-100 text-amber-800' : 'bg-slate-100 text-slate-600'}">
|
||
${escapeHtml(data.active_state)}
|
||
</span>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-2 text-center">
|
||
<div class="p-2 bg-slate-50 rounded-lg border border-slate-100">
|
||
<div class="text-xs font-bold text-slate-700">${data.total_messages || 0}</div>
|
||
<div class="text-[10px] text-slate-400">Всего сообщений</div>
|
||
</div>
|
||
<div class="p-2 bg-slate-50 rounded-lg border border-slate-100">
|
||
<div class="text-xs font-bold text-indigo-600">${data.ephemeral_messages || 0}</div>
|
||
<div class="text-[10px] text-slate-400">Служебных (UI)</div>
|
||
</div>
|
||
</div>
|
||
<div class="space-y-1.5 pt-2 border-t border-slate-100">
|
||
<button onclick="purgeEphemeralMessages()" class="w-full py-2 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 rounded-lg text-xs font-semibold transition flex items-center justify-center gap-1.5">
|
||
<i class="fa-solid fa-broom text-[11px]"></i> Очистить служебные карточки
|
||
</button>
|
||
<button onclick="clearAllChatContext()" class="w-full py-2 bg-rose-50 hover:bg-rose-100 text-rose-700 rounded-lg text-xs font-semibold transition flex items-center justify-center gap-1.5">
|
||
<i class="fa-solid fa-trash-can text-[11px]"></i> Полный сброс контекста
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} catch (e) {
|
||
container.innerHTML = `<div class="text-center py-8 text-rose-500 text-xs">Не удалось загрузить состояние сессии</div>`;
|
||
}
|
||
}
|
||
|
||
async function purgeEphemeralMessages() {
|
||
try {
|
||
const res = await fetch('/api/v1/context/purge-ephemeral', {
|
||
method: 'POST',
|
||
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ session_id: 'web_session_main' })
|
||
});
|
||
if (res.ok) loadContextView();
|
||
} catch (e) { alert('Ошибка сети'); }
|
||
}
|
||
|
||
async function clearAllChatContext() {
|
||
if (!confirm('Полностью очистить историю сообщений диалога?')) return;
|
||
try {
|
||
const res = await fetch('/api/v1/context/clear-all', {
|
||
method: 'POST',
|
||
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ session_id: 'web_session_main' })
|
||
});
|
||
if (res.ok) location.reload();
|
||
} catch (e) { alert('Ошибка сети'); }
|
||
}
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/sidebar/registries.js`
|
||
```js
|
||
/**
|
||
* Модуль вкладок "Реестры": исключения, удаленщики, командировки, флигель.
|
||
*/
|
||
|
||
window.openAddExceptionModal = function(category) {
|
||
const modal = document.getElementById("exception-modal");
|
||
const catInput = document.getElementById("exception-category-input");
|
||
const valInput = document.getElementById("exception-value-input");
|
||
const commInput = document.getElementById("exception-comment-input");
|
||
const headerEl = document.getElementById("exception-modal-header-text");
|
||
const labelEl = document.getElementById("exception-value-label");
|
||
const errEl = document.getElementById("exception-error-msg");
|
||
|
||
if (!modal) return;
|
||
if (errEl) errEl.classList.add("hidden");
|
||
if (catInput) catInput.value = category;
|
||
if (valInput) { valInput.value = ""; valInput.focus(); }
|
||
if (commInput) commInput.value = "";
|
||
|
||
const titles = {
|
||
'include_fio': 'Белый список (ФИО)',
|
||
'fio': 'Исключенный сотрудник (ФИО)',
|
||
'departments': 'Исключенный отдел',
|
||
'positions': 'Исключенная должность',
|
||
'turnstile_fio': 'Правый турникет (ФИО)',
|
||
'turnstile_departments': 'Правый турникет (Отделы)',
|
||
'fligel_fio': 'Флигель (ФИО)',
|
||
'fligel_departments': 'Флигель (Отделы)'
|
||
};
|
||
|
||
const isDept = category.includes('department');
|
||
const isPos = category.includes('position');
|
||
|
||
if (headerEl) headerEl.innerText = "Добавить в " + (titles[category] || "реестр");
|
||
if (labelEl) {
|
||
labelEl.innerText = isDept ? 'Название подразделения:' : (isPos ? 'Название должности:' : 'ФИО сотрудника:');
|
||
}
|
||
|
||
modal.classList.remove("hidden");
|
||
};
|
||
|
||
window.closeExceptionModal = function() {
|
||
const modal = document.getElementById("exception-modal");
|
||
if (modal) modal.classList.add("hidden");
|
||
};
|
||
|
||
window.submitExceptionModalForm = async function(e) {
|
||
e.preventDefault();
|
||
const cat = document.getElementById("exception-category-input")?.value;
|
||
const val = document.getElementById("exception-value-input")?.value.trim();
|
||
const comm = document.getElementById("exception-comment-input")?.value.trim() || "";
|
||
const errEl = document.getElementById("exception-error-msg");
|
||
|
||
if (!cat || !val) {
|
||
if (errEl) { errEl.innerText = "Заполните поле"; errEl.classList.remove("hidden"); }
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch("/api/v1/exceptions", {
|
||
method: "POST",
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify({ category: cat, value: val, comment: comm })
|
||
});
|
||
if (res.ok) {
|
||
closeExceptionModal();
|
||
loadExceptionsView();
|
||
} else {
|
||
const data = await res.json();
|
||
if (errEl) { errEl.innerText = data.detail || "Ошибка сохранения"; errEl.classList.remove("hidden"); }
|
||
}
|
||
} catch (err) {
|
||
if (errEl) { errEl.innerText = "Ошибка соединения"; errEl.classList.remove("hidden"); }
|
||
}
|
||
};
|
||
|
||
function switchRegistrySubTab(subTab) {
|
||
if (window.SidebarManager) {
|
||
SidebarManager.currentRegistrySubTab = subTab;
|
||
}
|
||
|
||
document.querySelectorAll('.registry-subtab-btn').forEach(btn => {
|
||
const isActive = btn.dataset.subtab === subTab;
|
||
btn.classList.toggle('text-indigo-600', isActive);
|
||
btn.classList.toggle('bg-white', isActive);
|
||
btn.classList.toggle('shadow-xs', isActive);
|
||
btn.classList.toggle('font-bold', isActive);
|
||
btn.classList.toggle('text-slate-600', !isActive);
|
||
});
|
||
|
||
if (subTab === 'exceptions') loadExceptionsView();
|
||
else if (subTab === 'remote') loadRemoteWorkersView();
|
||
else if (subTab === 'local_trip') loadManualAbsencesView('LOCAL_TRIP');
|
||
else if (subTab === 'other') loadManualAbsencesView('OTHER');
|
||
}
|
||
|
||
async function loadExceptionsView() {
|
||
const container = document.getElementById('registry-content-container');
|
||
if (!container) return;
|
||
|
||
container.innerHTML = '<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка правил...</div>';
|
||
|
||
try {
|
||
const res = await fetch('/api/v1/exceptions', { headers: AuthManager.getAuthHeaders() });
|
||
if (!res.ok) throw new Error('Ошибка сети');
|
||
const data = await res.json();
|
||
renderExceptionsView(data || {});
|
||
} catch (e) {
|
||
container.innerHTML = '<div class="text-center py-8 text-rose-500 text-xs">Не удалось загрузить реестры исключений</div>';
|
||
}
|
||
}
|
||
|
||
function renderExceptionsView(exceptions) {
|
||
const container = document.getElementById('registry-content-container');
|
||
if (!container) return;
|
||
|
||
const renderCard = (title, list, category, badgeClass, subBadge) => {
|
||
badgeClass = badgeClass || 'bg-slate-100 text-slate-700';
|
||
subBadge = subBadge || '';
|
||
|
||
let tags = '<span class="text-[11px] text-slate-400 italic">Список пуст</span>';
|
||
if (list && list.length > 0) {
|
||
tags = list.map(item => {
|
||
const val = escapeHtml(item);
|
||
return '<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-lg text-xs font-medium ' + badgeClass + '">' +
|
||
'<span>' + val + '</span>' +
|
||
'<button data-cat="' + category + '" data-val="' + val + '" class="btn-remove-exc text-slate-400 hover:text-rose-500 transition">' +
|
||
'<i class="fa-solid fa-xmark text-[10px]"></i>' +
|
||
'</button>' +
|
||
'</span>';
|
||
}).join('');
|
||
}
|
||
|
||
const count = list ? list.length : 0;
|
||
return '<div class="p-3 bg-white rounded-xl border border-slate-200 shadow-xs mb-3">' +
|
||
'<div class="flex items-center justify-between mb-2">' +
|
||
'<div class="flex items-center gap-1.5">' +
|
||
'<span class="text-xs font-bold text-slate-800">' + title + ' (' + count + ')</span>' +
|
||
subBadge +
|
||
'</div>' +
|
||
'<button data-add-cat="' + category + '" class="btn-add-exc text-xs font-bold text-indigo-600 hover:text-indigo-800">+ Добавить</button>' +
|
||
'</div>' +
|
||
'<div class="flex flex-wrap gap-1.5">' + tags + '</div>' +
|
||
'</div>';
|
||
};
|
||
|
||
container.innerHTML =
|
||
renderCard('Белый список (ФИО)', exceptions.include_fio, 'include_fio', 'bg-indigo-50 text-indigo-700') +
|
||
renderCard('Исключенные сотрудники (ФИО)', exceptions.fio, 'fio') +
|
||
renderCard('Исключенные отделы', exceptions.departments, 'departments') +
|
||
renderCard('Исключенные должности', exceptions.positions, 'positions') +
|
||
renderCard('Пр. турникет (ФИО)', exceptions.turnstile_fio, 'turnstile_fio', 'bg-emerald-50 text-emerald-700 border border-emerald-200', '<span class="px-1.5 py-0.5 bg-emerald-50 text-emerald-700 rounded text-[9px] font-bold border border-emerald-200">Оба турникета</span>') +
|
||
renderCard('Пр. турникет (Отделы)', exceptions.turnstile_departments, 'turnstile_departments', 'bg-emerald-50 text-emerald-700 border border-emerald-200', '<span class="px-1.5 py-0.5 bg-emerald-50 text-emerald-700 rounded text-[9px] font-bold border border-emerald-200">Оба турникета</span>') +
|
||
renderCard('Флигель (ФИО)', exceptions.fligel_fio, 'fligel_fio', 'bg-indigo-50 text-indigo-700 border border-indigo-200', '<span class="px-1.5 py-0.5 bg-indigo-50 text-indigo-700 rounded text-[9px] font-bold border border-indigo-200">Дверь 23</span>') +
|
||
renderCard('Флигель (Отделы)', exceptions.fligel_departments, 'fligel_departments', 'bg-indigo-50 text-indigo-700 border border-indigo-200', '<span class="px-1.5 py-0.5 bg-indigo-50 text-indigo-700 rounded text-[9px] font-bold border border-indigo-200">Дверь 23</span>');
|
||
|
||
container.querySelectorAll('.btn-add-exc').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
openAddExceptionModal(e.currentTarget.getAttribute('data-add-cat'));
|
||
});
|
||
});
|
||
|
||
container.querySelectorAll('.btn-remove-exc').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
const target = e.currentTarget;
|
||
removeExceptionItem(target.getAttribute('data-cat'), target.getAttribute('data-val'));
|
||
});
|
||
});
|
||
}
|
||
|
||
async function removeExceptionItem(category, value) {
|
||
if (!confirm('Удалить "' + value + '" из категории "' + category + '"?')) return;
|
||
try {
|
||
const res = await fetch('/api/v1/exceptions?category=' + encodeURIComponent(category) + '&value=' + encodeURIComponent(value), {
|
||
method: 'DELETE',
|
||
headers: AuthManager.getAuthHeaders()
|
||
});
|
||
if (res.ok) loadExceptionsView();
|
||
else alert('Ошибка при удалении');
|
||
} catch (e) {
|
||
alert('Ошибка сети');
|
||
}
|
||
}
|
||
|
||
async function loadRemoteWorkersView() {
|
||
const container = document.getElementById('registry-content-container');
|
||
if (!container) return;
|
||
|
||
container.innerHTML = '<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка удаленщиков...</div>';
|
||
|
||
try {
|
||
const res = await fetch('/api/v1/remote-workers', { headers: AuthManager.getAuthHeaders() });
|
||
const data = await res.json();
|
||
const workers = data.workers || [];
|
||
|
||
let listHtml = '';
|
||
if (workers.length === 0) {
|
||
listHtml = '<div class="text-center py-8 text-xs text-slate-400 bg-white border border-slate-200 rounded-xl p-4">Список удаленщиков пуст</div>';
|
||
} else {
|
||
listHtml = workers.map(w => {
|
||
const fio = escapeHtml(w.fio);
|
||
const dept = escapeHtml(w.department || 'Все');
|
||
const dFrom = escapeHtml(w.date_from || '—');
|
||
const dTo = escapeHtml(w.date_to || 'бессрочно');
|
||
|
||
return '<div class="p-3 bg-white border border-slate-200 rounded-xl shadow-xs text-xs flex flex-col gap-1.5">' +
|
||
'<div class="flex items-center justify-between">' +
|
||
'<span class="font-bold text-slate-800">' + fio + '</span>' +
|
||
'<div class="flex items-center gap-1.5">' +
|
||
'<button data-edit-fio="' + fio + '" data-edit-dept="' + (w.department || '') + '" data-edit-from="' + (w.date_from || '') + '" data-edit-to="' + (w.date_to || '') + '" class="btn-edit-remote text-slate-400 hover:text-emerald-600 transition" title="Редактировать"><i class="fa-solid fa-pen-to-square"></i></button>' +
|
||
'<button data-del-fio="' + fio + '" class="btn-del-remote text-slate-400 hover:text-rose-500 transition" title="Удалить"><i class="fa-solid fa-trash-can"></i></button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="flex items-center justify-between text-[11px] text-slate-500">' +
|
||
'<span>' + dept + '</span>' +
|
||
'<span class="font-mono text-[10px] bg-slate-100 px-1.5 py-0.5 rounded">' + dFrom + ' по ' + dTo + '</span>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}).join('');
|
||
}
|
||
|
||
container.innerHTML =
|
||
'<div class="flex items-center justify-between px-1 mb-2">' +
|
||
'<span class="text-xs font-bold text-slate-700">Удаленные сотрудники: ' + workers.length + '</span>' +
|
||
'<button id="btn-add-remote" class="px-2.5 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow-xs transition">+ Добавить</button>' +
|
||
'</div>' +
|
||
'<div class="space-y-2">' + listHtml + '</div>';
|
||
|
||
const addBtn = document.getElementById('btn-add-remote');
|
||
if (addBtn) {
|
||
addBtn.addEventListener('click', () => openRemoteWorkerModal('ADD'));
|
||
}
|
||
|
||
container.querySelectorAll('.btn-edit-remote').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
const t = e.currentTarget;
|
||
openRemoteWorkerModal('EDIT', t.getAttribute('data-edit-fio'), t.getAttribute('data-edit-dept'), t.getAttribute('data-edit-from'), t.getAttribute('data-edit-to'));
|
||
});
|
||
});
|
||
|
||
container.querySelectorAll('.btn-del-remote').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
deleteRemoteWorkerItem(e.currentTarget.getAttribute('data-del-fio'));
|
||
});
|
||
});
|
||
} catch (e) {
|
||
container.innerHTML = '<div class="text-center py-8 text-rose-500 text-xs">Не удалось загрузить удаленщиков</div>';
|
||
}
|
||
}
|
||
|
||
async function deleteRemoteWorkerItem(fio) {
|
||
if (!confirm('Удалить сотрудника "' + fio + '"?')) return;
|
||
try {
|
||
const res = await fetch('/api/v1/remote-workers?fio=' + encodeURIComponent(fio), {
|
||
method: 'DELETE',
|
||
headers: AuthManager.getAuthHeaders()
|
||
});
|
||
if (res.ok) loadRemoteWorkersView();
|
||
} catch (e) {
|
||
alert('Ошибка сети');
|
||
}
|
||
}
|
||
|
||
async function loadManualAbsencesView(type) {
|
||
const container = document.getElementById('registry-content-container');
|
||
if (!container) return;
|
||
|
||
const isTrip = (type === 'LOCAL_TRIP');
|
||
const titleText = isTrip ? 'Местные командировки' : 'Иные причины';
|
||
const btnColor = isTrip ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-purple-600 hover:bg-purple-700';
|
||
|
||
container.innerHTML = '<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...</div>';
|
||
|
||
try {
|
||
const res = await fetch('/api/v1/manual-absences/?type=' + encodeURIComponent(type), {
|
||
headers: AuthManager.getAuthHeaders()
|
||
});
|
||
const data = await res.json();
|
||
const items = data.items || [];
|
||
|
||
let listHtml = '';
|
||
if (items.length === 0) {
|
||
listHtml = '<div class="text-center py-8 text-xs text-slate-400 bg-white border border-slate-200 rounded-xl p-4">Нет записей</div>';
|
||
} else {
|
||
listHtml = items.map(it => {
|
||
const fio = escapeHtml(it.fio);
|
||
const reason = escapeHtml(it.reason || '');
|
||
const dept = escapeHtml(it.department || '—');
|
||
const dStart = it.date_start ? it.date_start.replace(/-/g, '.') : '';
|
||
const dEnd = it.date_end ? it.date_end.replace(/-/g, '.') : '';
|
||
|
||
// Форматирование срока
|
||
let dateBadge = '';
|
||
if (dStart && dEnd && dStart === dEnd) {
|
||
dateBadge = dStart;
|
||
} else if (dStart && dEnd) {
|
||
dateBadge = `${dStart} — ${dEnd}`;
|
||
} else if (dEnd) {
|
||
dateBadge = `по ${dEnd}`;
|
||
} else if (dStart) {
|
||
dateBadge = `с ${dStart}`;
|
||
} else {
|
||
dateBadge = 'бессрочно';
|
||
}
|
||
|
||
const badgeBg = isTrip
|
||
? 'bg-indigo-50 text-indigo-700 border-indigo-200'
|
||
: 'bg-purple-50 text-purple-700 border-purple-200';
|
||
|
||
return `
|
||
<div class="p-3 bg-white border border-slate-200 rounded-xl shadow-xs text-xs flex flex-col gap-1.5 hover:border-slate-300 transition">
|
||
<!-- 1-я строка: ФИО и кнопки управления -->
|
||
<div class="flex items-center justify-between gap-2">
|
||
<span class="font-bold text-slate-800 truncate" title="${fio}">${fio}</span>
|
||
<div class="flex items-center gap-1 shrink-0">
|
||
<button data-edit-id="${it.id}" data-edit-fio="${fio}" data-edit-start="${it.date_start || ''}" data-edit-end="${it.date_end || ''}"
|
||
class="btn-edit-abs text-slate-400 hover:text-indigo-600 p-1 transition" title="Редактировать сроки">
|
||
<i class="fa-solid fa-pen-to-square"></i>
|
||
</button>
|
||
<button data-del-id="${it.id}"
|
||
class="btn-del-abs text-slate-400 hover:text-rose-500 p-1 transition" title="Удалить">
|
||
<i class="fa-solid fa-trash-can"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
${!isTrip && reason ? `<div class="text-[11px] text-slate-600 font-medium">${reason}</div>` : ''}
|
||
|
||
<!-- 2-я строка: Отдел слева и Четкий срок отсутствия справа -->
|
||
<div class="flex items-center justify-between text-[11px] pt-1 border-t border-slate-100">
|
||
<span class="text-slate-400 truncate max-w-[200px]" title="${dept}">${dept}</span>
|
||
<span class="font-mono text-[10px] px-2 py-0.5 rounded-md border font-semibold shrink-0 ${badgeBg}">
|
||
<i class="fa-regular fa-calendar-days mr-1 text-[9px]"></i>${dateBadge}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<div class="flex items-center justify-between px-1 mb-2">
|
||
<span class="text-xs font-bold text-slate-700">${titleText}: ${items.length}</span>
|
||
<button id="btn-add-absence" class="px-2.5 py-1 ${btnColor} text-white rounded-lg text-xs font-bold shadow-xs transition">+ Добавить</button>
|
||
</div>
|
||
<div class="space-y-2">${listHtml}</div>
|
||
`;
|
||
|
||
const addBtn = document.getElementById('btn-add-absence');
|
||
if (addBtn) {
|
||
addBtn.addEventListener('click', () => openManualAbsenceModal(type));
|
||
}
|
||
|
||
// Слушатели кнопок редактирования
|
||
container.querySelectorAll('.btn-edit-abs').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
const t = e.currentTarget;
|
||
const id = t.getAttribute('data-edit-id');
|
||
const fio = t.getAttribute('data-edit-fio');
|
||
const start = t.getAttribute('data-edit-start');
|
||
const end = t.getAttribute('data-edit-end');
|
||
|
||
openManualAbsenceModal(type, {
|
||
id: id,
|
||
fio: fio,
|
||
date_start: start,
|
||
date_end: end
|
||
});
|
||
});
|
||
});
|
||
|
||
// Слушатели кнопок удаления
|
||
container.querySelectorAll('.btn-del-abs').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
deleteManualAbsenceRecord(e.currentTarget.getAttribute('data-del-id'), type);
|
||
});
|
||
});
|
||
} catch (e) {
|
||
container.innerHTML = '<div class="text-center py-8 text-rose-500 text-xs">Ошибка загрузки</div>';
|
||
}
|
||
}
|
||
|
||
// Быстрое модальное окно / диалог изменения дат
|
||
async function openEditAbsenceDatesModal(id, fio, dateStart, dateEnd, type) {
|
||
const newEnd = prompt(`Укажите новую дату окончания для сотрудника:\n${fio}\n(формат ГГГГ-ММ-ДД):`, dateEnd || '');
|
||
if (newEnd === null) return;
|
||
|
||
try {
|
||
const res = await fetch(`/api/v1/manual-absences/${id}`, {
|
||
method: 'PUT',
|
||
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: parseInt(id),
|
||
date_start: dateStart || null,
|
||
date_end: newEnd.trim() || null
|
||
})
|
||
});
|
||
|
||
if (res.ok) {
|
||
loadManualAbsencesView(type);
|
||
} else {
|
||
alert('Не удалось обновить дату');
|
||
}
|
||
} catch (err) {
|
||
alert('Ошибка сети при обновлении');
|
||
}
|
||
}
|
||
|
||
async function deleteManualAbsenceRecord(id, type) {
|
||
if (!confirm('Удалить эту запись?')) return;
|
||
try {
|
||
const res = await fetch('/api/v1/manual-absences/' + encodeURIComponent(id), {
|
||
method: 'DELETE',
|
||
headers: AuthManager.getAuthHeaders()
|
||
});
|
||
if (res.ok) loadManualAbsencesView(type);
|
||
} catch (e) {
|
||
alert('Ошибка сети');
|
||
}
|
||
}
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/sidebar/snapshots.js`
|
||
```js
|
||
/**
|
||
* Модуль вкладки "Срезы": список снапшотов по диапазону дат, создание, инспекция и генерация отчетов.
|
||
*/
|
||
|
||
// 1. Вспомогательные функции форматирования дат
|
||
function formatDateDDMMYYYY(d) {
|
||
const day = String(d.getDate()).padStart(2, '0');
|
||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||
const year = d.getFullYear();
|
||
return `${day}.${month}.${year}`;
|
||
}
|
||
|
||
function formatDateToISO(d) {
|
||
const year = d.getFullYear();
|
||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||
const day = String(d.getDate()).padStart(2, '0');
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
function isoToBackendFormat(isoStr) {
|
||
if (!isoStr) return '';
|
||
if (isoStr.includes('.')) return isoStr;
|
||
const [y, m, d] = isoStr.split('-');
|
||
return `${d}.${m}.${y}`;
|
||
}
|
||
|
||
// 2. Автозаполнение полей создания среза текущими датой и временем
|
||
function initManualSnapshotInputs() {
|
||
const now = new Date();
|
||
const dateInput = document.getElementById('manual-snapshot-date');
|
||
const timeInput = document.getElementById('manual-snapshot-time');
|
||
|
||
if (dateInput) {
|
||
dateInput.value = formatDateDDMMYYYY(now);
|
||
}
|
||
if (timeInput) {
|
||
const hh = String(now.getHours()).padStart(2, '0');
|
||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||
timeInput.value = `${hh}:${mm}`;
|
||
}
|
||
}
|
||
|
||
// 3. Быстрые пресеты периода
|
||
window.setSnapshotDatePreset = function(preset) {
|
||
const today = new Date();
|
||
const fromInput = document.getElementById('snapshots-date-from');
|
||
const toInput = document.getElementById('snapshots-date-to');
|
||
|
||
if (!fromInput || !toInput) return;
|
||
|
||
if (preset === 'today') {
|
||
const str = formatDateToISO(today);
|
||
fromInput.value = str;
|
||
toInput.value = str;
|
||
} else if (preset === 'yesterday') {
|
||
const y = new Date();
|
||
y.setDate(today.getDate() - 1);
|
||
const str = formatDateToISO(y);
|
||
fromInput.value = str;
|
||
toInput.value = str;
|
||
} else if (preset === 'days3') {
|
||
const start = new Date();
|
||
start.setDate(today.getDate() - 2);
|
||
fromInput.value = formatDateToISO(start);
|
||
toInput.value = formatDateToISO(today);
|
||
} else if (preset === 'days7') {
|
||
const start = new Date();
|
||
start.setDate(today.getDate() - 6);
|
||
fromInput.value = formatDateToISO(start);
|
||
toInput.value = formatDateToISO(today);
|
||
}
|
||
|
||
loadSnapshotsView();
|
||
};
|
||
|
||
// 4. Загрузка списка срезов с группировкой и сортировкой
|
||
async function loadSnapshotsView() {
|
||
// Гарантированно заполняем поля даты и времени создания среза
|
||
initManualSnapshotInputs();
|
||
|
||
const listContainer = document.getElementById('snapshots-list');
|
||
const countBadge = document.getElementById('snapshots-count-badge');
|
||
const fromInput = document.getElementById('snapshots-date-from');
|
||
const toInput = document.getElementById('snapshots-date-to');
|
||
|
||
if (!listContainer) return;
|
||
|
||
const todayIso = formatDateToISO(new Date());
|
||
if (fromInput && !fromInput.value) fromInput.value = todayIso;
|
||
if (toInput && !toInput.value) toInput.value = todayIso;
|
||
|
||
const dateFrom = fromInput ? isoToBackendFormat(fromInput.value) : isoToBackendFormat(todayIso);
|
||
const dateTo = toInput ? isoToBackendFormat(toInput.value) : dateFrom;
|
||
|
||
listContainer.innerHTML = `<div class="text-center py-10 text-slate-400 text-xs"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка срезов...</div>`;
|
||
|
||
try {
|
||
const url = `/api/v1/snapshots?date_from=${encodeURIComponent(dateFrom)}&date_to=${encodeURIComponent(dateTo)}`;
|
||
const res = await fetch(url, { headers: AuthManager.getAuthHeaders() });
|
||
if (!res.ok) throw new Error('Ошибка сети');
|
||
const data = await res.json();
|
||
const snapshots = data.snapshots || [];
|
||
|
||
if (countBadge) countBadge.innerText = `Срезы в базе: ${snapshots.length}`;
|
||
|
||
if (snapshots.length === 0) {
|
||
listContainer.innerHTML = `
|
||
<div class="text-center py-8 text-slate-400 text-xs space-y-2">
|
||
<p>За выбранный период срезы не найдены</p>
|
||
<button onclick="setSnapshotDatePreset('yesterday')" class="px-2.5 py-1 text-[11px] bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition font-medium shadow-2xs">
|
||
Показать за вчера
|
||
</button>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// Вспомогательный ключ для сортировки от новых к старым
|
||
function getSortKey(s) {
|
||
const sid = (s.snapshot_id || s.id || '').toUpperCase();
|
||
const digits = sid.replace(/\D/g, '');
|
||
if (sid.includes('FINAL') && digits.length >= 8) {
|
||
return `${digits.substring(0, 8)}_235959`;
|
||
}
|
||
return digits.padEnd(14, '0');
|
||
}
|
||
|
||
snapshots.sort((a, b) => getSortKey(b).localeCompare(getSortKey(a)));
|
||
|
||
// Группировка по дням
|
||
const groups = {};
|
||
snapshots.forEach(s => {
|
||
const sid = s.snapshot_id || s.id || '';
|
||
let groupDate = s.date || '';
|
||
|
||
if (!groupDate) {
|
||
const digits = sid.replace(/\D/g, '');
|
||
if (digits.length >= 8) {
|
||
const y = digits.substring(0, 4);
|
||
const m = digits.substring(4, 6);
|
||
const d = digits.substring(6, 8);
|
||
groupDate = `${d}.${m}.${y}`;
|
||
} else {
|
||
groupDate = 'Другие срезы';
|
||
}
|
||
}
|
||
|
||
if (!groups[groupDate]) groups[groupDate] = [];
|
||
groups[groupDate].push(s);
|
||
});
|
||
|
||
// Сортировка дат по убыванию
|
||
const sortedDates = Object.keys(groups).sort((d1, d2) => {
|
||
const parseDate = (str) => {
|
||
const p = str.split('.');
|
||
return p.length === 3 ? new Date(p[2], p[1] - 1, p[0]).getTime() : 0;
|
||
};
|
||
return parseDate(d2) - parseDate(d1);
|
||
});
|
||
|
||
let html = '';
|
||
for (const dateLabel of sortedDates) {
|
||
const items = groups[dateLabel];
|
||
html += `
|
||
<div class="pt-2 pb-1 flex items-center gap-2">
|
||
<span class="text-[11px] font-bold text-slate-600 uppercase tracking-wider flex items-center gap-1.5">
|
||
<i class="fa-regular fa-calendar-days text-indigo-500 text-xs"></i> ${escapeHtml(dateLabel)}
|
||
</span>
|
||
<div class="h-px bg-slate-200 flex-1"></div>
|
||
<span class="text-[10px] text-slate-400 font-semibold">${items.length} срез.</span>
|
||
</div>
|
||
`;
|
||
|
||
html += items.map(s => {
|
||
const sid = s.snapshot_id || s.id;
|
||
const isFinal = Boolean(s.is_final) || sid.toUpperCase().includes('FINAL');
|
||
const badgeFinal = isFinal ? `<span class="ml-1.5 px-1.5 py-0.5 bg-amber-100 text-amber-800 rounded text-[9px] font-bold">Финал Y</span>` : '';
|
||
const cnt = s.record_count ?? s.count ?? s.records_count ?? 0;
|
||
const timeStr = s.snapshot_time || s.time || '—';
|
||
|
||
return `
|
||
<div class="p-3 bg-white rounded-xl border border-slate-200 hover:border-indigo-200 transition shadow-xs flex items-center justify-between group mb-2">
|
||
<div>
|
||
<div class="flex items-center">
|
||
<span class="text-xs font-bold font-mono text-slate-800">${escapeHtml(sid)}</span>
|
||
${badgeFinal}
|
||
</div>
|
||
<div class="text-[10px] text-slate-400 mt-0.5">${escapeHtml(timeStr)} · ${cnt} зап.</div>
|
||
</div>
|
||
<div class="flex items-center gap-1.5">
|
||
<button onclick="openSnapshotInspector('${escapeHtml(sid)}')" class="px-2.5 py-1 bg-slate-100 hover:bg-indigo-50 text-slate-600 hover:text-indigo-600 rounded-lg text-[10px] font-semibold transition">Инспекция</button>
|
||
${!isFinal ? `
|
||
<button onclick="deleteSnapshotItem('${escapeHtml(sid)}')" class="w-6 h-6 flex items-center justify-center text-slate-300 hover:text-rose-500 rounded transition opacity-0 group-hover:opacity-100" title="Удалить срез"><i class="fa-regular fa-trash-can text-[11px]"></i></button>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
listContainer.innerHTML = html;
|
||
} catch (e) {
|
||
listContainer.innerHTML = `<div class="text-center py-8 text-rose-500 text-xs">Не удалось загрузить срезы</div>`;
|
||
}
|
||
}
|
||
|
||
// 5. Ручное создание среза
|
||
async function createSnapshotManual() {
|
||
const dateVal = document.getElementById('manual-snapshot-date')?.value || '';
|
||
const timeVal = document.getElementById('manual-snapshot-time')?.value || '';
|
||
const btn = document.getElementById('btn-create-snapshot');
|
||
|
||
if (!dateVal) { alert('Укажите дату среза'); return; }
|
||
|
||
btn.disabled = true;
|
||
btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin mr-1.5"></i> Создание...`;
|
||
|
||
try {
|
||
const res = await fetch('/api/v1/snapshots/create', {
|
||
method: 'POST',
|
||
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ date_str: dateVal, time_str: timeVal })
|
||
});
|
||
if (res.ok) {
|
||
loadSnapshotsView();
|
||
} else {
|
||
const data = await res.json();
|
||
alert(`Ошибка: ${data.detail || 'Не удалось сформировать срез'}`);
|
||
}
|
||
} catch (e) {
|
||
alert('Ошибка сети');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerHTML = `<i class="fa-solid fa-camera mr-1.5"></i> Сделать срез`;
|
||
}
|
||
}
|
||
|
||
// 6. Удаление среза
|
||
async function deleteSnapshotItem(snapshotId) {
|
||
if (!confirm(`Удалить срез ${snapshotId}?`)) return;
|
||
try {
|
||
const res = await fetch('/api/v1/snapshots', {
|
||
method: 'DELETE',
|
||
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ snapshot_ids: [snapshotId] })
|
||
});
|
||
if (res.ok) loadSnapshotsView();
|
||
} catch (e) { alert('Ошибка сети'); }
|
||
}
|
||
|
||
// 7. Генерация отчетов On-Demand
|
||
window.generateReportDirect = async function(reportType) {
|
||
const dateInput = document.getElementById('manual-snapshot-date')?.value || '';
|
||
const timeInput = document.getElementById('manual-snapshot-time')?.value || '';
|
||
|
||
const labelMap = {
|
||
'SVODKA': 'Сводки',
|
||
'SIMPLIFIED': 'Упрощенного отчета',
|
||
'DETAILED': 'Детального отчета'
|
||
};
|
||
|
||
const targetLabel = labelMap[reportType] || 'отчета';
|
||
|
||
try {
|
||
const res = await fetch('/api/v1/reports/generate', {
|
||
method: 'POST',
|
||
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
report_type: reportType,
|
||
date: dateInput || null,
|
||
time: timeInput || null
|
||
})
|
||
});
|
||
|
||
if (!res.ok) {
|
||
alert(`Ошибка сервера (${res.status}) при создании ${targetLabel}`);
|
||
return;
|
||
}
|
||
|
||
const data = await res.json();
|
||
const rep = data.reports?.[0];
|
||
|
||
if (rep && rep.status === 'success' && rep.download_url) {
|
||
window.open(rep.download_url, '_blank');
|
||
} else {
|
||
alert(`Ошибка: ${rep?.error || 'Не удалось сформировать файл'}`);
|
||
}
|
||
} catch (e) {
|
||
console.error('[Reports] Ошибка генерации:', e);
|
||
alert('Сетевая ошибка при запросе формирования отчета');
|
||
}
|
||
};
|
||
|
||
// 8. Инициализация при первичной загрузке страницы
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const todayIso = formatDateToISO(new Date());
|
||
const fromInput = document.getElementById('snapshots-date-from');
|
||
const toInput = document.getElementById('snapshots-date-to');
|
||
if (fromInput && !fromInput.value) fromInput.value = todayIso;
|
||
if (toInput && !toInput.value) toInput.value = todayIso;
|
||
|
||
initManualSnapshotInputs();
|
||
});
|
||
|
||
// Экспортируем в глобальную область, чтобы core.js при переключении вкладок вызывал эту функцию
|
||
window.loadSnapshotsView = loadSnapshotsView;
|
||
window.createSnapshotManual = createSnapshotManual;
|
||
window.deleteSnapshotItem = deleteSnapshotItem;
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/snapshot_inspector.js`
|
||
```js
|
||
/**
|
||
* ===============================================================================
|
||
* FILE: modules/web_api/static/js/snapshot_inspector.js
|
||
* ROLE: Управление модальным окном полноразмерной инспекции срезов СКУД
|
||
* (фильтрация по ФИО, статусам, подразделениям и табличное представление).
|
||
* ===============================================================================
|
||
*/
|
||
|
||
let currentInspectorData = [];
|
||
let currentActiveSnapshotId = null;
|
||
|
||
async function openSnapshotInspector(snapshotId) {
|
||
currentActiveSnapshotId = snapshotId;
|
||
const modal = document.getElementById("snapshot-inspector-modal");
|
||
const titleEl = document.getElementById("inspector-modal-title");
|
||
const subEl = document.getElementById("inspector-modal-subtitle");
|
||
const tbody = document.getElementById("inspector-table-body");
|
||
|
||
if (!modal) return;
|
||
modal.classList.remove("hidden");
|
||
titleEl.innerText = `Инспекция среза #${snapshotId}`;
|
||
subEl.innerText = "Загрузка данных из базы...";
|
||
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-12 text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-2"></i> Загрузка записей...</td></tr>`;
|
||
|
||
try {
|
||
const res = await fetch(`/api/v1/snapshots/${snapshotId}/details`, { headers: AuthManager.getAuthHeaders() });
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
currentInspectorData = data.records || [];
|
||
subEl.innerText = `Дата: ${data.date} · Всего записей: ${currentInspectorData.length}`;
|
||
populateDepartmentFilter(currentInspectorData);
|
||
renderInspectorTable(currentInspectorData);
|
||
} else {
|
||
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-12 text-rose-500">Ошибка загрузки деталей среза</td></tr>`;
|
||
}
|
||
} catch (e) {
|
||
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-12 text-rose-500">Ошибка сети</td></tr>`;
|
||
}
|
||
}
|
||
|
||
function closeSnapshotInspectorModal() {
|
||
const modal = document.getElementById("snapshot-inspector-modal");
|
||
if (modal) modal.classList.add("hidden");
|
||
}
|
||
|
||
function populateDepartmentFilter(records) {
|
||
const select = document.getElementById("inspector-filter-dept");
|
||
if (!select) return;
|
||
const depts = [...new Set(records.map(r => r.department || r.Подразделение || "Без подразделения"))].sort();
|
||
select.innerHTML = `<option value="ALL">Все подразделения (${depts.length})</option>` +
|
||
depts.map(d => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join('');
|
||
}
|
||
|
||
function renderInspectorTable(records) {
|
||
const tbody = document.getElementById("inspector-table-body");
|
||
const countEl = document.getElementById("inspector-records-count");
|
||
if (!tbody) return;
|
||
|
||
if (records.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-12 text-slate-400">Нет записей, соответствующих фильтрам</td></tr>`;
|
||
if (countEl) countEl.innerText = "Показано записей: 0";
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = records.map((r, idx) => {
|
||
const fio = r.fio || r.Сотрудник || "—";
|
||
const dept = r.department || r.Подразделение || "—";
|
||
const timeIn = r.time_in || r.Начало_дня || "Нет входа";
|
||
const firstAct = r.first_activity || r.Первая_активность || "—";
|
||
const timeOut = r.time_out || r.Конец_дня || "Нет выхода";
|
||
const duration = r.duration || r.Находился_в_здании || "00:00";
|
||
|
||
// ⭐️ Жестко определяем присутствие по факту наличия времени входа
|
||
const isPresent = timeIn && timeIn !== "Нет входа" && timeIn !== "—";
|
||
|
||
// Корректируем статус: если человек зашел, он точно присутствует
|
||
let status = r.status || r.Статус || "";
|
||
if (!status || status.includes("Отсутствовал") || status.includes("Нет событий")) {
|
||
status = isPresent ? "Присутствовал" : "Отсутствовал";
|
||
} else if (isPresent && !status.includes("Присутствовал")) {
|
||
status = "Присутствовал";
|
||
}
|
||
|
||
return `
|
||
<tr class="hover:bg-slate-50 transition">
|
||
<td class="p-3 text-slate-400 font-mono text-[11px]">${idx + 1}</td>
|
||
<td class="p-3 font-bold text-slate-800">${escapeHtml(fio)}</td>
|
||
<td class="p-3 text-slate-600">${escapeHtml(dept)}</td>
|
||
<td class="p-3 font-mono ${isPresent ? 'text-emerald-600 font-semibold' : 'text-slate-400'}">${escapeHtml(timeIn)}</td>
|
||
<td class="p-3 font-mono text-slate-500">${escapeHtml(firstAct)}</td>
|
||
<td class="p-3 font-mono text-slate-600">${escapeHtml(timeOut)}</td>
|
||
<td class="p-3 font-mono font-semibold text-slate-700">${escapeHtml(duration)}</td>
|
||
<td class="p-3">
|
||
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold ${isPresent ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' : 'bg-slate-100 text-slate-500'}">
|
||
${escapeHtml(status)}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
|
||
if (countEl) countEl.innerText = `Показано записей: ${records.length} из ${currentInspectorData.length}`;
|
||
}
|
||
|
||
function filterInspectorTable() {
|
||
const query = document.getElementById("inspector-search-input")?.value.toLowerCase() || "";
|
||
const statusFilter = document.getElementById("inspector-filter-status")?.value || "ALL";
|
||
const deptFilter = document.getElementById("inspector-filter-dept")?.value || "ALL";
|
||
|
||
const filtered = currentInspectorData.filter(r => {
|
||
const fio = (r.fio || r.Сотрудник || "").toLowerCase();
|
||
const dept = r.department || r.Подразделение || "Без подразделения";
|
||
const status = r.status || r.Статус || "";
|
||
const timeIn = r.time_in || r.Начало_дня || "Нет входа";
|
||
const isPresent = status.includes("Присутствовал") || timeIn !== "Нет входа";
|
||
|
||
const matchesQuery = fio.includes(query) || dept.toLowerCase().includes(query);
|
||
const matchesDept = deptFilter === "ALL" || dept === deptFilter;
|
||
|
||
let matchesStatus = true;
|
||
if (statusFilter === "PRESENT") matchesStatus = isPresent;
|
||
if (statusFilter === "ABSENT") matchesStatus = !isPresent;
|
||
|
||
return matchesQuery && matchesDept && matchesStatus;
|
||
});
|
||
|
||
renderInspectorTable(filtered);
|
||
}
|
||
|
||
window.downloadInspectorExport = function(format = 'xlsx') {
|
||
if (!currentActiveSnapshotId) {
|
||
alert('Срез не выбран');
|
||
return;
|
||
}
|
||
const cleanId = String(currentActiveSnapshotId).replace(/^#/, '').trim();
|
||
const url = `/api/v1/snapshots/${encodeURIComponent(cleanId)}/export?format=${format}`;
|
||
window.open(url, '_blank');
|
||
};
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/tasks.js`
|
||
```js
|
||
/**
|
||
* ===============================================================================
|
||
* FILE: modules/web_api/static/js/tasks.js
|
||
* ROLE: Управление персональными задачами оператора (CRUD, фильтры, рендер).
|
||
* ===============================================================================
|
||
*/
|
||
|
||
window.escapeHtml = window.escapeHtml || function (str) {
|
||
if (str === null || str === undefined) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
};
|
||
|
||
let currentTasksFilter = 'ALL';
|
||
let tasksCache = [];
|
||
|
||
function getTasksContainer() {
|
||
return document.getElementById("tasks-list-container") ||
|
||
document.getElementById("sidebar-dynamic-content") ||
|
||
document.getElementById("tasks-list");
|
||
}
|
||
|
||
async function loadTasks() {
|
||
const container = getTasksContainer();
|
||
if (!container) return;
|
||
|
||
container.innerHTML = `
|
||
<div class="text-center py-8 text-xs text-slate-400">
|
||
<i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка задач...
|
||
</div>
|
||
`;
|
||
|
||
try {
|
||
const res = await fetch("/api/v1/tasks", {
|
||
headers: AuthManager.getAuthHeaders()
|
||
});
|
||
|
||
if (!res.ok) {
|
||
if (res.status === 401) {
|
||
showAuthModal();
|
||
return;
|
||
}
|
||
throw new Error(`Ошибка сервера (${res.status})`);
|
||
}
|
||
|
||
const data = await res.json();
|
||
tasksCache = Array.isArray(data) ? data : (data.tasks || []);
|
||
renderTasksUI();
|
||
} catch (err) {
|
||
console.error("[Tasks] Ошибка загрузки:", err);
|
||
container.innerHTML = `
|
||
<div class="p-4 text-xs text-rose-500 text-center flex flex-col items-center gap-2">
|
||
<i class="fa-solid fa-triangle-exclamation text-base"></i>
|
||
<span>Не удалось загрузить задачи</span>
|
||
<button onclick="loadTasks()" class="px-2.5 py-1 bg-slate-200 hover:bg-slate-300 text-slate-700 rounded text-[11px] font-semibold transition">Повторить</button>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
function setTaskFilter(filter) {
|
||
currentTasksFilter = filter;
|
||
renderTasksUI();
|
||
}
|
||
|
||
function renderTasksUI() {
|
||
const container = getTasksContainer();
|
||
if (!container) return;
|
||
|
||
let filtered = tasksCache;
|
||
if (currentTasksFilter === 'IN_PROGRESS') {
|
||
filtered = tasksCache.filter(t => t.status === 'IN_PROGRESS');
|
||
} else if (currentTasksFilter === 'BACKLOG') {
|
||
filtered = tasksCache.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED');
|
||
} else if (currentTasksFilter === 'COMPLETED') {
|
||
filtered = tasksCache.filter(t => t.status === 'COMPLETED' || t.status === 'DONE');
|
||
}
|
||
|
||
const filtersHtml = `
|
||
<div class="flex items-center gap-1 p-1 bg-slate-200/70 rounded-lg text-[11px] font-semibold mb-2">
|
||
<button onclick="setTaskFilter('ALL')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'ALL' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Все</button>
|
||
<button onclick="setTaskFilter('IN_PROGRESS')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'IN_PROGRESS' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">В работе</button>
|
||
<button onclick="setTaskFilter('BACKLOG')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'BACKLOG' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Планы</button>
|
||
<button onclick="setTaskFilter('COMPLETED')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'COMPLETED' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Готово</button>
|
||
</div>
|
||
`;
|
||
|
||
const addBtnHtml = `
|
||
<div class="flex items-center justify-between px-1 mb-1.5">
|
||
<span class="text-xs font-bold text-slate-700">Задачи: ${filtered.length}</span>
|
||
<button onclick="openCreateTaskModal()" class="px-2 py-1 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
|
||
<i class="fa-solid fa-plus text-[10px]"></i> Новая
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
if (filtered.length === 0) {
|
||
container.innerHTML = `
|
||
${filtersHtml}
|
||
${addBtnHtml}
|
||
<div class="text-center py-8 text-xs text-slate-400 bg-white border border-slate-200 rounded-xl p-4">
|
||
Нет задач в выбранной категории
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
const itemsHtml = filtered.map(t => {
|
||
const isDone = t.status === 'COMPLETED' || t.status === 'DONE';
|
||
const priorityColors = {
|
||
'HIGH': 'bg-rose-50 text-rose-700 border-rose-200',
|
||
'MEDIUM': 'bg-amber-50 text-amber-700 border-amber-200',
|
||
'LOW': 'bg-slate-50 text-slate-600 border-slate-200'
|
||
};
|
||
const pClass = priorityColors[t.priority] || priorityColors['MEDIUM'];
|
||
|
||
return `
|
||
<div class="flex flex-col p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-1.5 shadow-sm hover:border-indigo-300 transition">
|
||
<div class="flex items-start justify-between gap-2">
|
||
<div class="flex items-center gap-1.5 min-w-0">
|
||
<button onclick="toggleTaskStatus(${t.id}, '${t.status}')" class="text-slate-400 hover:text-indigo-600 transition shrink-0">
|
||
<i class="fa-${isDone ? 'solid fa-circle-check text-emerald-500' : 'regular fa-circle'} text-sm"></i>
|
||
</button>
|
||
<span class="font-bold text-slate-800 ${isDone ? 'line-through text-slate-400' : ''} truncate">${escapeHtml(t.title)}</span>
|
||
</div>
|
||
<span class="px-1.5 py-0.5 rounded text-[9px] font-semibold border ${pClass} shrink-0">${t.priority || 'NORMAL'}</span>
|
||
</div>
|
||
|
||
<div class="flex items-center justify-between text-[10px] text-slate-400 pt-1 border-t border-slate-100">
|
||
<span>${t.task_id || ('#' + t.id)} · ${escapeHtml(t.module || 'general')}</span>
|
||
<div class="flex items-center gap-1">
|
||
<button onclick="deleteTaskItem(${t.id})" class="text-slate-400 hover:text-rose-600 p-0.5 transition" title="Удалить">
|
||
<i class="fa-solid fa-trash-can text-[11px]"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
container.innerHTML = `
|
||
${filtersHtml}
|
||
${addBtnHtml}
|
||
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
|
||
${itemsHtml}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function toggleTaskStatus(id, currentStatus) {
|
||
const newStatus = (currentStatus === 'COMPLETED' || currentStatus === 'DONE') ? 'IN_PROGRESS' : 'COMPLETED';
|
||
try {
|
||
const res = await fetch(`/api/v1/tasks/${id}`, {
|
||
method: "PATCH",
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify({ status: newStatus })
|
||
});
|
||
if (res.ok) {
|
||
loadTasks();
|
||
}
|
||
} catch (e) {
|
||
console.error("Ошибка смены статуса задачи:", e);
|
||
}
|
||
}
|
||
|
||
async function deleteTaskItem(id) {
|
||
if (!confirm("Удалить эту задачу?")) return;
|
||
try {
|
||
const res = await fetch(`/api/v1/tasks/${id}`, {
|
||
method: "DELETE",
|
||
headers: AuthManager.getAuthHeaders()
|
||
});
|
||
if (res.ok) {
|
||
loadTasks();
|
||
}
|
||
} catch (e) {
|
||
alert("Ошибка сети при удалении");
|
||
}
|
||
}
|
||
|
||
async function openCreateTaskModal() {
|
||
const title = prompt("Введите описание новой задачи:");
|
||
if (!title || !title.trim()) return;
|
||
|
||
try {
|
||
const res = await fetch("/api/v1/tasks", {
|
||
method: "POST",
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
title: title.trim(),
|
||
priority: "MEDIUM",
|
||
status: "IN_PROGRESS"
|
||
})
|
||
});
|
||
if (res.ok) {
|
||
loadTasks();
|
||
} else {
|
||
alert("Не удалось создать задачу");
|
||
}
|
||
} catch (e) {
|
||
alert("Ошибка сети");
|
||
}
|
||
}
|
||
|
||
window.loadTasks = loadTasks;
|
||
window.setTaskFilter = setTaskFilter;
|
||
```
|
||
|
||
## File: `./modules/web_api/static/modals/admin_modal.html`
|
||
```html
|
||
<div id="admin-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-lg w-full p-6 flex flex-col gap-4 max-h-[85vh]">
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||
<i class="fa-solid fa-users-gear text-indigo-600"></i> Управление учетными записями
|
||
</h3>
|
||
<button onclick="closeAdminModal()" class="text-slate-400 hover:text-slate-600"><i class="fa-solid fa-xmark"></i></button>
|
||
</div>
|
||
|
||
<form onsubmit="handleCreateUser(event)" class="p-3 bg-slate-50 border border-slate-200 rounded-xl flex flex-col gap-2">
|
||
<span class="text-xs font-bold text-slate-700">Создать нового пользователя:</span>
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<input type="text" id="new-user-username" placeholder="Логин" required class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
|
||
<input type="password" id="new-user-password" placeholder="Пароль" required class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
|
||
</div>
|
||
<input type="text" id="new-user-fullname" placeholder="ФИО" class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
|
||
<div class="flex items-center justify-between">
|
||
<label class="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer">
|
||
<input type="checkbox" id="new-user-admin" class="rounded border-slate-300 text-indigo-600" />
|
||
<span>Права администратора</span>
|
||
</label>
|
||
<button type="submit" class="px-3 py-1 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow">
|
||
Создать
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<div class="flex-1 overflow-y-auto flex flex-col gap-1.5" id="admin-users-list">
|
||
<div class="text-center py-4 text-xs text-slate-400">Загрузка пользователей...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
## File: `./modules/web_api/static/modals/auth_modal.html`
|
||
```html
|
||
<div id="auth-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-sm w-full p-6 flex flex-col gap-4">
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-10 h-10 rounded-xl bg-indigo-600 text-white flex items-center justify-center font-bold text-lg shadow">
|
||
<i class="fa-solid fa-shield-halved"></i>
|
||
</div>
|
||
<div>
|
||
<h2 class="text-sm font-bold text-slate-800">Авторизация в системе</h2>
|
||
<p class="text-[11px] text-slate-400">SCUD Orion AI Security Access</p>
|
||
</div>
|
||
</div>
|
||
|
||
<form id="auth-form" onsubmit="handleLoginSubmit(event)" class="flex flex-col gap-3">
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Имя пользователя:</label>
|
||
<input type="text" id="auth-username" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50" placeholder="Логин" />
|
||
</div>
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Пароль:</label>
|
||
<input type="password" id="auth-password" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50" placeholder="••••••••" />
|
||
</div>
|
||
<div id="auth-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||
<button type="submit" class="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-bold rounded-lg text-xs shadow transition mt-1">
|
||
Войти в систему
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
## File: `./modules/web_api/static/modals/exception_modal.html`
|
||
```html
|
||
<div id="exception-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-md w-full p-6 flex flex-col gap-4">
|
||
<div class="flex items-center justify-between">
|
||
<h3 id="exception-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||
<i class="fa-solid fa-user-shield text-indigo-600"></i>
|
||
<span id="exception-modal-header-text">Добавление в реестр</span>
|
||
</h3>
|
||
<button type="button" onclick="closeExceptionModal()" class="text-slate-400 hover:text-slate-600">
|
||
<i class="fa-solid fa-xmark"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<form id="exception-modal-form" onsubmit="submitExceptionModalForm(event)" class="flex flex-col gap-3">
|
||
<input type="hidden" id="exception-category-input" value="" />
|
||
|
||
<div class="relative">
|
||
<label id="exception-value-label" class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника:</label>
|
||
<input type="text" id="exception-value-input" autocomplete="off" required
|
||
placeholder="Начните вводить фамилию..."
|
||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-800" />
|
||
<div id="exception-suggestions" class="hidden absolute left-0 right-0 top-full mt-1 bg-white border border-slate-300 rounded-lg shadow-xl z-30 max-h-48 overflow-y-auto"></div>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Примечание / основание (опционально):</label>
|
||
<input type="text" id="exception-comment-input" placeholder="Например: служебная записка, водитель, лаборатория"
|
||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-800" />
|
||
</div>
|
||
|
||
<div id="exception-error-msg" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||
|
||
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
|
||
<button type="button" onclick="closeExceptionModal()"
|
||
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
|
||
Отмена
|
||
</button>
|
||
<button type="submit" id="exception-submit-btn"
|
||
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition flex items-center gap-1.5">
|
||
<i class="fa-solid fa-check text-xs"></i>
|
||
<span>Добавить</span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
## File: `./modules/web_api/static/modals/manual_absence_modal.html`
|
||
```html
|
||
<div id="manual-absence-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-md w-full p-6 flex flex-col gap-4">
|
||
<div class="flex items-center justify-between">
|
||
<h3 id="manual-absence-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||
<i class="fa-solid fa-location-dot text-indigo-600"></i>
|
||
<span id="manual-absence-modal-title-text">Добавление в реестр</span>
|
||
</h3>
|
||
<button type="button" onclick="closeManualAbsenceModal()" class="text-slate-400 hover:text-slate-600 p-1 rounded-lg hover:bg-slate-100 transition">
|
||
<i class="fa-solid fa-xmark text-base"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<form id="manual-absence-form" onsubmit="submitManualAbsence(event)" class="flex flex-col gap-3">
|
||
<input type="hidden" id="manual-absence-id" value="" />
|
||
<input type="hidden" id="manual-absence-dept" value="" />
|
||
<input type="hidden" id="manual-absence-pos" value="" />
|
||
|
||
<div class="relative">
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника (автоподбор из 1С):</label>
|
||
<input type="text" id="manual-absence-fio-input" autocomplete="off" required placeholder="Начните вводить фамилию..."
|
||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-800 transition" />
|
||
<div id="manual-absence-suggestions" class="hidden absolute left-0 right-0 top-full mt-1 bg-white border border-slate-300 rounded-lg shadow-xl z-30 max-h-48 overflow-y-auto"></div>
|
||
</div>
|
||
|
||
<div id="manual-absence-reason-block" class="hidden">
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Причина отсутствия:</label>
|
||
<select id="manual-absence-reason-select" class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-800"></select>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Дата начала:</label>
|
||
<input type="date" id="manual-absence-start-date" required
|
||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
|
||
<span class="text-[10px] text-slate-400 mt-0.5 block">Дата выхода/отбытия</span>
|
||
</div>
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Дата окончания:</label>
|
||
<input type="date" id="manual-absence-end-date" required
|
||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
|
||
<span class="text-[10px] text-slate-400 mt-0.5 block">Включительно</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="manual-absence-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||
|
||
<div class="flex items-center justify-end gap-2 mt-2 pt-3 border-t border-slate-100">
|
||
<button type="button" onclick="closeManualAbsenceModal()"
|
||
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
|
||
Отмена
|
||
</button>
|
||
<button type="submit" id="manual-absence-submit-btn"
|
||
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow-xs transition">
|
||
Сохранить
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
## File: `./modules/web_api/static/modals/presence_modal.html`
|
||
```html
|
||
<div id="presence-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
|
||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl h-[88vh] flex flex-col overflow-hidden border border-slate-200">
|
||
|
||
<!-- Шапка -->
|
||
<div class="px-6 py-4 border-b border-slate-200 bg-slate-50/80 flex items-center justify-between">
|
||
<div class="flex items-center space-x-3">
|
||
<div class="w-10 h-10 rounded-xl bg-emerald-500/10 text-emerald-600 flex items-center justify-center font-bold text-xl">
|
||
🏢
|
||
</div>
|
||
<div>
|
||
<h3 class="text-lg font-bold text-slate-800">Оперативный мониторинг присутствия</h3>
|
||
<p id="presence-modal-subtitle" class="text-xs text-slate-500">Загрузка данных...</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center space-x-2">
|
||
<button id="btn-presence-force-refresh" class="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-semibold flex items-center space-x-1.5 transition-colors shadow-sm">
|
||
<span>🔄</span>
|
||
<span>Запросить из СКУД</span>
|
||
</button>
|
||
<button id="btn-close-presence-modal" class="text-slate-400 hover:text-slate-600 p-2 rounded-lg hover:bg-slate-100 transition-colors">
|
||
✕
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Метрики (плашки) - 7 колонок для точного баланса штата 1С -->
|
||
<div class="grid grid-cols-2 sm:grid-cols-7 gap-2 p-4 bg-slate-100/60 border-b border-slate-200">
|
||
<div class="bg-white p-2.5 rounded-xl border border-slate-200/80 text-center">
|
||
<div class="text-[11px] text-slate-500 font-medium">Штат 1С</div>
|
||
<div id="metric-total" class="text-lg font-bold text-slate-800">—</div>
|
||
</div>
|
||
<div class="bg-emerald-50 p-2.5 rounded-xl border border-emerald-200/80 text-center">
|
||
<div class="text-[11px] text-emerald-700 font-medium">В здании</div>
|
||
<div id="metric-inside" class="text-lg font-bold text-emerald-700">—</div>
|
||
</div>
|
||
<div class="bg-amber-50 p-2.5 rounded-xl border border-amber-200/80 text-center">
|
||
<div class="text-[11px] text-amber-700 font-medium">Вышли</div>
|
||
<div id="metric-outside" class="text-lg font-bold text-amber-700">—</div>
|
||
</div>
|
||
<div class="bg-blue-50 p-2.5 rounded-xl border border-blue-200/80 text-center">
|
||
<div class="text-[11px] text-blue-700 font-medium">Удаленка</div>
|
||
<div id="metric-remote" class="text-lg font-bold text-blue-700">—</div>
|
||
</div>
|
||
<div class="bg-purple-50 p-2.5 rounded-xl border border-purple-200/80 text-center">
|
||
<div class="text-[11px] text-purple-700 font-medium">Отпуск/Ком.</div>
|
||
<div id="metric-absence" class="text-lg font-bold text-purple-700">—</div>
|
||
</div>
|
||
<div class="bg-rose-50 p-2.5 rounded-xl border border-rose-200/80 text-center">
|
||
<div class="text-[11px] text-rose-700 font-medium">Не пришли</div>
|
||
<div id="metric-not-entered" class="text-lg font-bold text-rose-700">—</div>
|
||
</div>
|
||
<div class="bg-slate-200/70 p-2.5 rounded-xl border border-slate-300 text-center">
|
||
<div class="text-[11px] text-slate-600 font-medium">Исключения</div>
|
||
<div id="metric-excluded" class="text-lg font-bold text-slate-700">—</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Панель управления и фильтрации -->
|
||
<div class="p-4 border-b border-slate-200 flex flex-wrap items-center justify-between gap-3 bg-white">
|
||
<!-- Табы статусов -->
|
||
<div class="flex items-center space-x-1 overflow-x-auto bg-slate-100 p-1 rounded-xl text-xs font-semibold">
|
||
<button class="presence-tab-btn px-3 py-1.5 rounded-lg bg-white shadow-xs text-slate-800" data-tab="ALL">Все (<span id="tab-cnt-all">0</span>)</button>
|
||
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="INSIDE">В здании (<span id="tab-cnt-inside">0</span>)</button>
|
||
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="OUTSIDE">Вышли (<span id="tab-cnt-outside">0</span>)</button>
|
||
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="REMOTE">Удаленка (<span id="tab-cnt-remote">0</span>)</button>
|
||
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="ABSENCE">Отсутствуют (<span id="tab-cnt-absence">0</span>)</button>
|
||
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="NOT_ENTERED">Не пришли (<span id="tab-cnt-not-entered">0</span>)</button>
|
||
<button class="presence-tab-btn px-3 py-1.5 rounded-lg text-slate-600 hover:text-slate-900" data-tab="EXCLUDED">Исключения (<span id="tab-cnt-excluded">0</span>)</button>
|
||
</div>
|
||
|
||
<!-- Поиск по ФИО и фильтр отдела -->
|
||
<div class="flex items-center space-x-2 w-full sm:w-auto">
|
||
<input type="text" id="presence-search-input" placeholder="🔍 Поиск сотрудника..." class="text-xs px-3 py-1.5 bg-slate-50 border border-slate-200 rounded-lg w-48 focus:outline-emerald-500 focus:bg-white">
|
||
<select id="presence-dept-filter" class="text-xs px-3 py-1.5 bg-slate-50 border border-slate-200 rounded-lg max-w-[180px] focus:outline-emerald-500">
|
||
<option value="ALL">Все подразделения</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Таблица сотрудников -->
|
||
<div class="flex-1 overflow-y-auto p-4">
|
||
<table class="w-full text-left text-xs border-collapse">
|
||
<thead class="sticky top-0 bg-slate-50 border-b border-slate-200 text-slate-500 uppercase tracking-wider">
|
||
<tr>
|
||
<th class="py-2.5 px-3 font-semibold">Сотрудник</th>
|
||
<th class="py-2.5 px-3 font-semibold">Отдел</th>
|
||
<th class="py-2.5 px-3 font-semibold">Должность</th>
|
||
<th class="py-2.5 px-3 font-semibold text-center">Статус</th>
|
||
<th class="py-2.5 px-3 font-semibold text-center">Отметка</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="presence-table-body" class="divide-y divide-slate-100">
|
||
<tr>
|
||
<td colspan="5" class="py-8 text-center text-slate-400">Загрузка данных...</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
## File: `./modules/web_api/static/modals/profile_modal.html`
|
||
```html
|
||
<div id="profile-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-sm w-full p-6 flex flex-col gap-4">
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||
<i class="fa-solid fa-key text-indigo-600"></i> Смена пароля
|
||
</h3>
|
||
<button onclick="closeProfileModal()" class="text-slate-400 hover:text-slate-600"><i class="fa-solid fa-xmark"></i></button>
|
||
</div>
|
||
<form onsubmit="handleChangePassword(event)" class="flex flex-col gap-3">
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Текущий пароль:</label>
|
||
<input type="password" id="old-pass" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500" />
|
||
</div>
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Новый пароль (мин. 4 симв.):</label>
|
||
<input type="password" id="new-pass" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500" />
|
||
</div>
|
||
<div id="pass-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||
<div class="flex items-center justify-end gap-2 mt-2">
|
||
<button type="button" onclick="closeProfileModal()" class="px-3 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium">Отмена</button>
|
||
<button type="submit" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition">Сохранить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
## File: `./modules/web_api/static/modals/remote_worker_modal.html`
|
||
```html
|
||
<div id="remote-worker-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-md w-full p-6 flex flex-col gap-4">
|
||
<div class="flex items-center justify-between">
|
||
<h3 id="remote-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||
<i class="fa-solid fa-house-laptop text-emerald-600"></i>
|
||
<span>Параметры удаленной работы</span>
|
||
</h3>
|
||
<button type="button" onclick="closeRemoteWorkerModal()" class="text-slate-400 hover:text-slate-600">
|
||
<i class="fa-solid fa-xmark"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<form id="remote-worker-form" onsubmit="handleRemoteWorkerSubmit(event)" class="flex flex-col gap-3">
|
||
<input type="hidden" id="rw-mode" value="ADD" />
|
||
|
||
<div class="relative">
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника (автоподбор из 1С):</label>
|
||
<input type="text" id="rw-fio" required autocomplete="off" placeholder="Начните вводить фамилию..."
|
||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50" />
|
||
<div id="rw-fio-suggestions" class="hidden absolute left-0 right-0 top-full mt-1 bg-white border border-slate-300 rounded-lg shadow-xl z-30 max-h-48 overflow-y-auto"></div>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Подразделение:</label>
|
||
<input type="text" id="rw-dept" placeholder="Все"
|
||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50" />
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Дата начала:</label>
|
||
<input type="date" id="rw-date-from"
|
||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50 text-slate-700" />
|
||
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = с сегодняшнего дня</span>
|
||
</div>
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Дата окончания:</label>
|
||
<input type="date" id="rw-date-to"
|
||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50 text-slate-700" />
|
||
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = бессрочно</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="rw-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||
|
||
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
|
||
<button type="button" onclick="closeRemoteWorkerModal()"
|
||
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
|
||
Отмена
|
||
</button>
|
||
<button type="submit" id="rw-submit-btn"
|
||
class="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow transition">
|
||
Сохранить
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
## File: `./modules/web_api/static/modals/snapshot_inspector_modal.html`
|
||
```html
|
||
<div id="snapshot-inspector-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 w-full max-w-6xl h-[90vh] flex flex-col overflow-hidden">
|
||
|
||
<!-- ШАПКА МОДАЛКИ ИНСПЕКЦИИ СРЕЗА -->
|
||
<div class="px-6 py-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between shrink-0">
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-9 h-9 rounded-xl bg-indigo-600 text-white flex items-center justify-center shadow-xs">
|
||
<i class="fa-solid fa-magnifying-glass-chart text-sm"></i>
|
||
</div>
|
||
<div>
|
||
<h3 id="inspector-modal-title" class="text-sm font-bold text-slate-800">Инспекция среза</h3>
|
||
<p id="inspector-modal-subtitle" class="text-[11px] text-slate-400">Загрузка данных...</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- КНОПКИ ЭКСПОРТА И ЗАКРЫТИЯ -->
|
||
<div class="flex items-center gap-2">
|
||
<button onclick="downloadInspectorExport('xlsx')"
|
||
class="px-3 py-1.5 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border border-emerald-200 rounded-lg text-xs font-semibold transition flex items-center gap-1.5 shadow-xs"
|
||
title="Скачать срез в формате Excel (.xlsx)">
|
||
<i class="fa-solid fa-file-excel text-emerald-600"></i>
|
||
<span>Excel</span>
|
||
</button>
|
||
|
||
<button onclick="downloadInspectorExport('csv')"
|
||
class="px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 border border-slate-200 rounded-lg text-xs font-semibold transition flex items-center gap-1.5 shadow-xs"
|
||
title="Скачать срез в CSV (для анализа нейросетями)">
|
||
<i class="fa-solid fa-file-csv text-slate-600"></i>
|
||
<span>CSV</span>
|
||
</button>
|
||
|
||
<button onclick="closeSnapshotInspectorModal()"
|
||
class="text-slate-400 hover:text-slate-600 p-2 rounded-lg hover:bg-slate-200 transition ml-1"
|
||
title="Закрыть">
|
||
<i class="fa-solid fa-xmark text-base"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ПАНЕЛЬ ФИЛЬТРОВ И ПОИСКА -->
|
||
<div class="px-6 py-3 bg-white border-b border-slate-200 flex flex-wrap items-center gap-3 shrink-0">
|
||
<div class="flex-1 min-w-[240px] relative">
|
||
<i class="fa-solid fa-magnifying-glass absolute left-3 top-2.5 text-slate-400 text-xs"></i>
|
||
<input type="text" id="inspector-search-input" oninput="filterInspectorTable()" placeholder="Поиск по ФИО или должности..."
|
||
class="w-full text-xs pl-8 pr-3 py-2 bg-slate-50 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500" />
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<select id="inspector-filter-status" onchange="filterInspectorTable()" class="text-xs px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500">
|
||
<option value="ALL">Все статусы</option>
|
||
<option value="PRESENT">Присутствовал</option>
|
||
<option value="ABSENT">Отсутствовал</option>
|
||
<option value="ANOMALY">Аномалии</option>
|
||
</select>
|
||
|
||
<select id="inspector-filter-dept" onchange="filterInspectorTable()" class="text-xs px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500">
|
||
<option value="ALL">Все подразделения</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ТАБЛИЦА ДАННЫХ (СКРОЛЛИРУЕМАЯ ОБЛАСТЬ НА ВСЮ ВЫСОТУ) -->
|
||
<div class="flex-1 overflow-y-auto p-6 bg-slate-50">
|
||
<div class="bg-white border border-slate-200 rounded-xl shadow-xs overflow-hidden">
|
||
<table class="w-full text-left border-collapse">
|
||
<thead>
|
||
<tr class="bg-slate-100 border-b border-slate-200 text-[11px] font-bold text-slate-600 uppercase tracking-wider">
|
||
<th class="p-3">#</th>
|
||
<th class="p-3">Сотрудник</th>
|
||
<th class="p-3">Подразделение</th>
|
||
<th class="p-3">Вход</th>
|
||
<th class="p-3">Первая акт.</th>
|
||
<th class="p-3">Выход</th>
|
||
<th class="p-3">В здании</th>
|
||
<th class="p-3">Статус</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="inspector-table-body" class="divide-y divide-slate-100 text-xs text-slate-700">
|
||
<tr><td colspan="8" class="text-center py-8 text-slate-400">Выберите срез для инспекции</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ПОДВАЛ МОДАЛКИ -->
|
||
<div class="px-6 py-3 bg-white border-t border-slate-200 flex items-center justify-between shrink-0">
|
||
<span id="inspector-records-count" class="text-xs font-semibold text-slate-500">Записей не найдено</span>
|
||
<button onclick="closeSnapshotInspectorModal()" class="px-4 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-xs font-bold transition">
|
||
Закрыть
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
```
|