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:
korabayev 2026-05-22 13:20:06 +05:00
commit b2cd0c71d9
7 changed files with 1382 additions and 0 deletions

445
app.js Normal file
View 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#039;");
}
// ---------- 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
View 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
View 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
View 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
View 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
View 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
View 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))
);
});