// Shared small components function Pill({ kind = "muted", children }) { return ( {children} ); } function FileIcon({ type }) { const labels = { xlsx: "XLS", pdf: "PDF", docx: "DOC", csv: "CSV" }; return {labels[type] || "FILE"}; } function Card({ title, subtitle, action, children, bodyStyle, style, className = "" }) { return (
{(title || action) && (
{title &&

{title}

} {subtitle &&

{subtitle}

}
{action}
)}
{children}
); } function Avatar({ name, color, size = 36 }) { const bg = color || "linear-gradient(135deg, #b8caea, #6f88b2)"; const fontSize = size <= 28 ? 11 : size <= 36 ? 13 : 15; return (
{initials(name)}
); } function CheckBox({ on, onChange }) { return ( { e.stopPropagation(); onChange(!on); }}> {on && } ); } // Combobox — a text input with type-to-filter suggestions (autocomplete). // options: [{ value, label, sub? }]. Selecting calls onSelect(value). // allowFree + onFreeEnter lets a free-typed query be submitted on Enter. function Combobox({ options = [], value, onSelect, placeholder, emptyText = "Ничего не найдено", style, inputStyle, allowFree = false, onFreeEnter, autoFocus }) { const [open, setOpen] = React.useState(false); const [q, setQ] = React.useState(""); const [hi, setHi] = React.useState(0); const ref = React.useRef(null); const selected = options.find(o => o.value === value); const text = open ? q : (selected ? selected.label : (value || "")); const ql = q.trim().toLowerCase(); const filtered = (open && ql) ? options.filter(o => o.label.toLowerCase().includes(ql) || (o.sub || "").toLowerCase().includes(ql)) : options; const show = filtered.slice(0, 60); React.useEffect(() => { if (!open) return; const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, [open]); const choose = (o) => { onSelect && onSelect(o.value); setOpen(false); setQ(""); }; const onKey = (e) => { if (e.key === "ArrowDown") { e.preventDefault(); if (!open) setOpen(true); setHi(h => Math.min(show.length - 1, h + 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setHi(h => Math.max(0, h - 1)); } else if (e.key === "Enter") { e.preventDefault(); if (open && show[hi]) choose(show[hi]); else if (allowFree && onFreeEnter && q.trim()) { onFreeEnter(q.trim()); setOpen(false); setQ(""); } } else if (e.key === "Escape") { setOpen(false); } }; return (
{ setQ(e.target.value); setOpen(true); setHi(0); }} onFocus={() => { setQ(""); setOpen(true); setHi(0); }} onKeyDown={onKey} /> {open && (
{show.length === 0 ? (
{emptyText}
) : show.map((o, i) => (
setHi(i)} onMouseDown={(e) => { e.preventDefault(); choose(o); }} style={{ display: "flex", justifyContent: "space-between", gap: 10, padding: "8px 11px", fontSize: 13, cursor: "pointer", background: i === hi ? "var(--ink-50)" : "transparent", fontWeight: o.value === value ? 600 : 400 }}> {o.label} {o.sub && {o.sub}}
))}
)}
); } // ---- live directory (people/departments) loaded from the backend, not static ---- window.__dirVer = window.__dirVer || 0; function refreshDirectory() { if (!window.API) return Promise.resolve(); return Promise.all([ window.API.people ? window.API.people().catch(() => null) : null, window.API.departments ? window.API.departments().catch(() => null) : null, ]).then(([p, d]) => { // Pickers/filters use only active employees; dismissed (active===false) are // kept in history but hidden here. The Employees page calls the API directly // for the full list. if (p && typeof p === "object" && Object.keys(p).length) { window.PEOPLE = Object.fromEntries(Object.entries(p).filter(([, v]) => !v || v.active !== false)); } if (Array.isArray(d) && d.length) window.DEPARTMENTS = d; window.__dirVer++; window.dispatchEvent(new Event("directory-updated")); }); } // useDirectory re-renders the caller whenever the directory is refreshed. function useDirectory() { const [, force] = React.useReducer(x => x + 1, 0); React.useEffect(() => { const h = () => force(); window.addEventListener("directory-updated", h); return () => window.removeEventListener("directory-updated", h); }, []); return { people: window.PEOPLE || {}, departments: window.DEPARTMENTS || [] }; } // MicButton — voice dictation via the browser's built-in Web Speech API // (Chrome/Edge). Appends each finalized phrase to the target field through // onText; no backend, no API cost. On unsupported browsers it explains why. function MicButton({ onText, lang = "ru-RU", size = 15, className = "icon-btn", title = "Голосовой ввод", style }) { const [listening, setListening] = React.useState(false); const recRef = React.useRef(null); const Rec = (typeof window !== "undefined") && (window.SpeechRecognition || window.webkitSpeechRecognition); // Stop recognition if the component unmounts mid-recording. React.useEffect(() => () => { try { recRef.current && recRef.current.stop(); } catch (_) {} }, []); const toggle = () => { if (!Rec) { alert("Голосовой ввод не поддерживается в этом браузере. Откройте сайт в Chrome или Edge."); return; } if (listening) { try { recRef.current && recRef.current.stop(); } catch (_) {} return; } const rec = new Rec(); rec.lang = lang; rec.interimResults = true; rec.continuous = true; rec.onresult = (e) => { for (let i = e.resultIndex; i < e.results.length; i++) { if (e.results[i].isFinal) { const chunk = (e.results[i][0].transcript || "").trim(); if (chunk) onText(chunk); } } }; rec.onerror = () => setListening(false); rec.onend = () => setListening(false); recRef.current = rec; try { rec.start(); setListening(true); } catch (_) { setListening(false); } }; const tip = !Rec ? "Голосовой ввод недоступен (нужен Chrome/Edge)" : (listening ? "Остановить запись" : title); return ( ); } Object.assign(window, { Pill, FileIcon, Card, Avatar, CheckBox, Combobox, refreshDirectory, useDirectory, MicButton });