// 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 (
);
}
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()}>
);
}
ReactDOM.createRoot(document.getElementById("root")).render();