// 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 });