fix teacher, parent and admin pages

This commit is contained in:
root
2026-04-25 00:00:10 -04:00
parent 7fe34dde0d
commit 3e77fc92c7
275 changed files with 46412 additions and 3325 deletions
+286 -36
View File
@@ -1,6 +1,9 @@
import { useState } from 'react'
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthProvider'
import { CiPublicFooter } from '../components/CiPartials'
import { readPortalStylePrefs, resolvePortalColorMode, type PortalStylePrefs } from '../lib/portalAppearance'
import { isParentPortalRole, isTeacherPortalRole } from '../lib/portalRoles'
const parentNavItems = [
{ to: '/app/parent/home', icon: 'bi bi-house-door', label: 'Dashboard' },
@@ -16,20 +19,152 @@ const parentNavItems = [
{ to: '/app/parent/scores', icon: 'bi bi-clipboard-data', label: 'Scores' },
] as const
/** Teacher top shortcuts (aligned with legacy teacher navbar). */
const teacherNavItems = [
{ to: '/app/teacher_dashboard', icon: 'bi bi-house-door', label: 'Dashboard' },
{ to: '/app/teacher/showupdate-attendance', icon: 'bi bi-calendar-check', label: 'Attendance' },
{ to: '/app/teacher/inventory/book-distribute', icon: 'bi bi-book', label: 'Books' },
{ to: '/app/teacher/calendar', icon: 'bi bi-calendar2-week', label: 'Calendar' },
{ to: '/app/teacher/class-view', icon: 'bi bi-pencil-square', label: 'Class' },
{ to: '/app/teacher/class-progress-submit', icon: 'bi bi-card-checklist', label: 'Class Progress' },
{ to: '/app/teacher/competition-scores', icon: 'bi bi-trophy', label: 'Competitions' },
{ to: '/app/teacher/exam-drafts', icon: 'bi bi-file-earmark-text', label: 'Exam Drafts' },
{ to: '/app/teacher/print-requests', icon: 'bi bi-printer', label: 'Print' },
{ to: '/app/student/score-card/list', icon: 'bi bi-card-text', label: 'Score Card' },
{ to: '/app/teacher/scores', icon: 'bi bi-bar-chart-line', label: 'Scores' },
{ to: '/app/teacher/absence-vacation', icon: 'bi bi-megaphone', label: 'TimeOff' },
] as const
function initialsFromName(name: string | undefined | null): string {
const t = name?.trim()
if (!t) return '?'
const parts = t.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
const a = parts[0][0] ?? ''
const b = parts[parts.length - 1][0] ?? ''
return (a + b).toUpperCase()
}
if (parts[0].length >= 2) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] ?? '?').toUpperCase()
}
function formatRoleLabel(slug: string): string {
const t = slug.trim()
if (!t) return ''
return t.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())
}
export function MainLayout() {
const { user, logout, selectedRole, roles } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [navOpen, setNavOpen] = useState(false)
const [portalStyle, setPortalStyle] = useState<PortalStylePrefs>(readPortalStylePrefs)
const [osPrefersDark, setOsPrefersDark] = useState(() =>
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches,
)
const [userMenuOpen, setUserMenuOpen] = useState(false)
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const clusterRef = useRef<HTMLDivElement>(null)
const userMenuTriggerRef = useRef<HTMLButtonElement>(null)
const clearCloseTimer = useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
}
}, [])
const openUserMenu = useCallback(() => {
clearCloseTimer()
setUserMenuOpen(true)
}, [clearCloseTimer])
const closeUserMenu = useCallback(() => {
clearCloseTimer()
const root = clusterRef.current
if (root?.contains(document.activeElement)) {
userMenuTriggerRef.current?.focus()
}
setUserMenuOpen(false)
}, [clearCloseTimer])
const scheduleCloseUserMenu = useCallback(() => {
clearCloseTimer()
closeTimerRef.current = setTimeout(() => {
const root = clusterRef.current
if (root?.contains(document.activeElement)) {
closeTimerRef.current = null
return
}
closeUserMenu()
closeTimerRef.current = null
}, 200)
}, [clearCloseTimer, closeUserMenu])
useEffect(() => () => clearCloseTimer(), [clearCloseTimer])
const onClusterBlurCapture = useCallback(() => {
window.setTimeout(() => {
const root = clusterRef.current
if (!root || !root.contains(document.activeElement)) {
closeUserMenu()
}
}, 0)
}, [closeUserMenu])
const onAvatarClick = useCallback(() => {
if (typeof window === 'undefined') return
if (window.matchMedia('(hover: none) and (pointer: coarse)').matches) {
setUserMenuOpen((open) => !open)
}
}, [])
const closeMenus = useCallback(() => {
closeUserMenu()
setNavOpen(false)
}, [closeUserMenu])
useEffect(() => {
const sync = () => setPortalStyle(readPortalStylePrefs())
window.addEventListener('alrahma-portal-style', sync)
return () => window.removeEventListener('alrahma-portal-style', sync)
}, [])
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onChange = () => setOsPrefersDark(mq.matches)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [])
const role = selectedRole ?? roles[0] ?? ''
const canSwitchRole = roles.length > 1
const dashboardPath =
role === 'parent'
isParentPortalRole(role)
? '/app/parent/home'
: role === 'teacher' || role === 'teacher_assistant'
? '/app/teacher/dashboard'
: isTeacherPortalRole(role)
? '/app/teacher_dashboard'
: '/app/home'
const mainNavItems = isParentPortalRole(role)
? parentNavItems
: isTeacherPortalRole(role)
? teacherNavItems
: []
const initials = initialsFromName(user?.name)
const resolvedColorMode = resolvePortalColorMode(portalStyle, osPrefersDark)
return (
<div className="main-layout-app d-flex flex-column min-vh-100">
<div
className="main-layout-app d-flex flex-column min-vh-100"
data-portal-density={portalStyle.density}
data-portal-font-scale={portalStyle.fontScale}
data-portal-font-family={portalStyle.fontFamily}
data-portal-accent={portalStyle.accent}
data-portal-color-mode={resolvedColorMode}
>
<header className="navbar navbar-expand-lg custom-navbar sticky-top shadow-sm px-3">
<div className="container-fluid">
<Link className="navbar-brand fw-semibold d-inline-flex align-items-center gap-2" to={dashboardPath}>
@@ -47,32 +182,152 @@ export function MainLayout() {
<span className="navbar-toggler-icon" />
</button>
<div id="mainLayoutNavbar" className={`collapse navbar-collapse ${navOpen ? 'show' : ''}`}>
<nav className="ms-lg-auto me-lg-3 my-3 my-lg-0 main-layout-menu parent-navbar-menu" aria-label="Parent navigation">
{parentNavItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => `nav-link parent-navbar-link ${isActive ? 'active fw-semibold' : ''}`}
onClick={() => setNavOpen(false)}
>
<i className={item.icon} aria-hidden />
<span>{item.label}</span>
</NavLink>
))}
<nav
className="ms-lg-auto me-lg-2 my-2 my-lg-0 main-layout-menu"
aria-label={
isTeacherPortalRole(role)
? 'Teacher portal navigation'
: isParentPortalRole(role)
? 'Parent portal navigation'
: 'Portal navigation'
}
>
<ul className="navbar-nav list-unstyled parent-navbar-menu mb-0">
{mainNavItems.map((item) => (
<li key={item.to} className="nav-item">
<NavLink
to={item.to}
end={
item.to === '/app/teacher_dashboard' ||
item.to === '/app/parent/home' ||
item.to === '/app/student/score-card/list'
}
className={({ isActive }) =>
`nav-link parent-navbar-link ${isActive ? 'active fw-semibold' : ''}`
}
onClick={() => setNavOpen(false)}
>
<i className={item.icon} aria-hidden />
<span>{item.label}</span>
</NavLink>
</li>
))}
</ul>
</nav>
<div className="d-flex align-items-center gap-2">
<span className="navbar-text small">{user?.name}</span>
<button
type="button"
className="btn btn-outline-light btn-sm"
onClick={() => {
logout()
navigate('/login', { replace: true })
}}
{user ? (
<div
ref={clusterRef}
className="main-layout-user-cluster d-flex align-items-center ms-lg-2"
onMouseEnter={openUserMenu}
onMouseLeave={scheduleCloseUserMenu}
onFocusCapture={openUserMenu}
onBlurCapture={onClusterBlurCapture}
>
Log out
</button>
</div>
<div className="main-layout-user-avatar-wrap">
<button
ref={userMenuTriggerRef}
type="button"
className="main-layout-user-trigger"
id="main-layout-user-trigger"
aria-haspopup="true"
aria-expanded={userMenuOpen}
aria-controls="main-layout-user-menu"
onClick={onAvatarClick}
>
<div className="main-layout-user-avatar rounded-circle text-white fw-semibold d-flex align-items-center justify-content-center">
<span className="main-layout-user-initials">{initials}</span>
</div>
</button>
{canSwitchRole ? (
<Link
to="/app/select-role"
state={{ from: location.pathname }}
className="main-layout-user-avatar-chip main-layout-user-avatar-chip--roles"
title="Switch role"
aria-label="Switch role"
onClick={closeMenus}
>
<i className="bi bi-arrow-left-right" aria-hidden />
</Link>
) : null}
<Link
to="/app/portal-style"
className="main-layout-user-avatar-chip main-layout-user-avatar-chip--style"
title="Style edit"
aria-label="Style edit"
onClick={closeMenus}
>
<i className="bi bi-palette" aria-hidden />
</Link>
</div>
<div
id="main-layout-user-menu"
role="region"
aria-labelledby="main-layout-user-trigger"
aria-hidden={!userMenuOpen}
className={`main-layout-user-dropdown card border-0 shadow-sm py-2 ${userMenuOpen ? 'is-open' : ''}`}
>
<div className="px-3 pb-1">
<div className="main-layout-user-dropdown-header">Signed in as</div>
<div className="small text-dark text-truncate fw-semibold" title={user.name}>
{user.name}
</div>
{role ? (
<>
<div className="main-layout-user-dropdown-header mt-2">Role</div>
<div
className="small text-dark text-truncate fw-semibold main-layout-user-dropdown-role"
title={formatRoleLabel(role)}
>
{formatRoleLabel(role)}
</div>
</>
) : null}
</div>
<div className="dropdown-divider mx-2 my-1" />
{canSwitchRole ? (
<Link
className="dropdown-item d-flex align-items-center gap-2 py-2"
to="/app/select-role"
state={{ from: location.pathname }}
onClick={closeMenus}
>
<i className="bi bi-arrow-left-right" aria-hidden />
Switch role
</Link>
) : null}
<Link
className="dropdown-item d-flex align-items-center gap-2 py-2"
to="/app/portal-style"
onClick={closeMenus}
>
<i className="bi bi-palette" aria-hidden />
Style edit
</Link>
<Link
className="dropdown-item d-flex align-items-center gap-2 py-2"
to="/app/account"
onClick={closeMenus}
>
<i className="bi bi-gear" aria-hidden />
Settings
</Link>
<div className="dropdown-divider mx-2 my-1" />
<button
type="button"
className="dropdown-item d-flex align-items-center gap-2 py-2 text-danger"
onClick={() => {
closeMenus()
logout()
navigate('/login', { replace: true })
}}
>
<i className="bi bi-box-arrow-right" aria-hidden />
Log out
</button>
</div>
</div>
) : null}
</div>
</div>
</header>
@@ -81,12 +336,7 @@ export function MainLayout() {
<Outlet />
</main>
<footer className="footer py-4 mt-auto border-top">
<div className="container text-muted small d-flex flex-wrap justify-content-between gap-2">
<span>Al Rahma Sunday School</span>
<span>Parent and Teacher Portal</span>
</div>
</footer>
<CiPublicFooter />
<div className="modal fade" id="familyCardModal" tabIndex={-1} aria-hidden="true">
<div className="modal-dialog modal-xl modal-dialog-scrollable">
+102 -29
View File
@@ -1,5 +1,5 @@
import { useEffect, useState, type ReactNode } from 'react'
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { fetchNavMenu } from '../api/session'
import type { NavItem } from '../api/types'
import { useAuth } from '../auth/AuthProvider'
@@ -9,31 +9,55 @@ import {
} from '../lib/ciSpaPaths'
function normalizeNavItems(payload: unknown): NavItem[] {
if (Array.isArray(payload)) {
return payload as 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 rawItems
}
if (
payload &&
typeof payload === 'object' &&
'data' in payload &&
Array.isArray((payload as { data: unknown }).data)
) {
return (payload as { data: NavItem[] }).data
const byId = new Map<number, NavItem>()
for (const item of rawItems) {
byId.set(item.id, { ...item, children: [] })
}
return []
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 tree
}
function NavTree({ items }: { items: NavItem[] }) {
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">
<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} />
<NavBranch key={item.id} item={item} depth={0} pathname={pathname} />
))}
</ul>
)
}
function NavBranch({ item, depth }: { item: NavItem; depth: number }) {
function NavBranch({ item, depth, pathname }: { item: NavItem; depth: number; pathname: string }) {
const enabled = item.is_enabled !== 0
if (!enabled) return null
@@ -47,10 +71,26 @@ function NavBranch({ item, depth }: { item: NavItem; depth: number }) {
<i className={`${item.icon_class} me-2`} aria-hidden />
) : null
const children = (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])
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}
</>
)
@@ -58,6 +98,12 @@ function NavBranch({ item, depth }: { item: NavItem; depth: number }) {
`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
@@ -66,33 +112,54 @@ function NavBranch({ item, depth }: { item: NavItem; depth: number }) {
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) {
} 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 = (
<span className="nav-link disabled" style={{ paddingLeft: pad }}>
<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}
</span>
</button>
)
}
const children = (item.children ?? []).filter((c) => c.is_enabled !== 0)
return (
<li>
<li className={`mgmt-nav-branch mgmt-nav-depth-${Math.min(depth, 4)}`}>
{main}
{children.length > 0 ? (
<ul className="nav flex-column gap-1 ps-0 mb-0 mt-1 list-unstyled">
{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} />
<NavBranch key={ch.id} item={ch} depth={depth + 1} pathname={pathname} />
))}
</ul>
) : null}
@@ -103,6 +170,7 @@ function NavBranch({ item, depth }: { item: NavItem; depth: number }) {
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)
@@ -129,6 +197,8 @@ export function ManagementLayout() {
}
}, [])
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">
@@ -175,12 +245,15 @@ export function ManagementLayout() {
<div className="wrapper flex-grow-1">
<aside id="navbarManagement" className="mgmt-sidebar" data-mgmt-menu-mode="dark">
<div className="mgmt-sidebar-content">
<div className="px-2 py-2 small text-muted border-bottom mb-2">Navigation</div>
<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 ${isActive ? 'active fw-semibold' : ''}`
`nav-link rounded mb-2 mgmt-static-link ${isActive ? 'active fw-semibold' : ''}`
}
>
<i className="bi bi-house-door me-2" aria-hidden />
@@ -189,7 +262,7 @@ export function ManagementLayout() {
<NavLink
to="/app/browse"
className={({ isActive }) =>
`nav-link rounded mb-2 ${isActive ? 'active fw-semibold' : ''}`
`nav-link rounded mb-2 mgmt-static-link ${isActive ? 'active fw-semibold' : ''}`
}
>
<i className="bi bi-diagram-3 me-2" aria-hidden />
@@ -198,7 +271,7 @@ export function ManagementLayout() {
{menuError ? (
<div className="alert alert-warning small py-2">{menuError}</div>
) : (
<NavTree items={items} />
<NavTree items={navTree} pathname={location.pathname} />
)}
</nav>
</div>
+28
View File
@@ -0,0 +1,28 @@
/* Public site chrome (green header) — logo + title */
.public-shell .public-navbar-brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.public-shell .public-navbar-logo-wrap {
width: 42px;
height: 42px;
flex-shrink: 0;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
/* Bitmap is clipped to a circle and fills the disc (same idea as `.main-layout-logo`). */
.public-shell .public-navbar-logo {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
object-position: center;
border: 2px solid rgba(255, 255, 255, 0.45);
box-sizing: border-box;
background: rgba(255, 255, 255, 0.95);
}
+20 -12
View File
@@ -1,5 +1,7 @@
import { Link, Outlet } from 'react-router-dom'
import { useAuth } from '../auth/AuthProvider'
import { CiPublicFooter } from '../components/CiPartials'
import './PublicLayout.css'
export function PublicLayout() {
const { isAuthenticated, user, logout } = useAuth()
@@ -11,8 +13,16 @@ export function PublicLayout() {
style={{ backgroundColor: '#2c9b09' }}
>
<div className="container">
<Link className="navbar-brand fw-semibold" to="/">
Al Rahma Sunday School
<Link className="navbar-brand fw-semibold public-navbar-brand" to="/">
<span className="public-navbar-logo-wrap" aria-hidden>
<img
src="/images/logo.png"
alt=""
className="public-navbar-logo"
decoding="async"
/>
</span>
<span>Al Rahma Sunday School</span>
</Link>
<button
className="navbar-toggler"
@@ -32,11 +42,13 @@ export function PublicLayout() {
Home
</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/docs">
API docs
</Link>
</li>
{isAuthenticated ? (
<li className="nav-item">
<Link className="nav-link" to="/docs">
API docs
</Link>
</li>
) : null}
{isAuthenticated ? (
<>
<li className="nav-item">
@@ -82,11 +94,7 @@ export function PublicLayout() {
<Outlet />
</main>
<footer className="border-top bg-white py-4 mt-auto">
<div className="container text-center text-muted small">
<p className="mb-0">Al Rahma Sunday School</p>
</div>
</footer>
<CiPublicFooter />
</div>
)
}