// Main app shell — auth gate + role-aware sidebar + topbar + page router + tweaks const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "accent": "#4f9bd1", "headingFont": "Source Serif 4", "bodyFont": "DM Sans", "sidebar": "navy", "density": "regular" }/*EDITMODE-END*/; const ACCENTS = { "#4f9bd1": { soft: "#e6f1f9", strong: "#2f7ab0" }, "#3aa7a0": { soft: "#dff2f0", strong: "#1f807a" }, "#5468d6": { soft: "#e6e8f7", strong: "#3845b0" }, "#c98851": { soft: "#f7ead9", strong: "#a96731" }, }; const SIDEBAR_THEMES = { navy: { bg: "#1a2332", text: "#e8ecf3" }, charcoal:{ bg: "#1d1f24", text: "#e8ecf3" }, ivory: { bg: "#f6f5f1", text: "#22231e" }, }; // Which pages each role may open. const ROLE_PAGES = { admin: ["dashboard", "orders", "docs", "employees", "chat", "settings"], controller: ["dashboard", "orders", "docs", "employees", "chat"], executor: ["orders", "docs", "chat"], }; const ROLE_LABEL = { admin: "Администратор", controller: "Отдел контроля", executor: "Исполнитель" }; // Hash routing: the page lives in location.hash (#/employees) so switching tabs // changes the URL and a refresh keeps you on the same page. The hash never hits // the Go server, so no backend/SPA-fallback changes are needed. const PAGE_KEYS = ["dashboard", "orders", "docs", "employees", "chat", "settings"]; function pageFromHash() { const h = (window.location.hash || "").replace(/^#\/?/, ""); return PAGE_KEYS.includes(h) ? h : ""; } function App() { const [me, setMe] = React.useState(undefined); // undefined=loading, null=guest, object=user const [page, setPage] = React.useState(() => pageFromHash() || "orders"); const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const [intent, setIntent] = React.useState(null); const [overdueCount, setOverdueCount] = React.useState(0); const [pwOpen, setPwOpen] = React.useState(false); // Who am I? React.useEffect(() => { if (!window.API) { setMe(null); return; } window.API.me().then(u => { window.ME = u; setMe(u); }).catch(() => { window.ME = null; setMe(null); }); }, []); // Browser back/forward (or a manually edited hash) → switch the page. React.useEffect(() => { const onHash = () => { const p = pageFromHash(); if (p) setPage(p); }; window.addEventListener("hashchange", onHash); return () => window.removeEventListener("hashchange", onHash); }, []); // Keep the URL hash in sync with the active page once authenticated, clamping // to pages the role may open (so a disallowed hash redirects to a valid one). React.useEffect(() => { if (!me) return; const allowed = ROLE_PAGES[me.role] || ["orders"]; const eff = allowed.includes(page) ? page : allowed[0]; if (eff !== page) { setPage(eff); return; } const target = "#/" + eff; if (window.location.hash !== target) window.location.hash = target; }, [page, me]); // Apply tweaks via CSS variables on :root (also on the login screen). React.useEffect(() => { const root = document.documentElement; const a = ACCENTS[t.accent] || ACCENTS["#4f9bd1"]; root.style.setProperty("--accent", t.accent); root.style.setProperty("--accent-soft", a.soft); root.style.setProperty("--accent-strong", a.strong); root.style.setProperty("--sans", `"${t.bodyFont}", system-ui, sans-serif`); root.style.setProperty("--serif", `"${t.headingFont}", Georgia, serif`); const sb = SIDEBAR_THEMES[t.sidebar] || SIDEBAR_THEMES.navy; root.style.setProperty("--ink-700", sb.bg); document.body.style.fontSize = t.density === "compact" ? "13px" : t.density === "comfy" ? "15px" : "14px"; }, [t.accent, t.headingFont, t.bodyFont, t.sidebar, t.density]); React.useEffect(() => { const fonts = new Set([t.headingFont, t.bodyFont]); fonts.forEach(f => { const id = `gf-${f.replace(/\s+/g, "-")}`; if (document.getElementById(id)) return; const link = document.createElement("link"); link.id = id; link.rel = "stylesheet"; link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(f)}:wght@400;500;600;700&display=swap`; document.head.appendChild(link); }); }, [t.headingFont, t.bodyFont]); // Overdue badge — only when authenticated. React.useEffect(() => { if (!me || !window.API) return; if (window.refreshDirectory) window.refreshDirectory(); // live people/departments from the DB window.API.listTasks().then(ts => { if (!Array.isArray(ts)) return; setOverdueCount(ts.filter(x => (typeof effStatus === "function" ? effStatus(x) : x.status) === "overdue").length); }).catch(() => {}); }, [me]); const goOrders = (patch) => { setIntent({ ...patch, _ts: Date.now() }); setPage("orders"); }; if (me === undefined) { return
Загрузка…
; } if (me === null) { return { window.ME = u; setMe(u); }} />; } const allowed = ROLE_PAGES[me.role] || ["orders"]; const PAGES = { dashboard: { label: "Дашборд", icon: I.Dashboard, comp: () => }, orders: { label: "Поручения", icon: I.Clipboard, comp: () => }, docs: { label: "Документы", icon: I.Docs, comp: () => }, employees: { label: "Сотрудники", icon: I.Users, comp: () => }, chat: { label: "ИИ-ассистент", icon: I.Sparkle, comp: () => , badge: "AI" }, settings: { label: "Настройки", icon: I.Settings, comp: () => }, }; const effPage = allowed.includes(page) ? page : allowed[0]; const CurrentPage = PAGES[effPage].comp; const currentLabel = PAGES[effPage].label; const mainNav = allowed.filter(k => k !== "settings"); const sb = SIDEBAR_THEMES[t.sidebar]; const sidebarStyle = { background: sb.bg, color: sb.text }; const logout = () => { window.API.logout().finally(() => { window.ME = null; setMe(null); }); }; return (
Юр. центр / {currentLabel}
goOrders({ query })} onPerson={(assignee) => goOrders({ assignee })} />
setTweak("accent", v)} /> setTweak("sidebar", v)} /> setTweak("headingFont", v)} /> setTweak("bodyFont", v)} /> setTweak("density", v)} /> {pwOpen && setPwOpen(false)} />}
); } // Topbar search with live suggestions: matching employees (→ open their поручения) // and a free-text option (→ search task text). Backed by the live directory. function TopbarSearch({ onQuery, onPerson }) { const { people } = useDirectory(); const [q, setQ] = React.useState(""); const [open, setOpen] = React.useState(false); const [hi, setHi] = React.useState(0); const ref = React.useRef(null); const ql = q.trim().toLowerCase(); const matches = ql ? Object.keys(people).filter(n => n.toLowerCase().includes(ql)).slice(0, 6) : []; React.useEffect(() => { const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, []); const submitQuery = () => { if (q.trim()) { onQuery(q.trim()); setOpen(false); } }; const pickPerson = (name) => { onPerson(name); setQ(""); setOpen(false); }; const onKey = (e) => { if (e.key === "Enter") { e.preventDefault(); if (open && matches[hi]) pickPerson(matches[hi]); else submitQuery(); } else if (e.key === "ArrowDown") { e.preventDefault(); setOpen(true); setHi(h => Math.min(matches.length - 1, h + 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setHi(h => Math.max(0, h - 1)); } else if (e.key === "Escape") setOpen(false); }; return (
{ setQ(e.target.value); setOpen(true); setHi(0); }} onFocus={() => setOpen(true)} onKeyDown={onKey} /> ⌘K {open && (matches.length > 0 || q.trim()) && (
{matches.length > 0 &&
Сотрудники
} {matches.map((n, i) => (
setHi(i)} onMouseDown={(e) => { e.preventDefault(); pickPerson(n); }} style={{ display: "flex", justifyContent: "space-between", gap: 10, padding: "7px 11px", fontSize: 13, cursor: "pointer", background: i === hi ? "var(--ink-50)" : "transparent" }}> {n} {people[n] && people[n].dept}
))} {q.trim() && (
{ e.preventDefault(); submitQuery(); }} style={{ display: "flex", alignItems: "center", gap: 6, padding: "8px 11px", fontSize: 12.5, cursor: "pointer", borderTop: matches.length > 0 ? "1px solid var(--ink-100)" : "none", color: "var(--accent-strong)" }}> Искать «{q.trim()}» по тексту поручений
)}
)}
); } function LoginPage({ onLogin }) { const [login, setLogin] = React.useState(""); const [password, setPassword] = React.useState(""); const [err, setErr] = React.useState(""); const [busy, setBusy] = React.useState(false); const submit = async (e) => { if (e) e.preventDefault(); if (!login.trim() || !password) return; setBusy(true); setErr(""); try { const u = await window.API.login(login.trim(), password); onLogin(u); } catch (e2) { setErr(String((e2 && e2.message) || e2)); setBusy(false); } }; return (
П
Паблисити CRM
Вход в систему контроля поручений
setLogin(e.target.value)} autoFocus />
setPassword(e.target.value)} />
{err &&
{err}
}
); } function ChangePasswordModal({ onClose }) { const [oldP, setOldP] = React.useState(""); const [newP, setNewP] = React.useState(""); const [msg, setMsg] = React.useState(""); const [busy, setBusy] = React.useState(false); const submit = async () => { if (!oldP || newP.length < 6) { setMsg("Новый пароль — минимум 6 символов"); return; } setBusy(true); setMsg(""); try { await window.API.changePassword(oldP, newP); setMsg("Пароль изменён ✓"); setOldP(""); setNewP(""); setTimeout(onClose, 900); } catch (e) { setMsg(String((e && e.message) || e)); } finally { setBusy(false); } }; return (
e.stopPropagation()}>

Смена пароля

setOldP(e.target.value)} />
setNewP(e.target.value)} />
{msg &&
{msg}
}
); } ReactDOM.createRoot(document.getElementById("root")).render();