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/mvp.html
zhanakova 7f3e01d7c4 feat: add self-contained single-file MVP (mvp.html)
Самодостаточный одностраничник со всем кодом, моками и стилями внутри.
Открывается двойным кликом, не требует сервера или fetch().

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

617 lines
32 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>
*{box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;margin:0;background:#f5f6f8;color:#1d1f23;line-height:1.45}
header{background:#1d1f23;color:#fff;padding:16px 24px;display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:12px}
header h1{margin:0;font-size:20px}
header .subtitle{margin:2px 0 0;font-size:13px;opacity:.7}
.header-controls{display:flex;gap:16px;align-items:center;flex-wrap:wrap}
.tabs button,.lang button{background:transparent;color:#fff;border:1px solid #3a3d44;padding:6px 14px;border-radius:6px;cursor:pointer;font:inherit}
.tabs button.active,.lang button.active{background:#4a8cff;border-color:#4a8cff}
.lang button{padding:6px 10px;font-size:12px}
main{padding:24px;max-width:1100px;margin:0 auto}
.view.hidden{display:none}
textarea{width:100%;font:inherit;padding:12px;border:1px solid #d0d4dc;border-radius:6px;resize:vertical;background:#fff}
button.primary,button:not(.ghost):not(.tabs button):not(.lang button){background:#4a8cff;color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font:inherit;margin-top:12px}
button.ghost{background:transparent;border:1px solid #d0d4dc;color:#1d1f23}
button:hover{opacity:.9}
.btn-like{display:inline-block;background:#4a8cff;color:#fff;padding:8px 16px;border-radius:6px;cursor:pointer;margin-top:12px}
#task-result{margin-top:24px;display:flex;flex-direction:column;gap:16px}
.zone-card,.assignee-card,.history-item{background:#fff;border:1px solid #e2e5eb;border-radius:8px;padding:16px}
.zone-card.choice{cursor:pointer;transition:border-color .15s}
.zone-card.choice:hover{border-color:#4a8cff}
.zone-card h3{margin:0 0 6px;font-size:15px}
.zone-card .score{font-size:12px;color:#6b7280}
.assignee-card{border-color:#4a8cff}
.assignee-card .name{font-size:18px;font-weight:600}
.assignee-card .meta{color:#6b7280;font-size:13px;margin-top:4px}
.assignee-card .note{background:#fff3cd;padding:8px 12px;border-radius:6px;margin-top:8px;font-size:13px}
.assignee-card pre{background:#f5f6f8;padding:10px;border-radius:6px;white-space:pre-wrap;font-size:13px;margin-top:12px;font-family:ui-monospace,"SF Mono",Menlo,monospace}
.assignee-card .actions{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
.error-card{background:#fff5f5;border:1px solid #f5b1b1;padding:16px;border-radius:8px}
.error-card .actions{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px;align-items:center}
.simbase-log{background:#e7f3ff;border:1px solid #b8daff;padding:10px 14px;border-radius:6px;font-size:13px;margin-bottom:16px;display:none}
.simbase-log.show{display:block}
table{width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden}
th,td{padding:10px 12px;text-align:left;border-bottom:1px solid #e2e5eb;font-size:14px;vertical-align:middle}
th{background:#f8f9fb;font-weight:600;font-size:13px}
tr.vacation td{background:#fffbea}
tr.inactive td{color:#9ca3af}
td select,td input{font:inherit;padding:4px 6px;border:1px solid #d0d4dc;border-radius:4px}
td button{padding:4px 10px;margin:0 2px}
.history-item{margin-bottom:12px}
.history-item h4{margin:0 0 6px;font-size:14px}
.history-item .date{color:#6b7280;font-size:12px}
.history-item .task-text{margin:8px 0;font-size:14px}
.history-item .status{display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;background:#e2e5eb}
.history-item .status.in_progress{background:#cfe8ff}
.history-item .status.done{background:#c6f6d5}
.history-item .status.cancelled{background:#fed7d7}
.history-item .comments{margin-top:12px;border-top:1px solid #e2e5eb;padding-top:10px}
.history-item .comment{font-size:13px;padding:6px 0;border-bottom:1px dashed #eef0f4}
.history-item .comment:last-child{border-bottom:none}
.history-item .comment .who{font-weight:600}
.history-item .comment .when{color:#9ca3af;font-size:11px;margin-left:6px}
.history-item .comment-form{margin-top:10px;display:flex;gap:6px;flex-wrap:wrap;align-items:center}
.history-item .comment-form select,.history-item .comment-form input{padding:6px 8px;font:inherit;border:1px solid #d0d4dc;border-radius:4px}
.history-item .comment-form input{flex:1;min-width:200px}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#1d1f23;color:#fff;padding:10px 20px;border-radius:6px;font-size:14px;opacity:0;transition:opacity .2s;pointer-events:none;z-index:1000}
.toast.show{opacity:1}
.banner{background:#fff8e1;border:1px solid #ffe082;border-radius:6px;padding:8px 12px;font-size:13px;margin-bottom:16px}
</style>
</head>
<body>
<header>
<div class="brand">
<h1 data-i18n="app.title"></h1>
<p class="subtitle" data-i18n="app.subtitle"></p>
</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">
<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>
</section>
<section id="view-people" class="view hidden">
<div class="toolbar">
<button id="simbase-sync" data-i18n="people.simbase_sync"></button>
<button id="people-reset" class="ghost" 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>
</section>
<section id="view-history" class="view hidden">
<div id="history-list"></div>
</section>
</main>
<script>
// ===== EMBEDDED DATA =====
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 task 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."
}
};
// ===== STATE =====
const LS = {
dir: "mvp.directory",
hist: "mvp.history",
lang: "mvp.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;
function saveDir(){ localStorage.setItem(LS.dir, JSON.stringify(state.directory)); }
function saveHist(){ localStorage.setItem(LS.hist, JSON.stringify(state.history)); }
// ===== i18n =====
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();
}
// ===== Classifier =====
function normalize(s){
return s.toLowerCase().replace(/ё/g,"е").replace(/[^\p{L}\p{N}\s]/gu," ").split(/\s+/).filter(Boolean);
}
function escapeReg(s){ return s.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"); }
function scoreZones(text){
const tokenStr = normalize(text).join(" ");
return state.directory.zones.map(z => {
let score = 0; const hits = [];
for (const k of z.keywords) {
const n = normalize(k).join(" ");
const c = (tokenStr.match(new RegExp("\\b"+escapeReg(n)+"\\b","g"))||[]).length;
if (c>0){ score += c*1.0; hits.push(k); }
}
for (const sys of z.systems) {
const n = normalize(sys).join(" ");
const c = (tokenStr.match(new RegExp("\\b"+escapeReg(n)+"\\b","g"))||[]).length;
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 };
}
// ===== Task view =====
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.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")}: ${z.score.toFixed(1)}${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">${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 id="copy-msg">${t("task.copy")}</button>
<button id="save-history" class="ghost">${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 id="copy-msg">${t("task.copy")}</button>
<button id="save-history" class="ghost">${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 id="save-history" class="ghost">${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"));
}
// ===== People view =====
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>${esc(pName(p))}</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:#9ca3af">${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();
}
// ===== History view =====
function renderHistory(){
const root = document.getElementById("history-list"); if (!root) return;
root.innerHTML = "";
if (!state.history.entries.length) { root.innerHTML = `<p>${t("history.empty")}</p>`; 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 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);
const lines = [
`Задача: ${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}`)
];
return lines.join("\n");
}
// ===== Helpers =====
function pName(p){ return state.lang==="en" && p.nameEn ? p.nameEn : p.name; }
function zoneTitle(z){ return state.lang==="en" && z.titleEn ? z.titleEn : z.title; }
function esc(s){ return (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);
}
// ===== Bindings =====
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;
}
// ===== Init =====
bindUI(); applyLang(); renderPeople(); renderHistory();
</script>
</body>
</html>