diff --git a/frontend/src/pages/CategoriesPage.css b/frontend/src/pages/CategoriesPage.css
index c865e68..a52678f 100644
--- a/frontend/src/pages/CategoriesPage.css
+++ b/frontend/src/pages/CategoriesPage.css
@@ -57,3 +57,77 @@
font-size: .82rem; color: var(--text-muted); line-height: 1.5;
}
}
+
+/* Facet tabs — the axis you are curating.
+ They scroll sideways rather than wrapping onto three lines on a phone. */
+.cat-facets {
+ display: flex; gap: 6px; margin-bottom: 10px; padding-bottom: 4px;
+ overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: thin;
+}
+.cat-facet {
+ display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0;
+ min-height: 40px; padding: 8px 14px; border-radius: 999px; cursor: pointer;
+ border: 1px solid var(--border); background: var(--card-bg); color: var(--text);
+ font: inherit; font-size: 0.86rem; font-weight: 600; white-space: nowrap;
+}
+.cat-facet:hover { border-color: var(--primary); }
+.cat-facet.is-active { background: var(--primary); border-color: var(--primary); color: #fff; }
+.cat-facet-count {
+ font-size: 0.72rem; font-weight: 600; padding: 1px 7px; border-radius: 999px;
+ background: var(--bg); color: var(--text-muted);
+}
+.cat-facet.is-active .cat-facet-count { background: rgba(255,255,255,0.22); color: #fff; }
+.cat-blurb { margin: 0 0 12px; font-size: 0.83rem; color: var(--text-muted); }
+
+.cat-header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
+.cat-create { margin-bottom: 12px; }
+
+/* Expanding is its own control: opening a branch must not look like editing it. */
+.cat-expand {
+ flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center;
+ width: 30px; min-height: 34px; padding: 0; cursor: pointer; font: inherit;
+ background: none; border: 1px solid transparent; border-radius: 8px; color: var(--text-muted);
+}
+.cat-expand:hover { background: var(--bg); border-color: var(--border); color: var(--primary); }
+.cat-expand:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
+.cat-expand.is-leaf { pointer-events: none; }
+.cat-chevron { display: inline-block; font-size: 1.1rem; line-height: 1; transition: transform 0.15s ease; }
+.cat-expand[aria-expanded='true'] .cat-chevron { transform: rotate(90deg); }
+.cat-row.is-open { background: var(--bg); }
+
+.cat-label { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
+.cat-trail { font-size: 0.73rem; font-weight: 400; color: var(--text-muted); overflow-wrap: anywhere; }
+.cat-badge.is-system { background: var(--option-sel-bg); color: var(--primary); border-color: var(--primary); }
+
+.cat-more {
+ padding: 10px 14px; font-size: 0.8rem; color: var(--text-muted);
+ border-bottom: 1px solid var(--border);
+}
+.cat-notice { color: var(--primary); font-size: 0.84rem; margin: 10px 0 0; }
+
+/* Attaching questions without leaving the row you are filing them into. */
+.cat-attach { width: 100%; padding: 8px 0 4px; display: flex; flex-direction: column; gap: 8px; }
+.cat-attach-search { display: flex; gap: 8px; flex-wrap: wrap; }
+.cat-attach-search input {
+ flex: 1; min-width: 160px; padding: 7px 11px; font-size: 0.87rem;
+ border: 1px solid var(--border); border-radius: 8px;
+ background: var(--input-bg); color: var(--text);
+}
+.cat-attach-list {
+ list-style: none; margin: 0; padding: 0; max-height: 260px; overflow-y: auto;
+ border: 1px solid var(--border); border-radius: 8px; background: var(--bg);
+}
+.cat-attach-list li { border-bottom: 1px solid var(--border); }
+.cat-attach-list li:last-child { border-bottom: 0; }
+.cat-attach-list label {
+ display: flex; gap: 10px; align-items: flex-start; padding: 9px 11px;
+ cursor: pointer; font-size: 0.83rem; line-height: 1.45;
+}
+.cat-attach-list input[type='checkbox'] { width: 17px; height: 17px; margin: 1px 0 0; flex-shrink: 0; }
+
+@media (max-width: 640px) {
+ .cat-facet { padding: 8px 12px; font-size: 0.82rem; }
+ .cat-header-actions { width: 100%; }
+ .cat-header-actions .btn { flex: 1; }
+ .cat-attach-search .btn { flex: 1; }
+}
diff --git a/frontend/src/pages/CategoriesPage.jsx b/frontend/src/pages/CategoriesPage.jsx
index 4f6de2d..fe4cf23 100644
--- a/frontend/src/pages/CategoriesPage.jsx
+++ b/frontend/src/pages/CategoriesPage.jsx
@@ -10,55 +10,138 @@ const apiError = (err, fallback) => {
return fallback
}
+/**
+ * The five axes a question can be filed under.
+ *
+ * Topics live in their own table (question_categories); the other four are rows
+ * in question_tags separated by `type`. They are edited on one screen because to
+ * whoever is curating them they are the same job — the storage split is an
+ * implementation detail, not something to make somebody navigate around.
+ */
+const FACETS = [
+ { key: 'topics', label: 'Topics', singular: 'topic', source: 'category',
+ blurb: 'The discipline tree — Cardiology, Neonatology, and what sits beneath them.' },
+ { key: 'systems', label: 'Systems', singular: 'system', source: 'tag', type: 'system', group: 'systems',
+ blurb: 'Organ systems, deliberately flat. Symptoms and diseases hang off these.' },
+ { key: 'symptoms', label: 'Symptoms', singular: 'symptom', source: 'tag', type: 'keyword', group: 'keywords',
+ blurb: 'Presenting complaints, grouped by the system they present in.' },
+ { key: 'diseases', label: 'Diseases', singular: 'disease', source: 'tag', type: 'disease', group: 'diseases',
+ blurb: 'Named conditions.' },
+ { key: 'subjects', label: 'Subjects', singular: 'subject', source: 'tag', type: 'subject', group: 'subjects',
+ blurb: 'Broad exam subjects.' },
+]
+
+// Symptoms alone run to four thousand rows. Drawing them all costs a second of
+// layout and gives nobody anything to read, so the tree opens closed and the
+// root list is capped — search is the way through a list this size.
+const ROOT_LIMIT = 150
+const SEARCH_LIMIT = 100
+
export default function CategoriesPage() {
+ const [facetKey, setFacetKey] = useState('topics')
const [categories, setCategories] = useState([])
+ const [tags, setTags] = useState({ subjects: [], diseases: [], keywords: [], systems: [] })
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
+ const [notice, setNotice] = useState('')
const [query, setQuery] = useState('')
+ const [expanded, setExpanded] = useState({})
- const [editing, setEditing] = useState(null) // category id being renamed/reparented
+ const [editing, setEditing] = useState(null)
const [draftName, setDraftName] = useState('')
const [draftParent, setDraftParent] = useState('')
- const [deleting, setDeleting] = useState(null) // category id awaiting confirmation
+ const [deleting, setDeleting] = useState(null)
const [moveTo, setMoveTo] = useState('')
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const [newParent, setNewParent] = useState('')
const [busy, setBusy] = useState(false)
+ // Attaching questions to whichever entry is open for it.
+ const [attaching, setAttaching] = useState(null)
+ const [attachQuery, setAttachQuery] = useState('')
+ const [attachResults, setAttachResults] = useState([])
+ const [attachPicked, setAttachPicked] = useState([])
+ const [attachSearching, setAttachSearching] = useState(false)
+
+ const facet = FACETS.find(f => f.key === facetKey) || FACETS[0]
+
const load = useCallback(() => {
setLoading(true)
- api.get('/question-categories/')
- .then(res => setCategories(Array.isArray(res.data) ? res.data : []))
- .catch(err => setError(apiError(err, 'Could not load categories')))
+ Promise.all([
+ api.get('/question-categories/').catch(() => ({ data: [] })),
+ api.get('/tags').catch(() => ({ data: {} })),
+ ])
+ .then(([cats, tagRes]) => {
+ setCategories(Array.isArray(cats.data) ? cats.data : [])
+ const t = tagRes.data || {}
+ setTags({
+ subjects: t.subjects || [], diseases: t.diseases || [],
+ keywords: t.keywords || [], systems: t.systems || [],
+ })
+ })
+ .catch(err => setError(apiError(err, 'Could not load the taxonomy')))
.finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
+ // Clearing per-facet state on a tab change, so an open editor or a half-typed
+ // search does not follow you onto a different axis.
+ useEffect(() => {
+ setQuery(''); setExpanded({}); setEditing(null); setDeleting(null)
+ setCreating(false); setAttaching(null); setError(''); setNotice('')
+ }, [facetKey])
+
+ /** Rows for the open facet, normalised to one shape whichever table they came from. */
+ const rows = useMemo(() => {
+ if (facet.source === 'category') {
+ return categories.map(c => ({
+ id: c.id, name: c.name, parent_id: c.parent_id || null,
+ count: c.question_count || 0, description: c.description ?? null,
+ }))
+ }
+ const own = (tags[facet.group] || []).map(t => ({
+ id: t.id, name: t.name, parent_id: t.parent_id || null, count: t.count || 0,
+ }))
+ if (facet.group === 'systems') return own
+ // A symptom filed under a system has a parent that is not in this list, so
+ // the system is drawn in as a heading — otherwise 726 of them vanish.
+ const ownIds = new Set(own.map(r => r.id))
+ const usedSystems = new Set(own.map(r => r.parent_id).filter(id => id && !ownIds.has(id)))
+ const headings = (tags.systems || [])
+ .filter(s => usedSystems.has(s.id))
+ .map(s => ({ id: s.id, name: s.name, parent_id: null, count: 0, isHeading: true }))
+ return [...headings, ...own]
+ }, [facet, categories, tags])
+
const childrenOf = useMemo(() => {
const map = {}
- for (const cat of categories) (map[cat.parent_id || 0] ||= []).push(cat)
+ const known = new Set(rows.map(r => r.id))
+ for (const row of rows) {
+ // A parent outside this facet means the row is a root here, not an orphan.
+ const parent = row.parent_id && known.has(row.parent_id) ? row.parent_id : 0
+ ;(map[parent] ||= []).push(row)
+ }
for (const list of Object.values(map)) list.sort((a, b) => a.name.localeCompare(b.name))
return map
- }, [categories])
+ }, [rows])
- /** Questions in a category plus everything beneath it. */
- const rollup = useCallback((cat) => {
- let total = cat.question_count || 0
- for (const child of childrenOf[cat.id] || []) total += rollup(child)
- return total
+ const byId = useMemo(() => Object.fromEntries(rows.map(r => [r.id, r])), [rows])
+
+ /** Questions here plus everything beneath — the number the filter will return. */
+ const rollup = useMemo(() => {
+ const totals = {}
+ const walk = (row) => {
+ let sum = row.count || 0
+ for (const child of childrenOf[row.id] || []) sum += walk(child)
+ totals[row.id] = sum
+ return sum
+ }
+ for (const root of childrenOf[0] || []) walk(root)
+ return totals
}, [childrenOf])
-
- const matches = useCallback((cat) => {
- if (!query.trim()) return true
- const needle = query.trim().toLowerCase()
- if (cat.name.toLowerCase().includes(needle)) return true
- return (childrenOf[cat.id] || []).some(matches)
- }, [query, childrenOf])
-
- // A category cannot become its own descendant's child.
const descendantIds = useCallback((id) => {
const out = new Set()
const walk = (parent) => {
@@ -68,152 +151,345 @@ export default function CategoriesPage() {
return out
}, [childrenOf])
- const startEdit = (cat) => {
- setDeleting(null)
- setEditing(cat.id)
- setDraftName(cat.name)
- setDraftParent(cat.parent_id ?? '')
+ const trail = useCallback((row) => {
+ const parts = []
+ let cursor = row
+ let guard = 0
+ while (cursor && guard++ < 8) { parts.unshift(cursor.name); cursor = byId[cursor.parent_id] }
+ return parts
+ }, [byId])
+
+ const parentOptions = useCallback((excludeId) => {
+ const blocked = excludeId ? new Set([excludeId, ...descendantIds(excludeId)]) : new Set()
+ const same = rows.filter(r => !r.isHeading && !blocked.has(r.id))
+ if (facet.source === 'category' || facet.group === 'systems') return same
+ // Anything may sit under a system; that is how symptoms get grouped.
+ const systems = (tags.systems || [])
+ .filter(s => !blocked.has(s.id))
+ .map(s => ({ id: s.id, name: `${s.name} (system)` }))
+ return [...systems, ...same]
+ }, [rows, facet, tags, descendantIds])
+
+ const endpoint = facet.source === 'category' ? '/question-categories' : '/tags'
+
+ const startEdit = (row) => {
+ setDeleting(null); setAttaching(null)
+ setEditing(row.id); setDraftName(row.name); setDraftParent(row.parent_id ?? '')
}
- const save = async (cat) => {
+ const save = async (row) => {
const name = draftName.trim()
- if (!name) { setError('A category needs a name'); return }
+ if (!name) { setError(`A ${facet.singular} needs a name`); return }
setBusy(true); setError('')
try {
- await api.patch(`/question-categories/${cat.id}`, {
- name, description: cat.description ?? null,
- parent_id: draftParent === '' ? null : Number(draftParent),
- })
- setEditing(null)
- load()
- } catch (err) { setError(apiError(err, 'Could not save this category')) }
+ const parent_id = draftParent === '' ? null : Number(draftParent)
+ if (facet.source === 'category') {
+ await api.patch(`${endpoint}/${row.id}`, { name, description: row.description ?? null, parent_id })
+ } else {
+ await api.patch(`${endpoint}/${row.id}`, { name, parent_id })
+ }
+ setEditing(null); load()
+ } catch (err) { setError(apiError(err, `Could not save this ${facet.singular}`)) }
finally { setBusy(false) }
}
- const remove = async (cat) => {
+ const remove = async (row) => {
setBusy(true); setError('')
try {
- await api.delete(`/question-categories/${cat.id}`,
+ await api.delete(`${endpoint}/${row.id}`,
{ params: moveTo === '' ? {} : { move_to: Number(moveTo) } })
- setDeleting(null); setMoveTo('')
- load()
- } catch (err) { setError(apiError(err, 'Could not delete this category')) }
+ setDeleting(null); setMoveTo(''); load()
+ } catch (err) { setError(apiError(err, `Could not delete this ${facet.singular}`)) }
finally { setBusy(false) }
}
const create = async () => {
const name = newName.trim()
- if (!name) { setError('A category needs a name'); return }
+ if (!name) { setError(`A ${facet.singular} needs a name`); return }
setBusy(true); setError('')
try {
- await api.post('/question-categories/', {
- name, description: null, parent_id: newParent === '' ? null : Number(newParent),
- })
+ const parent_id = newParent === '' ? null : Number(newParent)
+ if (facet.source === 'category') {
+ await api.post(`${endpoint}/`, { name, description: null, parent_id })
+ } else {
+ await api.post(`${endpoint}/`, { name, type: facet.type, parent_id, sort_order: 100 })
+ }
setCreating(false); setNewName(''); setNewParent('')
+ setNotice(`Added “${name}”. It is in every picker now.`)
load()
- } catch (err) { setError(apiError(err, 'Could not create this category')) }
+ } catch (err) { setError(apiError(err, `Could not create this ${facet.singular}`)) }
finally { setBusy(false) }
}
- const parentOptions = (excludeId) => {
- const blocked = excludeId ? new Set([excludeId, ...descendantIds(excludeId)]) : new Set()
- return categories.filter(c => !blocked.has(c.id))
+ const startAttach = (row) => {
+ setEditing(null); setDeleting(null)
+ setAttaching(row.id); setAttachQuery(''); setAttachResults([]); setAttachPicked([])
+ }
+
+ const searchQuestions = async () => {
+ if (!attachQuery.trim()) return
+ setAttachSearching(true); setError('')
+ try {
+ const res = await api.get('/questions/bank', { params: { q: attachQuery.trim(), limit: 25 } })
+ setAttachResults(res.data?.questions || res.data?.items || [])
+ } catch (err) { setError(apiError(err, 'Could not search questions')) }
+ finally { setAttachSearching(false) }
+ }
+
+ const attach = async (row) => {
+ if (!attachPicked.length) return
+ setBusy(true); setError('')
+ try {
+ if (facet.source === 'category') {
+ await api.post('/questions/bulk-category',
+ { question_ids: attachPicked, category_id: row.id })
+ } else {
+ await api.post(`/tags/${row.id}/questions`, { question_ids: attachPicked })
+ }
+ setNotice(`Added ${attachPicked.length} question${attachPicked.length === 1 ? '' : 's'} to “${row.name}”.`)
+ setAttaching(null); setAttachPicked([]); setAttachResults([])
+ load()
+ } catch (err) { setError(apiError(err, 'Could not attach those questions')) }
+ finally { setBusy(false) }
+ }
+
+ // Deleting a category with subcategories is refused server-side; tags instead
+ // pull their children up a level, so only one facet needs the guard.
+ const blockedByChildren = (row) =>
+ facet.source === 'category' && (childrenOf[row.id] || []).length > 0
+
+ const needle = query.trim().toLowerCase()
+
+ const renderActions = (row) => (
+
+ {row.isHeading ? (
+ system
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+ )
+
+ const renderPanels = (row) => (
+ <>
+ {deleting === row.id && (
+
+ {/* The two tables behave differently on delete and the confirmation has
+ to say which you are getting: a category with children is refused by
+ the server, whereas a tag's children rise to its parent. */}
+ {blockedByChildren(row) ? (
+ Move its {childrenOf[row.id].length} subcategor{childrenOf[row.id].length === 1 ? 'y' : 'ies'} first.
+ ) : (
+ <>
+
+ Delete “{row.name}”?
+ {(childrenOf[row.id] || []).length > 0 &&
+ ` Its ${childrenOf[row.id].length} child entr${childrenOf[row.id].length === 1 ? 'y moves' : 'ies move'} up a level.`}
+ {` Move its ${row.count} question${row.count === 1 ? '' : 's'} to:`}
+
+
+
+ >
+ )}
+
+
+ )
+
+ // Searching flattens to the matching rows with their trail: when you type a
+ // name you want the row, not the branch you would have had to walk to reach it.
+ const renderSearch = () => {
+ const hits = rows.filter(r => !r.isHeading && r.name.toLowerCase().includes(needle))
+ if (!hits.length) return