// Documentation / Wiki screen — keyword search + article + TOC
function Documentation({ user, perms }) {
const [articleId, setArticleId] = useState("gs-overview");
const [query, setQuery] = useState("");
const [category, setCategory] = useState(null); // null = all
// Search
const q = query.trim().toLowerCase();
const searchResults = q ? DOC_INDEX.filter(a => a.searchText.includes(q)).slice(0, 20) : null;
const article = DOC_ARTICLES.find(a => a.id === articleId) || DOC_ARTICLES[0];
return (
>
}
/>
{/* Hero search */}
TCMS Knowledge Base
{DOC_ARTICLES.length} articles · {DOC_CATEGORIES.length} categories · keyword indexed
setQuery(e.target.value)}
placeholder="Search articles, tags, content…"
style={{ width: 360, height: 36, fontSize: 13 }}
autoFocus
/>
{q ? (
{ setArticleId(id); setQuery(""); }} />
) : (
{/* LEFT — category tree */}
{/* CENTER — article */}
{/* RIGHT — TOC + related */}
)}
);
}
function DocsSidebar({ articleId, onSelect }) {
return (
{DOC_CATEGORIES.map(cat => {
const items = DOC_ARTICLES.filter(a => a.category === cat.id);
return (
{cat.label}
{items.map(a => (
))}
);
})}
);
}
function ArticleView({ article, onNavigate }) {
if (!article) return null;
const cat = DOC_CATEGORIES.find(c => c.id === article.category);
return (
{cat?.label}
·
Updated {article.updated}
{article.author && <>
·{article.author}>}
{article.version && <>
·v{article.version}>}
{article.title}
{article.excerpt}
{article.tags?.map(t => (
#{t}
))}
{article.blocks.map((b, i) => )}
{/* Footer nav */}
);
}
function Block({ block }) {
switch (block.type) {
case "p":
return {block.text}
;
case "h2":
return {block.text}
;
case "h3":
return {block.text}
;
case "ul":
return (
{block.items.map((it, i) => - {it}
)}
);
case "ol":
return (
{block.items.map((it, i) => - {it}
)}
);
case "code":
return (
{block.text}
);
case "kv":
return (
{block.rows.map(([k, v], i) => (
| {k} |
{v} |
))}
);
case "table":
return (
{block.headers.map((h, i) => | {h} | )}
{block.rows.map((row, ri) => (
{row.map((c, ci) => | {c} | )}
))}
);
case "callout": {
const tones = {
info: { bg: "var(--teal-soft)", bd: "#c8e3ea", color: "var(--teal)", icon: "shield" },
warn: { bg: "var(--amber-soft)", bd: "#f0d8a8", color: "var(--amber)", icon: "bell" },
tip: { bg: "var(--green-soft)", bd: "#bfd9c4", color: "var(--green)", icon: "sparkle" },
};
const t = tones[block.tone] || tones.info;
return (
{block.title && {block.title}.}{" "}
{block.text}
);
}
case "permMatrix":
return ;
case "divider":
return ;
default:
return null;
}
}
function PermMatrix() {
const features = [
{ k: "viewRates", l: "View rates (own scope)" },
{ k: "edit", l: "Create / update rates" },
{ k: "import", l: "Import rates (template / AI)" },
{ k: "export", l: "Export rate sheet" },
{ k: "calculator", l: "Rate Calculator" },
{ k: "aiqa", l: "AI Q&A (DIFY)" },
{ k: "admin", l: "Admin functions" },
];
const roles = ["rate_calculator", "read", "edit", "fbp", "procurement", "admin"];
return (
| Feature |
{roles.map(r => {r} | )}
{features.map(f => (
| {f.l} |
{roles.map(r => (
{PERMS[r][f.k]
? ✓
: —}
|
))}
))}
);
}
function ArticleAside({ article, onNavigate }) {
const headings = article.blocks.filter(b => b.type === "h2" || b.type === "h3").map(b => ({ text: b.text, level: b.type, id: slug(b.text) }));
const related = (article.related || []).map(id => DOC_ARTICLES.find(a => a.id === id)).filter(Boolean);
return (
{headings.length > 0 && (
)}
{related.length > 0 && (
Related
{related.map(r => (
))}
)}
{article.category === "releases" && article.releaseDate && (
Version
v{article.version}
Released {article.releaseDate}
)}
);
}
function SearchResults({ results, query, onOpen }) {
if (results.length === 0) {
return (
No results for "{query}"
Try a shorter keyword, or browse the categories below.
{DOC_CATEGORIES.map(c => (
))}
);
}
return (
Found {results.length} article{results.length === 1 ? "" : "s"} matching "{query}"
{results.map(r => {
const cat = DOC_CATEGORIES.find(c => c.id === r.category);
// Find a snippet around the match
const idx = r.searchText.indexOf(query);
let snippet = r.excerpt;
if (idx >= 0) {
const start = Math.max(0, idx - 40);
const end = Math.min(r.searchText.length, idx + query.length + 80);
snippet = (start > 0 ? "…" : "") + r.searchText.slice(start, end) + (end < r.searchText.length ? "…" : "");
}
return (
onOpen(r.id)}
onMouseEnter={e => e.currentTarget.style.borderColor = "var(--teal)"}
onMouseLeave={e => e.currentTarget.style.borderColor = "var(--line)"}>
{cat?.label}
Updated {r.updated}
{r.version &&
v{r.version}}
{r.tags?.slice(0, 6).map(t => (
{t.toLowerCase().includes(query) ? #{t} : `#${t}`}
))}
);
})}
);
}
function Highlight({ text, q }) {
if (!q) return <>{text}>;
const lc = text.toLowerCase();
const parts = [];
let i = 0, p = 0;
while ((i = lc.indexOf(q, p)) >= 0) {
if (i > p) parts.push({text.slice(p, i)});
parts.push({text.slice(i, i + q.length)});
p = i + q.length;
}
if (p < text.length) parts.push({text.slice(p)});
return <>{parts}>;
}
function slug(s) {
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
Object.assign(window, { Documentation });