feat(reports): stabilize on-demand generation, live presence and 1C fallback

This commit is contained in:
2026-09-25 13:00:11 +03:00
parent 2f8cc1bffd
commit 90656944c5
42 changed files with 13410 additions and 6778 deletions
+4 -1
View File
@@ -151,7 +151,10 @@ def process_chat_message(
f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_tasks без лишних вопросов!\n"
f" - КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО переспрашивать у оператора фильтры, статус или категорию задач текстом! "
f"Интерактивная карточка в интерфейсе содержит все нужные фильтры.\n"
f"4. ПРАВИЛА И РЕГЛАМЕНТЫ: При любых вопросах о правилах компании или арбитраже ТЫ ОБЯЗАН СРАЗУ вызвать db_get_rules.\n"
f"4. ПРАВИЛА КОМПАНИИ: Инструмент db_get_rules вызывай СТРОГО при вопросах о внутренних регламентах, "
"политиках или локальных правилах НАШЕЙ компании (например, 'покажи правила компании', 'какие у нас регламенты'). "
"При вопросах по Трудовому кодексу РФ (ТК РФ), законам РФ или общим юридическим нормам — отвечай подробно "
"из своих профессиональных знаний, не вызывая db_get_rules!"
f"5. Запрещено выдумывать факты и цифры по системе СКУД/1С без вызова инструментов.\n"
f"6. ОБЩИЙ ДИАЛОГ: На любые отвлечённые, познавательные, научные или бытовые вопросы "
f"(расстояние между планетами или городами, программирование, кругозор) отвечай полно, доброжелательно и интересно, не отказывая пользователю.\n\n"
+1 -1
View File
@@ -40,7 +40,7 @@ def init_prompt_nodes_table():
# Раздел 1
(1, 0, "РОЛЬ И ЗАДАЧИ АССИСТЕНТА"),
(1, 1, "Управление бэклогом задач проекта (через db_get_tasks, db_add_task, db_update_task_status, db_delete_task)."),
(1, 2, "Консультация по правилам и арбитражу кадровых данных/СКУД из базы знаний (через db_get_rules)."),
(1, 2, "Консультация по внутренним регламентам компании (через db_get_rules). Вопросы по ТК РФ и законам поясняй напрямую."),
(1, 3, "Предоставление справки о возможностях и примерах команд (СТРОГО через db_get_reference)."),
(1, 4, "Просмотр аномалий СКУД ⟷ 1С (СТРОГО через db_get_anomalies)."),
(1, 5, "Поддержка диалога с операторами и администраторами системы."),
+4
View File
@@ -37,6 +37,8 @@ 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(
@@ -71,6 +73,8 @@ 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("/")
+3
View File
@@ -12,11 +12,13 @@ class ExceptionItem(BaseModel):
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):
@@ -24,6 +26,7 @@ def api_add_exception(item: ExceptionItem):
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):
+53 -11
View File
@@ -1,8 +1,7 @@
"""
===============================================================================
FILE: modules/web_api/routers/files.py
ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен
через изолированные UUID-директории инструментов.
ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен.
===============================================================================
"""
@@ -10,6 +9,7 @@ import os
import time
import shutil
import urllib.parse
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
@@ -17,13 +17,17 @@ 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)
REPORTS_DIR = os.path.join(BASE_ROOT, "output", "reports")
TEMP_REPORTS_DIR = "/tmp/scud_reports"
SESSION_TTL_HOURS = 24 # Срок жизни временных сессионных выгрузок
os.makedirs(WEB_OUTPUT_DIR, exist_ok=True)
os.makedirs(REPORTS_DIR, exist_ok=True)
os.makedirs(TEMP_REPORTS_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()
@@ -40,9 +44,6 @@ def purge_old_tool_sessions(tool_dir_path: str):
@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)
@@ -52,16 +53,14 @@ async def download_file(tool_name: str, session_uuid: str, filename: str):
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"):
if safe_filename.endswith((".md", ".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(
@@ -70,4 +69,47 @@ async def download_file(tool_name: str, session_uuid: str, filename: str):
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
}
)
def _find_report_recursively(target_filename: str) -> Optional[str]:
"""Рекурсивный поиск файла отчета по имени в /tmp и во всех подпапках output/reports/."""
# 1. Проверяем /tmp/scud_reports
tmp_path = os.path.join(TEMP_REPORTS_DIR, target_filename)
if os.path.exists(tmp_path) and os.path.isfile(tmp_path):
return tmp_path
# 2. Проверяем прямой путь в output/reports
direct_path = os.path.join(REPORTS_DIR, target_filename)
if os.path.exists(direct_path) and os.path.isfile(direct_path):
return direct_path
# 3. Рекурсивный поиск по подкаталогам (год/месяц)
for root, _, files in os.walk(REPORTS_DIR):
if target_filename in files:
return os.path.join(root, target_filename)
return None
@router.get("/download/reports/{filename:path}")
async def download_report_direct(filename: str):
"""
Прямое скачивание отчетов:
- Декодирует UTF-8 URL (%20 -> пробел).
- Ищет файл в output/reports/{YEAR}/{MONTH} и во временном буфере /tmp/scud_reports.
"""
decoded_name = urllib.parse.unquote(filename).strip()
safe_filename = os.path.basename(decoded_name)
target_path = _find_report_recursively(safe_filename)
if not target_path or 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}"}
)
@@ -68,4 +68,21 @@ def api_add_manual_absence(req: AddAbsenceRequest):
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"}
+26
View File
@@ -0,0 +1,26 @@
"""
===============================================================================
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)
+128
View File
@@ -0,0 +1,128 @@
"""
===============================================================================
FILE: modules/web_api/routers/reports.py
ROLE: REST API On-Demand генерации отчетов:
- Сводка: за СЕГОДНЯ (оперативный контроль, текущий срез).
- Детальный и Упрощенный отчет: строго за ВЧЕРА по финальному срезу Y.
===============================================================================
"""
import os
import logging
from datetime import datetime, timedelta
from typing import Optional
from pydantic import BaseModel
from fastapi import APIRouter, HTTPException
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"])
def get_previous_workday(target_date_str: str) -> str:
"""Вычисляет дату предыдущей рабочей смены (в понедельник возвращает пятницу)."""
clean_date = target_date_str.replace('_', '.')
try:
dt = datetime.strptime(clean_date, "%d.%m.%Y")
days_back = 3 if dt.weekday() == 0 else 1
return (dt - timedelta(days=days_back)).strftime("%d.%m.%Y")
except Exception:
return DATE_YESTERDAY
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):
"""
Генерирует выбранный отчет:
- SVODKA: на текущую дату (date).
- DETAILED / SIMPLIFIED: строго за вчерашний рабочий день по финальному срезу Y.
"""
target_date = (req.date or DATE_TODAY).replace('_', '.')
yesterday_date = get_previous_workday(target_date)
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. Детальный отчет (строго за ВЧЕРА по финальному срезу Y)
if r_type in ["DETAILED", "ALL"]:
res_det = generate_otchet_service(target_date=yesterday_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. Упрощенный отчет (строго за ВЧЕРА по финальному срезу Y)
if r_type in ["SIMPLIFIED", "ALL"]:
try:
df_scud_y = load_best_snapshot_for_date(yesterday_date, prefer_final_y=True)
if df_scud_y is not None and not df_scud_y.empty:
df_staff_y, df_abs_y = load_1c_files_for_date(yesterday_date)
df_merged_y = merge_scud_and_1c(df_scud_y, df_staff_y, df_abs_y)
out_path = generate_simplified_excel(df_merged_y, date_str=yesterday_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"Финальный срез СКУД (Y) за {yesterday_date} не найден.",
"status": "error"
})
except Exception as e:
logger.exception(f"Ошибка формирования упрощенного отчета: {e}")
results.append({
"type": "Упрощенный отчет",
"error": str(e),
"status": "error"
})
return {
"date": target_date,
"yesterday_date": yesterday_date,
"requested_type": r_type,
"reports": results
}
+372 -25
View File
@@ -1,53 +1,400 @@
"""
===============================================================================
FILE: modules/web_api/routers/snapshots.py
ROLE: REST API эндпоинты для управления и моментального создания срезов СКУД.
ROLE: Роутер срезов СКУД:
- Список срезов за период дат (/api/v1/snapshots)
- Инспекция среза (/api/v1/snapshots/{snapshot_id}/details и /inspect)
- Экспорт среза в Excel (.xlsx) и CSV (UTF-8 с BOM)
- Ручное создание среза (/create)
- Удаление срезов (пакетное через snapshot_ids)
===============================================================================
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
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 routers.auth import get_current_user
from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
from services.scud_export import run_export
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]
@router.get("")
def api_get_snapshots(date_str: Optional[str] = None, current_user = Depends(get_current_user)):
return get_snapshots_registry(date_str=date_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 api_create_instant_snapshot(req: CreateSnapshotRequest, current_user = Depends(get_current_user)):
"""Моментальный опрос MS SQL СКУД и запись свежего среза в SQLite."""
def create_snapshot(req: CreateSnapshotRequest):
from services.scud_export import run_export
target_date = (req.date_str or DATE_TODAY).replace('_', '.')
try:
success = run_export(input_date=req.date_str, save_xlsx=True, debug=False)
if not success:
raise HTTPException(status_code=500, detail="Ошибка при обращении к MS SQL Орион")
fresh_data = get_snapshots_registry(date_str=req.date_str)
return {"status": "success", "message": "Срез успешно создан", "data": fresh_data}
run_export(input_date=target_date, save_xlsx=True, debug=False)
return {"status": "ok", "message": f"Срез за {target_date} успешно создан"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка создания среза: {str(e)}")
logger.error(f"Ошибка создания среза: {e}")
raise HTTPException(status_code=500, detail=f"Не удалось создать срез: {e}")
@router.delete("")
def api_delete_snapshots(req: DeleteSnapshotsRequest, current_user = Depends(get_current_user)):
safe_ids = [s for s in req.snapshot_ids if not str(s).startswith("Y")]
if not safe_ids:
raise HTTPException(status_code=400, detail="Итоговый Y-срез защищен от удаления")
@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)
res = delete_snapshots_safely(snapshot_ids=safe_ids)
return {"status": "success", "deleted_count": res.get("deleted_count", len(safe_ids))}
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"
)
+173 -49
View File
@@ -8,21 +8,9 @@
<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;
}
/* Шрифт сообщений чата */
* { 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 {
@@ -33,31 +21,167 @@
#chat-messages-container code {
font-size: 13.5px !important;
}
.user-chat-bubble {
scroll-margin-top: 24px !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-80 md:w-96 bg-white border-r border-slate-200 flex flex-col shrink-0 h-full shadow-sm z-10 select-none">
<!-- ЛЕВАЯ КОЛОНКА (САЙДБАР) -->
<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 id="sidebar-dynamic-header" class="shrink-0 bg-slate-50 border-b border-slate-200"></div>
<!-- НАВИГАЦИОННЫЙ ТАБ-БАР -->
<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 id="sidebar-dynamic-content" class="flex-1 overflow-y-auto p-2 flex flex-col gap-2">
<div id="tasks-list-container" class="flex-1 flex flex-col gap-2">
<div class="text-center py-10 text-xs text-slate-400">
<i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...
<!-- РАБОЧИЕ ОБЛАСТИ ВКЛАДОК -->
<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">
@@ -73,11 +197,9 @@
<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>
@@ -100,6 +222,13 @@
<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>
<!-- ЛЕНТА ЧАТА -->
@@ -150,25 +279,20 @@
<!-- КОНТЕЙНЕР ДИНАМИЧЕСКИХ МОДАЛЬНЫХ ОКОН -->
<div id="modals-container"></div>
<!-- ГЛОБАЛЬНАЯ DRAG-AND-DROP ЗОНА -->
<div id="global-drag-overlay"
class="fixed inset-0 bg-indigo-900/40 backdrop-blur-xs z-50 hidden flex items-center justify-center pointer-events-none transition-all duration-200">
<div class="bg-white border-2 border-dashed border-indigo-500 rounded-3xl p-10 flex flex-col items-center gap-3 shadow-2xl scale-100 transition-transform">
<div class="w-16 h-16 rounded-2xl bg-indigo-50 text-indigo-600 flex items-center justify-center text-3xl shadow-inner">
<i class="fa-solid fa-cloud-arrow-up animate-bounce"></i>
</div>
<div class="text-base font-bold text-slate-800">Перетащите файл в любую точку окна</div>
<div class="text-xs text-slate-500 font-medium">PDF-документы, сканы, отчеты или изображения</div>
</div>
</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/auth.js?v=2.5.8"></script>
<script src="/static/js/tasks.js?v=2.5.8"></script>
<script src="/static/js/manual_absences.js?v=2.5.8"></script>
<script src="/static/js/sidebar.js?v=2.5.8"></script>
<script src="/static/js/chat/task_widget.js?v=2.5.8"></script>
<script src="/static/js/chat/core.js?v=2.5.8"></script>
<script src="/static/js/app.js?v=2.5.8"></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>
+25 -6
View File
@@ -28,7 +28,9 @@ async function loadModals() {
'exception_modal.html',
'auth_modal.html',
'profile_modal.html',
'admin_modal.html'
'admin_modal.html',
'snapshot_inspector_modal.html',
'presence_modal.html'
];
const container = document.getElementById('modals-container');
@@ -36,7 +38,7 @@ async function loadModals() {
for (const file of modalFiles) {
try {
const res = await fetch(`/static/modals/${file}?v=2.5.8`);
const res = await fetch(`/static/modals/${file}?v=2.6.0`);
if (res.ok) {
const html = await res.text();
container.insertAdjacentHTML('beforeend', html);
@@ -99,7 +101,7 @@ async function handleLoginSubmit(e) {
AuthManager.setSession(data.token, data.username, data.full_name, data.is_admin, data.user_id);
hideAuthModal();
updateUIState();
if (window.SidebarManager) SidebarManager.setHub('TASKS');
if (window.SidebarManager) SidebarManager.switchTab('tasks');
} else {
const err = await res.json();
if (errEl) {
@@ -272,9 +274,14 @@ function openRemoteWorkerModal(mode = 'ADD', fio = '', dept = 'Все', dateFrom
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') {
@@ -340,7 +347,7 @@ async function handleRemoteWorkerSubmit(e) {
if (res.ok) {
closeRemoteWorkerModal();
if (window.SidebarManager) SidebarManager.renderContent();
if (window.SidebarManager) SidebarManager.switchRegistrySubTab('remote');
} else {
const err = await res.json();
if (errEl) {
@@ -370,7 +377,19 @@ document.addEventListener("DOMContentLoaded", async () => {
setupStaffAutocomplete(excInput, "exception-suggestions");
}
// 3. Авто-высота поля ввода команд
// 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() {
@@ -380,7 +399,7 @@ document.addEventListener("DOMContentLoaded", async () => {
});
}
// 4. Проверка сессии пользователя
// 5. Проверка сессии пользователя
if (IS_GUEST) {
hideAuthModal();
updateUIState();
+124 -140
View File
@@ -1,8 +1,8 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/manual_absences.js
* ROLE: Модальные окна "Мест. командир.", "Иное", живой автокомплит ФИО из 1С:ЗУП
* и мгновенная синхронизация с боковой панелью SidebarManager.
* ROLE: Модальные окна "Мест. командир.", "Иное", универсальный автокомплит ФИО
* и синхронизация с боковой панелью SidebarManager.
* ===============================================================================
*/
@@ -21,18 +21,24 @@ async function loadAbsenceReasons() {
}
}
function openManualAbsenceModal(type) {
// ⭐️ Открытие окна: режим создания (item = null) или редактирования (item = {...})
function openManualAbsenceModal(type, editItem = null) {
activeAbsenceType = type;
const isTrip = type === 'LOCAL_TRIP';
const titleEl = document.getElementById('manual-absence-modal-title');
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');
if (titleEl) {
titleEl.innerHTML = isTrip
? '<i class="fa-solid fa-location-dot text-indigo-600 mr-2"></i>Местная командировка'
: '<i class="fa-solid fa-clipboard-list text-purple-600 mr-2"></i>Иные причины отсутствия';
}
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) {
@@ -43,31 +49,37 @@ function openManualAbsenceModal(type) {
}
}
// Сброс полей ввода
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 suggestionsBox = document.getElementById('manual-absence-suggestions');
const today = new Date().toISOString().split('T')[0];
if (fioInput) fioInput.value = '';
if (deptInput) deptInput.value = '';
if (posInput) posInput.value = '';
if (startDateInput) startDateInput.value = '';
if (suggestionsBox) {
suggestionsBox.classList.add('hidden');
suggestionsBox.innerHTML = '';
}
// Окончание по умолчанию — сегодняшний день
if (endDateInput) {
const today = new Date().toISOString().split('T')[0];
endDateInput.value = today;
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;
}
loadManualAbsencesTable();
const modal = document.getElementById('manual-absence-modal');
if (modal) modal.classList.remove('hidden');
}
@@ -76,7 +88,65 @@ function closeManualAbsenceModal() {
if (modal) modal.classList.add('hidden');
}
// Живой автокомплит ФИО из базы zup_staff
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);
@@ -103,7 +173,7 @@ function setupStaffAutocomplete(inputEl, suggestionsBoxId) {
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"
onclick="selectStaffSuggestion('${escapeHtml(it.fio)}', '${escapeHtml(it.department)}', '${escapeHtml(it.position)}')">
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>
@@ -114,118 +184,32 @@ function setupStaffAutocomplete(inputEl, suggestionsBoxId) {
}
}, 200);
});
document.addEventListener('click', (e) => {
if (!inputEl.contains(e.target) && !box.contains(e.target)) {
box.classList.add('hidden');
}
});
}
function selectStaffSuggestion(fio, dept, pos) {
const fioInput = document.getElementById('manual-absence-fio-input');
const deptInput = document.getElementById('manual-absence-dept');
const posInput = document.getElementById('manual-absence-pos');
const box = document.getElementById('manual-absence-suggestions');
function selectStaffSuggestion(fio, dept, pos, targetInputId, boxId) {
const targetFioEl = document.getElementById(targetInputId);
if (targetFioEl) targetFioEl.value = fio;
if (fioInput) fioInput.value = fio;
if (deptInput) deptInput.value = dept;
if (posInput) posInput.value = pos;
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');
}
async function submitManualAbsence() {
const fioInput = document.getElementById('manual-absence-fio-input');
const fio = fioInput ? fioInput.value.trim() : '';
if (!fio) {
alert('Укажите ФИО сотрудника');
return;
}
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 payload = {
absence_type: activeAbsenceType,
fio: fio,
department: deptVal,
position: posVal,
date_start: startDateVal,
date_end: endDateVal,
reason: activeAbsenceType === 'LOCAL_TRIP' ? 'Местная командировка' : (reasonSelect ? reasonSelect.value : 'Иное')
};
try {
const res = await fetch('/api/v1/manual-absences/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
if (fioInput) fioInput.value = '';
loadManualAbsencesTable();
// Обновляем список карточек в боковой панели и закрываем окно
if (window.SidebarManager && typeof SidebarManager.renderContent === 'function') {
SidebarManager.renderContent();
}
closeManualAbsenceModal();
} else {
alert('Ошибка добавления записи');
}
} catch (e) {
alert('Сетевая ошибка при добавлении');
}
}
async function loadManualAbsencesTable() {
const tableContainer = document.getElementById('manual-absences-table-body');
if (!tableContainer) return;
try {
const res = await fetch(`/api/v1/manual-absences/?type=${activeAbsenceType}`);
if (!res.ok) return;
const data = await res.json();
const items = data.items || [];
if (items.length === 0) {
tableContainer.innerHTML = '<tr><td colspan="5" class="text-center p-4 text-xs text-slate-400">Нет активных записей</td></tr>';
return;
}
tableContainer.innerHTML = items.map(it => `
<tr class="border-b border-slate-100 text-xs hover:bg-slate-50">
<td class="p-2 font-bold text-slate-800">${escapeHtml(it.fio)}</td>
<td class="p-2 text-slate-500">${escapeHtml(it.department || '—')}</td>
<td class="p-2 text-slate-600">${escapeHtml(it.reason)}</td>
<td class="p-2 text-center text-slate-500 font-mono text-[11px]">${it.date_start || '—'} / ${it.date_end || '—'}</td>
<td class="p-2 text-center">
<button onclick="deleteManualAbsenceRecord(${it.id})" class="text-slate-400 hover:text-rose-600 transition p-1" title="Удалить">
<i class="fa-solid fa-trash-can"></i>
</button>
</td>
</tr>
`).join('');
} catch (e) {
console.error(e);
}
}
async function deleteManualAbsenceRecord(id) {
if (!confirm('Удалить эту запись?')) return;
try {
const res = await fetch(`/api/v1/manual-absences/${id}`, { method: 'DELETE' });
if (res.ok) {
loadManualAbsencesTable();
if (window.SidebarManager && typeof SidebarManager.renderContent === 'function') {
SidebarManager.renderContent();
}
}
} catch (e) {
alert('Ошибка при удалении');
}
}
document.addEventListener('DOMContentLoaded', () => {
loadAbsenceReasons();
setupStaffAutocomplete(
document.getElementById('manual-absence-fio-input'),
'manual-absence-suggestions'
);
});
+176
View File
@@ -0,0 +1,176 @@
/**
* ===============================================================================
* 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();
-673
View File
@@ -1,673 +0,0 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/sidebar.js
* ROLE: Контроллер левого сайдбара с 5-хабовой навигацией, реестрами (2x2),
* модальным окном добавления исключений с автокомплитом из 1С:ЗУП.
* ===============================================================================
*/
window.escapeHtml = function(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
window.SidebarManager = {
currentHub: 'TASKS',
currentSubTab: {
'REGISTRIES': 'REMOTE'
},
hubs: [
{ id: 'TASKS', label: 'Задачи', icon: 'fa-list-check' },
{ id: 'SNAPSHOTS', label: 'Срезы', icon: 'fa-camera' },
{ id: 'REGISTRIES', label: 'Реестры', icon: 'fa-address-book' },
{ id: 'PROMPT', label: 'Промпт', icon: 'fa-terminal' },
{ id: 'CONTEXT', label: 'Контекст', icon: 'fa-comments' }
],
// Сетка реестров 2x2
subTabs: {
'REGISTRIES': [
{ id: 'REMOTE', label: 'Удаленщики', icon: 'fa-house-laptop' },
{ id: 'EXCEPTIONS', label: 'Исключения', icon: 'fa-user-shield' },
{ id: 'LOCAL_TRIP', label: 'Мест. командир.', icon: 'fa-location-dot' },
{ id: 'OTHER', label: 'Иное', icon: 'fa-clipboard-list' }
]
},
init() {
this.renderHeader();
this.renderContent();
},
setHub(hubId) {
this.currentHub = hubId;
this.renderHeader();
this.renderContent();
},
setSubTab(subTabId) {
this.currentSubTab[this.currentHub] = subTabId;
this.renderHeader();
this.renderContent();
},
renderHeader() {
const headerContainer = document.getElementById("sidebar-dynamic-header");
if (!headerContainer) return;
// 1. Основные 5 Хабов
const hubsHtml = `
<div class="flex items-center border-b border-slate-200 bg-slate-50/80 px-1 pt-1.5 overflow-x-auto gap-0.5">
${this.hubs.map(h => {
const isActive = this.currentHub === h.id;
return `
<button onclick="SidebarManager.setHub('${h.id}')"
class="flex-1 py-1.5 px-1 flex flex-col items-center gap-1 border-b-2 font-bold text-[10px] transition ${
isActive
? 'border-indigo-600 text-indigo-600 bg-white rounded-t-lg shadow-sm'
: 'border-transparent text-slate-500 hover:text-slate-800 hover:bg-slate-100/60 rounded-t-lg'
}">
<i class="fa-solid ${h.icon} text-xs"></i>
<span class="truncate">${h.label}</span>
</button>
`;
}).join('')}
</div>
`;
// 2. Подвкладки реестров (Сетка 2x2)
let subTabsHtml = '';
if (this.subTabs[this.currentHub]) {
const currentActiveSub = this.currentSubTab[this.currentHub] || this.subTabs[this.currentHub][0].id;
subTabsHtml = `
<div class="grid grid-cols-2 gap-1.5 p-1.5 bg-slate-100/90 border-b border-slate-200">
${this.subTabs[this.currentHub].map(st => {
const isSubActive = currentActiveSub === st.id;
return `
<button onclick="SidebarManager.setSubTab('${st.id}')"
class="py-1 px-2 rounded-md text-[11px] font-semibold flex items-center justify-center gap-1.5 transition ${
isSubActive
? 'bg-white text-indigo-700 shadow-sm'
: 'text-slate-600 hover:text-slate-900 hover:bg-white/50'
}">
<i class="fa-solid ${st.icon} text-[10px]"></i>
<span>${st.label}</span>
</button>
`;
}).join('')}
</div>
`;
}
headerContainer.innerHTML = hubsHtml + subTabsHtml;
},
renderContent() {
const contentContainer = document.getElementById("sidebar-dynamic-content");
if (!contentContainer) return;
contentContainer.scrollTop = 0;
switch (this.currentHub) {
case 'TASKS':
this.renderTasksView(contentContainer);
break;
case 'SNAPSHOTS':
this.renderSnapshotsView(contentContainer);
break;
case 'REGISTRIES':
this.renderRegistriesView(contentContainer);
break;
case 'PROMPT':
this.renderPromptView(contentContainer);
break;
case 'CONTEXT':
this.renderContextView(contentContainer);
break;
default:
contentContainer.innerHTML = `<div class="p-4 text-xs text-slate-400 text-center">Раздел в разработке</div>`;
}
},
// =========================================================================
// ХАБ 1: ЗАДАЧИ
// =========================================================================
renderTasksView(container) {
container.innerHTML = `
<div id="tasks-list-container" class="flex-1 flex flex-col gap-2">
<div class="text-center py-10 text-xs text-slate-400">
<i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка задач...
</div>
</div>
`;
if (window.loadTasks) {
window.loadTasks();
}
},
// =========================================================================
// ХАБ 2: СРЕЗЫ СКУД
// =========================================================================
async renderSnapshotsView(container) {
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/snapshots", { headers: AuthManager.getAuthHeaders() });
const data = res.ok ? await res.json() : { snapshots: [] };
const snaps = data.snapshots || [];
if (snaps.length === 0) {
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400">Срезы СКУД не найдены</div>`;
return;
}
const itemsHtml = snaps.map(s => `
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-indigo-300 transition">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="font-bold text-slate-800">${escapeHtml(s.snapshot_id)}</span>
${s.is_final ? '<span class="px-1.5 py-0.2 rounded text-[9px] font-bold bg-amber-50 text-amber-700 border border-amber-200">Финал Y</span>' : ''}
</div>
<div class="text-[10px] text-slate-400 mt-0.5">${escapeHtml(s.snapshot_time)} · ${s.record_count || 0} зап.</div>
</div>
<button onclick="window.sendChatAction('покажи срез ${escapeHtml(s.snapshot_id)}')" class="px-2 py-1 bg-slate-100 hover:bg-indigo-50 text-slate-600 hover:text-indigo-600 rounded-lg text-[10px] font-semibold transition" title="Открыть в чате">
Инспекция
</button>
</div>
`).join('');
container.innerHTML = `
<div class="p-2 flex flex-col gap-2">
<div class="flex items-center justify-between px-1">
<span class="text-xs font-bold text-slate-700">Всего срезов: ${snaps.length}</span>
<button onclick="SidebarManager.renderSnapshotsView(document.getElementById('sidebar-dynamic-content'))" class="text-slate-400 hover:text-indigo-600 p-1" title="Обновить">
<i class="fa-solid fa-arrows-rotate text-xs"></i>
</button>
</div>
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">${itemsHtml}</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки срезов</div>`;
}
},
// =========================================================================
// ХАБ 3: РЕЕСТРЫ (УДАЛЕНЩИКИ + ИСКЛЮЧЕНИЯ + МЕСТ. КОМАНДИР. + ИНОЕ)
// =========================================================================
renderRegistriesView(container) {
const subTab = this.currentSubTab['REGISTRIES'] || 'REMOTE';
if (subTab === 'REMOTE') {
this.renderRemoteWorkersView(container);
} else if (subTab === 'EXCEPTIONS') {
this.renderExceptionsView(container);
} else {
this.renderManualAbsencesView(container, subTab);
}
},
async renderRemoteWorkersView(container) {
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/remote-workers", { headers: AuthManager.getAuthHeaders() });
const data = res.ok ? await res.json() : { workers: [] };
const workers = data.workers || [];
const listHtml = workers.map(w => {
const dFrom = w.date_from ? w.date_from : 'сегодня';
const dTo = w.date_to ? w.date_to : 'бессрочно';
const periodLabel = (!w.date_to) ? `с ${dFrom} (бессрочно)` : `${dFrom} — ${dTo}`;
return `
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-emerald-300 transition">
<div class="min-w-0 flex-1">
<div class="font-bold text-slate-800 truncate">${escapeHtml(w.fio)}</div>
<div class="text-[10px] text-slate-400 truncate">${escapeHtml(w.department || 'Все')}</div>
<div class="mt-0.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">
<i class="fa-regular fa-calendar-days text-[8px]"></i>
<span>${escapeHtml(periodLabel)}</span>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0">
<button onclick="openRemoteWorkerModal('EDIT', '${escapeHtml(w.fio)}', '${escapeHtml(w.department || 'Все')}', '${escapeHtml(w.date_from || '')}', '${escapeHtml(w.date_to || '')}')"
class="text-slate-400 hover:text-emerald-600 p-1.5 rounded-lg hover:bg-emerald-50 transition"
title="Изменить сроки удаленки">
<i class="fa-solid fa-pen-to-square text-xs"></i>
</button>
<button onclick="SidebarManager.deleteRemoteWorker('${escapeHtml(w.fio)}')"
class="text-slate-400 hover:text-rose-600 p-1.5 rounded-lg hover:bg-rose-50 transition"
title="Удалить">
<i class="fa-solid fa-trash-can text-xs"></i>
</button>
</div>
</div>
`;
}).join('');
container.innerHTML = `
<div class="p-2 flex flex-col gap-2.5">
<div class="flex items-center justify-between px-1">
<span class="text-xs font-bold text-slate-700">В реестре: ${workers.length} чел.</span>
<button onclick="openRemoteWorkerModal('ADD')" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-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>
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
${workers.length > 0 ? listHtml : '<div class="text-center py-8 text-xs text-slate-400">Список удаленщиков пуст</div>'}
</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки реестра удаленщиков</div>`;
}
},
async deleteRemoteWorker(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) {
this.renderContent();
} else {
alert("Ошибка удаления");
}
} catch (e) {
alert("Ошибка сети");
}
},
async renderExceptionsView(container) {
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/exceptions/", { headers: AuthManager.getAuthHeaders() });
const data = res.ok ? await res.json() : {};
const categories = [
{ key: 'include_fio', title: 'Белый список (ФИО)' },
{ key: 'fio', title: 'Исключенные сотрудники (ФИО)' },
{ key: 'departments', title: 'Исключенные отделы' },
{ key: 'positions', title: 'Исключенные должности' },
{ key: 'turnstile_fio', title: 'Пр. турникет (ФИО)', badge: 'Оба турникета' },
{ key: 'turnstile_departments', title: 'Пр. турникет (Отделы)', badge: 'Оба турникета' }
];
const html = categories.map(cat => {
const items = data[cat.key] || [];
const isTurnstile = cat.key.startsWith('turnstile_');
const badgeHtml = cat.badge
? `<span class="px-1.5 py-0.2 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">${cat.badge}</span>`
: '';
return `
<div class="bg-white border ${isTurnstile ? 'border-emerald-200/80 bg-emerald-50/10' : 'border-slate-200'} rounded-xl p-3 flex flex-col gap-2 shadow-sm">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<span class="font-bold text-xs text-slate-700">${cat.title} (${items.length})</span>
${badgeHtml}
</div>
<button onclick="openExceptionModal('${cat.key}', '${cat.title}')" class="text-indigo-600 hover:text-indigo-800 text-xs font-bold">
+ Добавить
</button>
</div>
<div class="flex flex-wrap gap-1">
${items.map(it => `
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] ${isTurnstile ? 'bg-emerald-50 text-emerald-800 border border-emerald-200' : 'bg-slate-100 text-slate-700 border border-slate-200'}">
${escapeHtml(it)}
<button onclick="SidebarManager.deleteExceptionItem('${cat.key}', '${escapeHtml(it)}')" class="hover:text-rose-600 ml-0.5">×</button>
</span>
`).join('') || '<span class="text-[10px] text-slate-400">Пусто</span>'}
</div>
</div>
`;
}).join('');
container.innerHTML = `<div class="p-2 flex flex-col gap-2 max-h-[75vh] overflow-y-auto">${html}</div>`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки исключений</div>`;
}
},
async deleteExceptionItem(category, value) {
if (!confirm(`Удалить "${value}" из реестра?`)) return;
try {
const res = await fetch(`/api/v1/exceptions/?category=${encodeURIComponent(category)}&value=${encodeURIComponent(value)}`, {
method: "DELETE",
headers: AuthManager.getAuthHeaders()
});
if (res.ok) this.renderContent();
else alert("Ошибка удаления");
} catch (e) {
alert("Ошибка сети");
}
},
async renderManualAbsencesView(container, absenceType) {
const typeLabel = absenceType === 'LOCAL_TRIP' ? 'местных командировок' : 'иных отсутствий';
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка ${typeLabel}...</div>`;
try {
const res = await fetch(`/api/v1/manual-absences/?type=${absenceType}`);
const data = res.ok ? await res.json() : { items: [] };
const items = data.items || [];
const listHtml = items.map(it => {
const dFrom = it.date_start ? it.date_start : 'сегодня';
const dTo = it.date_end ? it.date_end : 'сегодня';
const periodLabel = (dFrom === dTo) ? `на ${dFrom}` : `${dFrom} — ${dTo}`;
const badgeText = absenceType === 'LOCAL_TRIP' ? 'Местная командировка' : escapeHtml(it.reason);
return `
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-indigo-300 transition">
<div class="min-w-0 flex-1">
<div class="font-bold text-slate-800 truncate">${escapeHtml(it.fio)}</div>
<div class="text-[10px] text-slate-400 truncate">${escapeHtml(it.department || 'Все')} · ${badgeText}</div>
<div class="mt-0.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-indigo-50 text-indigo-700 border border-indigo-200">
<i class="fa-regular fa-calendar-days text-[8px]"></i>
<span>${escapeHtml(periodLabel)}</span>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0">
<button onclick="SidebarManager.deleteManualAbsenceRecord(${it.id})"
class="text-slate-400 hover:text-rose-600 p-1.5 rounded-lg hover:bg-rose-50 transition"
title="Удалить">
<i class="fa-solid fa-trash-can text-xs"></i>
</button>
</div>
</div>
`;
}).join('');
container.innerHTML = `
<div class="p-2 flex flex-col gap-2.5">
<div class="flex items-center justify-between px-1">
<span class="text-xs font-bold text-slate-700">В реестре: ${items.length} чел.</span>
<button onclick="openManualAbsenceModal('${absenceType}')" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-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>
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
${items.length > 0 ? listHtml : '<div class="text-center py-8 text-xs text-slate-400">Список пуст</div>'}
</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки реестра</div>`;
}
},
async deleteManualAbsenceRecord(id) {
if (!confirm("Удалить эту запись из реестра?")) return;
try {
const res = await fetch(`/api/v1/manual-absences/${id}`, { method: "DELETE" });
if (res.ok) {
this.renderContent();
} else {
alert("Ошибка удаления");
}
} catch (e) {
alert("Ошибка сети");
}
},
// =========================================================================
// ХАБ 4: СИСТЕМНЫЙ ПРОМПТ И БАЗА ЗНАНИЙ
// =========================================================================
renderPromptView(container) {
container.innerHTML = `
<div class="p-3 flex flex-col gap-3">
<div class="text-[11px] text-slate-600 leading-relaxed bg-white border border-slate-200 rounded-xl p-3 shadow-sm flex flex-col gap-1.5">
<span class="font-bold text-slate-800 flex items-center gap-1.5">
<i class="fa-solid fa-sliders text-indigo-600"></i> Инструкции и регламенты ИИ
</span>
<span>Управление системными директивами, базой знаний и правилами арбитража кадровых аномалий СКУД и 1С.</span>
</div>
<div class="flex flex-col gap-2">
<button onclick="window.sendChatAction('покажи системный промпт')"
class="w-full py-2 px-3 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-terminal text-xs"></i>
<span>Показать системный промпт</span>
</button>
<button onclick="window.sendChatAction('покажи правила компании')"
class="w-full py-2 px-3 bg-white hover:bg-slate-50 active:bg-slate-100 text-slate-700 border border-slate-300 rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-book-bookmark text-emerald-600 text-xs"></i>
<span>База знаний и правила компании</span>
</button>
</div>
</div>
`;
},
// =========================================================================
// ХАБ 5: УПРАВЛЕНИЕ КОНТЕКСТОМ СЕССИИ
// =========================================================================
renderContextView(container) {
container.innerHTML = `
<div class="p-3 flex flex-col gap-3">
<div class="text-[11px] text-slate-600 leading-relaxed bg-white border border-slate-200 rounded-xl p-3 shadow-sm flex flex-col gap-2">
<span class="font-bold text-slate-800 flex items-center gap-1.5">
<i class="fa-solid fa-brain text-indigo-600"></i> Управление памятью чата
</span>
<div class="flex flex-col gap-1.5 pt-1 border-t border-slate-100">
<div class="flex items-start gap-1.5">
<span class="w-2 h-2 rounded-full bg-amber-500 mt-1 shrink-0"></span>
<div>
<span class="font-bold text-slate-700">Мягкая очистка:</span>
<span class="text-slate-500"> удаляет только служебные транзакции (карточки срезов, временные превью промпта, промежуточные подтверждения). Смысловой диалог пользователя сохраняется.</span>
</div>
</div>
<div class="flex items-start gap-1.5">
<span class="w-2 h-2 rounded-full bg-rose-500 mt-1 shrink-0"></span>
<div>
<span class="font-bold text-slate-700">Полный сброс:</span>
<span class="text-slate-500"> полностью стирает контекст активной сессии из базы данных и очищает окно чата. Используется при переходе к новой дате или новой теме анализа.</span>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<button onclick="window.sendChatAction('очисти контекст')"
class="w-full py-2 px-3 bg-amber-500 hover:bg-amber-600 active:bg-amber-700 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-broom text-xs"></i>
<span>Мягкая очистка контекста</span>
</button>
<button onclick="SidebarManager.handleFullSessionReset()"
class="w-full py-2 px-3 bg-rose-600 hover:bg-rose-700 active:bg-rose-800 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-trash-arrow-up text-xs"></i>
<span>Полный сброс сессии</span>
</button>
</div>
</div>
`;
},
async handleFullSessionReset() {
if (!confirm("Вы действительно хотите полностью очистить историю диалога и сбросить сессию чата?")) {
return;
}
const chatContainer = document.getElementById("chat-messages-container");
if (chatContainer) {
chatContainer.innerHTML = `
<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">
Сессия чата очищена. Память ассистента сброшена. Задайте новый вопрос или команду.
</div>
</div>
</div>
`;
}
try {
await fetch("/api/v1/chat", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({
message: "сбрось сессию полностью",
session_id: "web_session_main"
})
});
} catch (e) {
console.error("Ошибка запроса сброса сессии:", e);
}
}
};
// ============================================================================
// МОДАЛЬНОЕ ОКНО ИСКЛЮЧЕНИЙ И АВТОКОМПЛИТ 1С
// ============================================================================
window.openExceptionModal = function(category, title = "") {
const modal = document.getElementById("exception-modal");
const headerText = document.getElementById("exception-modal-header-text");
const catInput = document.getElementById("exception-category-input");
const valInput = document.getElementById("exception-value-input");
const labelEl = document.getElementById("exception-value-label");
const commentInput = document.getElementById("exception-comment-input");
const errEl = document.getElementById("exception-error-msg");
const suggestionsBox = document.getElementById("exception-suggestions");
if (!modal) return;
if (errEl) errEl.classList.add("hidden");
if (suggestionsBox) {
suggestionsBox.classList.add("hidden");
suggestionsBox.innerHTML = "";
}
if (catInput) catInput.value = category;
if (commentInput) commentInput.value = "";
if (valInput) valInput.value = "";
if (headerText) headerText.innerText = title || "Добавление в реестр";
if (labelEl && valInput) {
if (category.includes("fio")) {
labelEl.innerText = "ФИО сотрудника (автоподбор из 1С):";
valInput.placeholder = "Начните вводить фамилию...";
} else if (category.includes("department")) {
labelEl.innerText = "Подразделение:";
valInput.placeholder = "Например: ЭТО, ЛЦ, ОВК";
} else {
labelEl.innerText = "Должность:";
valInput.placeholder = "Например: Уборщик, Слесарь";
}
}
modal.classList.remove("hidden");
if (valInput) valInput.focus();
};
window.closeExceptionModal = function() {
const modal = document.getElementById("exception-modal");
if (modal) modal.classList.add("hidden");
};
window.submitExceptionModalForm = async function(e) {
e.preventDefault();
const category = document.getElementById("exception-category-input").value;
const value = document.getElementById("exception-value-input").value.trim();
const comment = document.getElementById("exception-comment-input")?.value.trim() || "";
const errEl = document.getElementById("exception-error-msg");
if (!value) return;
try {
const res = await fetch("/api/v1/exceptions/", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ category: category, value: value, comment: comment })
});
if (res.ok) {
closeExceptionModal();
if (window.SidebarManager) SidebarManager.renderContent();
} else {
const err = await res.json().catch(() => ({}));
if (errEl) {
errEl.innerText = err.detail || "Ошибка сохранения";
errEl.classList.remove("hidden");
}
}
} catch (err) {
if (errEl) {
errEl.innerText = "Ошибка соединения с сервером";
errEl.classList.remove("hidden");
}
}
};
let excSearchTimeout = null;
document.addEventListener("DOMContentLoaded", () => {
if (window.AuthManager && AuthManager.isAuthenticated()) {
SidebarManager.init();
}
const inputEl = document.getElementById("exception-value-input");
const box = document.getElementById("exception-suggestions");
if (inputEl && box) {
inputEl.addEventListener("input", function() {
const category = document.getElementById("exception-category-input")?.value || "";
if (!category.includes("fio")) {
box.classList.add("hidden");
return;
}
const val = this.value.trim();
clearTimeout(excSearchTimeout);
if (val.length < 2) {
box.classList.add("hidden");
box.innerHTML = "";
return;
}
excSearchTimeout = 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"
onclick="selectExceptionStaff('${escapeHtml(it.fio)}')">
<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);
});
}
});
window.selectExceptionStaff = function(fio) {
const inputEl = document.getElementById("exception-value-input");
const box = document.getElementById("exception-suggestions");
if (inputEl) inputEl.value = fio;
if (box) {
box.classList.add("hidden");
box.innerHTML = "";
}
};
+70
View File
@@ -0,0 +1,70 @@
/**
* Роутер табов сайдбара и общие утилиты.
*/
window.escapeHtml = window.escapeHtml || function (str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
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();
});
@@ -0,0 +1,100 @@
/**
* Модуль вкладок "Промпт" и "Контекст".
*/
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('Ошибка сети'); }
}
@@ -0,0 +1,420 @@
/**
* Модуль вкладок "Реестры": исключения, удаленщики, командировки, флигель.
*/
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('Ошибка сети');
}
}
@@ -0,0 +1,308 @@
/**
* Модуль вкладки "Срезы": список снапшотов по диапазону дат, создание, инспекция и генерация отчетов.
*/
// 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 fromInput = document.getElementById('snapshots-date-from');
const dateInput = fromInput ? isoToBackendFormat(fromInput.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: 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;
@@ -0,0 +1,138 @@
/**
* ===============================================================================
* 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');
};
+10
View File
@@ -5,6 +5,16 @@
* ===============================================================================
*/
window.escapeHtml = window.escapeHtml || function (str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
let currentTasksFilter = 'ALL';
let tasksCache = [];
@@ -3,54 +3,57 @@
<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>Добавление в реестр</span>
<span id="manual-absence-modal-title-text">Добавление в реестр</span>
</h3>
<button type="button" onclick="closeManualAbsenceModal()" class="text-slate-400 hover:text-slate-600">
<i class="fa-solid fa-xmark"></i>
<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>
<div class="flex flex-col gap-3">
<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" 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" />
<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>
<input type="hidden" id="manual-absence-dept" />
<input type="hidden" id="manual-absence-pos" />
<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"></select>
<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"
<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>
<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"
<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>
<span class="text-[10px] text-slate-400 mt-0.5 block">Включительно</span>
</div>
</div>
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
<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="button" onclick="submitManualAbsence()"
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition">
<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>
</div>
</form>
</div>
</div>
@@ -0,0 +1,101 @@
<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>
@@ -13,10 +13,11 @@
<form id="remote-worker-form" onsubmit="handleRemoteWorkerSubmit(event)" class="flex flex-col gap-3">
<input type="hidden" id="rw-mode" value="ADD" />
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника:</label>
<input type="text" id="rw-fio" required placeholder="Например: Иванов Иван Иванович"
<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>
@@ -0,0 +1,94 @@
<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>