Deploy ExecutiveOS AI PWA to Gitea Pages
Static site for HCL Notes / Lotus Notes executive copilot demo. Russian UI, PWA-installable, offline-capable via Service Worker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
b2cd0c71d9
445
app.js
Normal file
445
app.js
Normal file
@ -0,0 +1,445 @@
|
|||||||
|
// ExecutiveOS AI — vanilla JS SPA (RU UI)
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Локализация ключей → подписи UI
|
||||||
|
const CATEGORY_LABEL = {
|
||||||
|
Approval: "Согласование",
|
||||||
|
Board: "Совет директоров",
|
||||||
|
Escalation: "Эскалация",
|
||||||
|
Financial: "Финансы",
|
||||||
|
Legal: "Правовое",
|
||||||
|
HR: "Кадры",
|
||||||
|
Informational: "Инфо",
|
||||||
|
Urgent: "Срочное"
|
||||||
|
};
|
||||||
|
const RECO_LABEL = {
|
||||||
|
Sign: "Подписать",
|
||||||
|
Approve: "Согласовать",
|
||||||
|
Vote: "Голосовать",
|
||||||
|
Review: "Проверить",
|
||||||
|
Reject: "Отклонить",
|
||||||
|
Escalate: "Эскалировать",
|
||||||
|
Delegate: "Делегировать",
|
||||||
|
Ignore: "Игнорировать"
|
||||||
|
};
|
||||||
|
const PRI_LABEL = {
|
||||||
|
P1: "P1 · Критический",
|
||||||
|
P2: "P2 · Высокий",
|
||||||
|
P3: "P3 · Обычный"
|
||||||
|
};
|
||||||
|
const RESOLVED_LABEL = {
|
||||||
|
"Signed": "Подписано",
|
||||||
|
"Approved": "Согласовано",
|
||||||
|
"Rejected": "Отклонено",
|
||||||
|
"Delegated": "Делегировано",
|
||||||
|
"Escalated": "Эскалировано",
|
||||||
|
"Ignored": "Проигнорировано",
|
||||||
|
"Reviewed": "Просмотрено",
|
||||||
|
"Voted FOR": "Голос ЗА",
|
||||||
|
"Voted AGAINST": "Голос ПРОТИВ",
|
||||||
|
"Done": "Готово"
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATE = {
|
||||||
|
route: "briefing",
|
||||||
|
resolved: {} // локальное состояние, без backend
|
||||||
|
};
|
||||||
|
|
||||||
|
const D = window.MOCK_DATA;
|
||||||
|
|
||||||
|
// ---------- Утилиты ----------
|
||||||
|
function $(sel, root = document) { return root.querySelector(sel); }
|
||||||
|
function $$(sel, root = document) { return Array.from(root.querySelectorAll(sel)); }
|
||||||
|
|
||||||
|
function fmtAge(days) {
|
||||||
|
if (days <= 0) return "сегодня";
|
||||||
|
if (days === 1) return "1 день назад";
|
||||||
|
if (days >= 2 && days <= 4) return `${days} дня назад`;
|
||||||
|
return `${days} дн. назад`;
|
||||||
|
}
|
||||||
|
function slaState(email) {
|
||||||
|
if (!email.sla_hours) return { label: "—", state: "ok" };
|
||||||
|
const hoursElapsed = email.age_days * 24;
|
||||||
|
const remaining = email.sla_hours - hoursElapsed;
|
||||||
|
if (remaining < 0) return { label: `просрочен на ${Math.abs(remaining).toFixed(0)} ч`, state: "breach" };
|
||||||
|
if (remaining < 12) return { label: `< ${remaining.toFixed(0)} ч`, state: "near" };
|
||||||
|
return { label: `${remaining.toFixed(0)} ч`, state: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(name) {
|
||||||
|
const tpl = document.getElementById("tpl-" + name);
|
||||||
|
const view = document.getElementById("view");
|
||||||
|
view.innerHTML = "";
|
||||||
|
if (tpl) view.appendChild(tpl.content.cloneNode(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(route) {
|
||||||
|
STATE.route = route;
|
||||||
|
$$("#nav button").forEach(b => b.classList.toggle("active", b.dataset.route === route));
|
||||||
|
location.hash = route;
|
||||||
|
if (route === "briefing") renderBriefing();
|
||||||
|
else if (route === "inbox") renderInbox();
|
||||||
|
else if (route === "approvals") renderApprovals();
|
||||||
|
else if (route === "escalations") renderEscalations();
|
||||||
|
else if (route === "search") renderSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Брифинг ----------
|
||||||
|
function renderBriefing() {
|
||||||
|
mount("briefing");
|
||||||
|
const dateEl = $("#brief-date");
|
||||||
|
dateEl.textContent =
|
||||||
|
`${D.meta.today} · последняя синхронизация ${new Date(D.meta.last_sync).toLocaleString("ru-RU")} · источник: ${D.meta.source}`;
|
||||||
|
|
||||||
|
const emails = D.emails;
|
||||||
|
const critical = emails.filter(e => e.priority === "P1" && e.unread).length;
|
||||||
|
const pending = emails.filter(e => e.action_required && !STATE.resolved[e.id]).length;
|
||||||
|
const boardItems = emails.filter(e => e.category === "Board").length;
|
||||||
|
const slaBreaches = emails.filter(e => slaState(e).state === "breach").length;
|
||||||
|
|
||||||
|
setKpi("critical", critical);
|
||||||
|
setKpi("pending", pending);
|
||||||
|
setKpi("board", boardItems);
|
||||||
|
setKpi("sla", slaBreaches);
|
||||||
|
|
||||||
|
$("#brief-summary").textContent = D.briefing_text;
|
||||||
|
|
||||||
|
const top3 = emails
|
||||||
|
.filter(e => e.action_required && !STATE.resolved[e.id])
|
||||||
|
.sort((a, b) => b.ai_score - a.ai_score)
|
||||||
|
.slice(0, 3);
|
||||||
|
const ol = $("#brief-top3");
|
||||||
|
top3.forEach(e => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.innerHTML = `
|
||||||
|
<div><strong>${escapeHtml(e.subject)}</strong></div>
|
||||||
|
<span class="sub">
|
||||||
|
<span class="pri-${e.priority}" style="font-weight:600">${PRI_LABEL[e.priority]}</span> ·
|
||||||
|
${escapeHtml(e.from.name)} (${escapeHtml(e.from.title)}) ·
|
||||||
|
AI-балл ${e.ai_score} · рекомендация: <strong>${RECO_LABEL[e.recommendation]}</strong>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
ol.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#btn-refresh").onclick = () => {
|
||||||
|
$("#btn-refresh").textContent = "Синхронизация…";
|
||||||
|
setTimeout(() => navigate("briefing"), 400);
|
||||||
|
};
|
||||||
|
$("#btn-mark-read").onclick = () => {
|
||||||
|
D.emails.forEach(e => e.unread = false);
|
||||||
|
navigate("briefing");
|
||||||
|
};
|
||||||
|
|
||||||
|
initCopilot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setKpi(key, value) {
|
||||||
|
const el = $(`[data-k="${key}"]`);
|
||||||
|
if (el) el.textContent = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- AI-копилот ----------
|
||||||
|
const CP_SUGGESTIONS = [
|
||||||
|
"Что требует моей подписи?",
|
||||||
|
"Что просрочено?",
|
||||||
|
"Покажи критические инциденты",
|
||||||
|
"Что готовить к заседанию Совета?",
|
||||||
|
"Что можно делегировать?"
|
||||||
|
];
|
||||||
|
|
||||||
|
function initCopilot() {
|
||||||
|
const sug = $("#cp-suggest");
|
||||||
|
CP_SUGGESTIONS.forEach(q => {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.className = "chip"; b.textContent = q;
|
||||||
|
b.onclick = () => askCopilot(q);
|
||||||
|
sug.appendChild(b);
|
||||||
|
});
|
||||||
|
$("#cp-ask").onclick = () => {
|
||||||
|
const v = $("#cp-input").value.trim();
|
||||||
|
if (v) askCopilot(v);
|
||||||
|
};
|
||||||
|
$("#cp-input").addEventListener("keydown", e => {
|
||||||
|
if (e.key === "Enter") $("#cp-ask").click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function askCopilot(question) {
|
||||||
|
const q = question.toLowerCase();
|
||||||
|
const out = $("#cp-answer");
|
||||||
|
const items = D.emails;
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
const matchSign = /(подпис|sign|подпишу|sign-off)/i.test(q);
|
||||||
|
const matchOverdue = /(просроч|overdue|опазд|дедлайн)/i.test(q);
|
||||||
|
const matchUrgent = /(критич|срочн|urgent|p1|инцидент|p-?1)/i.test(q);
|
||||||
|
const matchBoard = /(совет|board|заседан|комитет)/i.test(q);
|
||||||
|
const matchDelegate = /(делегир|delegate|раздать|передать)/i.test(q);
|
||||||
|
const matchPriority = /(приоритет|priorit|важн|top|топ)/i.test(q);
|
||||||
|
|
||||||
|
if (matchSign) {
|
||||||
|
const sign = items.filter(e => e.recommendation === "Sign" && !STATE.resolved[e.id]);
|
||||||
|
parts.push(formatList("✍️ Требуют подписи:", sign));
|
||||||
|
}
|
||||||
|
if (matchOverdue) {
|
||||||
|
const over = items.filter(e => slaState(e).state === "breach");
|
||||||
|
parts.push(formatList("⏰ Просрочены по SLA:", over));
|
||||||
|
}
|
||||||
|
if (matchUrgent) {
|
||||||
|
const urg = items.filter(e => e.priority === "P1");
|
||||||
|
parts.push(formatList("🚨 Критические (P1):", urg));
|
||||||
|
}
|
||||||
|
if (matchBoard) {
|
||||||
|
const brd = items.filter(e => e.category === "Board");
|
||||||
|
parts.push(formatList("🏛 К заседанию Совета:", brd));
|
||||||
|
}
|
||||||
|
if (matchDelegate) {
|
||||||
|
const del = items.filter(e => e.recommendation === "Delegate");
|
||||||
|
parts.push(formatList("↪ Можно делегировать:", del));
|
||||||
|
}
|
||||||
|
if (matchPriority || parts.length === 0) {
|
||||||
|
const top = items
|
||||||
|
.filter(e => e.action_required && !STATE.resolved[e.id])
|
||||||
|
.sort((a, b) => b.ai_score - a.ai_score)
|
||||||
|
.slice(0, 5);
|
||||||
|
parts.push(formatList("🎯 Топ-5 по AI-баллу:", top));
|
||||||
|
}
|
||||||
|
|
||||||
|
out.textContent = parts.join("\n\n");
|
||||||
|
out.classList.add("show");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatList(header, items) {
|
||||||
|
if (items.length === 0) return header + "\n (пусто)";
|
||||||
|
const lines = items.map(e => {
|
||||||
|
const sla = slaState(e);
|
||||||
|
return ` • [${e.priority}] ${e.subject}\n от ${e.from.name} (${e.from.title}) · ${fmtAge(e.age_days)} · SLA ${sla.label} · → ${RECO_LABEL[e.recommendation]}`;
|
||||||
|
});
|
||||||
|
return header + "\n" + lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Входящие ----------
|
||||||
|
function renderInbox() {
|
||||||
|
mount("inbox");
|
||||||
|
const catSel = $("#inbox-cat");
|
||||||
|
D.categories.forEach(c => {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = c; o.textContent = CATEGORY_LABEL[c] || c;
|
||||||
|
catSel.appendChild(o);
|
||||||
|
});
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
const q = $("#inbox-search").value.toLowerCase().trim();
|
||||||
|
const cat = catSel.value;
|
||||||
|
const pri = $("#inbox-pri").value;
|
||||||
|
const unreadOnly = $("#inbox-unread").checked;
|
||||||
|
const list = $("#inbox-list");
|
||||||
|
list.innerHTML = "";
|
||||||
|
|
||||||
|
const filtered = D.emails
|
||||||
|
.filter(e => !q ||
|
||||||
|
e.subject.toLowerCase().includes(q) ||
|
||||||
|
e.from.name.toLowerCase().includes(q) ||
|
||||||
|
(e.preview || "").toLowerCase().includes(q))
|
||||||
|
.filter(e => !cat || e.category === cat)
|
||||||
|
.filter(e => !pri || e.priority === pri)
|
||||||
|
.filter(e => !unreadOnly || e.unread)
|
||||||
|
.sort((a, b) => b.ai_score - a.ai_score);
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
list.innerHTML = `<div class="card muted small" style="text-align:center;padding:24px">Ничего не найдено.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filtered.forEach(e => list.appendChild(renderEmailCard(e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
$("#inbox-search").addEventListener("input", draw);
|
||||||
|
catSel.addEventListener("change", draw);
|
||||||
|
$("#inbox-pri").addEventListener("change", draw);
|
||||||
|
$("#inbox-unread").addEventListener("change", draw);
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmailCard(e) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = `email${e.unread ? " unread" : ""}${e.priority === "P1" ? " urgent" : ""}`;
|
||||||
|
const cat = e.category.toLowerCase();
|
||||||
|
const sla = slaState(e);
|
||||||
|
const slaColor = sla.state === "breach" ? "danger" : sla.state === "near" ? "warn" : "muted";
|
||||||
|
div.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<span class="pri-badge pri-${e.priority}">${e.priority}</span>
|
||||||
|
<div class="score-bar" title="AI-балл ${e.ai_score}"><div class="score-fill" style="width:${e.ai_score}%"></div></div>
|
||||||
|
<div class="muted small mono r" style="margin-top:4px">${e.ai_score}</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="subject">${escapeHtml(e.subject)}</div>
|
||||||
|
<div class="from">${escapeHtml(e.from.name)} · ${escapeHtml(e.from.title)} · ${escapeHtml(e.from.email)}</div>
|
||||||
|
<div class="preview">${escapeHtml(e.preview)}</div>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag cat-${cat}">${CATEGORY_LABEL[e.category] || e.category}</span>
|
||||||
|
${e.attachments ? `<span class="tag">📎 ${e.attachments}</span>` : ""}
|
||||||
|
${e.unread ? `<span class="tag" style="color:var(--accent);border-color:var(--accent)">новое</span>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<div class="age">${fmtAge(e.age_days)}</div>
|
||||||
|
${e.sla_hours ? `<div class="age ${slaColor}">SLA ${sla.label}</div>` : ""}
|
||||||
|
<div class="reco reco-${e.recommendation.toLowerCase()}">→ ${RECO_LABEL[e.recommendation]}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
div.onclick = () => alert(
|
||||||
|
`AI-обоснование:\n\n${e.ai_rationale}\n\n— Сводка —\n${e.summary}`
|
||||||
|
);
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Согласования ----------
|
||||||
|
function renderApprovals() {
|
||||||
|
mount("approvals");
|
||||||
|
const list = $("#appr-list");
|
||||||
|
list.innerHTML = "";
|
||||||
|
|
||||||
|
const items = D.emails
|
||||||
|
.filter(e => e.action_required &&
|
||||||
|
["Sign", "Approve", "Vote", "Review", "Reject", "Escalate", "Delegate"].includes(e.recommendation))
|
||||||
|
.sort((a, b) => b.ai_score - a.ai_score);
|
||||||
|
|
||||||
|
items.forEach(e => {
|
||||||
|
const resolved = STATE.resolved[e.id];
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "approval" + (resolved ? " resolved" : "");
|
||||||
|
const sla = slaState(e);
|
||||||
|
const slaClass = sla.state === "breach" ? "danger" : sla.state === "near" ? "warn" : "muted";
|
||||||
|
card.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<div class="head">
|
||||||
|
<span class="pri-badge pri-${e.priority}">${e.priority}</span>
|
||||||
|
<span class="tag cat-${e.category.toLowerCase()}">${CATEGORY_LABEL[e.category] || e.category}</span>
|
||||||
|
${resolved ? `<span class="resolved-tag">✓ ${RESOLVED_LABEL[resolved] || resolved}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
<div class="subject" style="margin-top:10px">${escapeHtml(e.subject)}</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span>от <strong>${escapeHtml(e.from.name)}</strong> (${escapeHtml(e.from.title)})</span>
|
||||||
|
<span>${fmtAge(e.age_days)}</span>
|
||||||
|
${e.sla_hours ? `<span class="${slaClass}">SLA ${sla.label}</span>` : ""}
|
||||||
|
${e.attachments ? `<span>📎 вложений: ${e.attachments}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
<div class="summary">${escapeHtml(e.summary)}</div>
|
||||||
|
<div class="ai-box">
|
||||||
|
<strong>AI · балл ${e.ai_score}/100 · → ${RECO_LABEL[e.recommendation]}</strong><br/>
|
||||||
|
<span class="muted small">${escapeHtml(e.ai_rationale)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
${approvalButtons(e)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
card.querySelectorAll("button[data-act]").forEach(b => {
|
||||||
|
b.onclick = () => {
|
||||||
|
STATE.resolved[e.id] = b.dataset.act;
|
||||||
|
renderApprovals();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
list.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = `<div class="card muted small" style="text-align:center;padding:24px">Открытых решений нет.</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function approvalButtons(e) {
|
||||||
|
const r = e.recommendation;
|
||||||
|
if (r === "Sign") return `<button class="btn-sign" data-act="Signed">✍️ Подписать</button><button data-act="Delegated" class="btn-delegate">Делегировать</button>`;
|
||||||
|
if (r === "Approve") return `<button class="btn-approve" data-act="Approved">✓ Согласовать</button><button data-act="Rejected" class="btn-reject">Отклонить</button><button data-act="Delegated" class="btn-delegate">Делегировать</button>`;
|
||||||
|
if (r === "Vote") return `<button class="btn-approve" data-act="Voted FOR">Голос ЗА</button><button data-act="Voted AGAINST" class="btn-reject">Голос ПРОТИВ</button>`;
|
||||||
|
if (r === "Review") return `<button class="btn-approve" data-act="Reviewed">✓ Просмотрено</button><button data-act="Delegated" class="btn-delegate">Делегировать</button>`;
|
||||||
|
if (r === "Escalate") return `<button class="btn-reject" data-act="Escalated">↑ Эскалировать</button><button data-act="Ignored" class="btn-delegate">Игнорировать</button>`;
|
||||||
|
if (r === "Delegate") return `<button data-act="Delegated" class="btn-delegate">↪ Делегировать</button><button class="btn-approve" data-act="Approved">Решить самому</button>`;
|
||||||
|
return `<button data-act="Done" class="btn-approve">Готово</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Эскалации ----------
|
||||||
|
function renderEscalations() {
|
||||||
|
mount("escalations");
|
||||||
|
const rows = D.emails
|
||||||
|
.filter(e => e.category === "Escalation" || e.priority === "P1")
|
||||||
|
.sort((a, b) => b.age_days - a.age_days);
|
||||||
|
const tbody = $("#esc-rows");
|
||||||
|
rows.forEach(e => {
|
||||||
|
const sla = slaState(e);
|
||||||
|
const slaCls = sla.state === "breach" ? "danger" : sla.state === "near" ? "warn" : "muted";
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
const status = STATE.resolved[e.id]
|
||||||
|
? `<span class="accent">${RESOLVED_LABEL[STATE.resolved[e.id]] || STATE.resolved[e.id]}</span>`
|
||||||
|
: `<span class="${e.priority === 'P1' ? 'danger' : 'warn'}">${RECO_LABEL[e.recommendation]}</span>`;
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="mono">${fmtAge(e.age_days)}</td>
|
||||||
|
<td>${escapeHtml(e.subject)}</td>
|
||||||
|
<td class="muted small">${escapeHtml(e.from.name)}</td>
|
||||||
|
<td>${escapeHtml(e.from.title)}</td>
|
||||||
|
<td class="r ${slaCls}">${sla.label}</td>
|
||||||
|
<td>${status}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
if (rows.length === 0) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="6" class="muted small" style="text-align:center;padding:24px">Открытых эскалаций нет.</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Поиск ----------
|
||||||
|
function renderSearch() {
|
||||||
|
mount("search");
|
||||||
|
const sug = $("#srch-suggest");
|
||||||
|
["бюджет", "EU AI Act", "Совет", "Председатель", "аудит", "Сокол", "SecOps", "ESG"].forEach(s => {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.className = "chip"; b.textContent = s;
|
||||||
|
b.onclick = () => { $("#srch-input").value = s; doSearch(); };
|
||||||
|
sug.appendChild(b);
|
||||||
|
});
|
||||||
|
$("#srch-go").onclick = doSearch;
|
||||||
|
$("#srch-input").addEventListener("keydown", e => { if (e.key === "Enter") doSearch(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function doSearch() {
|
||||||
|
const q = $("#srch-input").value.toLowerCase().trim();
|
||||||
|
const out = $("#srch-results");
|
||||||
|
out.innerHTML = "";
|
||||||
|
if (!q) return;
|
||||||
|
const hay = (e) =>
|
||||||
|
(e.subject + " " + e.preview + " " + e.summary + " " +
|
||||||
|
e.from.name + " " + e.from.title + " " +
|
||||||
|
(CATEGORY_LABEL[e.category] || "") + " " + e.category).toLowerCase();
|
||||||
|
const results = D.emails.filter(e => hay(e).includes(q));
|
||||||
|
if (results.length === 0) {
|
||||||
|
out.innerHTML = `<div class="card muted small" style="text-align:center;padding:24px">Совпадений нет.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
results.forEach(e => out.appendChild(renderEmailCard(e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Helpers ----------
|
||||||
|
function escapeHtml(s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Router ----------
|
||||||
|
document.getElementById("nav").addEventListener("click", (e) => {
|
||||||
|
const r = e.target.dataset.route;
|
||||||
|
if (r) navigate(r);
|
||||||
|
});
|
||||||
|
window.addEventListener("hashchange", () => {
|
||||||
|
const r = location.hash.replace("#", "") || "briefing";
|
||||||
|
if (r !== STATE.route) navigate(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
const start = location.hash.replace("#", "") || "briefing";
|
||||||
|
navigate(start);
|
||||||
|
})();
|
||||||
351
data.js
Normal file
351
data.js
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
// ExecutiveOS AI — мок-данные (демо интеграции HCL Notes / Lotus Notes)
|
||||||
|
// Все имена/отправители/суммы вымышлены. Компания: АО «АКМЕ Холдинг».
|
||||||
|
// "сегодня" = 2026-05-22 для расчета возраста писем и SLA в app.js.
|
||||||
|
|
||||||
|
window.MOCK_DATA = {
|
||||||
|
meta: {
|
||||||
|
today: "2026-05-22",
|
||||||
|
user: "ceo@acme.corp",
|
||||||
|
source: "HCL Notes / Lotus Notes (Domino bridge)",
|
||||||
|
last_sync: "2026-05-22T08:14:00Z"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Канонические ключи (для логики) → русские подписи (для UI) — в app.js
|
||||||
|
categories: ["Approval", "Board", "Escalation", "Financial", "Legal", "HR", "Informational", "Urgent"],
|
||||||
|
priorities: ["P1", "P2", "P3"],
|
||||||
|
|
||||||
|
emails: [
|
||||||
|
{
|
||||||
|
id: "e01",
|
||||||
|
received_at: "2026-05-22T07:42:00Z",
|
||||||
|
from: { name: "Анна Петрова", email: "chairman@acme.corp", title: "Председатель совета директоров" },
|
||||||
|
subject: "Утверждение CAPEX Q2 — пересмотренный план $42 млн",
|
||||||
|
preview: "Прошу рассмотреть и подписать пересмотренный план CAPEX до заседания Совета в пятницу. Ключевые изменения: +$8M на AI-инфраструктуру, −$3M из устаревшего ЦОД.",
|
||||||
|
summary: "Председатель Совета просит подпись CEO на пересмотренном CAPEX Q2 ($42 млн). Главный сдвиг: +$8M в AI-инфра, −$3M из легаси-ЦОД. Привязано к голосованию Совета в пятницу.",
|
||||||
|
category: "Approval",
|
||||||
|
priority: "P1",
|
||||||
|
ai_score: 96,
|
||||||
|
recommendation: "Sign",
|
||||||
|
ai_rationale: "Отправитель — Председатель Совета (высшая инстанция). Тема пересекается с голосованием через 3 дня. Риск: срыв дедлайна срывает закрытие Q2-бюджета. Исторически подобные пересмотры CAPEX одобряются в 9 случаях из 10.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 0,
|
||||||
|
attachments: 2,
|
||||||
|
sla_hours: 48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e02",
|
||||||
|
received_at: "2026-05-21T18:11:00Z",
|
||||||
|
from: { name: "Маркус Лян", email: "cfo@acme.corp", title: "Финансовый директор (CFO)" },
|
||||||
|
subject: "Резерв непредвиденных расходов FY26 — на подпись",
|
||||||
|
preview: "Финансы закрыли резерв на уровне 3,2% от opex. Требуется электронная подпись на приложенном меморандуме до конца квартала.",
|
||||||
|
summary: "CFO подтверждает резерв FY26 на уровне 3,2% от opex ($14,1 млн). Стандартная подпись меморандума, изменений политики vs прошлого года нет.",
|
||||||
|
category: "Financial",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 82,
|
||||||
|
recommendation: "Sign",
|
||||||
|
ai_rationale: "Рутинный финансовый контроль. Соответствует прошлогоднему паттерну. Низкий операционный риск. Рекомендация: подписать.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 1,
|
||||||
|
attachments: 1,
|
||||||
|
sla_hours: 72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e03",
|
||||||
|
received_at: "2026-05-21T14:30:00Z",
|
||||||
|
from: { name: "Юридический департамент", email: "legal@acme.corp", title: "Главный юрист" },
|
||||||
|
subject: "Продление контракта с TechCo MSA — $1,2 млн/год",
|
||||||
|
preview: "Продление MSA с TechCo. Лимит ответственности снижен с 12 до 6 месяцев платежей. Просьба рассмотреть или делегировать.",
|
||||||
|
summary: "Legal эскалирует продление MSA с TechCo. Особенность: лимит ответственности уменьшен в 2 раза. Стоимость контракта $1,2 млн/год.",
|
||||||
|
category: "Legal",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 74,
|
||||||
|
recommendation: "Review",
|
||||||
|
ai_rationale: "Условия ответственности существенно слабее предыдущего MSA. Ниже обязательного порога ревью CEO ($5 млн), но нестандартное изменение требует внимания.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 1,
|
||||||
|
attachments: 3,
|
||||||
|
sla_hours: 96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e04",
|
||||||
|
received_at: "2026-05-20T22:05:00Z",
|
||||||
|
from: { name: "SecOps Alert", email: "secops@acme.corp", title: "Служба безопасности" },
|
||||||
|
subject: "ИНЦИДЕНТ P1 — Подозрительный вход админа с внешнего IP",
|
||||||
|
preview: "Привилегированный аккаунт 'svc-domino-admin' аутентифицировался с не-VPN IP 185.x.x.x в 22:04 UTC. Учётные данные автоматически ротированы. Ожидаем решение об эскалации.",
|
||||||
|
summary: "SecOps зафиксировал подозрительный привилегированный вход. Учётные данные авторотированы. Утечки данных не зафиксировано. SecOps просит CEO эскалировать к CISO + уведомить аудит-комитет Совета.",
|
||||||
|
category: "Escalation",
|
||||||
|
priority: "P1",
|
||||||
|
ai_score: 99,
|
||||||
|
recommendation: "Escalate",
|
||||||
|
ai_rationale: "Паттерн компрометации привилегированной учётки. Вероятно сработал порог обязательного раскрытия. Рекомендация: немедленная эскалация к CISO и предварительное уведомление аудит-комитета.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 2,
|
||||||
|
attachments: 1,
|
||||||
|
sla_hours: 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e05",
|
||||||
|
received_at: "2026-05-20T11:30:00Z",
|
||||||
|
from: { name: "Сара Ким", email: "chro@acme.corp", title: "Директор по персоналу (CHRO)" },
|
||||||
|
subject: "Промоушены до уровня директор — 3 кандидата",
|
||||||
|
preview: "Три промоушена до директора в Q2. Все прошли Совет по талантам. Требуется финальное одобрение CEO.",
|
||||||
|
summary: "CHRO просит подпись по 3 промоушенам директоров. Все предварительно одобрены Советом по талантам. Годовое влияние на ФОТ +$420K.",
|
||||||
|
category: "HR",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 71,
|
||||||
|
recommendation: "Approve",
|
||||||
|
ai_rationale: "Стандартный пакет промоушенов. Совет по талантам уже одобрил. Стоимость в пределах Q2-плана. Исторический процент одобрения CEO предодобренных промоушенов: 94%.",
|
||||||
|
action_required: true,
|
||||||
|
unread: false,
|
||||||
|
age_days: 2,
|
||||||
|
attachments: 1,
|
||||||
|
sla_hours: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e06",
|
||||||
|
received_at: "2026-05-19T16:45:00Z",
|
||||||
|
from: { name: "Секретариат Совета", email: "board-sec@acme.corp", title: "Секретариат Совета директоров" },
|
||||||
|
subject: "Заседание Совета 24 мая — материалы pre-read (4 документа)",
|
||||||
|
preview: "Pre-read: обновление стратегии Q2, M&A-pipeline, риск-дашборд, ESG-отчет. Просьба ознакомиться до сессии 09:00 в пятницу.",
|
||||||
|
summary: "Пакет материалов к заседанию Совета 24 мая. 4 документа: стратегия Q2, M&A-pipeline, риск-дашборд, ESG. Всего ~62 страницы. AI-сводка приложена.",
|
||||||
|
category: "Board",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 88,
|
||||||
|
recommendation: "Review",
|
||||||
|
ai_rationale: "Стандартный pre-read Совета. Сгенерирована автоматическая сводка. M&A-pipeline содержит 1 материальный пункт, требующий знакомства CEO до голосования.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 3,
|
||||||
|
attachments: 4,
|
||||||
|
sla_hours: 96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e07",
|
||||||
|
received_at: "2026-05-19T09:00:00Z",
|
||||||
|
from: { name: "Investor Relations", email: "ir@acme.corp", title: "Глава IR" },
|
||||||
|
subject: "Сценарий звонка с инвесторами — финальный драфт",
|
||||||
|
preview: "Финальный сценарий звонка 28 мая. Тезисы согласованы с CFO. Просьба проверить вступительное слово.",
|
||||||
|
summary: "Сценарий звонка с инвесторами v3. Вступление акцентирует ROI AI-инфраструктуры. Согласовано с CFO. Два варианта Q&A ожидают предпочтения CEO.",
|
||||||
|
category: "Financial",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 78,
|
||||||
|
recommendation: "Review",
|
||||||
|
ai_rationale: "Публичный материал. Две формулировки про AI ROI — рекомендую консервативный вариант, проверенный юристами, учитывая предыдущий запрос SEC.",
|
||||||
|
action_required: true,
|
||||||
|
unread: false,
|
||||||
|
age_days: 3,
|
||||||
|
attachments: 1,
|
||||||
|
sla_hours: 144
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e08",
|
||||||
|
received_at: "2026-05-18T20:11:00Z",
|
||||||
|
from: { name: "Внутренний аудит", email: "audit@acme.corp", title: "Глава внутреннего аудита" },
|
||||||
|
subject: "Аудит — нарушение разделения полномочий (Закупки)",
|
||||||
|
preview: "Аудит выявил 2 случая, когда один и тот же пользователь утверждал PO и проводил оплату. План устранения приложен.",
|
||||||
|
summary: "Внутренний аудит поднимает разрыв SoD в Закупках (2 случая). План устранения готов. Рекомендуется уведомление Аудит-комитета.",
|
||||||
|
category: "Escalation",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 84,
|
||||||
|
recommendation: "Escalate",
|
||||||
|
ai_rationale: "Находка по SoD имеет комплаенс-последствия. Рекомендую эскалировать в Аудит-комитет на следующем заседании. План устранения уже подготовлен.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 4,
|
||||||
|
attachments: 2,
|
||||||
|
sla_hours: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e09",
|
||||||
|
received_at: "2026-05-18T13:22:00Z",
|
||||||
|
from: { name: "Команда M&A", email: "ma@acme.corp", title: "Глава Corporate Development" },
|
||||||
|
subject: "Проект «Сокол» — term sheet, требуется голосование",
|
||||||
|
preview: "Term sheet по проекту «Сокол» (цель — NorthernCloud). Голосование Совета 24 мая. Просьба предварительного одобрения.",
|
||||||
|
summary: "Команда M&A подаёт term sheet по NorthernCloud ($380 млн). Стратегическое соответствие: расширение AI-инфраструктуры. Due diligence выявил 1 IP-замечание (mitigated).",
|
||||||
|
category: "Board",
|
||||||
|
priority: "P1",
|
||||||
|
ai_score: 92,
|
||||||
|
recommendation: "Vote",
|
||||||
|
ai_rationale: "Материальная M&A-сделка. Требуется голосование Совета. Предварительное одобрение CEO сигнализирует о поддержке. IP-вопрос отмечен в DD; юридическое заключение приложено.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 4,
|
||||||
|
attachments: 5,
|
||||||
|
sla_hours: 72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e10",
|
||||||
|
received_at: "2026-05-17T08:00:00Z",
|
||||||
|
from: { name: "Операционный директор", email: "coo@acme.corp", title: "COO" },
|
||||||
|
subject: "Сбой в EU-регионе — драфт post-mortem",
|
||||||
|
preview: "Post-mortem сбоя в EU-регионе 14 мая. RCA: неверно настроенный failover. План действий: 4 направления. FYI.",
|
||||||
|
summary: "COO делится post-mortem сбоя в EU 14 мая. Простой 47 мин, затронуло 12K пользователей. Причина: misconfig failover. 4 направления устранения.",
|
||||||
|
category: "Informational",
|
||||||
|
priority: "P3",
|
||||||
|
ai_score: 38,
|
||||||
|
recommendation: "Ignore",
|
||||||
|
ai_rationale: "Информационное. Owner — COO. Действия CEO не требуются, если только Risk-комитет Совета не эскалирует. Помечено FYI.",
|
||||||
|
action_required: false,
|
||||||
|
unread: false,
|
||||||
|
age_days: 5,
|
||||||
|
attachments: 1,
|
||||||
|
sla_hours: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e11",
|
||||||
|
received_at: "2026-05-17T07:14:00Z",
|
||||||
|
from: { name: "Анна Петрова", email: "chairman@acme.corp", title: "Председатель совета директоров" },
|
||||||
|
subject: "Re: Стратегическая сессия — даты в июне",
|
||||||
|
preview: "Подтверждаю 18–19 июня для стратегической сессии. Прошу ответить со списком участников исполкома.",
|
||||||
|
summary: "Председатель подтверждает даты стратегической сессии (18–19 июня). Ждёт подтверждения CEO + список участников.",
|
||||||
|
category: "Approval",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 75,
|
||||||
|
recommendation: "Approve",
|
||||||
|
ai_rationale: "Подтверждение календаря от Председателя. VIP-отправитель. Быстрый ответ усиливает сигнал доверия. Черновик ответа готов.",
|
||||||
|
action_required: true,
|
||||||
|
unread: false,
|
||||||
|
age_days: 5,
|
||||||
|
attachments: 0,
|
||||||
|
sla_hours: 48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e12",
|
||||||
|
received_at: "2026-05-16T19:33:00Z",
|
||||||
|
from: { name: "Маркетинг", email: "cmo@acme.corp", title: "CMO" },
|
||||||
|
subject: "Бренд-кампания — финальное согласование креатива",
|
||||||
|
preview: "Финальный креатив для бренд-кампании Q3. Просьба проверить приложенное видео + 4 print-варианта.",
|
||||||
|
summary: "CMO просит подпись по креативу бренд-кампании Q3. Соответствует бренд-гайдлайнам. Согласовано с командой Comms.",
|
||||||
|
category: "Approval",
|
||||||
|
priority: "P3",
|
||||||
|
ai_score: 58,
|
||||||
|
recommendation: "Delegate",
|
||||||
|
ai_rationale: "Операционное маркетинговое решение. Рекомендую делегировать CMO + VP Comms, если у CEO нет стратегических предпочтений.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 6,
|
||||||
|
attachments: 5,
|
||||||
|
sla_hours: 168
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e13",
|
||||||
|
received_at: "2026-05-15T12:00:00Z",
|
||||||
|
from: { name: "ESG-комитет", email: "esg@acme.corp", title: "Глава ESG" },
|
||||||
|
subject: "Годовой отчет по устойчивому развитию — на подпись",
|
||||||
|
preview: "Годовой ESG-отчет приложен. Требуется подпись руководства перед публичной подачей.",
|
||||||
|
summary: "ESG-отчет готов к публичной подаче. Проверен Legal + ESG-комитетом. Требуется подпись CEO на сопроводительном письме.",
|
||||||
|
category: "Approval",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 76,
|
||||||
|
recommendation: "Sign",
|
||||||
|
ai_rationale: "Годовая публикация. Стандартный процесс. Все upstream-проверки завершены. Дедлайн подачи — 31 мая.",
|
||||||
|
action_required: true,
|
||||||
|
unread: false,
|
||||||
|
age_days: 7,
|
||||||
|
attachments: 1,
|
||||||
|
sla_hours: 96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e14",
|
||||||
|
received_at: "2026-05-14T10:15:00Z",
|
||||||
|
from: { name: "HR Operations", email: "hr-ops@acme.corp", title: "Директор HR" },
|
||||||
|
subject: "Whistleblower-обращение — чувствительное (HR-2026-0042)",
|
||||||
|
preview: "Конфиденциальное обращение по анонимному каналу. Запущено внутреннее расследование. Брифинг для CEO eyes-only.",
|
||||||
|
summary: "Конфиденциальный whistleblower-кейс (HR-2026-0042). Расследуют HR + Legal. На этом этапе — только процедурное уведомление.",
|
||||||
|
category: "Escalation",
|
||||||
|
priority: "P1",
|
||||||
|
ai_score: 95,
|
||||||
|
recommendation: "Escalate",
|
||||||
|
ai_rationale: "Чувствительный HR-вопрос. Рекомендую конфиденциальный брифинг с Главным юристом и Главой HR. Возможно уведомление Аудит-комитета в зависимости от масштаба.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 8,
|
||||||
|
attachments: 0,
|
||||||
|
sla_hours: 48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e15",
|
||||||
|
received_at: "2026-05-14T09:00:00Z",
|
||||||
|
from: { name: "Благотворительный фонд", email: "donations@external.org", title: "Директор" },
|
||||||
|
subject: "Партнёрство — спонсорство ежегодного гала",
|
||||||
|
preview: "Предложение спонсорства гала-вечера. Уровни от $25K до $200K. Возможность для бренда.",
|
||||||
|
summary: "Внешний питч благотворительного партнёрства. Уровни $25K–$200K. Бренд-соответствие умеренное.",
|
||||||
|
category: "Informational",
|
||||||
|
priority: "P3",
|
||||||
|
ai_score: 22,
|
||||||
|
recommendation: "Delegate",
|
||||||
|
ai_rationale: "Внешнее обращение. Ниже порога CEO. Передать в Comms или Фонд.",
|
||||||
|
action_required: false,
|
||||||
|
unread: false,
|
||||||
|
age_days: 8,
|
||||||
|
attachments: 1,
|
||||||
|
sla_hours: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e16",
|
||||||
|
received_at: "2026-05-12T15:00:00Z",
|
||||||
|
from: { name: "Риск-комитет", email: "risk@acme.corp", title: "Директор по рискам" },
|
||||||
|
subject: "Обновление реестра рисков за квартал",
|
||||||
|
preview: "Реестр рисков Q2 обновлён. Два новых «красных» пункта: цепочка поставок (полупроводники) и регуляторика (EU AI Act).",
|
||||||
|
summary: "Реестр рисков Q2: два новых «красных» пункта — риск полупроводниковой цепочки, комплаенс EU AI Act. Назначены ответственные за митигацию.",
|
||||||
|
category: "Board",
|
||||||
|
priority: "P2",
|
||||||
|
ai_score: 83,
|
||||||
|
recommendation: "Review",
|
||||||
|
ai_rationale: "Существенные изменения в реестре рисков. Стоит ознакомиться CEO до риск-сессии Совета на следующей неделе.",
|
||||||
|
action_required: true,
|
||||||
|
unread: false,
|
||||||
|
age_days: 10,
|
||||||
|
attachments: 1,
|
||||||
|
sla_hours: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e17",
|
||||||
|
received_at: "2026-05-10T08:45:00Z",
|
||||||
|
from: { name: "Speaker Bureau", email: "bookings@speakerbureau.com", title: "Букинг-агент" },
|
||||||
|
subject: "Приглашение с keynote — TechSummit Берлин, окт 2026",
|
||||||
|
preview: "Приглашение с keynote. Гонорар предложен. Тема: «AI-native enterprises». Аудитория: ~2000 руководителей.",
|
||||||
|
summary: "Внешнее приглашение к выступлению. Берлин, октябрь 2026. Соответствует thought leadership.",
|
||||||
|
category: "Informational",
|
||||||
|
priority: "P3",
|
||||||
|
ai_score: 45,
|
||||||
|
recommendation: "Delegate",
|
||||||
|
ai_rationale: "Внешнее приглашение. Передать в Comms для оценки. Срочного решения не требуется.",
|
||||||
|
action_required: false,
|
||||||
|
unread: false,
|
||||||
|
age_days: 12,
|
||||||
|
attachments: 0,
|
||||||
|
sla_hours: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e18",
|
||||||
|
received_at: "2026-05-08T16:30:00Z",
|
||||||
|
from: { name: "Регулятор (Еврокомиссия)", email: "ec-comm@eu.regulator", title: "Официальное уведомление" },
|
||||||
|
subject: "Информационный запрос — соответствие EU AI Act (статья 13)",
|
||||||
|
preview: "Формальный запрос информации по статье 13 EU AI Act. Дедлайн ответа: 5 июня 2026.",
|
||||||
|
summary: "Формальный запрос Еврокомиссии по EU AI Act, ст. 13. Жёсткий дедлайн 5 июня. Legal + Compliance готовят ответ.",
|
||||||
|
category: "Legal",
|
||||||
|
priority: "P1",
|
||||||
|
ai_score: 97,
|
||||||
|
recommendation: "Escalate",
|
||||||
|
ai_rationale: "Формальный регуляторный запрос с законодательным дедлайном. Рекомендую: CEO в курсе, Legal ведёт, уведомить Совет. Штрафные риски значительные.",
|
||||||
|
action_required: true,
|
||||||
|
unread: true,
|
||||||
|
age_days: 14,
|
||||||
|
attachments: 2,
|
||||||
|
sla_hours: 96
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
briefing_text:
|
||||||
|
"Доброе утро. Сегодня 22 мая 2026 г.\n\n" +
|
||||||
|
"В работе 18 писем за последние 14 дней. AI-приоритизация выделила:\n\n" +
|
||||||
|
"• 5 критических (P1) — включая инцидент безопасности SecOps (svc-domino-admin) и формальный запрос регулятора EU AI Act с дедлайном 5 июня.\n" +
|
||||||
|
"• 4 ожидают подписи — CAPEX Q2 ($42 млн), бюджетный резерв FY26, ESG-отчёт, продление MSA TechCo.\n" +
|
||||||
|
"• 3 материала к заседанию Совета 24 мая (M&A «Сокол», риск-регистр, ESG).\n\n" +
|
||||||
|
"Рекомендую начать день с инцидента SecOps (P1, окно SLA <4 ч) и сразу подписать CAPEX-меморандум Председателя — голосование Совета через 2 дня."
|
||||||
|
};
|
||||||
19
icon.svg
Normal file
19
icon.svg
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#7bb7ff"/>
|
||||||
|
<stop offset="100%" stop-color="#5cf2a0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#121620"/>
|
||||||
|
<stop offset="100%" stop-color="#0a0c10"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
||||||
|
<rect x="80" y="120" width="352" height="56" rx="14" fill="url(#g)"/>
|
||||||
|
<rect x="80" y="208" width="240" height="40" rx="10" fill="#7bb7ff" opacity="0.45"/>
|
||||||
|
<rect x="80" y="268" width="300" height="40" rx="10" fill="#7bb7ff" opacity="0.30"/>
|
||||||
|
<rect x="80" y="328" width="180" height="40" rx="10" fill="#7bb7ff" opacity="0.20"/>
|
||||||
|
<circle cx="402" cy="370" r="44" fill="#5cf2a0"/>
|
||||||
|
<path d="M385 370 l14 14 l30 -30" stroke="#0a0c10" stroke-width="10" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
193
index.html
Normal file
193
index.html
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ExecutiveOS AI — Помощник для руководителя (Lotus)</title>
|
||||||
|
<link rel="manifest" href="manifest.webmanifest" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="icon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="icon.svg" />
|
||||||
|
<meta name="theme-color" content="#7bb7ff" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="ExecutiveOS" />
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="logo-dot"></span>
|
||||||
|
<span><strong>ExecutiveOS</strong> <span class="muted">AI</span></span>
|
||||||
|
</div>
|
||||||
|
<nav class="nav" id="nav">
|
||||||
|
<button data-route="briefing" class="active">Брифинг</button>
|
||||||
|
<button data-route="inbox">Входящие</button>
|
||||||
|
<button data-route="approvals">Согласования</button>
|
||||||
|
<button data-route="escalations">Эскалации</button>
|
||||||
|
<button data-route="search">AI-поиск</button>
|
||||||
|
</nav>
|
||||||
|
<div class="user">
|
||||||
|
<span id="online-dot" class="status-dot"></span>
|
||||||
|
<span class="mono">ceo@acme.corp</span>
|
||||||
|
<span class="muted small">· HCL Notes</span>
|
||||||
|
<button id="btn-install" class="btn-ghost install-btn" hidden>↓ Установить</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="view"></main>
|
||||||
|
|
||||||
|
<!-- Брифинг -->
|
||||||
|
<template id="tpl-briefing">
|
||||||
|
<section>
|
||||||
|
<div class="row spread wrap">
|
||||||
|
<div>
|
||||||
|
<h1>Ежедневный брифинг</h1>
|
||||||
|
<div class="muted small" id="brief-date"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row gap">
|
||||||
|
<button id="btn-refresh" class="btn-ghost">↻ Синхронизация с Lotus</button>
|
||||||
|
<button id="btn-mark-read" class="btn-ghost">Отметить все просмотренными</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kpi-grid">
|
||||||
|
<div class="card kpi"><div class="kpi-label">Критических непрочитанных</div><div class="kpi-value danger" data-k="critical"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">Ожидают решения</div><div class="kpi-value warn" data-k="pending"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">К Совету директоров</div><div class="kpi-value" data-k="board"></div></div>
|
||||||
|
<div class="card kpi"><div class="kpi-label">Нарушений SLA</div><div class="kpi-value danger" data-k="sla"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2-1">
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">Сводка дня</div>
|
||||||
|
<div id="brief-summary" class="briefing"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">Топ-3 приоритета</div>
|
||||||
|
<ol id="brief-top3" class="priorities"></ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">AI-копилот</div>
|
||||||
|
<div class="copilot">
|
||||||
|
<div class="row gap">
|
||||||
|
<input id="cp-input" placeholder="«Что требует моей подписи?», «Покажи просроченные»…" />
|
||||||
|
<button id="cp-ask" class="btn-primary">Спросить</button>
|
||||||
|
</div>
|
||||||
|
<div id="cp-suggest" class="chips"></div>
|
||||||
|
<div id="cp-answer" class="answer"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Входящие -->
|
||||||
|
<template id="tpl-inbox">
|
||||||
|
<section>
|
||||||
|
<h1>Приоритетные входящие</h1>
|
||||||
|
<div class="card row gap wrap">
|
||||||
|
<input id="inbox-search" placeholder="Поиск по теме, отправителю, тексту…" />
|
||||||
|
<select id="inbox-cat">
|
||||||
|
<option value="">Все категории</option>
|
||||||
|
</select>
|
||||||
|
<select id="inbox-pri">
|
||||||
|
<option value="">Все приоритеты</option>
|
||||||
|
<option value="P1">P1 — Критический</option>
|
||||||
|
<option value="P2">P2 — Высокий</option>
|
||||||
|
<option value="P3">P3 — Обычный</option>
|
||||||
|
</select>
|
||||||
|
<label class="row gap small"><input type="checkbox" id="inbox-unread" /> <span>Только непрочитанные</span></label>
|
||||||
|
</div>
|
||||||
|
<div id="inbox-list" class="email-list"></div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Согласования -->
|
||||||
|
<template id="tpl-approvals">
|
||||||
|
<section>
|
||||||
|
<div class="row spread wrap">
|
||||||
|
<h1>Ожидают согласования</h1>
|
||||||
|
<div class="muted small">AI оценил риск и предложил действие. Решение всегда за вами.</div>
|
||||||
|
</div>
|
||||||
|
<div id="appr-list" class="approval-list"></div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Эскалации -->
|
||||||
|
<template id="tpl-escalations">
|
||||||
|
<section>
|
||||||
|
<h1>Монитор эскалаций</h1>
|
||||||
|
<div class="card">
|
||||||
|
<table class="data">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Возраст</th><th>Тема</th><th>От кого</th><th>Должность</th>
|
||||||
|
<th class="r">SLA</th><th>Статус</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="esc-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Поиск -->
|
||||||
|
<template id="tpl-search">
|
||||||
|
<section>
|
||||||
|
<h1>AI-поиск по корпоративной почте</h1>
|
||||||
|
<div class="card stack">
|
||||||
|
<div class="row gap">
|
||||||
|
<input id="srch-input" placeholder="Семантический поиск по письмам, согласованиям, материалам Совета…" />
|
||||||
|
<button id="srch-go" class="btn-primary">Найти</button>
|
||||||
|
</div>
|
||||||
|
<div class="chips" id="srch-suggest"></div>
|
||||||
|
</div>
|
||||||
|
<div id="srch-results" class="email-list"></div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="data.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
<script>
|
||||||
|
// ---- PWA: регистрация Service Worker ----
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
navigator.serviceWorker.register("./sw.js")
|
||||||
|
.then(reg => console.log("[PWA] SW registered:", reg.scope))
|
||||||
|
.catch(err => console.error("[PWA] SW error:", err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Install prompt ----
|
||||||
|
let deferredPrompt = null;
|
||||||
|
const installBtn = document.getElementById("btn-install");
|
||||||
|
window.addEventListener("beforeinstallprompt", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
deferredPrompt = e;
|
||||||
|
installBtn.hidden = false;
|
||||||
|
});
|
||||||
|
installBtn.addEventListener("click", async () => {
|
||||||
|
if (!deferredPrompt) return;
|
||||||
|
deferredPrompt.prompt();
|
||||||
|
const { outcome } = await deferredPrompt.userChoice;
|
||||||
|
console.log("[PWA] install outcome:", outcome);
|
||||||
|
deferredPrompt = null;
|
||||||
|
installBtn.hidden = true;
|
||||||
|
});
|
||||||
|
window.addEventListener("appinstalled", () => {
|
||||||
|
installBtn.hidden = true;
|
||||||
|
console.log("[PWA] installed");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Online/offline индикатор ----
|
||||||
|
const dot = document.getElementById("online-dot");
|
||||||
|
function updateOnline() {
|
||||||
|
const online = navigator.onLine;
|
||||||
|
dot.classList.toggle("offline", !online);
|
||||||
|
dot.title = online ? "Online — Lotus синхронизирован" : "Offline — данные из кэша";
|
||||||
|
}
|
||||||
|
window.addEventListener("online", updateOnline);
|
||||||
|
window.addEventListener("offline", updateOnline);
|
||||||
|
updateOnline();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
manifest.webmanifest
Normal file
33
manifest.webmanifest
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "ExecutiveOS AI — Lotus Copilot",
|
||||||
|
"short_name": "ExecutiveOS",
|
||||||
|
"description": "AI Chief of Staff для руководителя. Интеграция с HCL Notes / Lotus Notes.",
|
||||||
|
"lang": "ru",
|
||||||
|
"start_url": "./index.html",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#0a0c10",
|
||||||
|
"theme_color": "#7bb7ff",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
|
||||||
|
],
|
||||||
|
"categories": ["productivity", "business"],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Брифинг",
|
||||||
|
"url": "./index.html#briefing",
|
||||||
|
"description": "Ежедневный AI-брифинг"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Согласования",
|
||||||
|
"url": "./index.html#approvals",
|
||||||
|
"description": "Ожидают решения"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Эскалации",
|
||||||
|
"url": "./index.html#escalations",
|
||||||
|
"description": "Критические P1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
285
styles.css
Normal file
285
styles.css
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
:root {
|
||||||
|
--ink: #0a0c10;
|
||||||
|
--panel: #121620;
|
||||||
|
--panel-2: #181d29;
|
||||||
|
--line: #1e2535;
|
||||||
|
--muted: #7a8699;
|
||||||
|
--text: #e7ecf3;
|
||||||
|
--accent: #7bb7ff;
|
||||||
|
--accent-2: #5cf2a0;
|
||||||
|
--warn: #ffb454;
|
||||||
|
--danger: #ff5c7a;
|
||||||
|
--urgent: #ff3d65;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
background: var(--ink); color: var(--text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", system-ui, sans-serif;
|
||||||
|
font-size: 14px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.accent { color: var(--accent); }
|
||||||
|
.warn { color: var(--warn); }
|
||||||
|
.danger { color: var(--danger); }
|
||||||
|
.small { font-size: 12px; }
|
||||||
|
.r { text-align: right; }
|
||||||
|
.wrap { flex-wrap: wrap; gap: 12px; }
|
||||||
|
.spread { justify-content: space-between; align-items: baseline; }
|
||||||
|
.gap { gap: 12px; }
|
||||||
|
.stack { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
/* Topbar */
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; gap: 24px;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(10, 12, 16, 0.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 10px; font-size: 16px; letter-spacing: -0.01em; }
|
||||||
|
.logo-dot {
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||||
|
border-radius: 3px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.nav { display: flex; gap: 4px; }
|
||||||
|
.nav button {
|
||||||
|
background: transparent; border: 1px solid transparent;
|
||||||
|
color: var(--muted); padding: 6px 12px; border-radius: 6px;
|
||||||
|
font-size: 13px; cursor: pointer; transition: 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.nav button:hover { color: var(--text); }
|
||||||
|
.nav button.active { background: var(--panel); border-color: var(--line); color: var(--text); }
|
||||||
|
.user {
|
||||||
|
margin-left: auto; display: flex; align-items: center; gap: 8px;
|
||||||
|
color: var(--muted); font-size: 12px;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: var(--accent-2);
|
||||||
|
box-shadow: 0 0 8px var(--accent-2);
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
.status-dot.offline {
|
||||||
|
background: var(--warn);
|
||||||
|
box-shadow: 0 0 8px var(--warn);
|
||||||
|
}
|
||||||
|
.install-btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
main { max-width: 1280px; margin: 0 auto; padding: 32px 28px; }
|
||||||
|
section { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.01em; margin: 0; }
|
||||||
|
|
||||||
|
.row { display: flex; align-items: center; }
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI */
|
||||||
|
.kpi-grid {
|
||||||
|
display: grid; gap: 16px;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
@media (max-width: 880px) { .kpi-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
.kpi { padding: 18px 20px; }
|
||||||
|
.kpi-label {
|
||||||
|
font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.kpi-value {
|
||||||
|
font-size: 28px; font-weight: 600; margin-top: 8px;
|
||||||
|
font-variant-numeric: tabular-nums; letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2-1 {
|
||||||
|
display: grid; gap: 16px;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
}
|
||||||
|
@media (max-width: 880px) { .grid-2-1 { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* Briefing */
|
||||||
|
.briefing {
|
||||||
|
margin-top: 14px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.priorities {
|
||||||
|
margin: 12px 0 0; padding: 0; list-style: none;
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.priorities li {
|
||||||
|
background: var(--ink); border: 1px solid var(--line);
|
||||||
|
border-radius: 8px; padding: 12px;
|
||||||
|
}
|
||||||
|
.priorities .sub { display: block; font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
input[type="text"], input:not([type]), select {
|
||||||
|
background: var(--ink); border: 1px solid var(--line); color: var(--text);
|
||||||
|
padding: 8px 12px; border-radius: 6px; font-size: 13px;
|
||||||
|
font-family: inherit; outline: none;
|
||||||
|
}
|
||||||
|
input:focus, select:focus { border-color: var(--accent); }
|
||||||
|
input { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
button.btn-primary {
|
||||||
|
background: var(--accent); color: var(--ink); border: 0;
|
||||||
|
padding: 8px 16px; border-radius: 6px; font-weight: 600;
|
||||||
|
font-size: 13px; cursor: pointer; font-family: inherit;
|
||||||
|
}
|
||||||
|
button.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
button.btn-ghost {
|
||||||
|
background: transparent; border: 1px solid var(--line); color: var(--text);
|
||||||
|
padding: 6px 14px; border-radius: 6px; font-size: 13px;
|
||||||
|
cursor: pointer; font-family: inherit;
|
||||||
|
}
|
||||||
|
button.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
/* Copilot */
|
||||||
|
.copilot { margin-top: 10px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.chip {
|
||||||
|
background: transparent; border: 1px solid var(--line); color: var(--muted);
|
||||||
|
font-size: 11px; padding: 4px 10px; border-radius: 999px;
|
||||||
|
cursor: pointer; font-family: inherit;
|
||||||
|
}
|
||||||
|
.chip:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.answer {
|
||||||
|
background: var(--ink); border: 1px solid var(--line);
|
||||||
|
border-radius: 8px; padding: 14px 16px;
|
||||||
|
font-size: 13px; line-height: 1.7;
|
||||||
|
white-space: pre-wrap; display: none;
|
||||||
|
}
|
||||||
|
.answer.show { display: block; }
|
||||||
|
|
||||||
|
/* Email list */
|
||||||
|
.email-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.email {
|
||||||
|
background: var(--panel); border: 1px solid var(--line);
|
||||||
|
border-radius: 10px; padding: 14px 16px;
|
||||||
|
display: grid; grid-template-columns: 60px 1fr auto; gap: 14px;
|
||||||
|
align-items: start; cursor: pointer; transition: 0.15s;
|
||||||
|
}
|
||||||
|
.email:hover { border-color: var(--accent); }
|
||||||
|
.email.unread { border-left: 3px solid var(--accent); }
|
||||||
|
.email.urgent { border-left: 3px solid var(--urgent); }
|
||||||
|
.email .pri-badge {
|
||||||
|
display: inline-block; text-align: center;
|
||||||
|
padding: 4px 8px; font-size: 11px; font-weight: 600;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--ink); border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.pri-P1 { color: var(--urgent); border-color: var(--urgent) !important; }
|
||||||
|
.pri-P2 { color: var(--warn); border-color: var(--warn) !important; }
|
||||||
|
.pri-P3 { color: var(--muted); }
|
||||||
|
.email .meta { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||||
|
.email .subject { font-weight: 500; }
|
||||||
|
.email .from { color: var(--muted); font-size: 12px; }
|
||||||
|
.email .preview { color: var(--muted); font-size: 12px; margin-top: 4px; line-height: 1.5; }
|
||||||
|
.email .tags { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
|
||||||
|
.tag {
|
||||||
|
font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
padding: 2px 8px; border-radius: 3px;
|
||||||
|
background: var(--ink); border: 1px solid var(--line); color: var(--muted);
|
||||||
|
}
|
||||||
|
.tag.cat-approval { color: var(--warn); border-color: var(--warn); }
|
||||||
|
.tag.cat-board { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.tag.cat-escalation, .tag.cat-urgent { color: var(--urgent); border-color: var(--urgent); }
|
||||||
|
.tag.cat-financial, .tag.cat-legal, .tag.cat-hr { color: var(--accent-2); border-color: var(--accent-2); }
|
||||||
|
.tag.cat-informational { color: var(--muted); }
|
||||||
|
.email .actions {
|
||||||
|
display: flex; flex-direction: column; align-items: flex-end; gap: 6px;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
.email .age { font-size: 11px; color: var(--muted); font-family: ui-monospace, monospace; }
|
||||||
|
.email .reco {
|
||||||
|
font-size: 11px; padding: 4px 10px; border-radius: 999px;
|
||||||
|
background: var(--ink); border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.reco-approve { color: var(--accent-2); border-color: var(--accent-2); }
|
||||||
|
.reco-sign { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.reco-vote { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.reco-review { color: var(--warn); border-color: var(--warn); }
|
||||||
|
.reco-reject { color: var(--danger); border-color: var(--danger); }
|
||||||
|
.reco-escalate{ color: var(--urgent); border-color: var(--urgent); }
|
||||||
|
.reco-delegate{ color: var(--muted); }
|
||||||
|
.reco-ignore { color: var(--muted); }
|
||||||
|
|
||||||
|
.score-bar {
|
||||||
|
width: 100%; height: 4px; background: var(--ink);
|
||||||
|
border-radius: 2px; margin-top: 6px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.score-fill {
|
||||||
|
height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Approval cards */
|
||||||
|
.approval-list { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.approval {
|
||||||
|
background: var(--panel); border: 1px solid var(--line);
|
||||||
|
border-radius: 12px; padding: 18px 20px;
|
||||||
|
display: grid; gap: 16px;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) { .approval { grid-template-columns: 1fr; } }
|
||||||
|
.approval .head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.approval .subject { font-weight: 600; font-size: 15px; }
|
||||||
|
.approval .meta-row {
|
||||||
|
margin-top: 8px; color: var(--muted); font-size: 12px;
|
||||||
|
display: flex; gap: 14px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.approval .summary { margin-top: 12px; font-size: 13px; line-height: 1.6; color: var(--text); }
|
||||||
|
.approval .ai-box {
|
||||||
|
margin-top: 12px; padding: 12px 14px;
|
||||||
|
background: var(--ink); border: 1px solid var(--line);
|
||||||
|
border-radius: 8px; font-size: 13px;
|
||||||
|
}
|
||||||
|
.approval .ai-box strong { color: var(--accent); }
|
||||||
|
.approval .controls {
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
min-width: 160px; align-items: stretch;
|
||||||
|
}
|
||||||
|
.approval .controls button {
|
||||||
|
font-family: inherit; font-size: 13px;
|
||||||
|
padding: 8px 12px; border-radius: 6px; cursor: pointer;
|
||||||
|
background: var(--ink); border: 1px solid var(--line); color: var(--text);
|
||||||
|
}
|
||||||
|
.approval .controls .btn-approve { background: var(--accent-2); color: var(--ink); border: 0; font-weight: 600; }
|
||||||
|
.approval .controls .btn-reject { color: var(--danger); border-color: var(--danger); }
|
||||||
|
.approval .controls .btn-delegate{ color: var(--muted); }
|
||||||
|
.approval .controls .btn-sign { background: var(--accent); color: var(--ink); border: 0; font-weight: 600; }
|
||||||
|
.approval.resolved { opacity: 0.55; }
|
||||||
|
.approval .resolved-tag { color: var(--accent-2); font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table.data {
|
||||||
|
width: 100%; border-collapse: collapse; margin-top: 6px; font-size: 13px;
|
||||||
|
}
|
||||||
|
table.data th {
|
||||||
|
color: var(--muted); font-weight: 500;
|
||||||
|
text-transform: uppercase; font-size: 11px; letter-spacing: 0.08em;
|
||||||
|
text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
table.data td {
|
||||||
|
padding: 10px 6px; border-bottom: 1px solid var(--line);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
table.data tr:last-child td { border-bottom: 0; }
|
||||||
56
sw.js
Normal file
56
sw.js
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
// ExecutiveOS AI — Service Worker (PWA cache + offline)
|
||||||
|
const CACHE_NAME = "executive-os-v1";
|
||||||
|
const APP_SHELL = [
|
||||||
|
"./",
|
||||||
|
"./index.html",
|
||||||
|
"./styles.css",
|
||||||
|
"./app.js",
|
||||||
|
"./data.js",
|
||||||
|
"./manifest.webmanifest",
|
||||||
|
"./icon.svg"
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((names) =>
|
||||||
|
Promise.all(names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache-first для app shell, network-first для прочего.
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const req = event.request;
|
||||||
|
if (req.method !== "GET") return;
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const isShell = APP_SHELL.some((p) => url.pathname.endsWith(p.replace("./", "/")) || url.pathname.endsWith(p.replace("./", "")));
|
||||||
|
|
||||||
|
if (isShell) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(req).then((cached) => cached || fetch(req).then((res) => {
|
||||||
|
const copy = res.clone();
|
||||||
|
caches.open(CACHE_NAME).then((c) => c.put(req, copy));
|
||||||
|
return res;
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network-first fallback for everything else (e.g. future /api/* calls)
|
||||||
|
event.respondWith(
|
||||||
|
fetch(req).then((res) => {
|
||||||
|
const copy = res.clone();
|
||||||
|
caches.open(CACHE_NAME).then((c) => c.put(req, copy));
|
||||||
|
return res;
|
||||||
|
}).catch(() => caches.match(req))
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user