The Staff Hub
The two staff workspaces — school-wide administration and the campus-level teaching hub — and where each lives.
Staff don't get one dashboard — they get two. Which one you land in depends on what you're here to do, and the app draws a hard line between the two so the work never blurs together.
- The School Hub (
/staff-hub/school/*) — school-wide operations. Admissions, the staff directory, campuses, roles, audit logs, developer tooling. The view of the whole institution. - The Campus Hub (
/staff-hub/campus/$campusId/*) — the day-to-day inside one campus. Classrooms, your subjects, that campus's people, its academic year and classes.
A school administrator lives in the first; a campus coordinator or teacher lives in the second. Many staff can reach both, and the footer of each sidebar carries a button to jump across — Campus Hub from the school side, Admin Dashboard from the campus side.
Which hub you can see is permission-driven
Neither hub is "for admins" and the other "for teachers" — both are gated by permissions. Every nav item, route, and action is wrapped in <Can> or guarded by requirePermission, so two staff members can open the same hub and see different menus.
The School Hub
The school hub is school-wide ops, and its shape lives entirely in one file — src/components/navigation/admin-staff.sidebar.tsx. Two sidebar groups, with collapsible clusters inside them:
- Overview — the Hub dashboard, Inbounds (everything arriving from outside: Admissions, Jobs, Inquiries), People (the roster: Staff, Students, Guardians, Guests), plus Rover, the Knowledge Base, and Socials.
- Organisation — structure and configuration: Campuses, Departments, School Settings, the school-wide Academic Year, Roles, Audit Logs, Templates (lesson-planning skeletons — see Templates), and the developer tooling: Service Health, Directory Sync, API Keys.
The grouping is just a collapsible wrapper around permission-gated links. Notice the Resource each item checks:
<Can action={Action.MANAGE} resource={Resource.CAMPUS} userType="staff">
<SidebarMenuButton onClick={() => navigate({ to: paths.campuses })}>
<span>Campuses</span>
</SidebarMenuButton>
</Can>The developer entries go a step further — they're wrapped in <Can role="Software Developer">, so only developers ever see Service Health, Directory Sync, or API Keys. (Directory Sync is the Microsoft side of that.)
The matching routes live under src/routes/_authenticated/staff-hub/school/ — admissions.tsx, staff.tsx, campuses/, roles.tsx, audit-logs/, directory-sync.tsx, and so on. The route.tsx there mounts the admin sidebar and a side-panel layout around an <Outlet />.
The Campus Hub
Switch to a campus and the sidebar narrows to one campus's world — src/components/navigation/staff.sidebar.tsx. The campus id is a route param, so every link is scoped to the campus you're standing in:
const { campusId } = useParams({
from: '/_authenticated/staff-hub/campus/$campusId',
})
const paths = {
hub: `/staff-hub/campus/${campusId}/hub`,
classrooms: `/staff-hub/campus/${campusId}/classrooms`,
// ...
}Its sections are tighter than the school hub's:
- Dashboard — your home base: Hub, Classrooms, My Subjects, the campus's People (Staff, Students, Guardians), and Rover.
- My Campus — the academic machinery: Academic Year, Departments, Enrolments, and an Academics cluster (Classes, Subjects, Grading — the grading config screen).
- Bursary — money for this campus: Payment Gateways, Payment Items.
Open a subject from My Subjects and its workspace has three tabs — Exams (CBT authoring), Schemes of Work (lesson planning), and Gradebook (score entry).
The header here is interactive in a way the school header isn't — it's a dropdown showing the campus's academic years, with the current one badged. Picking one switches the hub's viewing year (a URL search param the whole platform respects — see Viewing Academic Year); on any non-current year a sky ring wraps the app with a "back to current" pill. And because staff can belong to more than one campus, the footer holds a Switch Campus menu (only shown when you can reach more than one):
const showCampusSwitcher = (allowedCampuses?.length ?? 0) > 1The routes mirror the menu under src/routes/_authenticated/staff-hub/campus.$campusId/ — hub.tsx, classrooms/, my-subjects/, classes/, subjects/, departments/, academic-year/. Each list page is wrapped in a <Can> for its resource, so an item like Subjects simply doesn't render without LIST on Resource.SUBJECT.
How the screens are built
Both hubs are assembled from the same three building blocks, so once you learn one screen you've learned them all.
| You want to… | It's built from | Where it lives |
|---|---|---|
| Show a list (staff, students, classes) | a data table | src/components/data-tables/* |
| Create or edit something | a surface | create-class.surface.tsx, create-department.surface.tsx |
| Inspect one record | a side panel | staff-profile.panel.tsx |
So a campus coordinator opening Classes sees a data table; clicking New class opens the create-class surface; and clicking a staff member anywhere opens the staff-profile side panel over the page they're on — no navigation, no lost context.
Gates appear twice — and that's on purpose
A nav item being hidden by <Can> is a convenience, not security. The route it points to also guards itself with requirePermission, and the API enforces the same rule again. Hiding the link keeps the UI tidy; the route guard is what actually protects the page. See permissions for the full layering.
Where to go next
Permissions
How Can and requirePermission gate every hub, route, and action.
Data Tables
The list-view pattern behind every roster in both hubs.
Side Panels and Surfaces
The detail panels and create-edit overlays the hubs are built from.
Routing and App Shell
How authenticated routes, sidebars, and outlets fit together.