feat: статический MVP помощника распределения задач для Gitea Pages
Добавлен веб-интерфейс помощника руководителя: - классификатор задач по ключевым словам (RU/EN); - CRUD сотрудников + замещающие при отпуске; - история задач с комментариями и копированием; - mock-синхронизация с системой «Симбейс» (приказы на отпуск). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
349caf237b
commit
01e606a1a2
507
app.js
Normal file
507
app.js
Normal file
@ -0,0 +1,507 @@
|
||||
// ===== State =====
|
||||
const state = {
|
||||
directory: null,
|
||||
i18n: null,
|
||||
lang: localStorage.getItem("app.lang") || "ru",
|
||||
history: { entries: [] },
|
||||
pendingResult: null,
|
||||
};
|
||||
|
||||
const DOMINANCE = 2.0;
|
||||
|
||||
// ===== Init =====
|
||||
async function init() {
|
||||
const [dir, i18n, history] = await Promise.all([
|
||||
loadDirectory(),
|
||||
fetchJson("i18n.json"),
|
||||
loadHistory(),
|
||||
]);
|
||||
state.directory = dir;
|
||||
state.i18n = i18n;
|
||||
state.history = history;
|
||||
bindUI();
|
||||
applyLang();
|
||||
renderPeople();
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
async function loadDirectory() {
|
||||
const stored = localStorage.getItem("app.directory");
|
||||
if (stored) return JSON.parse(stored);
|
||||
return fetchJson("directory.json");
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const stored = localStorage.getItem("app.history");
|
||||
if (stored) return JSON.parse(stored);
|
||||
return fetchJson("history.json").catch(() => ({ entries: [] }));
|
||||
}
|
||||
|
||||
function saveDirectory() {
|
||||
localStorage.setItem("app.directory", JSON.stringify(state.directory));
|
||||
}
|
||||
function saveHistory() {
|
||||
localStorage.setItem("app.history", JSON.stringify(state.history));
|
||||
}
|
||||
|
||||
async function fetchJson(path) {
|
||||
const r = await fetch(path);
|
||||
if (!r.ok) throw new Error(`Cannot load ${path}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// ===== i18n =====
|
||||
function t(key, vars = {}) {
|
||||
let s = (state.i18n[state.lang] || {})[key] || key;
|
||||
for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, v);
|
||||
return s;
|
||||
}
|
||||
function applyLang() {
|
||||
document.documentElement.lang = state.lang;
|
||||
document.querySelectorAll("[data-i18n]").forEach(el => el.textContent = t(el.dataset.i18n));
|
||||
document.querySelectorAll("[data-i18n-attr]").forEach(el => {
|
||||
const [attr, key] = el.dataset.i18nAttr.split(":");
|
||||
el.setAttribute(attr, t(key));
|
||||
});
|
||||
document.querySelectorAll(".lang button").forEach(b => b.classList.toggle("active", b.dataset.lang === state.lang));
|
||||
if (state.pendingResult) renderResult(state.pendingResult);
|
||||
renderPeople();
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
// ===== Classifier (вариант A — keyword scoring) =====
|
||||
function normalize(s) {
|
||||
return s.toLowerCase()
|
||||
.replace(/ё/g, "е")
|
||||
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
||||
.split(/\s+/).filter(Boolean);
|
||||
}
|
||||
function scoreZones(taskText) {
|
||||
const tokens = normalize(taskText);
|
||||
const tokenSet = tokens.join(" ");
|
||||
return state.directory.zones.map(zone => {
|
||||
let score = 0;
|
||||
const hits = [];
|
||||
for (const kw of zone.keywords) {
|
||||
const n = normalize(kw).join(" ");
|
||||
const count = (tokenSet.match(new RegExp("\\b" + escapeReg(n) + "\\b", "g")) || []).length;
|
||||
if (count > 0) { score += count * 1.0; hits.push(kw); }
|
||||
}
|
||||
for (const sys of zone.systems) {
|
||||
const n = normalize(sys).join(" ");
|
||||
const count = (tokenSet.match(new RegExp("\\b" + escapeReg(n) + "\\b", "g")) || []).length;
|
||||
if (count > 0) { score += count * 1.5; hits.push(sys); }
|
||||
}
|
||||
return { zoneId: zone.id, score, hits };
|
||||
}).filter(x => x.score > 0).sort((a, b) => b.score - a.score);
|
||||
}
|
||||
function escapeReg(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||
|
||||
function assignPerson(zoneId) {
|
||||
const candidates = state.directory.people.filter(p => p.zoneId === zoneId);
|
||||
const primary = candidates.find(p => p.role === "primary");
|
||||
const backup = candidates.find(p => p.role === "backup");
|
||||
|
||||
if (primary && primary.status === "active") {
|
||||
return { main: primary, fallback: backup && backup.status === "active" ? backup : null };
|
||||
}
|
||||
if (primary && primary.status === "vacation" && primary.delegateId) {
|
||||
const delegate = state.directory.people.find(p => p.id === primary.delegateId);
|
||||
if (delegate && delegate.status === "active") {
|
||||
return { main: delegate, fallback: null, delegateFor: primary };
|
||||
}
|
||||
}
|
||||
if (backup && backup.status === "active") {
|
||||
return { main: backup, fallback: null, fromBackup: primary };
|
||||
}
|
||||
return { error: "NO_ASSIGNEE", zoneId };
|
||||
}
|
||||
|
||||
// ===== UI: classify task =====
|
||||
function onClassify() {
|
||||
const text = document.getElementById("task-input").value.trim();
|
||||
if (!text) return;
|
||||
const sorted = scoreZones(text);
|
||||
const result = { text, sorted, multi: false, chosenZoneId: null };
|
||||
|
||||
if (sorted.length === 0) {
|
||||
result.multi = true;
|
||||
result.allZones = state.directory.zones.map(z => ({ zoneId: z.id, score: 0, hits: [] }));
|
||||
result.noSignal = true;
|
||||
} else if (sorted.length === 1 || sorted[0].score >= DOMINANCE * (sorted[1]?.score || 0)) {
|
||||
result.chosenZoneId = sorted[0].zoneId;
|
||||
} else {
|
||||
result.multi = true;
|
||||
}
|
||||
state.pendingResult = result;
|
||||
renderResult(result);
|
||||
}
|
||||
|
||||
function renderResult(result) {
|
||||
const root = document.getElementById("task-result");
|
||||
root.innerHTML = "";
|
||||
|
||||
if (result.chosenZoneId) {
|
||||
renderAssignee(root, result.chosenZoneId, result);
|
||||
return;
|
||||
}
|
||||
// multi-choice
|
||||
const heading = document.createElement("p");
|
||||
heading.textContent = result.noSignal ? t("task.no_signal") : t("task.multi");
|
||||
root.appendChild(heading);
|
||||
|
||||
const zones = result.noSignal ? result.allZones : result.sorted.slice(0, 3);
|
||||
for (const z of zones) {
|
||||
const zone = state.directory.zones.find(x => x.id === z.zoneId);
|
||||
const card = document.createElement("div");
|
||||
card.className = "zone-card choice";
|
||||
card.innerHTML = `
|
||||
<h3>${escapeHtml(zoneTitle(zone))}</h3>
|
||||
<div class="score">${t("task.score")}: ${z.score.toFixed(1)}${z.hits.length ? " — " + z.hits.map(escapeHtml).join(", ") : ""}</div>
|
||||
`;
|
||||
card.onclick = () => {
|
||||
result.chosenZoneId = z.zoneId;
|
||||
renderResult(result);
|
||||
};
|
||||
root.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAssignee(root, zoneId, result) {
|
||||
const zone = state.directory.zones.find(z => z.id === zoneId);
|
||||
const assignment = assignPerson(zoneId);
|
||||
|
||||
if (assignment.error === "NO_ASSIGNEE") {
|
||||
const err = document.createElement("div");
|
||||
err.className = "error-card";
|
||||
err.innerHTML = `
|
||||
<strong>${t("task.none")}</strong>
|
||||
<div style="margin-top:8px; font-size:13px;">${t("people.zone")}: ${escapeHtml(zoneTitle(zone))}</div>
|
||||
<div class="actions">
|
||||
<button class="ghost" id="assign-manual">${t("task.assign_manual")}</button>
|
||||
<button class="ghost" id="take-self">${t("task.take_self")}</button>
|
||||
</div>
|
||||
`;
|
||||
root.appendChild(err);
|
||||
err.querySelector("#assign-manual").onclick = () => manualAssign(result, zone);
|
||||
err.querySelector("#take-self").onclick = () => takeSelf(result, zone);
|
||||
return;
|
||||
}
|
||||
|
||||
const main = assignment.main;
|
||||
const role = assignment.delegateFor ? "delegate" : (main.role === "primary" ? "primary" : "backup");
|
||||
const card = document.createElement("div");
|
||||
card.className = "assignee-card";
|
||||
let note = "";
|
||||
if (assignment.delegateFor) {
|
||||
note = `<div class="note">${t("task.delegate_note", {
|
||||
primary: personName(assignment.delegateFor),
|
||||
until: assignment.delegateFor.vacationUntil || "—"
|
||||
})}</div>`;
|
||||
} else if (assignment.fromBackup) {
|
||||
note = `<div class="note">${t("task.no_backup")}</div>`;
|
||||
} else if (!assignment.fallback) {
|
||||
note = `<div class="note">${t("task.no_backup")}</div>`;
|
||||
}
|
||||
|
||||
const msg = buildMessage(main, role, zone, result.text, assignment.delegateFor);
|
||||
card.innerHTML = `
|
||||
<div class="name">${escapeHtml(personName(main))}</div>
|
||||
<div class="meta">${t("task.assignee")} · ${t("role." + role)} · ${escapeHtml(zoneTitle(zone))} · ${escapeHtml(main.contact || "—")}</div>
|
||||
${assignment.fallback ? `<div class="meta">${t("task.fallback")}: ${escapeHtml(personName(assignment.fallback))}</div>` : ""}
|
||||
${note}
|
||||
<pre>${escapeHtml(msg)}</pre>
|
||||
<div class="actions">
|
||||
<button id="copy-msg">${t("task.copy")}</button>
|
||||
<button id="save-history" class="ghost">${t("task.save")}</button>
|
||||
</div>
|
||||
`;
|
||||
root.appendChild(card);
|
||||
card.querySelector("#copy-msg").onclick = () => { navigator.clipboard.writeText(msg); toast(t("task.copied")); };
|
||||
card.querySelector("#save-history").onclick = () => saveTaskToHistory(result, zone, main, role);
|
||||
}
|
||||
|
||||
function buildMessage(person, role, zone, taskText, primaryIfDelegate) {
|
||||
const template = t("msg.template");
|
||||
const delegate = primaryIfDelegate
|
||||
? " " + t("task.delegate_note", { primary: personName(primaryIfDelegate), until: primaryIfDelegate.vacationUntil || "—" })
|
||||
: "";
|
||||
return template
|
||||
.replaceAll("{name}", personName(person))
|
||||
.replaceAll("{task}", taskText)
|
||||
.replaceAll("{role}", t("role." + role))
|
||||
.replaceAll("{zone}", zoneTitle(zone))
|
||||
.replaceAll("{delegate}", delegate);
|
||||
}
|
||||
|
||||
function manualAssign(result, zone) {
|
||||
const list = state.directory.people.filter(p => p.status === "active");
|
||||
const opts = list.map(p => `${personName(p)} (${zoneTitle(state.directory.zones.find(z => z.id === p.zoneId))})`);
|
||||
const pick = prompt(opts.map((o, i) => `${i + 1}. ${o}`).join("\n"));
|
||||
const idx = parseInt(pick, 10) - 1;
|
||||
if (isNaN(idx) || idx < 0 || idx >= list.length) return;
|
||||
const main = list[idx];
|
||||
const root = document.getElementById("task-result");
|
||||
root.innerHTML = "";
|
||||
result.chosenZoneId = zone.id;
|
||||
result.manualPersonId = main.id;
|
||||
const card = document.createElement("div");
|
||||
card.className = "assignee-card";
|
||||
const msg = buildMessage(main, "primary", zone, result.text, null);
|
||||
card.innerHTML = `
|
||||
<div class="name">${escapeHtml(personName(main))}</div>
|
||||
<div class="meta">${t("task.assignee")} · ${escapeHtml(zoneTitle(zone))} · ${escapeHtml(main.contact || "—")}</div>
|
||||
<pre>${escapeHtml(msg)}</pre>
|
||||
<div class="actions">
|
||||
<button id="copy-msg">${t("task.copy")}</button>
|
||||
<button id="save-history" class="ghost">${t("task.save")}</button>
|
||||
</div>
|
||||
`;
|
||||
root.appendChild(card);
|
||||
card.querySelector("#copy-msg").onclick = () => { navigator.clipboard.writeText(msg); toast(t("task.copied")); };
|
||||
card.querySelector("#save-history").onclick = () => saveTaskToHistory(result, zone, main, "primary", true);
|
||||
}
|
||||
|
||||
function takeSelf(result, zone) {
|
||||
const self = { id: "manager", name: state.lang === "ru" ? "Руководитель" : "Manager", nameEn: "Manager", contact: "" };
|
||||
const root = document.getElementById("task-result");
|
||||
root.innerHTML = "";
|
||||
const card = document.createElement("div");
|
||||
card.className = "assignee-card";
|
||||
card.innerHTML = `<div class="name">${escapeHtml(personName(self))}</div>
|
||||
<div class="meta">${t("task.take_self")}</div>
|
||||
<div class="actions"><button id="save-history" class="ghost">${t("task.save")}</button></div>`;
|
||||
root.appendChild(card);
|
||||
card.querySelector("#save-history").onclick = () => saveTaskToHistory(result, zone, self, "primary", true);
|
||||
}
|
||||
|
||||
function saveTaskToHistory(result, zone, main, role, manualOverride = false) {
|
||||
const entry = {
|
||||
id: "h-" + Date.now(),
|
||||
createdAt: new Date().toISOString(),
|
||||
taskText: result.text,
|
||||
suggestions: result.sorted ? result.sorted.map(s => ({ zoneId: s.zoneId, score: s.score, reason: s.hits.join(", ") })) : [],
|
||||
chosenZoneId: zone.id,
|
||||
chosenPersonId: main.id,
|
||||
delegateUsed: role === "delegate",
|
||||
manualOverride,
|
||||
comments: [],
|
||||
status: "open",
|
||||
};
|
||||
state.history.entries.unshift(entry);
|
||||
state.history.entries = state.history.entries.slice(0, 100);
|
||||
saveHistory();
|
||||
renderHistory();
|
||||
toast(t("task.saved"));
|
||||
}
|
||||
|
||||
// ===== People view =====
|
||||
function renderPeople() {
|
||||
const tbody = document.querySelector("#people-table tbody");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
for (const p of state.directory.people) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = p.status;
|
||||
const zone = state.directory.zones.find(z => z.id === p.zoneId);
|
||||
const delegate = p.delegateId ? state.directory.people.find(x => x.id === p.delegateId) : null;
|
||||
tr.innerHTML = `
|
||||
<td>${escapeHtml(personName(p))}</td>
|
||||
<td>${escapeHtml(zoneTitle(zone))}</td>
|
||||
<td>${t("role." + p.role)}</td>
|
||||
<td>
|
||||
<select data-pid="${p.id}" data-field="status">
|
||||
<option value="active" ${p.status === "active" ? "selected" : ""}>${t("status.active")}</option>
|
||||
<option value="vacation" ${p.status === "vacation" ? "selected" : ""}>${t("status.vacation")}</option>
|
||||
<option value="inactive" ${p.status === "inactive" ? "selected" : ""}>${t("status.inactive")}</option>
|
||||
</select>
|
||||
${p.vacationUntil ? `<div style="font-size:11px;color:#9ca3af;">${p.vacationUntil}</div>` : ""}
|
||||
</td>
|
||||
<td>
|
||||
<select data-pid="${p.id}" data-field="delegateId" ${p.status !== "vacation" ? "disabled" : ""}>
|
||||
<option value="">—</option>
|
||||
${state.directory.people.filter(x => x.id !== p.id && x.status === "active").map(x =>
|
||||
`<option value="${x.id}" ${p.delegateId === x.id ? "selected" : ""}>${escapeHtml(personName(x))}</option>`
|
||||
).join("")}
|
||||
</select>
|
||||
</td>
|
||||
<td>${escapeHtml(p.contact || "—")}</td>
|
||||
<td><button class="ghost" data-pid="${p.id}" data-action="del">🗑</button></td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
tbody.querySelectorAll("select").forEach(sel => {
|
||||
sel.onchange = () => {
|
||||
const p = state.directory.people.find(x => x.id === sel.dataset.pid);
|
||||
const field = sel.dataset.field;
|
||||
p[field] = sel.value || null;
|
||||
if (field === "status" && sel.value !== "vacation") {
|
||||
p.delegateId = null;
|
||||
p.vacationUntil = null;
|
||||
}
|
||||
saveDirectory();
|
||||
renderPeople();
|
||||
};
|
||||
});
|
||||
tbody.querySelectorAll("[data-action=del]").forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
if (!confirm("Удалить сотрудника?")) return;
|
||||
state.directory.people = state.directory.people.filter(x => x.id !== btn.dataset.pid);
|
||||
saveDirectory();
|
||||
renderPeople();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Simbase sync (mock) =====
|
||||
async function simbaseSync() {
|
||||
const log = document.getElementById("simbase-log");
|
||||
log.className = "simbase-log show";
|
||||
log.textContent = "→ GET /api/vacations (mock: simbase-mock.json)…";
|
||||
try {
|
||||
const data = await fetchJson("simbase-mock.json");
|
||||
let updated = 0;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
for (const order of data.vacations) {
|
||||
const person = state.directory.people.find(p => p.simbaseId === order.employeeId);
|
||||
if (!person) continue;
|
||||
if (today >= order.vacationStart && today <= order.vacationEnd) {
|
||||
if (person.status !== "vacation" || person.vacationUntil !== order.vacationEnd) {
|
||||
person.status = "vacation";
|
||||
person.vacationUntil = order.vacationEnd;
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
saveDirectory();
|
||||
renderPeople();
|
||||
log.textContent = "✓ " + t("people.simbase_done", { n: updated });
|
||||
setTimeout(() => log.classList.remove("show"), 4000);
|
||||
} catch (e) {
|
||||
log.textContent = "✗ Simbase error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== History view =====
|
||||
function renderHistory() {
|
||||
const root = document.getElementById("history-list");
|
||||
if (!root) return;
|
||||
root.innerHTML = "";
|
||||
if (!state.history.entries.length) {
|
||||
root.innerHTML = `<p>${t("history.empty")}</p>`;
|
||||
return;
|
||||
}
|
||||
for (const e of state.history.entries) {
|
||||
const zone = state.directory.zones.find(z => z.id === e.chosenZoneId);
|
||||
const person = state.directory.people.find(p => p.id === e.chosenPersonId);
|
||||
const item = document.createElement("div");
|
||||
item.className = "history-item";
|
||||
const date = new Date(e.createdAt).toLocaleString(state.lang === "ru" ? "ru-RU" : "en-US");
|
||||
item.innerHTML = `
|
||||
<h4>${escapeHtml(zone ? zoneTitle(zone) : "?")} → ${escapeHtml(person ? personName(person) : "?")} <span class="status ${e.status}">${t("history.status." + e.status)}</span></h4>
|
||||
<div class="date">${escapeHtml(date)}</div>
|
||||
<div class="task-text">${escapeHtml(e.taskText)}</div>
|
||||
<div class="actions"><button class="ghost" data-copy="${e.id}">${t("history.copy")}</button></div>
|
||||
<div class="comments">${(e.comments || []).map(c => `
|
||||
<div class="comment"><span class="who">${escapeHtml(c.authorName)}</span> <span class="when">${new Date(c.at).toLocaleString(state.lang === "ru" ? "ru-RU" : "en-US")}</span><div>${escapeHtml(c.text)}</div></div>
|
||||
`).join("")}
|
||||
<div class="comment-form">
|
||||
<select data-as="${e.id}">
|
||||
<option value="manager">${t("history.comment_as")}: ${state.lang === "ru" ? "Руководитель" : "Manager"}</option>
|
||||
${person ? `<option value="assignee">${t("history.comment_as")}: ${escapeHtml(personName(person))}</option>` : ""}
|
||||
</select>
|
||||
<input type="text" placeholder="${t("history.comment_placeholder")}" data-input="${e.id}">
|
||||
<button data-send="${e.id}">${t("history.send")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
root.appendChild(item);
|
||||
}
|
||||
root.querySelectorAll("[data-copy]").forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const e = state.history.entries.find(x => x.id === btn.dataset.copy);
|
||||
navigator.clipboard.writeText(formatHistoryText(e));
|
||||
toast(t("history.copied"));
|
||||
};
|
||||
});
|
||||
root.querySelectorAll("[data-send]").forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const id = btn.dataset.send;
|
||||
const e = state.history.entries.find(x => x.id === id);
|
||||
const input = root.querySelector(`[data-input="${id}"]`);
|
||||
const as = root.querySelector(`[data-as="${id}"]`).value;
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
const person = state.directory.people.find(p => p.id === e.chosenPersonId);
|
||||
e.comments = e.comments || [];
|
||||
e.comments.push({
|
||||
at: new Date().toISOString(),
|
||||
authorRole: as,
|
||||
authorName: as === "manager" ? (state.lang === "ru" ? "Руководитель" : "Manager") : personName(person),
|
||||
text,
|
||||
});
|
||||
if (e.status === "open") e.status = "in_progress";
|
||||
saveHistory();
|
||||
renderHistory();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function formatHistoryText(e) {
|
||||
const zone = state.directory.zones.find(z => z.id === e.chosenZoneId);
|
||||
const person = state.directory.people.find(p => p.id === e.chosenPersonId);
|
||||
const lines = [
|
||||
`Задача: ${e.taskText}`,
|
||||
`Зона: ${zone ? zoneTitle(zone) : "?"}`,
|
||||
`Исполнитель: ${person ? personName(person) : "?"}`,
|
||||
`Статус: ${t("history.status." + e.status)}`,
|
||||
"",
|
||||
"Комментарии:",
|
||||
...(e.comments || []).map(c => `[${new Date(c.at).toLocaleString()}] ${c.authorName}: ${c.text}`),
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
function personName(p) { return state.lang === "en" && p.nameEn ? p.nameEn : p.name; }
|
||||
function zoneTitle(z) { return state.lang === "en" && z.titleEn ? z.titleEn : z.title; }
|
||||
function escapeHtml(s) { return (s ?? "").toString().replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); }
|
||||
|
||||
function toast(msg) {
|
||||
let el = document.querySelector(".toast");
|
||||
if (!el) { el = document.createElement("div"); el.className = "toast"; document.body.appendChild(el); }
|
||||
el.textContent = msg;
|
||||
el.classList.add("show");
|
||||
setTimeout(() => el.classList.remove("show"), 1800);
|
||||
}
|
||||
|
||||
// ===== Bindings =====
|
||||
function bindUI() {
|
||||
document.querySelectorAll(".tabs button").forEach(b => {
|
||||
b.onclick = () => {
|
||||
document.querySelectorAll(".tabs button").forEach(x => x.classList.toggle("active", x === b));
|
||||
document.querySelectorAll(".view").forEach(v => v.classList.toggle("hidden", v.id !== "view-" + b.dataset.tab));
|
||||
};
|
||||
});
|
||||
document.querySelectorAll(".lang button").forEach(b => {
|
||||
b.onclick = () => { state.lang = b.dataset.lang; localStorage.setItem("app.lang", state.lang); applyLang(); };
|
||||
});
|
||||
document.getElementById("task-classify").onclick = onClassify;
|
||||
document.getElementById("simbase-sync").onclick = simbaseSync;
|
||||
document.getElementById("people-export").onclick = () => {
|
||||
const blob = new Blob([JSON.stringify(state.directory, null, 2)], { type: "application/json" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "directory.json";
|
||||
a.click();
|
||||
};
|
||||
document.getElementById("people-import").onchange = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
state.directory = JSON.parse(await file.text());
|
||||
saveDirectory();
|
||||
renderPeople();
|
||||
};
|
||||
}
|
||||
|
||||
init();
|
||||
50
directory.json
Normal file
50
directory.json
Normal file
@ -0,0 +1,50 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-05-22T10:00:00Z",
|
||||
"zones": [
|
||||
{
|
||||
"id": "mobile",
|
||||
"title": "Мобильное приложение",
|
||||
"titleEn": "Mobile app",
|
||||
"keywords": ["push", "ios", "android", "приложение", "мобилка", "apk", "ipa", "уведомление", "пуш"],
|
||||
"systems": ["AppStore", "Google Play", "Firebase"],
|
||||
"examples": [
|
||||
"Сделать push-уведомление по платежам",
|
||||
"Поменять иконку приложения в iOS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "crm",
|
||||
"title": "CRM-система",
|
||||
"titleEn": "CRM",
|
||||
"keywords": ["crm", "црм", "клиент", "сделка", "карточка", "amocrm", "bitrix", "битрикс", "лид"],
|
||||
"systems": ["Bitrix24", "amoCRM"],
|
||||
"examples": ["Добавить поле «промо-код» в карточку клиента"]
|
||||
},
|
||||
{
|
||||
"id": "sms",
|
||||
"title": "SMS-сообщения",
|
||||
"titleEn": "SMS",
|
||||
"keywords": ["sms", "смс", "рассылка", "сообщение", "shortcode"],
|
||||
"systems": ["SMSC", "Twilio"],
|
||||
"examples": ["Запустить SMS-рассылку по базе клиентов"]
|
||||
},
|
||||
{
|
||||
"id": "web",
|
||||
"title": "Веб-сайт",
|
||||
"titleEn": "Web",
|
||||
"keywords": ["сайт", "лендинг", "форма", "html", "веб", "страница", "landing"],
|
||||
"systems": ["WordPress", "Tilda"],
|
||||
"examples": ["Поменять текст на главной странице сайта"]
|
||||
}
|
||||
],
|
||||
"people": [
|
||||
{ "id": "p1", "name": "Алина", "nameEn": "Alina", "zoneId": "mobile", "role": "primary", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-001", "contact": "@alina_tg" },
|
||||
{ "id": "p2", "name": "Адлет", "nameEn": "Adlet", "zoneId": "mobile", "role": "backup", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-002", "contact": "@adlet_tg" },
|
||||
{ "id": "p3", "name": "Динара", "nameEn": "Dinara", "zoneId": "crm", "role": "primary", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-003", "contact": "dinara@x.kz" },
|
||||
{ "id": "p4", "name": "Камила", "nameEn": "Kamila", "zoneId": "crm", "role": "backup", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-004", "contact": "kamila@x.kz" },
|
||||
{ "id": "p5", "name": "Михаил", "nameEn": "Mikhail", "zoneId": "sms", "role": "primary", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-005", "contact": "@mikhail_tg" },
|
||||
{ "id": "p6", "name": "Мухамед","nameEn": "Mukhamed","zoneId": "sms", "role": "backup", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-006", "contact": "@muhamed_tg" },
|
||||
{ "id": "p7", "name": "Сауле", "nameEn": "Saule", "zoneId": "web", "role": "primary", "status": "active", "delegateId": null, "vacationUntil": null, "simbaseId": "EMP-007", "contact": "@saule_tg" }
|
||||
]
|
||||
}
|
||||
33
history.json
Normal file
33
history.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"id": "h-001",
|
||||
"createdAt": "2026-05-18T09:14:00Z",
|
||||
"taskText": "Бизнес просит сделать push-уведомление клиентам о статусе платежа в мобильном приложении на iOS и Android.",
|
||||
"suggestions": [{ "zoneId": "mobile", "score": 4.5, "reason": "ключевые слова: push, ios, android, мобильное" }],
|
||||
"chosenZoneId": "mobile",
|
||||
"chosenPersonId": "p1",
|
||||
"delegateUsed": false,
|
||||
"manualOverride": false,
|
||||
"comments": [
|
||||
{ "at": "2026-05-18T09:15:00Z", "authorRole": "manager", "authorName": "Руководитель", "text": "Срок до конца недели." },
|
||||
{ "at": "2026-05-18T11:42:00Z", "authorRole": "assignee", "authorName": "Алина", "text": "Взяла в работу, оценка 2 дня." }
|
||||
],
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"id": "h-002",
|
||||
"createdAt": "2026-05-19T14:00:00Z",
|
||||
"taskText": "Добавить поле «промо-код» в карточку клиента в CRM Bitrix24.",
|
||||
"suggestions": [{ "zoneId": "crm", "score": 6.0, "reason": "ключевые слова: crm, клиент, карточка, bitrix" }],
|
||||
"chosenZoneId": "crm",
|
||||
"chosenPersonId": "p4",
|
||||
"delegateUsed": true,
|
||||
"manualOverride": false,
|
||||
"comments": [
|
||||
{ "at": "2026-05-19T14:05:00Z", "authorRole": "manager", "authorName": "Руководитель", "text": "Динара в отпуске, ушло Камиле как дублёру." }
|
||||
],
|
||||
"status": "open"
|
||||
}
|
||||
]
|
||||
}
|
||||
116
i18n.json
Normal file
116
i18n.json
Normal file
@ -0,0 +1,116 @@
|
||||
{
|
||||
"ru": {
|
||||
"app.title": "Помощник руководителя",
|
||||
"app.subtitle": "Распределение задач от бизнеса",
|
||||
"tab.task": "Задача",
|
||||
"tab.people": "Сотрудники",
|
||||
"tab.history": "История",
|
||||
"task.placeholder": "Вставьте сюда текст задачи от бизнеса…",
|
||||
"task.classify": "Определить ответственного",
|
||||
"task.result": "Результат",
|
||||
"task.multi": "Задача попадает в несколько направлений. Выберите:",
|
||||
"task.score": "вес",
|
||||
"task.assignee": "Ответственный",
|
||||
"task.fallback": "Резерв",
|
||||
"task.copy": "Скопировать сообщение",
|
||||
"task.copied": "Скопировано",
|
||||
"task.save": "Сохранить в историю",
|
||||
"task.saved": "Сохранено",
|
||||
"task.none": "Нет доступного ответственного. Все в зоне в отпуске.",
|
||||
"task.assign_manual": "Назначить вручную",
|
||||
"task.take_self": "Принять на себя",
|
||||
"task.delegate_note": "Замещает {primary} (отпуск до {until})",
|
||||
"task.no_backup": "В зоне нет резервного исполнителя",
|
||||
"task.no_signal": "Не удалось определить направление. Все зоны равновероятны.",
|
||||
"people.add": "+ Добавить сотрудника",
|
||||
"people.name": "ФИО",
|
||||
"people.zone": "Зона",
|
||||
"people.role": "Роль",
|
||||
"people.status": "Статус",
|
||||
"people.delegate": "Замещающий",
|
||||
"people.contact": "Контакт",
|
||||
"people.actions": "Действия",
|
||||
"people.simbase_sync": "Синхронизация с Симбейс",
|
||||
"people.simbase_done": "Синхронизация выполнена. Обновлено: {n}",
|
||||
"people.export": "Экспорт JSON",
|
||||
"people.import": "Импорт JSON",
|
||||
"people.pin_prompt": "Введите пин-код руководителя для правок",
|
||||
"people.pin_setup": "Задайте пин-код руководителя (4-6 символов)",
|
||||
"people.pin_wrong": "Неверный пин-код",
|
||||
"role.primary": "основной",
|
||||
"role.backup": "дублёр",
|
||||
"role.delegate": "замещающий",
|
||||
"status.active": "активен",
|
||||
"status.vacation": "в отпуске",
|
||||
"status.inactive": "недоступен",
|
||||
"history.empty": "История пуста",
|
||||
"history.copy": "Скопировать историю задачи",
|
||||
"history.copied": "Скопировано",
|
||||
"history.add_comment": "Добавить комментарий",
|
||||
"history.comment_as": "Подписаться как",
|
||||
"history.comment_placeholder": "Текст комментария…",
|
||||
"history.send": "Отправить",
|
||||
"history.status.open": "открыта",
|
||||
"history.status.in_progress": "в работе",
|
||||
"history.status.done": "выполнена",
|
||||
"history.status.cancelled": "отменена",
|
||||
"msg.template": "@{name}, прилетела задача от бизнеса:\n\n«{task}»\n\nТы {role} по направлению «{zone}».{delegate} Посмотри, пожалуйста."
|
||||
},
|
||||
"en": {
|
||||
"app.title": "Manager's assistant",
|
||||
"app.subtitle": "Routing business tasks to the team",
|
||||
"tab.task": "Task",
|
||||
"tab.people": "People",
|
||||
"tab.history": "History",
|
||||
"task.placeholder": "Paste the business task text here…",
|
||||
"task.classify": "Find assignee",
|
||||
"task.result": "Result",
|
||||
"task.multi": "Task matches multiple zones. Choose:",
|
||||
"task.score": "score",
|
||||
"task.assignee": "Assignee",
|
||||
"task.fallback": "Backup",
|
||||
"task.copy": "Copy message",
|
||||
"task.copied": "Copied",
|
||||
"task.save": "Save to history",
|
||||
"task.saved": "Saved",
|
||||
"task.none": "No available assignee. All in zone are on vacation.",
|
||||
"task.assign_manual": "Assign manually",
|
||||
"task.take_self": "Take it myself",
|
||||
"task.delegate_note": "Substituting for {primary} (vacation until {until})",
|
||||
"task.no_backup": "No backup in zone",
|
||||
"task.no_signal": "Could not detect zone. All zones equally likely.",
|
||||
"people.add": "+ Add person",
|
||||
"people.name": "Name",
|
||||
"people.zone": "Zone",
|
||||
"people.role": "Role",
|
||||
"people.status": "Status",
|
||||
"people.delegate": "Delegate",
|
||||
"people.contact": "Contact",
|
||||
"people.actions": "Actions",
|
||||
"people.simbase_sync": "Sync with Simbase",
|
||||
"people.simbase_done": "Sync complete. Updated: {n}",
|
||||
"people.export": "Export JSON",
|
||||
"people.import": "Import JSON",
|
||||
"people.pin_prompt": "Enter manager PIN to edit",
|
||||
"people.pin_setup": "Set manager PIN (4-6 chars)",
|
||||
"people.pin_wrong": "Wrong PIN",
|
||||
"role.primary": "primary",
|
||||
"role.backup": "backup",
|
||||
"role.delegate": "delegate",
|
||||
"status.active": "active",
|
||||
"status.vacation": "on vacation",
|
||||
"status.inactive": "inactive",
|
||||
"history.empty": "History is empty",
|
||||
"history.copy": "Copy task history",
|
||||
"history.copied": "Copied",
|
||||
"history.add_comment": "Add comment",
|
||||
"history.comment_as": "Sign as",
|
||||
"history.comment_placeholder": "Comment text…",
|
||||
"history.send": "Send",
|
||||
"history.status.open": "open",
|
||||
"history.status.in_progress": "in progress",
|
||||
"history.status.done": "done",
|
||||
"history.status.cancelled": "cancelled",
|
||||
"msg.template": "@{name}, a new business task:\n\n\"{task}\"\n\nYou are the {role} for \"{zone}\".{delegate} Please take a look."
|
||||
}
|
||||
}
|
||||
71
index.html
Normal file
71
index.html
Normal file
@ -0,0 +1,71 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Помощник руководителя</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand">
|
||||
<h1 data-i18n="app.title"></h1>
|
||||
<p class="subtitle" data-i18n="app.subtitle"></p>
|
||||
</div>
|
||||
<div class="header-controls">
|
||||
<nav class="tabs">
|
||||
<button data-tab="task" class="active" data-i18n="tab.task"></button>
|
||||
<button data-tab="people" data-i18n="tab.people"></button>
|
||||
<button data-tab="history" data-i18n="tab.history"></button>
|
||||
</nav>
|
||||
<div class="lang">
|
||||
<button data-lang="ru" class="active">RU</button>
|
||||
<button data-lang="en">EN</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- TAB: TASK -->
|
||||
<section id="view-task" class="view">
|
||||
<textarea id="task-input" rows="6" data-i18n-attr="placeholder:task.placeholder"></textarea>
|
||||
<button id="task-classify" class="primary" data-i18n="task.classify"></button>
|
||||
<div id="task-result"></div>
|
||||
</section>
|
||||
|
||||
<!-- TAB: PEOPLE -->
|
||||
<section id="view-people" class="view hidden">
|
||||
<div class="toolbar">
|
||||
<button id="simbase-sync" data-i18n="people.simbase_sync"></button>
|
||||
<button id="people-export" data-i18n="people.export"></button>
|
||||
<label class="btn-like">
|
||||
<span data-i18n="people.import"></span>
|
||||
<input type="file" id="people-import" accept="application/json" hidden>
|
||||
</label>
|
||||
</div>
|
||||
<div id="simbase-log" class="simbase-log"></div>
|
||||
<table id="people-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="people.name"></th>
|
||||
<th data-i18n="people.zone"></th>
|
||||
<th data-i18n="people.role"></th>
|
||||
<th data-i18n="people.status"></th>
|
||||
<th data-i18n="people.delegate"></th>
|
||||
<th data-i18n="people.contact"></th>
|
||||
<th data-i18n="people.actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- TAB: HISTORY -->
|
||||
<section id="view-history" class="view hidden">
|
||||
<div id="history-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
21
simbase-mock.json
Normal file
21
simbase-mock.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"_comment": "Мок системы Симбейс. В реальной интеграции — GET /api/vacations. Здесь просто читается как файл.",
|
||||
"vacations": [
|
||||
{
|
||||
"employeeId": "EMP-003",
|
||||
"fullName": "Динара Иванова",
|
||||
"vacationStart": "2026-05-20",
|
||||
"vacationEnd": "2026-06-10",
|
||||
"orderNumber": "О-2026-451",
|
||||
"orderDate": "2026-05-10"
|
||||
},
|
||||
{
|
||||
"employeeId": "EMP-005",
|
||||
"fullName": "Михаил Петров",
|
||||
"vacationStart": "2026-05-22",
|
||||
"vacationEnd": "2026-05-29",
|
||||
"orderNumber": "О-2026-477",
|
||||
"orderDate": "2026-05-15"
|
||||
}
|
||||
]
|
||||
}
|
||||
153
styles.css
Normal file
153
styles.css
Normal file
@ -0,0 +1,153 @@
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
margin: 0;
|
||||
background: #f5f6f8;
|
||||
color: #1d1f23;
|
||||
line-height: 1.45;
|
||||
}
|
||||
header {
|
||||
background: #1d1f23;
|
||||
color: #fff;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
header h1 { margin: 0; font-size: 20px; }
|
||||
header .subtitle { margin: 2px 0 0; font-size: 13px; opacity: 0.7; }
|
||||
.header-controls { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
|
||||
.tabs button, .lang button {
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
border: 1px solid #3a3d44;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.tabs button.active, .lang button.active { background: #4a8cff; border-color: #4a8cff; }
|
||||
.lang button { padding: 6px 10px; font-size: 12px; }
|
||||
|
||||
main { padding: 24px; max-width: 1100px; margin: 0 auto; }
|
||||
.view.hidden { display: none; }
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
padding: 12px;
|
||||
border: 1px solid #d0d4dc;
|
||||
border-radius: 6px;
|
||||
resize: vertical;
|
||||
background: #fff;
|
||||
}
|
||||
button.primary, button:not(.ghost):not(.tabs button):not(.lang button) {
|
||||
background: #4a8cff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
margin-top: 12px;
|
||||
}
|
||||
button.ghost { background: transparent; border: 1px solid #d0d4dc; color: #1d1f23; }
|
||||
button:hover { opacity: 0.9; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-like {
|
||||
display: inline-block;
|
||||
background: #4a8cff;
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
#task-result { margin-top: 24px; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.zone-card, .assignee-card, .history-item {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e5eb;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
.zone-card.choice { cursor: pointer; transition: border-color 0.15s; }
|
||||
.zone-card.choice:hover { border-color: #4a8cff; }
|
||||
.zone-card h3 { margin: 0 0 6px; font-size: 15px; }
|
||||
.zone-card .score { font-size: 12px; color: #6b7280; }
|
||||
.zone-card .reason { margin-top: 6px; font-size: 13px; color: #4b5563; }
|
||||
|
||||
.assignee-card { border-color: #4a8cff; }
|
||||
.assignee-card .name { font-size: 18px; font-weight: 600; }
|
||||
.assignee-card .meta { color: #6b7280; font-size: 13px; margin-top: 4px; }
|
||||
.assignee-card .note { background: #fff3cd; padding: 8px 12px; border-radius: 6px; margin-top: 8px; font-size: 13px; }
|
||||
.assignee-card pre {
|
||||
background: #f5f6f8;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
margin-top: 12px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
.assignee-card .actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||
|
||||
.error-card { background: #fff5f5; border: 1px solid #f5b1b1; padding: 16px; border-radius: 8px; }
|
||||
.error-card .actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||
|
||||
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; align-items: center; }
|
||||
.simbase-log {
|
||||
background: #e7f3ff;
|
||||
border: 1px solid #b8daff;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
display: none;
|
||||
}
|
||||
.simbase-log.show { display: block; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; }
|
||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #e2e5eb; font-size: 14px; vertical-align: middle; }
|
||||
th { background: #f8f9fb; font-weight: 600; font-size: 13px; }
|
||||
tr.vacation td { background: #fffbea; }
|
||||
tr.inactive td { color: #9ca3af; }
|
||||
td select, td input { font: inherit; padding: 4px 6px; border: 1px solid #d0d4dc; border-radius: 4px; }
|
||||
td button { padding: 4px 10px; margin: 0 2px; }
|
||||
|
||||
.history-item { margin-bottom: 12px; }
|
||||
.history-item h4 { margin: 0 0 6px; font-size: 14px; }
|
||||
.history-item .date { color: #6b7280; font-size: 12px; }
|
||||
.history-item .task-text { margin: 8px 0; font-size: 14px; }
|
||||
.history-item .status { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; background: #e2e5eb; }
|
||||
.history-item .status.in_progress { background: #cfe8ff; }
|
||||
.history-item .status.done { background: #c6f6d5; }
|
||||
.history-item .status.cancelled { background: #fed7d7; }
|
||||
.history-item .comments { margin-top: 12px; border-top: 1px solid #e2e5eb; padding-top: 10px; }
|
||||
.history-item .comment { font-size: 13px; padding: 6px 0; border-bottom: 1px dashed #eef0f4; }
|
||||
.history-item .comment:last-child { border-bottom: none; }
|
||||
.history-item .comment .who { font-weight: 600; }
|
||||
.history-item .comment .when { color: #9ca3af; font-size: 11px; margin-left: 6px; }
|
||||
.history-item .comment-form { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
||||
.history-item .comment-form select, .history-item .comment-form input { padding: 6px 8px; font: inherit; border: 1px solid #d0d4dc; border-radius: 4px; }
|
||||
.history-item .comment-form input { flex: 1; min-width: 200px; }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1d1f23;
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
Reference in New Issue
Block a user