349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
|
import { ApiHttpError } from '../api/http'
|
|
import { fetchNavMenu } from '../api/session'
|
|
import type { NavItem } from '../api/types'
|
|
import { useAuth } from '../auth/AuthProvider'
|
|
import {
|
|
isExternalNavUrl,
|
|
spaPathFromCiUrl,
|
|
} from '../lib/ciSpaPaths'
|
|
|
|
const navLabelCollator = new Intl.Collator(undefined, {
|
|
sensitivity: 'base',
|
|
numeric: true,
|
|
})
|
|
|
|
function compareNavItemsByLabel(a: NavItem, b: NavItem): number {
|
|
const labelResult = navLabelCollator.compare(a.label?.trim() || '', b.label?.trim() || '')
|
|
return labelResult || a.id - b.id
|
|
}
|
|
|
|
function sortNavTree(items: NavItem[]): NavItem[] {
|
|
return [...items]
|
|
.map((item) => ({
|
|
...item,
|
|
children: sortNavTree(item.children ?? []),
|
|
}))
|
|
.sort(compareNavItemsByLabel)
|
|
}
|
|
|
|
function normalizeNavItems(payload: unknown): NavItem[] {
|
|
const rawItems = Array.isArray(payload)
|
|
? (payload as NavItem[])
|
|
: payload &&
|
|
typeof payload === 'object' &&
|
|
'data' in payload &&
|
|
Array.isArray((payload as { data: unknown }).data)
|
|
? ((payload as { data: NavItem[] }).data ?? [])
|
|
: []
|
|
|
|
if (rawItems.length === 0) return []
|
|
if (rawItems.some((item) => Array.isArray(item.children) && item.children.length > 0)) {
|
|
return sortNavTree(rawItems)
|
|
}
|
|
|
|
const byId = new Map<number, NavItem>()
|
|
for (const item of rawItems) {
|
|
byId.set(item.id, { ...item, children: [] })
|
|
}
|
|
|
|
const tree: NavItem[] = []
|
|
for (const item of byId.values()) {
|
|
const parentId = item.menu_parent_id ?? null
|
|
if (parentId != null && byId.has(parentId)) {
|
|
byId.get(parentId)?.children.push(item)
|
|
} else {
|
|
tree.push(item)
|
|
}
|
|
}
|
|
|
|
return sortNavTree(tree)
|
|
}
|
|
|
|
function branchContainsPath(item: NavItem, pathname: string): boolean {
|
|
const spa = item.url ? spaPathFromCiUrl(item.url) : null
|
|
if (spa && pathname.startsWith(spa)) return true
|
|
return (item.children ?? []).some((child) => branchContainsPath(child, pathname))
|
|
}
|
|
|
|
function NavTree({ items, pathname }: { items: NavItem[]; pathname: string }) {
|
|
return (
|
|
<ul className="nav flex-column gap-1 ps-0 mb-0 list-unstyled mgmt-nav-tree">
|
|
{items.map((item) => (
|
|
<NavBranch key={item.id} item={item} depth={0} pathname={pathname} />
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
function NavBranch({ item, depth, pathname }: { item: NavItem; depth: number; pathname: string }) {
|
|
const enabled = item.is_enabled !== 0
|
|
const label = item.label?.trim() || 'Menu'
|
|
const url = item.url?.trim() || ''
|
|
const external = isExternalNavUrl(url)
|
|
const spa = spaPathFromCiUrl(url)
|
|
const pad = 8 + depth * 10
|
|
|
|
const icon = item.icon_class ? (
|
|
<i className={`${item.icon_class} me-2`} aria-hidden />
|
|
) : null
|
|
|
|
const children = sortNavTree((item.children ?? []).filter((c) => c.is_enabled !== 0))
|
|
const hasChildren = children.length > 0
|
|
const [open, setOpen] = useState(() => hasChildren && branchContainsPath(item, pathname))
|
|
|
|
useEffect(() => {
|
|
if (hasChildren && branchContainsPath(item, pathname)) {
|
|
setOpen(true)
|
|
}
|
|
}, [hasChildren, item, pathname])
|
|
|
|
if (!enabled) return null
|
|
|
|
const linkInner = (
|
|
<>
|
|
{icon}
|
|
<span className="nav-label-chip">{label}</span>
|
|
{hasChildren ? (
|
|
<i
|
|
className={`bi ${open ? 'bi-chevron-down' : 'bi-chevron-right'} ms-auto`}
|
|
aria-hidden
|
|
/>
|
|
) : null}
|
|
</>
|
|
)
|
|
|
|
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
|
`nav-link rounded py-2 px-2 ${isActive ? 'active fw-semibold' : ''}`
|
|
|
|
let main: ReactNode
|
|
const handleBranchToggle = () => {
|
|
if (hasChildren) {
|
|
setOpen((value) => !value)
|
|
}
|
|
}
|
|
|
|
if (external) {
|
|
main = (
|
|
<a
|
|
className="nav-link rounded py-2 px-2"
|
|
href={url}
|
|
target={item.target || '_self'}
|
|
rel={item.target === '_blank' ? 'noreferrer noopener' : undefined}
|
|
style={{ paddingLeft: pad }}
|
|
onClick={hasChildren ? (event) => {
|
|
event.preventDefault()
|
|
handleBranchToggle()
|
|
} : undefined}
|
|
>
|
|
{linkInner}
|
|
</a>
|
|
)
|
|
} else if (spa && !hasChildren) {
|
|
main = (
|
|
<NavLink end to={spa} className={linkClass} style={{ paddingLeft: pad }}>
|
|
{linkInner}
|
|
</NavLink>
|
|
)
|
|
} else if (spa && hasChildren) {
|
|
main = (
|
|
<button
|
|
type="button"
|
|
className="nav-link rounded py-2 px-2 w-100 text-start d-flex align-items-center"
|
|
style={{ paddingLeft: pad }}
|
|
aria-expanded={open}
|
|
onClick={handleBranchToggle}
|
|
>
|
|
{linkInner}
|
|
</button>
|
|
)
|
|
} else {
|
|
main = (
|
|
<button
|
|
type="button"
|
|
className="nav-link rounded py-2 px-2 w-100 text-start d-flex align-items-center border-0 bg-transparent"
|
|
style={{ paddingLeft: pad }}
|
|
aria-expanded={hasChildren ? open : undefined}
|
|
onClick={hasChildren ? handleBranchToggle : undefined}
|
|
disabled={!hasChildren}
|
|
>
|
|
{linkInner}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<li className={`mgmt-nav-branch mgmt-nav-depth-${Math.min(depth, 4)}`}>
|
|
{main}
|
|
{children.length > 0 && open ? (
|
|
<ul className="nav flex-column gap-1 ps-0 mb-0 mt-1 list-unstyled mgmt-nav-subtree">
|
|
{children.map((ch) => (
|
|
<NavBranch key={ch.id} item={ch} depth={depth + 1} pathname={pathname} />
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</li>
|
|
)
|
|
}
|
|
|
|
export function ManagementLayout() {
|
|
const { user, logout } = useAuth()
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
const [items, setItems] = useState<NavItem[]>([])
|
|
const [menuError, setMenuError] = useState<string | null>(null)
|
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
;(async () => {
|
|
try {
|
|
const res = await fetchNavMenu()
|
|
const list = res.data?.items
|
|
if (!cancelled && list != null) {
|
|
setItems(normalizeNavItems(list))
|
|
setMenuError(null)
|
|
}
|
|
} catch (e) {
|
|
if (!cancelled) {
|
|
if (e instanceof ApiHttpError && e.status === 401) {
|
|
logout()
|
|
navigate('/login', {
|
|
replace: true,
|
|
state: {
|
|
from: `${location.pathname}${location.search}${location.hash}`,
|
|
message: 'Your session expired. Please sign in again.',
|
|
},
|
|
})
|
|
return
|
|
}
|
|
setItems([])
|
|
setMenuError(e instanceof Error ? e.message : 'Unable to load menu.')
|
|
}
|
|
}
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [location.hash, location.pathname, location.search, logout, navigate])
|
|
|
|
const navTree = useMemo(() => normalizeNavItems(items), [items])
|
|
|
|
return (
|
|
<div className={`management-app d-flex flex-column min-vh-100 ${sidebarOpen ? 'mgmt-sidebar-open' : ''}`}>
|
|
<header className="navbar navbar-dark sticky-top border-bottom shadow-sm px-3 py-2">
|
|
<div className="container-fluid px-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
|
|
<Link className="navbar-brand fw-semibold mb-0" to="/app/home">
|
|
Al Rahma Sunday School
|
|
</Link>
|
|
<div className="d-flex align-items-center gap-2">
|
|
<span className="navbar-text small mb-0">{user?.name}</span>
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline-light btn-sm"
|
|
onClick={() => {
|
|
logout()
|
|
navigate('/login', { replace: true })
|
|
}}
|
|
>
|
|
Log out
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<button
|
|
id="mgmtSidebarToggle"
|
|
className="mgmt-sidebar-toggle"
|
|
type="button"
|
|
aria-label="Toggle sidebar"
|
|
aria-expanded={sidebarOpen}
|
|
onClick={() => setSidebarOpen((open) => !open)}
|
|
>
|
|
<i className="bi bi-chevron-right" aria-hidden />
|
|
</button>
|
|
|
|
{sidebarOpen ? (
|
|
<button
|
|
className="mgmt-sidebar-backdrop"
|
|
type="button"
|
|
aria-label="Close navigation"
|
|
onClick={() => setSidebarOpen(false)}
|
|
/>
|
|
) : null}
|
|
|
|
<div className="wrapper flex-grow-1">
|
|
<aside id="navbarManagement" className="mgmt-sidebar" data-mgmt-menu-mode="dark">
|
|
<div className="mgmt-sidebar-content">
|
|
<div className="mgmt-sidebar-header px-2 py-2 mb-2">
|
|
<div className="mgmt-sidebar-eyebrow">Management</div>
|
|
<div className="mgmt-sidebar-title">Navigation</div>
|
|
</div>
|
|
<nav className="pb-3">
|
|
<NavLink
|
|
to="/app/home"
|
|
className={({ isActive }) =>
|
|
`nav-link rounded mb-2 mgmt-static-link ${isActive ? 'active fw-semibold' : ''}`
|
|
}
|
|
>
|
|
<i className="bi bi-house-door me-2" aria-hidden />
|
|
<span className="nav-label-chip">Home</span>
|
|
</NavLink>
|
|
<NavLink
|
|
to="/app/browse"
|
|
className={({ isActive }) =>
|
|
`nav-link rounded mb-2 mgmt-static-link ${isActive ? 'active fw-semibold' : ''}`
|
|
}
|
|
>
|
|
<i className="bi bi-diagram-3 me-2" aria-hidden />
|
|
<span className="nav-label-chip">CI views</span>
|
|
</NavLink>
|
|
{menuError ? (
|
|
<div className="alert alert-warning small py-2">{menuError}</div>
|
|
) : (
|
|
<NavTree items={navTree} pathname={location.pathname} />
|
|
)}
|
|
</nav>
|
|
</div>
|
|
</aside>
|
|
|
|
<main className="content management-main">
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
|
|
<footer className="footer-back border-top bg-white py-3 mt-auto">
|
|
<div className="container-fluid px-4 text-muted small d-flex flex-wrap justify-content-between gap-2">
|
|
<span>Al Rahma Sunday School</span>
|
|
<span>Management Portal</span>
|
|
</div>
|
|
</footer>
|
|
|
|
<div className="modal fade" id="familyCardModal" tabIndex={-1} aria-hidden="true">
|
|
<div className="modal-dialog modal-xl modal-dialog-scrollable">
|
|
<div className="modal-content">
|
|
<div className="modal-header">
|
|
<h5 className="modal-title">Family</h5>
|
|
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close" />
|
|
</div>
|
|
<div className="modal-body p-0" id="familyCardContent" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="modal fade" id="paymentsModal" tabIndex={-1} aria-hidden="true">
|
|
<div className="modal-dialog modal-lg modal-dialog-scrollable">
|
|
<div className="modal-content">
|
|
<div className="modal-header">
|
|
<h5 className="modal-title">Payments</h5>
|
|
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close" />
|
|
</div>
|
|
<div className="modal-body p-0" id="paymentsModalContent" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|