style: красивый дизайн в самодостаточном index.html
Inline CSS+JS+данные. Решает проблему Gitea Pages, отдающего CSS как text/plain. Лиловый градиент, sticky-хедер, карточный лэйаут, моноширинный шаблон сообщений, анимация sync-лога, mobile-responsive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
726497fe99
commit
d8f3cfea24
826
index.html
826
index.html
@ -1,71 +1,799 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Помощник руководителя</title>
|
<title>Помощник руководителя</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
|
<div class="logo">📋</div>
|
||||||
|
<div>
|
||||||
<h1 data-i18n="app.title"></h1>
|
<h1 data-i18n="app.title"></h1>
|
||||||
<p class="subtitle" data-i18n="app.subtitle"></p>
|
<p class="subtitle" data-i18n="app.subtitle"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-controls">
|
</div>
|
||||||
<nav class="tabs">
|
<div class="header-controls">
|
||||||
<button data-tab="task" class="active" data-i18n="tab.task"></button>
|
<nav class="tabs">
|
||||||
<button data-tab="people" data-i18n="tab.people"></button>
|
<button data-tab="task" class="active" data-i18n="tab.task"></button>
|
||||||
<button data-tab="history" data-i18n="tab.history"></button>
|
<button data-tab="people" data-i18n="tab.people"></button>
|
||||||
</nav>
|
<button data-tab="history" data-i18n="tab.history"></button>
|
||||||
<div class="lang">
|
</nav>
|
||||||
<button data-lang="ru" class="active">RU</button>
|
<div class="lang">
|
||||||
<button data-lang="en">EN</button>
|
<button data-lang="ru" class="active">RU</button>
|
||||||
</div>
|
<button data-lang="en">EN</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<!-- TAB: TASK -->
|
<section id="view-task" class="view">
|
||||||
<section id="view-task" class="view">
|
<div class="card">
|
||||||
<textarea id="task-input" rows="6" data-i18n-attr="placeholder:task.placeholder"></textarea>
|
<textarea id="task-input" rows="6" data-i18n-attr="placeholder:task.placeholder"></textarea>
|
||||||
<button id="task-classify" class="primary" data-i18n="task.classify"></button>
|
<button id="task-classify" class="primary" data-i18n="task.classify"></button>
|
||||||
<div id="task-result"></div>
|
<div id="task-result"></div>
|
||||||
</section>
|
</div>
|
||||||
|
</section>
|
||||||
<!-- TAB: PEOPLE -->
|
<section id="view-people" class="view hidden">
|
||||||
<section id="view-people" class="view hidden">
|
<div class="card">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button id="simbase-sync" data-i18n="people.simbase_sync"></button>
|
<button class="btn" id="simbase-sync" data-i18n="people.simbase_sync"></button>
|
||||||
<button id="people-export" data-i18n="people.export"></button>
|
<button class="ghost" id="people-reset" data-i18n="people.reset"></button>
|
||||||
<label class="btn-like">
|
|
||||||
<span data-i18n="people.import"></span>
|
|
||||||
<input type="file" id="people-import" accept="application/json" hidden>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="simbase-log" class="simbase-log"></div>
|
<div id="simbase-log" class="simbase-log"></div>
|
||||||
<table id="people-table">
|
<table id="people-table">
|
||||||
<thead>
|
<thead><tr>
|
||||||
<tr>
|
<th data-i18n="people.name"></th>
|
||||||
<th data-i18n="people.name"></th>
|
<th data-i18n="people.zone"></th>
|
||||||
<th data-i18n="people.zone"></th>
|
<th data-i18n="people.role"></th>
|
||||||
<th data-i18n="people.role"></th>
|
<th data-i18n="people.status"></th>
|
||||||
<th data-i18n="people.status"></th>
|
<th data-i18n="people.delegate"></th>
|
||||||
<th data-i18n="people.delegate"></th>
|
<th data-i18n="people.contact"></th>
|
||||||
<th data-i18n="people.contact"></th>
|
</tr></thead>
|
||||||
<th data-i18n="people.actions"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="view-history" class="view hidden">
|
||||||
|
<div id="history-list"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
<!-- TAB: HISTORY -->
|
<script>
|
||||||
<section id="view-history" class="view hidden">
|
const SEED_DIRECTORY = {
|
||||||
<div id="history-list"></div>
|
version:1,
|
||||||
</section>
|
zones:[
|
||||||
</main>
|
{ 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."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
<script src="app.js"></script>
|
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 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user