This repository has been archived on 2026-11-14. You can view files and clone it, but cannot push or open issues or pull requests.
assistant/index.html
zhanakova 0455123d8e fix: классификатор не ловил кириллицу и склонения
- regex `\b` в JS не работает с кириллицей (русские буквы не word-char),
  поэтому ни одно русское ключевое слово не давало хитов;
- exact-match не учитывал склонения: «приложение» vs «приложении».

Заменил на ручной матч с допуском в 2 символа суффикса
(shared prefix ≥ max(keyword.length - 2, 4)). Сохранён exact-match
для коротких ключей (sms/ios/crm) чтобы избежать ложных срабатываний.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:43:14 +05:00

818 lines
37 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Помощник руководителя</title>
<style>
:root{
--bg-grad: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--primary: #6366f1;
--primary-dark: #4f46e5;
--accent: #ec4899;
--text: #0f172a;
--text-muted: #64748b;
--surface: #ffffff;
--surface-2: #f8fafc;
--border: #e2e8f0;
--warn-bg: #fef3c7;
--warn-border: #fcd34d;
--err-bg: #fee2e2;
--err-border: #fca5a5;
--ok-bg: #d1fae5;
--info-bg: #dbeafe;
--shadow-sm: 0 1px 2px rgba(15,23,42,.05);
--shadow: 0 4px 12px rgba(15,23,42,.08);
--shadow-lg: 0 20px 50px rgba(15,23,42,.12);
--radius: 12px;
--radius-sm: 8px;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", system-ui, sans-serif;
background: var(--bg-grad);
background-attachment: fixed;
color: var(--text);
line-height: 1.55;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
header{
background: rgba(255,255,255,.96);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
padding: 18px 32px;
display:flex;
justify-content:space-between;
align-items:center;
flex-wrap:wrap;
gap:16px;
box-shadow: var(--shadow);
position: sticky;
top: 0;
z-index: 10;
}
.brand{display:flex;align-items:center;gap:14px}
.brand .logo{
width:42px;height:42px;border-radius:12px;
background: var(--bg-grad);
display:flex;align-items:center;justify-content:center;
font-size:22px; color:#fff;
box-shadow: var(--shadow);
}
.brand h1{margin:0;font-size:18px;font-weight:700;letter-spacing:-.01em}
.brand .subtitle{margin:0;font-size:12px;color:var(--text-muted)}
.header-controls{display:flex;gap:14px;align-items:center;flex-wrap:wrap}
.tabs{display:flex;gap:4px;background:var(--surface-2);padding:4px;border-radius:10px}
.tabs button{
background:transparent;color:var(--text-muted);border:none;
padding:7px 16px;border-radius:7px;cursor:pointer;
font:inherit;font-weight:500;font-size:14px;
transition:all .15s;
}
.tabs button:hover{color:var(--text)}
.tabs button.active{background:var(--surface);color:var(--primary);box-shadow:var(--shadow-sm)}
.lang{display:flex;gap:2px;background:var(--surface-2);padding:3px;border-radius:8px}
.lang button{
background:transparent;color:var(--text-muted);border:none;
padding:5px 11px;border-radius:6px;cursor:pointer;font:inherit;
font-size:12px;font-weight:600;
}
.lang button.active{background:var(--primary);color:#fff}
main{padding:32px 24px;max-width:1100px;margin:0 auto}
.card{
background: var(--surface);
border-radius: var(--radius);
padding: 24px;
box-shadow: var(--shadow-lg);
}
.view.hidden{display:none}
textarea{
width:100%;
font:inherit;font-size:15px;
padding:14px 16px;
border:1.5px solid var(--border);
border-radius:var(--radius-sm);
resize:vertical;
background:#fff;
transition: border-color .15s, box-shadow .15s;
}
textarea:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px rgba(99,102,241,.15)}
button.btn,
button.primary{
background: var(--primary);
color:#fff;border:none;
padding:11px 22px;border-radius:9px;cursor:pointer;
font:inherit;font-weight:600;font-size:14px;
margin-top:14px;
transition: transform .1s, box-shadow .15s, background .15s;
box-shadow: var(--shadow-sm);
}
button.btn:hover,
button.primary:hover{background:var(--primary-dark);box-shadow:var(--shadow)}
button.btn:active,
button.primary:active{transform:translateY(1px)}
button.ghost{
background:transparent;border:1.5px solid var(--border);
color:var(--text);padding:9px 18px;border-radius:9px;
cursor:pointer;font:inherit;font-weight:500;font-size:14px;margin-top:14px;
transition:all .15s;
}
button.ghost:hover{border-color:var(--primary);color:var(--primary)}
#task-result{margin-top:20px;display:flex;flex-direction:column;gap:14px}
.zone-card{
background:#fff;border:1.5px solid var(--border);
border-radius:var(--radius-sm);padding:16px 18px;
transition:all .15s;
}
.zone-card.choice{cursor:pointer}
.zone-card.choice:hover{border-color:var(--primary);transform:translateY(-1px);box-shadow:var(--shadow)}
.zone-card h3{margin:0 0 4px;font-size:15px;font-weight:600}
.zone-card .score{font-size:12px;color:var(--text-muted)}
.assignee-card{
background:#fff;
border:none;
border-left:4px solid var(--primary);
border-radius:var(--radius-sm);
padding:20px 22px;
box-shadow:var(--shadow);
}
.assignee-card .name{font-size:20px;font-weight:700;letter-spacing:-.01em}
.assignee-card .meta{color:var(--text-muted);font-size:13px;margin-top:6px}
.assignee-card .note{
background:var(--warn-bg);
border:1px solid var(--warn-border);
padding:10px 14px;border-radius:8px;
margin-top:12px;font-size:13px;
}
.assignee-card pre{
background:var(--surface-2);
padding:14px 16px;border-radius:8px;
white-space:pre-wrap;font-size:13px;
margin-top:14px;
font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
line-height:1.6;
border:1px solid var(--border);
}
.assignee-card .actions{display:flex;gap:10px;margin-top:14px;flex-wrap:wrap}
.assignee-card .actions button{margin-top:0}
.error-card{
background:var(--err-bg);
border:1px solid var(--err-border);
padding:18px 20px;border-radius:var(--radius-sm);
}
.error-card strong{font-size:15px}
.error-card .actions{display:flex;gap:10px;margin-top:14px;flex-wrap:wrap}
.error-card .actions button{margin-top:0}
.toolbar{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:18px;align-items:center}
.toolbar button{margin-top:0}
.simbase-log{
background:var(--info-bg);
border:1px solid #93c5fd;
padding:11px 16px;border-radius:8px;
font-size:13px;margin-bottom:18px;
display:none;
font-family:ui-monospace,monospace;
}
.simbase-log.show{display:block;animation:slideDown .25s ease}
@keyframes slideDown{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
table{
width:100%;border-collapse:separate;border-spacing:0;
background:#fff;border-radius:var(--radius-sm);overflow:hidden;
box-shadow:var(--shadow-sm);
border:1px solid var(--border);
}
th,td{padding:12px 14px;text-align:left;font-size:14px;vertical-align:middle}
th{background:var(--surface-2);font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);border-bottom:1px solid var(--border)}
td{border-bottom:1px solid var(--border)}
tr:last-child td{border-bottom:none}
tr.vacation td{background:#fffbeb}
tr.inactive td{color:#94a3b8;background:var(--surface-2)}
td select,td input{
font:inherit;padding:6px 10px;
border:1px solid var(--border);border-radius:6px;background:#fff;
font-size:13px;
}
td select:focus,td input:focus{outline:none;border-color:var(--primary)}
.history-item{
background:#fff;border-radius:var(--radius-sm);
padding:18px 20px;margin-bottom:12px;
box-shadow:var(--shadow-sm);
border:1px solid var(--border);
}
.history-item h4{margin:0 0 6px;font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.history-item .date{color:var(--text-muted);font-size:12px}
.history-item .task-text{margin:10px 0;font-size:14px;line-height:1.6}
.history-item .status{
display:inline-block;padding:3px 10px;border-radius:12px;
font-size:11px;font-weight:600;
background:var(--surface-2);color:var(--text-muted);
}
.history-item .status.in_progress{background:var(--info-bg);color:#1e40af}
.history-item .status.done{background:var(--ok-bg);color:#065f46}
.history-item .status.cancelled{background:var(--err-bg);color:#991b1b}
.history-item .actions{margin-top:8px}
.history-item .actions button{margin-top:0;padding:6px 14px;font-size:13px}
.history-item .comments{margin-top:14px;border-top:1px solid var(--border);padding-top:12px}
.history-item .comment{font-size:13px;padding:8px 0;border-bottom:1px dashed var(--border)}
.history-item .comment:last-of-type{border-bottom:none}
.history-item .comment .who{font-weight:600;color:var(--text)}
.history-item .comment .when{color:var(--text-muted);font-size:11px;margin-left:6px}
.history-item .comment-form{margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.history-item .comment-form select,
.history-item .comment-form input{
padding:7px 10px;font:inherit;font-size:13px;
border:1px solid var(--border);border-radius:7px;background:#fff;
}
.history-item .comment-form input{flex:1;min-width:200px}
.history-item .comment-form button{margin-top:0;padding:7px 14px;font-size:13px}
.toast{
position:fixed;bottom:28px;left:50%;
transform:translateX(-50%) translateY(20px);
background:var(--text);color:#fff;
padding:12px 22px;border-radius:10px;
font-size:14px;font-weight:500;
opacity:0;transition:all .25s;
pointer-events:none;z-index:1000;
box-shadow:var(--shadow-lg);
}
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
p.section-hint{
color:var(--text-muted);font-size:13px;
margin:0 0 12px;
}
@media (max-width: 640px) {
header{padding:14px 16px}
main{padding:20px 14px}
.card{padding:18px}
.brand h1{font-size:16px}
table{font-size:13px}
th,td{padding:8px 10px}
}
</style>
</head>
<body>
<header>
<div class="brand">
<div class="logo">📋</div>
<div>
<h1 data-i18n="app.title"></h1>
<p class="subtitle" data-i18n="app.subtitle"></p>
</div>
</div>
<div class="header-controls">
<nav class="tabs">
<button data-tab="task" class="active" data-i18n="tab.task"></button>
<button data-tab="people" data-i18n="tab.people"></button>
<button data-tab="history" data-i18n="tab.history"></button>
</nav>
<div class="lang">
<button data-lang="ru" class="active">RU</button>
<button data-lang="en">EN</button>
</div>
</div>
</header>
<main>
<section id="view-task" class="view">
<div class="card">
<textarea id="task-input" rows="6" data-i18n-attr="placeholder:task.placeholder"></textarea>
<button id="task-classify" class="primary" data-i18n="task.classify"></button>
<div id="task-result"></div>
</div>
</section>
<section id="view-people" class="view hidden">
<div class="card">
<div class="toolbar">
<button class="btn" id="simbase-sync" data-i18n="people.simbase_sync"></button>
<button class="ghost" id="people-reset" data-i18n="people.reset"></button>
</div>
<div id="simbase-log" class="simbase-log"></div>
<table id="people-table">
<thead><tr>
<th data-i18n="people.name"></th>
<th data-i18n="people.zone"></th>
<th data-i18n="people.role"></th>
<th data-i18n="people.status"></th>
<th data-i18n="people.delegate"></th>
<th data-i18n="people.contact"></th>
</tr></thead>
<tbody></tbody>
</table>
</div>
</section>
<section id="view-history" class="view hidden">
<div id="history-list"></div>
</section>
</main>
<script>
const SEED_DIRECTORY = {
version:1,
zones:[
{ id:"mobile", title:"Мобильное приложение", titleEn:"Mobile app",
keywords:["push","ios","android","приложение","мобилка","apk","ipa","уведомление","пуш"],
systems:["AppStore","Google Play","Firebase"] },
{ id:"crm", title:"CRM-система", titleEn:"CRM",
keywords:["crm","црм","клиент","сделка","карточка","amocrm","bitrix","битрикс","лид"],
systems:["Bitrix24","amoCRM"] },
{ id:"sms", title:"SMS-сообщения", titleEn:"SMS",
keywords:["sms","смс","рассылка","сообщение","shortcode"],
systems:["SMSC","Twilio"] },
{ id:"web", title:"Веб-сайт", titleEn:"Web",
keywords:["сайт","лендинг","форма","html","веб","страница","landing"],
systems:["WordPress","Tilda"] }
],
people:[
{ id:"p1", name:"Алина", nameEn:"Alina", zoneId:"mobile", role:"primary", status:"active", delegateId:null, vacationUntil:null, simbaseId:"EMP-001", contact:"@alina_tg" },
{ id:"p2", name:"Адлет", nameEn:"Adlet", zoneId:"mobile", role:"backup", status:"active", delegateId:null, vacationUntil:null, simbaseId:"EMP-002", contact:"@adlet_tg" },
{ id:"p3", name:"Динара", nameEn:"Dinara", zoneId:"crm", role:"primary", status:"active", delegateId:null, vacationUntil:null, simbaseId:"EMP-003", contact:"dinara@x.kz" },
{ id:"p4", name:"Камила", nameEn:"Kamila", zoneId:"crm", role:"backup", status:"active", delegateId:null, vacationUntil:null, simbaseId:"EMP-004", contact:"kamila@x.kz" },
{ id:"p5", name:"Михаил", nameEn:"Mikhail", zoneId:"sms", role:"primary", status:"active", delegateId:null, vacationUntil:null, simbaseId:"EMP-005", contact:"@mikhail_tg" },
{ id:"p6", name:"Мухамед", nameEn:"Mukhamed", zoneId:"sms", role:"backup", status:"active", delegateId:null, vacationUntil:null, simbaseId:"EMP-006", contact:"@muhamed_tg" },
{ id:"p7", name:"Сауле", nameEn:"Saule", zoneId:"web", role:"primary", status:"active", delegateId:null, vacationUntil:null, simbaseId:"EMP-007", contact:"@saule_tg" }
]
};
const SEED_HISTORY = { entries:[
{ id:"h-001", createdAt:"2026-05-18T09:14:00Z",
taskText:"Бизнес просит push-уведомление клиентам о статусе платежа в мобильном приложении iOS и Android.",
suggestions:[{zoneId:"mobile",score:4.5,reason:"push, ios, android, приложение"}],
chosenZoneId:"mobile", chosenPersonId:"p1", delegateUsed:false, manualOverride:false, status:"in_progress",
comments:[
{ at:"2026-05-18T09:15:00Z", authorRole:"manager", authorName:"Руководитель", text:"Срок до конца недели." },
{ at:"2026-05-18T11:42:00Z", authorRole:"assignee", authorName:"Алина", text:"Взяла в работу, оценка 2 дня." }
]},
{ id:"h-002", createdAt:"2026-05-19T14:00:00Z",
taskText:"Добавить поле «промо-код» в карточку клиента в CRM Bitrix24.",
suggestions:[{zoneId:"crm",score:6,reason:"crm, клиент, карточка, bitrix"}],
chosenZoneId:"crm", chosenPersonId:"p4", delegateUsed:true, manualOverride:false, status:"open",
comments:[{ at:"2026-05-19T14:05:00Z", authorRole:"manager", authorName:"Руководитель", text:"Динара в отпуске, ушло Камиле как дублёру." }]}
]};
const SIMBASE_MOCK = { vacations:[
{ employeeId:"EMP-003", fullName:"Динара Иванова", vacationStart:"2026-05-20", vacationEnd:"2026-06-10", orderNumber:"О-2026-451" },
{ employeeId:"EMP-005", fullName:"Михаил Петров", vacationStart:"2026-05-22", vacationEnd:"2026-05-29", orderNumber:"О-2026-477" }
]};
const I18N = {
ru:{
"app.title":"Помощник руководителя","app.subtitle":"Распределение задач от бизнеса",
"tab.task":"Задача","tab.people":"Сотрудники","tab.history":"История",
"task.placeholder":"Вставьте сюда текст задачи от бизнеса…",
"task.classify":"Определить ответственного","task.multi":"Задача попадает в несколько направлений. Выберите:",
"task.score":"вес","task.assignee":"Ответственный","task.fallback":"Резерв",
"task.copy":"Скопировать сообщение","task.copied":"Скопировано",
"task.save":"Сохранить в историю","task.saved":"Сохранено",
"task.none":"Нет доступного ответственного. Все в зоне в отпуске.",
"task.assign_manual":"Назначить вручную","task.take_self":"Принять на себя",
"task.delegate_note":"Замещает {primary} (отпуск до {until})",
"task.no_backup":"В зоне нет резервного исполнителя",
"task.no_signal":"Не удалось определить направление. Все зоны равновероятны.",
"people.name":"ФИО","people.zone":"Зона","people.role":"Роль","people.status":"Статус",
"people.delegate":"Замещающий","people.contact":"Контакт",
"people.simbase_sync":"🔄 Синхронизация с Симбейс","people.simbase_done":"Синхронизация выполнена. Обновлено: {n}",
"people.reset":"Сброс к моку",
"role.primary":"основной","role.backup":"дублёр","role.delegate":"замещающий",
"status.active":"активен","status.vacation":"в отпуске","status.inactive":"недоступен",
"history.empty":"История пуста","history.copy":"Скопировать историю","history.copied":"Скопировано",
"history.comment_as":"Подписаться как","history.comment_placeholder":"Текст комментария…","history.send":"Отправить",
"history.status.open":"открыта","history.status.in_progress":"в работе","history.status.done":"выполнена","history.status.cancelled":"отменена",
"msg.template":"@{name}, прилетела задача от бизнеса:\n\n«{task}»\n\nТы {role} по направлению «{zone}».{delegate} Посмотри, пожалуйста."
},
en:{
"app.title":"Manager's assistant","app.subtitle":"Routing business tasks to the team",
"tab.task":"Task","tab.people":"People","tab.history":"History",
"task.placeholder":"Paste the business task text here…",
"task.classify":"Find assignee","task.multi":"Task matches multiple zones. Choose:",
"task.score":"score","task.assignee":"Assignee","task.fallback":"Backup",
"task.copy":"Copy message","task.copied":"Copied",
"task.save":"Save to history","task.saved":"Saved",
"task.none":"No available assignee. All in zone are on vacation.",
"task.assign_manual":"Assign manually","task.take_self":"Take it myself",
"task.delegate_note":"Substituting for {primary} (vacation until {until})",
"task.no_backup":"No backup in zone",
"task.no_signal":"Could not detect zone. All zones equally likely.",
"people.name":"Name","people.zone":"Zone","people.role":"Role","people.status":"Status",
"people.delegate":"Delegate","people.contact":"Contact",
"people.simbase_sync":"🔄 Sync with Simbase","people.simbase_done":"Sync complete. Updated: {n}",
"people.reset":"Reset to mock",
"role.primary":"primary","role.backup":"backup","role.delegate":"delegate",
"status.active":"active","status.vacation":"on vacation","status.inactive":"inactive",
"history.empty":"History is empty","history.copy":"Copy history","history.copied":"Copied",
"history.comment_as":"Sign as","history.comment_placeholder":"Comment text…","history.send":"Send",
"history.status.open":"open","history.status.in_progress":"in progress","history.status.done":"done","history.status.cancelled":"cancelled",
"msg.template":"@{name}, a new business task:\n\n\"{task}\"\n\nYou are the {role} for \"{zone}\".{delegate} Please take a look."
}
};
const LS = { dir:"app.directory", hist:"app.history", lang:"app.lang" };
const state = {
directory: JSON.parse(localStorage.getItem(LS.dir) || "null") || structuredClone(SEED_DIRECTORY),
history: JSON.parse(localStorage.getItem(LS.hist) || "null") || structuredClone(SEED_HISTORY),
lang: localStorage.getItem(LS.lang) || "ru",
pendingResult: null
};
const DOMINANCE = 2.0;
const saveDir = () => localStorage.setItem(LS.dir, JSON.stringify(state.directory));
const saveHist = () => localStorage.setItem(LS.hist, JSON.stringify(state.history));
function t(key, vars={}){
let s = (I18N[state.lang]||{})[key] || key;
for (const [k,v] of Object.entries(vars)) s = s.replaceAll("{"+k+"}", v);
return s;
}
function applyLang(){
document.documentElement.lang = state.lang;
document.querySelectorAll("[data-i18n]").forEach(el => el.textContent = t(el.dataset.i18n));
document.querySelectorAll("[data-i18n-attr]").forEach(el => {
const [attr,key] = el.dataset.i18nAttr.split(":");
el.setAttribute(attr, t(key));
});
document.querySelectorAll(".lang button").forEach(b => b.classList.toggle("active", b.dataset.lang===state.lang));
if (state.pendingResult) renderResult(state.pendingResult);
renderPeople(); renderHistory();
}
function normalize(s){
return s.toLowerCase().replace(/ё/g,"е").replace(/[^\p{L}\p{N}\s]/gu," ").split(/\s+/).filter(Boolean);
}
function matchTerm(tokens, term){
const norm = normalize(term);
if (norm.length === 0) return 0;
if (norm.length > 1) {
const joined = " " + tokens.join(" ") + " ";
const needle = " " + norm.join(" ") + " ";
let cnt = 0, idx = 0;
while ((idx = joined.indexOf(needle, idx)) !== -1) { cnt++; idx += needle.length - 1; }
return cnt;
}
const n = norm[0];
let cnt = 0;
for (const tok of tokens) {
if (tok === n) { cnt++; continue; }
if (n.length < 4) continue;
const k = Math.max(n.length - 2, 4);
if (tok.length < k || n.length < k) continue;
if (tok.slice(0, k) === n.slice(0, k)) cnt++;
}
return cnt;
}
function scoreZones(text){
const tokens = normalize(text);
return state.directory.zones.map(z => {
let score = 0; const hits = [];
for (const k of z.keywords) {
const c = matchTerm(tokens, k);
if (c > 0) { score += c * 1.0; hits.push(k); }
}
for (const sys of z.systems) {
const c = matchTerm(tokens, sys);
if (c > 0) { score += c * 1.5; hits.push(sys); }
}
return { zoneId:z.id, score, hits };
}).filter(x => x.score > 0).sort((a,b) => b.score - a.score);
}
function assignPerson(zoneId){
const cs = state.directory.people.filter(p => p.zoneId===zoneId);
const primary = cs.find(p => p.role==="primary");
const backup = cs.find(p => p.role==="backup");
if (primary && primary.status==="active")
return { main:primary, fallback: backup && backup.status==="active" ? backup : null };
if (primary && primary.status==="vacation" && primary.delegateId){
const d = state.directory.people.find(p => p.id===primary.delegateId);
if (d && d.status==="active") return { main:d, fallback:null, delegateFor:primary };
}
if (backup && backup.status==="active") return { main:backup, fallback:null, fromBackup:primary };
return { error:"NO_ASSIGNEE", zoneId };
}
function onClassify(){
const text = document.getElementById("task-input").value.trim();
if (!text) return;
const sorted = scoreZones(text);
const result = { text, sorted, multi:false, chosenZoneId:null };
if (sorted.length===0){
result.multi = true; result.noSignal = true;
result.allZones = state.directory.zones.map(z => ({ zoneId:z.id, score:0, hits:[] }));
} else if (sorted.length===1 || sorted[0].score >= DOMINANCE*(sorted[1]?.score||0)){
result.chosenZoneId = sorted[0].zoneId;
} else {
result.multi = true;
}
state.pendingResult = result;
renderResult(result);
}
function renderResult(result){
const root = document.getElementById("task-result"); root.innerHTML = "";
if (result.chosenZoneId){ renderAssignee(root, result.chosenZoneId, result); return; }
const h = document.createElement("p"); h.className = "section-hint";
h.textContent = result.noSignal ? t("task.no_signal") : t("task.multi");
root.appendChild(h);
const zones = result.noSignal ? result.allZones : result.sorted.slice(0,3);
for (const z of zones){
const zone = state.directory.zones.find(x=>x.id===z.zoneId);
const card = document.createElement("div"); card.className="zone-card choice";
card.innerHTML = `<h3>${esc(zoneTitle(zone))}</h3>
<div class="score">${t("task.score")}: <strong>${z.score.toFixed(1)}</strong>${z.hits.length?" — "+z.hits.map(esc).join(", "):""}</div>`;
card.onclick = () => { result.chosenZoneId = z.zoneId; renderResult(result); };
root.appendChild(card);
}
}
function renderAssignee(root, zoneId, result){
const zone = state.directory.zones.find(z=>z.id===zoneId);
const a = assignPerson(zoneId);
if (a.error==="NO_ASSIGNEE"){
const err = document.createElement("div"); err.className = "error-card";
err.innerHTML = `<strong>${t("task.none")}</strong>
<div style="margin-top:8px;font-size:13px;color:var(--text-muted)">${t("people.zone")}: ${esc(zoneTitle(zone))}</div>
<div class="actions">
<button class="ghost" id="assign-manual">${t("task.assign_manual")}</button>
<button class="ghost" id="take-self">${t("task.take_self")}</button>
</div>`;
root.appendChild(err);
err.querySelector("#assign-manual").onclick = () => manualAssign(result, zone);
err.querySelector("#take-self").onclick = () => takeSelf(result, zone);
return;
}
const main = a.main;
const role = a.delegateFor ? "delegate" : (main.role==="primary" ? "primary" : "backup");
let note = "";
if (a.delegateFor){
note = `<div class="note">${t("task.delegate_note", { primary: pName(a.delegateFor), until: a.delegateFor.vacationUntil||"—" })}</div>`;
} else if (a.fromBackup || !a.fallback){
note = `<div class="note">${t("task.no_backup")}</div>`;
}
const msg = buildMessage(main, role, zone, result.text, a.delegateFor);
const card = document.createElement("div"); card.className = "assignee-card";
card.innerHTML = `<div class="name">${esc(pName(main))}</div>
<div class="meta">${t("task.assignee")} · ${t("role."+role)} · ${esc(zoneTitle(zone))} · ${esc(main.contact||"—")}</div>
${a.fallback ? `<div class="meta">${t("task.fallback")}: ${esc(pName(a.fallback))}</div>` : ""}
${note}
<pre>${esc(msg)}</pre>
<div class="actions">
<button class="btn" id="copy-msg">${t("task.copy")}</button>
<button class="ghost" id="save-history">${t("task.save")}</button>
</div>`;
root.appendChild(card);
card.querySelector("#copy-msg").onclick = () => copyToClipboard(msg, t("task.copied"));
card.querySelector("#save-history").onclick = () => saveTaskToHistory(result, zone, main, role);
}
function buildMessage(person, role, zone, taskText, primaryIfDelegate){
const delegate = primaryIfDelegate
? " " + t("task.delegate_note", { primary: pName(primaryIfDelegate), until: primaryIfDelegate.vacationUntil||"—" })
: "";
return t("msg.template")
.replaceAll("{name}", pName(person))
.replaceAll("{task}", taskText)
.replaceAll("{role}", t("role."+role))
.replaceAll("{zone}", zoneTitle(zone))
.replaceAll("{delegate}", delegate);
}
function manualAssign(result, zone){
const list = state.directory.people.filter(p => p.status==="active");
const opts = list.map((p,i) => `${i+1}. ${pName(p)} (${zoneTitle(state.directory.zones.find(z=>z.id===p.zoneId))})`);
const pick = prompt(opts.join("\n"));
const idx = parseInt(pick,10)-1;
if (isNaN(idx)||idx<0||idx>=list.length) return;
const main = list[idx];
const root = document.getElementById("task-result"); root.innerHTML = "";
const msg = buildMessage(main, "primary", zone, result.text, null);
const card = document.createElement("div"); card.className = "assignee-card";
card.innerHTML = `<div class="name">${esc(pName(main))}</div>
<div class="meta">${t("task.assignee")} · ${esc(zoneTitle(zone))} · ${esc(main.contact||"—")}</div>
<pre>${esc(msg)}</pre>
<div class="actions">
<button class="btn" id="copy-msg">${t("task.copy")}</button>
<button class="ghost" id="save-history">${t("task.save")}</button>
</div>`;
root.appendChild(card);
card.querySelector("#copy-msg").onclick = () => copyToClipboard(msg, t("task.copied"));
card.querySelector("#save-history").onclick = () => saveTaskToHistory(result, zone, main, "primary", true);
}
function takeSelf(result, zone){
const self = { id:"manager", name: state.lang==="ru"?"Руководитель":"Manager", nameEn:"Manager", contact:"" };
const root = document.getElementById("task-result"); root.innerHTML = "";
const card = document.createElement("div"); card.className = "assignee-card";
card.innerHTML = `<div class="name">${esc(pName(self))}</div>
<div class="meta">${t("task.take_self")}</div>
<div class="actions"><button class="ghost" id="save-history">${t("task.save")}</button></div>`;
root.appendChild(card);
card.querySelector("#save-history").onclick = () => saveTaskToHistory(result, zone, self, "primary", true);
}
function saveTaskToHistory(result, zone, main, role, manualOverride=false){
const entry = {
id:"h-"+Date.now(),
createdAt:new Date().toISOString(),
taskText:result.text,
suggestions: result.sorted ? result.sorted.map(s=>({zoneId:s.zoneId,score:s.score,reason:s.hits.join(", ")})) : [],
chosenZoneId: zone.id,
chosenPersonId: main.id,
delegateUsed: role==="delegate",
manualOverride,
comments: [],
status: "open"
};
state.history.entries.unshift(entry);
state.history.entries = state.history.entries.slice(0,100);
saveHist(); renderHistory(); toast(t("task.saved"));
}
function renderPeople(){
const tbody = document.querySelector("#people-table tbody"); if (!tbody) return;
tbody.innerHTML = "";
for (const p of state.directory.people){
const tr = document.createElement("tr"); tr.className = p.status;
const zone = state.directory.zones.find(z=>z.id===p.zoneId);
tr.innerHTML = `
<td><strong>${esc(pName(p))}</strong></td>
<td>${esc(zoneTitle(zone))}</td>
<td>${t("role."+p.role)}</td>
<td>
<select data-pid="${p.id}" data-field="status">
<option value="active" ${p.status==="active"?"selected":""}>${t("status.active")}</option>
<option value="vacation" ${p.status==="vacation"?"selected":""}>${t("status.vacation")}</option>
<option value="inactive" ${p.status==="inactive"?"selected":""}>${t("status.inactive")}</option>
</select>
${p.vacationUntil?`<div style="font-size:11px;color:var(--text-muted);margin-top:2px">${p.vacationUntil}</div>`:""}
</td>
<td>
<select data-pid="${p.id}" data-field="delegateId" ${p.status!=="vacation"?"disabled":""}>
<option value="">—</option>
${state.directory.people.filter(x=>x.id!==p.id && x.status==="active").map(x =>
`<option value="${x.id}" ${p.delegateId===x.id?"selected":""}>${esc(pName(x))}</option>`
).join("")}
</select>
</td>
<td>${esc(p.contact||"—")}</td>`;
tbody.appendChild(tr);
}
tbody.querySelectorAll("select").forEach(sel => {
sel.onchange = () => {
const p = state.directory.people.find(x => x.id===sel.dataset.pid);
p[sel.dataset.field] = sel.value || null;
if (sel.dataset.field==="status" && sel.value!=="vacation"){ p.delegateId=null; p.vacationUntil=null; }
saveDir(); renderPeople();
};
});
}
function simbaseSync(){
const log = document.getElementById("simbase-log");
log.className = "simbase-log show";
log.textContent = "→ GET /api/vacations (mock)…";
let updated = 0;
const today = new Date().toISOString().slice(0,10);
for (const order of SIMBASE_MOCK.vacations){
const person = state.directory.people.find(p => p.simbaseId===order.employeeId);
if (!person) continue;
if (today >= order.vacationStart && today <= order.vacationEnd){
if (person.status!=="vacation" || person.vacationUntil!==order.vacationEnd){
person.status = "vacation"; person.vacationUntil = order.vacationEnd; updated++;
}
}
}
saveDir(); renderPeople();
log.textContent = "✓ " + t("people.simbase_done",{n:updated});
setTimeout(() => log.classList.remove("show"), 4000);
}
function resetMock(){
if (!confirm("Сбросить всё к моку?")) return;
state.directory = structuredClone(SEED_DIRECTORY);
state.history = structuredClone(SEED_HISTORY);
saveDir(); saveHist(); renderPeople(); renderHistory();
}
function renderHistory(){
const root = document.getElementById("history-list"); if (!root) return;
root.innerHTML = "";
if (!state.history.entries.length){
root.innerHTML = `<div class="card"><p style="margin:0;color:var(--text-muted)">${t("history.empty")}</p></div>`;
return;
}
for (const e of state.history.entries){
const zone = state.directory.zones.find(z=>z.id===e.chosenZoneId);
const person = state.directory.people.find(p=>p.id===e.chosenPersonId);
const item = document.createElement("div"); item.className = "history-item";
const date = new Date(e.createdAt).toLocaleString(state.lang==="ru"?"ru-RU":"en-US");
item.innerHTML = `
<h4>${esc(zone?zoneTitle(zone):"?")} → ${esc(person?pName(person):"?")}
<span class="status ${e.status}">${t("history.status."+e.status)}</span></h4>
<div class="date">${esc(date)}</div>
<div class="task-text">${esc(e.taskText)}</div>
<div class="actions"><button class="ghost" data-copy="${e.id}">${t("history.copy")}</button></div>
<div class="comments">${(e.comments||[]).map(c => `
<div class="comment"><span class="who">${esc(c.authorName)}</span>
<span class="when">${new Date(c.at).toLocaleString(state.lang==="ru"?"ru-RU":"en-US")}</span>
<div>${esc(c.text)}</div></div>`).join("")}
<div class="comment-form">
<select data-as="${e.id}">
<option value="manager">${t("history.comment_as")}: ${state.lang==="ru"?"Руководитель":"Manager"}</option>
${person ? `<option value="assignee">${t("history.comment_as")}: ${esc(pName(person))}</option>` : ""}
</select>
<input type="text" placeholder="${t("history.comment_placeholder")}" data-input="${e.id}">
<button class="btn" data-send="${e.id}">${t("history.send")}</button>
</div>
</div>`;
root.appendChild(item);
}
root.querySelectorAll("[data-copy]").forEach(btn => {
btn.onclick = () => {
const e = state.history.entries.find(x=>x.id===btn.dataset.copy);
copyToClipboard(formatHistoryText(e), t("history.copied"));
};
});
root.querySelectorAll("[data-send]").forEach(btn => {
btn.onclick = () => {
const id = btn.dataset.send;
const e = state.history.entries.find(x=>x.id===id);
const input = root.querySelector(`[data-input="${id}"]`);
const as = root.querySelector(`[data-as="${id}"]`).value;
const text = input.value.trim(); if (!text) return;
const person = state.directory.people.find(p=>p.id===e.chosenPersonId);
e.comments = e.comments || [];
e.comments.push({
at: new Date().toISOString(),
authorRole: as,
authorName: as==="manager" ? (state.lang==="ru"?"Руководитель":"Manager") : pName(person),
text
});
if (e.status==="open") e.status = "in_progress";
saveHist(); renderHistory();
};
});
}
function formatHistoryText(e){
const zone = state.directory.zones.find(z=>z.id===e.chosenZoneId);
const person = state.directory.people.find(p=>p.id===e.chosenPersonId);
return [
`Задача: ${e.taskText}`,
`Зона: ${zone?zoneTitle(zone):"?"}`,
`Исполнитель: ${person?pName(person):"?"}`,
`Статус: ${t("history.status."+e.status)}`,
"",
"Комментарии:",
...(e.comments||[]).map(c => `[${new Date(c.at).toLocaleString()}] ${c.authorName}: ${c.text}`)
].join("\n");
}
const pName = p => state.lang==="en" && p.nameEn ? p.nameEn : p.name;
const zoneTitle = z => state.lang==="en" && z.titleEn ? z.titleEn : z.title;
const esc = s => (s??"").toString().replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
function copyToClipboard(text, toastMsg){
if (navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(text).then(()=>toast(toastMsg)).catch(()=>fallbackCopy(text, toastMsg));
} else fallbackCopy(text, toastMsg);
}
function fallbackCopy(text, toastMsg){
const ta = document.createElement("textarea"); ta.value = text; ta.style.position="fixed"; ta.style.left="-9999px";
document.body.appendChild(ta); ta.select();
try { document.execCommand("copy"); toast(toastMsg); } catch(e){}
document.body.removeChild(ta);
}
function toast(msg){
let el = document.querySelector(".toast");
if (!el){ el = document.createElement("div"); el.className="toast"; document.body.appendChild(el); }
el.textContent = msg; el.classList.add("show");
setTimeout(()=>el.classList.remove("show"), 1800);
}
function bindUI(){
document.querySelectorAll(".tabs button").forEach(b => {
b.onclick = () => {
document.querySelectorAll(".tabs button").forEach(x => x.classList.toggle("active", x===b));
document.querySelectorAll(".view").forEach(v => v.classList.toggle("hidden", v.id !== "view-"+b.dataset.tab));
};
});
document.querySelectorAll(".lang button").forEach(b => {
b.onclick = () => { state.lang = b.dataset.lang; localStorage.setItem(LS.lang, state.lang); applyLang(); };
});
document.getElementById("task-classify").onclick = onClassify;
document.getElementById("simbase-sync").onclick = simbaseSync;
document.getElementById("people-reset").onclick = resetMock;
}
bindUI(); applyLang(); renderPeople(); renderHistory();
</script>
</body>
</html>