feat(turnstile): двухконтурный учет СКУД, реестры исключений с автокомплитом 1С и калибровка таймзон
This commit is contained in:
@@ -106,21 +106,31 @@ def process_chat_message(
|
||||
active_date = state_data.get("query_date", "выбранную дату")
|
||||
active_state_context = f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n- Отображаются срезы за {active_date}.\n"
|
||||
elif current_state_type == "SNAPSHOT_INSPECT":
|
||||
snap_id = state_data.get("snapshot_id", "")
|
||||
snap_date = state_data.get("log_date", "")
|
||||
records = state_data.get("records", [])
|
||||
|
||||
lines = []
|
||||
for r in records:
|
||||
st = "Присутствовал" if r.get("is_present") else "Отсутствовал"
|
||||
lines.append(f"- {r.get('fio')}: Отдел={r.get('department')}, Вход={r.get('time_in')}, Активность={r.get('first_activity')}, Выход={r.get('time_out')}, ВремяВЗдании={r.get('in_building')}, Статус={st}")
|
||||
|
||||
dump_str = "\n".join(lines)
|
||||
active_state_context = (
|
||||
f"\n[ТЕКУЩИЙ РЕЖИМ: АКТИВНА ИНСПЕКЦИЯ СРЕЗА СКУД #{snap_id} ЗА {snap_date}]\n"
|
||||
f"Оператор сейчас изучает срез #{snap_id}. При любых вопросах о сотрудниках, их времени входа/выхода, отделах или присутствии — "
|
||||
f"ТЫ ОБЯЗАН брать данные исключительно из этого списка активного среза:\n{dump_str}\n"
|
||||
)
|
||||
# ⭐️ Защита от залипания: если вопрос бытовой или отвлеченный, выходим из жесткого режима инспекции
|
||||
msg_l = user_message.lower().strip()
|
||||
scud_terms = ["срез", "скуд", "вход", "выход", "здани", "присутств", "отсутств", "кто в", "кто сейчас", "1с", "зуп", "турникет", "карточк", "инспекци"]
|
||||
if not any(t in msg_l for t in scud_terms) and len(msg_l.split()) <= 12:
|
||||
db_clear_session_state(session_id)
|
||||
current_state_type = None
|
||||
active_state_context = ""
|
||||
else:
|
||||
snap_id = state_data.get("snapshot_id", "")
|
||||
snap_date = state_data.get("log_date", "")
|
||||
records = state_data.get("records", [])
|
||||
|
||||
lines = []
|
||||
for r in records:
|
||||
st = "Присутствовал" if r.get("is_present") else "Отсутствовал"
|
||||
lines.append(f"- {r.get('fio')}: Отдел={r.get('department')}, Вход={r.get('time_in')}, Активность={r.get('first_activity')}, Выход={r.get('time_out')}, ВремяВЗдании={r.get('in_building')}, Статус={st}")
|
||||
|
||||
dump_str = "\n".join(lines)
|
||||
active_state_context = (
|
||||
f"\n[ТЕКУЩИЙ РЕЖИМ: АКТИВНА ИНСПЕКЦИЯ СРЕЗА СКУД #{snap_id} ЗА {snap_date}]\n"
|
||||
f"Оператор сейчас изучает срез #{snap_id}. При любых вопросах о сотрудниках, фильтрации по входам, выходам, времени или отделам:\n"
|
||||
f"1. ТЫ ОБЯЗАН ответить обычным текстом, проанализировав список ниже.\n"
|
||||
f"2. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать инструменты (tools), такие как db_get_snapshots!\n"
|
||||
f"Список сотрудников в активном срезе:\n{dump_str}\n"
|
||||
)
|
||||
|
||||
# ⭐️ Промпт с поддержкой Topic Drift и защитой от переспросов по задачам
|
||||
system_prompt_content = (
|
||||
|
||||
@@ -146,7 +146,11 @@ TOOLS_SCHEMA = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_snapshots",
|
||||
"description": "Получение списка снапшотов и срезов логов СКУД из базы данных за конкретную дату.",
|
||||
"description": (
|
||||
"Получение реестра/списка доступных снапшотов (файлов срезов) СКУД.\n"
|
||||
"ВЫЗЫВАТЬ ТОЛЬКО при прямом запросе на список срезов ('покажи срезы', 'какие есть снапшоты', 'срезы за дату').\n"
|
||||
"КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать эту функцию, если пользователь спрашивает о людях, сотрудниках, входах или выходах внутри уже открытого среза!"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -162,17 +166,21 @@ TOOLS_SCHEMA = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_delete_snapshots",
|
||||
"description": "Удаление снапшотов СКУД по идентификатору или дате.",
|
||||
"description": "Удаление дневных снапшотов СКУД по идентификатору или дате.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"snapshot_id": {
|
||||
"type": "string",
|
||||
"description": "Идентификатор конкретного снапшота"
|
||||
"description": "Идентификатор или список идентификаторов через запятую"
|
||||
},
|
||||
"day_str": {
|
||||
"type": "string",
|
||||
"description": "Дата всех снапшотов за день"
|
||||
},
|
||||
"confirmed": {
|
||||
"type": "boolean",
|
||||
"description": "Флаг окончательного подтверждения удаления пользователем"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ from fastapi import FastAPI, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from routers.manual_absences import router as manual_absences_router
|
||||
|
||||
from routers.auth import router as auth_router
|
||||
from routers.admin import router as admin_router
|
||||
@@ -69,6 +70,7 @@ app.include_router(exceptions_router)
|
||||
app.include_router(snapshots_router)
|
||||
app.include_router(remote_workers_router)
|
||||
app.include_router(context_router)
|
||||
app.include_router(manual_absences_router)
|
||||
|
||||
# ANCHOR[ROOT_STATIC_ROUTES]
|
||||
@app.get("/")
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: modules/web_api/routers/manual_absences.py
|
||||
ROLE: REST API эндпоинты для реестров "Мест. командир.", "Иное" и автокомплита.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from services.manual_absences_repo import (
|
||||
search_staff_suggestions,
|
||||
get_static_reasons,
|
||||
add_manual_absence,
|
||||
delete_manual_absence,
|
||||
get_manual_absences_list
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/manual-absences", tags=["Manual Absences"])
|
||||
|
||||
|
||||
class AddAbsenceRequest(BaseModel):
|
||||
absence_type: str # 'LOCAL_TRIP' или 'OTHER'
|
||||
fio: str
|
||||
reason: Optional[str] = ""
|
||||
department: Optional[str] = ""
|
||||
position: Optional[str] = ""
|
||||
date_start: Optional[str] = None
|
||||
date_end: Optional[str] = None
|
||||
comment: Optional[str] = ""
|
||||
|
||||
|
||||
@router.get("/staff-autocomplete")
|
||||
def api_staff_autocomplete(q: str = Query(..., min_length=2)):
|
||||
return search_staff_suggestions(q)
|
||||
|
||||
|
||||
@router.get("/reasons")
|
||||
def api_get_reasons():
|
||||
return {"reasons": get_static_reasons()}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def api_list_manual_absences(type: Optional[str] = None):
|
||||
return {"items": get_manual_absences_list(type)}
|
||||
|
||||
|
||||
@router.post("/")
|
||||
def api_add_manual_absence(req: AddAbsenceRequest):
|
||||
reason = req.reason or ("Местная командировка" if req.absence_type == "LOCAL_TRIP" else "Иное")
|
||||
res_id = add_manual_absence(
|
||||
absence_type=req.absence_type,
|
||||
fio=req.fio,
|
||||
reason=reason,
|
||||
department=req.department,
|
||||
position=req.position,
|
||||
date_start=req.date_start,
|
||||
date_end=req.date_end,
|
||||
comment=req.comment
|
||||
)
|
||||
if not res_id:
|
||||
raise HTTPException(status_code=400, detail="Не удалось добавить запись")
|
||||
return {"status": "success", "id": res_id}
|
||||
|
||||
|
||||
@router.delete("/{item_id}")
|
||||
def api_delete_manual_absence(item_id: int):
|
||||
if not delete_manual_absence(item_id):
|
||||
raise HTTPException(status_code=404, detail="Запись не найдена")
|
||||
return {"status": "success"}
|
||||
@@ -8,7 +8,6 @@
|
||||
<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;
|
||||
@@ -20,13 +19,8 @@
|
||||
|
||||
/* ⭐️ Воздух снизу для возможности поднятия вопроса на самый верх */
|
||||
#chat-messages-container {
|
||||
/*
|
||||
clamp(минимальный отступ, желаемый адаптивный, максимальный предел)
|
||||
Это гарантирует, что на огромных экранах отступ не раздуется до бесконечности,
|
||||
а на маленьких — не сожмет ленту в ноль.
|
||||
*/
|
||||
padding-bottom: clamp(400px, 85vh, 900px) !important;
|
||||
}
|
||||
padding-bottom: clamp(400px, 85vh, 900px) !important;
|
||||
}
|
||||
|
||||
/* Принудительное увеличение шрифта сообщений чата */
|
||||
#chat-messages-container .message-content,
|
||||
@@ -216,6 +210,116 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- МОДАЛЬНОЕ ОКНО: МЕСТНАЯ КОМАНДИРОВКА И ИНОЕ С АВТОКОМПЛИТОМ -->
|
||||
<div id="manual-absence-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||||
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-md w-full p-6 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="manual-absence-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<i class="fa-solid fa-location-dot text-indigo-600"></i>
|
||||
<span>Добавление в реестр</span>
|
||||
</h3>
|
||||
<button type="button" onclick="closeManualAbsenceModal()" class="text-slate-400 hover:text-slate-600">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Поле ФИО с автодополнением по 1С -->
|
||||
<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" />
|
||||
<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>
|
||||
</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"
|
||||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
|
||||
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = сегодня</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Окончание:</label>
|
||||
<input type="date" id="manual-absence-end-date"
|
||||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
|
||||
<span class="text-[10px] text-slate-400 mt-0.5 block">По умолчанию: сегодня</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 mt-2 pt-2 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- МОДАЛЬНОЕ ОКНО: ДОБАВЛЕНИЕ В РЕЕСТРЫ ИСКЛЮЧЕНИЙ И ТУРНИКЕТОВ -->
|
||||
<div id="exception-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||||
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-md w-full p-6 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="exception-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<i class="fa-solid fa-user-shield text-indigo-600"></i>
|
||||
<span id="exception-modal-header-text">Добавление в реестр</span>
|
||||
</h3>
|
||||
<button type="button" onclick="closeExceptionModal()" class="text-slate-400 hover:text-slate-600">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="exception-modal-form" onsubmit="submitExceptionModalForm(event)" class="flex flex-col gap-3">
|
||||
<input type="hidden" id="exception-category-input" value="" />
|
||||
|
||||
<!-- Поле ввода значения с автокомплитом -->
|
||||
<div class="relative">
|
||||
<label id="exception-value-label" class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника:</label>
|
||||
<input type="text" id="exception-value-input" autocomplete="off" required
|
||||
placeholder="Начните вводить фамилию..."
|
||||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-800" />
|
||||
<!-- Выпадающие подсказки из 1С -->
|
||||
<div id="exception-suggestions" class="hidden absolute left-0 right-0 top-full mt-1 bg-white border border-slate-300 rounded-lg shadow-xl z-30 max-h-48 overflow-y-auto"></div>
|
||||
</div>
|
||||
|
||||
<!-- Опциональный комментарий -->
|
||||
<div>
|
||||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Примечание / основание (опционально):</label>
|
||||
<input type="text" id="exception-comment-input" placeholder="Например: служебная записка, водитель, лаборатория"
|
||||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-800" />
|
||||
</div>
|
||||
|
||||
<div id="exception-error-msg" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
|
||||
<button type="button" onclick="closeExceptionModal()"
|
||||
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" id="exception-submit-btn"
|
||||
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition flex items-center gap-1.5">
|
||||
<i class="fa-solid fa-check text-xs"></i>
|
||||
<span>Добавить</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- МОДАЛЬНОЕ ОКНО АВТОРИЗАЦИИ -->
|
||||
<div id="auth-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||||
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-sm w-full p-6 flex flex-col gap-4">
|
||||
@@ -307,13 +411,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ПОДКЛЮЧЕНИЕ СКРИПТОВ (Версия v=2.5.6) -->
|
||||
<script src="/static/js/auth.js?v=2.5.6"></script>
|
||||
<script src="/static/js/tasks.js?v=2.5.6"></script>
|
||||
<script src="/static/js/sidebar.js?v=2.5.6"></script>
|
||||
<script src="/static/js/chat/task_widget.js?v=2.5.6"></script>
|
||||
<script src="/static/js/chat/core.js?v=2.5.6"></script>
|
||||
<script src="/static/js/app.js?v=2.5.6"></script>
|
||||
<!-- ПОДКЛЮЧЕНИЕ СКРИПТОВ -->
|
||||
<script src="/static/js/auth.js?v=2.5.7"></script>
|
||||
<script src="/static/js/tasks.js?v=2.5.7"></script>
|
||||
<script src="/static/js/manual_absences.js?v=2.5.7"></script>
|
||||
<script src="/static/js/sidebar.js?v=2.5.7"></script>
|
||||
<script src="/static/js/chat/task_widget.js?v=2.5.7"></script>
|
||||
<script src="/static/js/chat/core.js?v=2.5.7"></script>
|
||||
<script src="/static/js/app.js?v=2.5.7"></script>
|
||||
|
||||
<script>
|
||||
function showAuthModal() {
|
||||
|
||||
@@ -25,27 +25,22 @@ function saveCommandToHistory(commandText) {
|
||||
chatHistoryIndex = -1;
|
||||
}
|
||||
|
||||
// ⭐️ ЕДИНАЯ ФУНКЦИЯ СКРОЛЛА: плавное выравнивание вопроса к верхней границе
|
||||
// ⭐️ ЕДИНАЯ ФУНКЦИЯ СКРОЛЛА: выравнивание вопроса к верхней границе
|
||||
function scrollToUserMessageTop() {
|
||||
const container = document.getElementById("chat-messages-container");
|
||||
if (!container) return;
|
||||
|
||||
const userBubbles = container.querySelectorAll(".user-chat-bubble");
|
||||
const targetEl = userBubbles[userBubbles.length - 1] || container.lastElementChild;
|
||||
const targetEl = userBubbles[userBubbles.length - 1];
|
||||
if (!targetEl) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
const containerTop = container.getBoundingClientRect().top;
|
||||
const targetTop = targetEl.getBoundingClientRect().top;
|
||||
|
||||
const targetScroll = container.scrollTop + (targetTop - containerTop) - 16;
|
||||
const targetScroll = targetEl.offsetTop - container.offsetTop - 12;
|
||||
|
||||
container.scrollTo({
|
||||
top: Math.max(0, targetScroll),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}, 50);
|
||||
container.scrollTo({
|
||||
top: Math.max(0, targetScroll),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,7 +94,7 @@ function appendUserMessage(text, filename = null) {
|
||||
}
|
||||
|
||||
const msgHtml = `
|
||||
<div id="${msgId}" class="user-chat-bubble flex gap-3 max-w-4xl mx-auto w-full justify-end pt-3 scroll-mt-4">
|
||||
<div id="${msgId}" class="user-chat-bubble relative flex gap-3 max-w-4xl mx-auto w-full justify-end pt-3 scroll-mt-4">
|
||||
<div class="flex-1 max-w-2xl bg-indigo-600 text-white rounded-2xl rounded-tr-none p-4 shadow-sm">
|
||||
${fileBadge}
|
||||
<div class="text-sm leading-relaxed whitespace-pre-wrap">${escapeHtml(text)}</div>
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* ===============================================================================
|
||||
* FILE: modules/web_api/static/js/manual_absences.js
|
||||
* ROLE: Модальные окна "Мест. командир.", "Иное", живой автокомплит ФИО из 1С:ЗУП
|
||||
* и мгновенная синхронизация с боковой панелью SidebarManager.
|
||||
* ===============================================================================
|
||||
*/
|
||||
|
||||
let activeAbsenceType = 'LOCAL_TRIP'; // 'LOCAL_TRIP' или 'OTHER'
|
||||
let reasonsCache = [];
|
||||
|
||||
async function loadAbsenceReasons() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/manual-absences/reasons');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
reasonsCache = data.reasons || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка загрузки причин:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function openManualAbsenceModal(type) {
|
||||
activeAbsenceType = type;
|
||||
const isTrip = type === 'LOCAL_TRIP';
|
||||
const titleEl = document.getElementById('manual-absence-modal-title');
|
||||
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>Иные причины отсутствия';
|
||||
}
|
||||
|
||||
if (reasonBlock && reasonSelect) {
|
||||
if (isTrip) {
|
||||
reasonBlock.classList.add('hidden');
|
||||
} else {
|
||||
reasonBlock.classList.remove('hidden');
|
||||
reasonSelect.innerHTML = reasonsCache.map(r => `<option value="${r}">${r}</option>`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// Сброс полей ввода
|
||||
const 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');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
loadManualAbsencesTable();
|
||||
const modal = document.getElementById('manual-absence-modal');
|
||||
if (modal) modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeManualAbsenceModal() {
|
||||
const modal = document.getElementById('manual-absence-modal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Живой автокомплит ФИО из базы zup_staff
|
||||
let searchTimeout = null;
|
||||
function setupStaffAutocomplete(inputEl, suggestionsBoxId) {
|
||||
const box = document.getElementById(suggestionsBoxId);
|
||||
if (!inputEl || !box) return;
|
||||
|
||||
inputEl.addEventListener('input', function() {
|
||||
const val = this.value.trim();
|
||||
clearTimeout(searchTimeout);
|
||||
if (val.length < 2) {
|
||||
box.classList.add('hidden');
|
||||
box.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/manual-absences/staff-autocomplete?q=${encodeURIComponent(val)}`);
|
||||
if (!res.ok) return;
|
||||
const items = await res.json();
|
||||
if (items.length === 0) {
|
||||
box.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
box.innerHTML = items.map(it => `
|
||||
<div class="p-2 hover:bg-indigo-50 cursor-pointer border-b border-slate-100 flex flex-col text-xs"
|
||||
onclick="selectStaffSuggestion('${escapeHtml(it.fio)}', '${escapeHtml(it.department)}', '${escapeHtml(it.position)}')">
|
||||
<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);
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
if (fioInput) fioInput.value = fio;
|
||||
if (deptInput) deptInput.value = dept;
|
||||
if (posInput) posInput.value = pos;
|
||||
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'
|
||||
);
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* ===============================================================================
|
||||
* FILE: modules/web_api/static/js/sidebar.js
|
||||
* ROLE: Контроллер левого сайдбара с 5-хабовой навигацией, реестрами,
|
||||
* подробным описанием управления контекстом и интеграцией чата.
|
||||
* ROLE: Контроллер левого сайдбара с 5-хабовой навигацией, реестрами (2x2),
|
||||
* модальным окном добавления исключений с автокомплитом из 1С:ЗУП.
|
||||
* ===============================================================================
|
||||
*/
|
||||
|
||||
// Глобальная функция безопасного экранирования HTML
|
||||
window.escapeHtml = function(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
@@ -31,10 +30,13 @@ window.SidebarManager = {
|
||||
{ id: 'CONTEXT', label: 'Контекст', icon: 'fa-comments' }
|
||||
],
|
||||
|
||||
// Сетка реестров 2x2
|
||||
subTabs: {
|
||||
'REGISTRIES': [
|
||||
{ id: 'REMOTE', label: 'Удаленщики', icon: 'fa-house-laptop' },
|
||||
{ id: 'EXCEPTIONS', label: 'Исключения', icon: 'fa-user-shield' }
|
||||
{ id: 'EXCEPTIONS', label: 'Исключения', icon: 'fa-user-shield' },
|
||||
{ id: 'LOCAL_TRIP', label: 'Мест. командир.', icon: 'fa-location-dot' },
|
||||
{ id: 'OTHER', label: 'Иное', icon: 'fa-clipboard-list' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -79,17 +81,17 @@ window.SidebarManager = {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 2. Подвкладки (только для хабов, где они требуются, например Реестры)
|
||||
// 2. Подвкладки реестров (Сетка 2x2)
|
||||
let subTabsHtml = '';
|
||||
if (this.subTabs[this.currentHub]) {
|
||||
const currentActiveSub = this.currentSubTab[this.currentHub] || this.subTabs[this.currentHub][0].id;
|
||||
subTabsHtml = `
|
||||
<div class="flex items-center gap-1.5 p-1.5 bg-slate-100/90 border-b border-slate-200">
|
||||
<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="flex-1 py-1 px-2 rounded-md text-[11px] font-semibold flex items-center justify-center gap-1.5 transition ${
|
||||
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'
|
||||
@@ -196,14 +198,16 @@ window.SidebarManager = {
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// ХАБ 3: РЕЕСТРЫ (УДАЛЕНЩИКИ + ИСКЛЮЧЕНИЯ)
|
||||
// ХАБ 3: РЕЕСТРЫ (УДАЛЕНЩИКИ + ИСКЛЮЧЕНИЯ + МЕСТ. КОМАНДИР. + ИНОЕ)
|
||||
// =========================================================================
|
||||
renderRegistriesView(container) {
|
||||
const subTab = this.currentSubTab['REGISTRIES'] || 'REMOTE';
|
||||
if (subTab === 'REMOTE') {
|
||||
this.renderRemoteWorkersView(container);
|
||||
} else {
|
||||
} else if (subTab === 'EXCEPTIONS') {
|
||||
this.renderExceptionsView(container);
|
||||
} else {
|
||||
this.renderManualAbsencesView(container, subTab);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -289,22 +293,32 @@ window.SidebarManager = {
|
||||
{ key: 'include_fio', title: 'Белый список (ФИО)' },
|
||||
{ key: 'fio', title: 'Исключенные сотрудники (ФИО)' },
|
||||
{ key: 'departments', title: 'Исключенные отделы' },
|
||||
{ key: 'positions', 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 border-slate-200 rounded-xl p-3 flex flex-col gap-2 shadow-sm">
|
||||
<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">
|
||||
<span class="font-bold text-xs text-slate-700">${cat.title} (${items.length})</span>
|
||||
<button onclick="SidebarManager.addExceptionPrompt('${cat.key}')" class="text-indigo-600 hover:text-indigo-800 text-xs font-bold">
|
||||
<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] bg-slate-100 text-slate-700 border border-slate-200">
|
||||
<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>
|
||||
@@ -320,24 +334,8 @@ window.SidebarManager = {
|
||||
}
|
||||
},
|
||||
|
||||
async addExceptionPrompt(category) {
|
||||
const val = prompt(`Введите значение для категории [${category}]:`);
|
||||
if (!val || !val.trim()) return;
|
||||
try {
|
||||
const res = await fetch("/api/v1/exceptions/", {
|
||||
method: "POST",
|
||||
headers: AuthManager.getAuthHeaders(),
|
||||
body: JSON.stringify({ category: category, value: val.trim() })
|
||||
});
|
||||
if (res.ok) this.renderContent();
|
||||
else alert("Ошибка добавления");
|
||||
} catch (e) {
|
||||
alert("Ошибка сети");
|
||||
}
|
||||
},
|
||||
|
||||
async deleteExceptionItem(category, value) {
|
||||
if (!confirm(`Удалить "${value}" из ${category}?`)) return;
|
||||
if (!confirm(`Удалить "${value}" из реестра?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/exceptions/?category=${encodeURIComponent(category)}&value=${encodeURIComponent(value)}`, {
|
||||
method: "DELETE",
|
||||
@@ -350,6 +348,73 @@ window.SidebarManager = {
|
||||
}
|
||||
},
|
||||
|
||||
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: СИСТЕМНЫЙ ПРОМПТ И БАЗА ЗНАНИЙ
|
||||
// =========================================================================
|
||||
@@ -381,7 +446,7 @@ window.SidebarManager = {
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// ХАБ 5: УПРАВЛЕНИЕ КОНТЕКСТОМ СЕССИИ (С ПОДРОБНЫМ ОПИСАНИЕМ)
|
||||
// ХАБ 5: УПРАВЛЕНИЕ КОНТЕКСТОМ СЕССИИ
|
||||
// =========================================================================
|
||||
renderContextView(container) {
|
||||
container.innerHTML = `
|
||||
@@ -432,7 +497,6 @@ window.SidebarManager = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Очищаем локальное окно чата до стартового приветствия
|
||||
const chatContainer = document.getElementById("chat-messages-container");
|
||||
if (chatContainer) {
|
||||
chatContainer.innerHTML = `
|
||||
@@ -465,8 +529,145 @@ window.SidebarManager = {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// МОДАЛЬНОЕ ОКНО ИСКЛЮЧЕНИЙ И АВТОКОМПЛИТ 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 = "";
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user