386 lines
13 KiB
TypeScript
386 lines
13 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
|
import {
|
|
fetchTeacherDashboard,
|
|
isTeacherDashboardApiEnabled,
|
|
type TeacherDashboardPayload,
|
|
} from '../../api/teacherSession'
|
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
|
import { TeacherShell } from './TeacherShell'
|
|
import { useTeacherResource } from './useTeacherResource'
|
|
|
|
type TrashRow = { id: number; subject?: string; created_at?: string }
|
|
type TrashPayload = { trashedMessages?: TrashRow[] }
|
|
|
|
type MsgRow = { id: number; sender_id?: string | number; subject?: string; created_at?: string }
|
|
type InboxPayload = { receivedMessages?: MsgRow[] }
|
|
|
|
type SentPayload = { sentMessages?: MsgRow[] }
|
|
|
|
type TeacherMessagePayload = { message?: Record<string, unknown>; thread?: unknown[] }
|
|
|
|
function appendSchoolYearParam(path: string, schoolYear: string): string {
|
|
if (!schoolYear) return path
|
|
const [base, hash = ''] = path.split('#', 2)
|
|
const [pathname, query = ''] = base.split('?', 2)
|
|
const params = new URLSearchParams(query)
|
|
if (!params.get('school_year')) params.set('school_year', schoolYear)
|
|
const next = `${pathname}?${params.toString()}`
|
|
return hash ? `${next}#${hash}` : next
|
|
}
|
|
|
|
/** Every `/app/teacher/*` route so nothing is hidden behind the slim top nav alone. */
|
|
const TEACHER_PAGE_GROUPS = [
|
|
{
|
|
heading: 'Overview',
|
|
links: [
|
|
{ label: 'Dashboard', to: '/app/teacher_dashboard' },
|
|
{ label: 'No classes (notice)', to: '/app/teacher/no-classes' },
|
|
{ label: 'Select semester', to: '/app/teacher/select-semester' },
|
|
],
|
|
},
|
|
{
|
|
heading: 'Classes & attendance',
|
|
links: [
|
|
{ label: 'Class list', to: '/app/teacher/class-view' },
|
|
{ label: 'Attendance', to: '/app/teacher/showupdate-attendance' },
|
|
{ label: 'Absence / vacation', to: '/app/teacher/absence-vacation' },
|
|
],
|
|
},
|
|
{
|
|
heading: 'Grades & coursework',
|
|
links: [
|
|
{ label: 'Scores', to: '/app/teacher/scores' },
|
|
{ label: 'Homework list', to: '/app/teacher/homework-list' },
|
|
{ label: 'Add homework', to: '/app/teacher/add-homework' },
|
|
{ label: 'Add quiz', to: '/app/teacher/add-quiz' },
|
|
{ label: 'Add quiz column', to: '/app/teacher/add-quiz-column-form' },
|
|
{ label: 'Add midterm exam', to: '/app/teacher/add-midterm-exam' },
|
|
{ label: 'Add final exam', to: '/app/teacher/add-final-exam' },
|
|
{ label: 'Add participation', to: '/app/teacher/add-participation' },
|
|
{ label: 'Add project', to: '/app/teacher/add-project' },
|
|
{ label: 'Teacher assignments', to: '/app/teacher/teacher-assignment' },
|
|
{ label: 'Exam drafts', to: '/app/teacher/exam-drafts' },
|
|
],
|
|
},
|
|
{
|
|
heading: 'Class progress',
|
|
links: [
|
|
{ label: 'Submit progress', to: '/app/teacher/class-progress-submit' },
|
|
{ label: 'Progress history', to: '/app/teacher/class-progress-history' },
|
|
{ label: 'View progress', to: '/app/teacher/class-progress-view' },
|
|
],
|
|
},
|
|
{
|
|
heading: 'Competitions',
|
|
links: [
|
|
{
|
|
label: 'Competition scores',
|
|
to: '/app/teacher/competition-scores',
|
|
hint: 'Score entry uses /app/teacher/competition-scores/:competitionId',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
heading: 'Calendar',
|
|
links: [{ label: 'Calendar', to: '/app/teacher/calendar' }],
|
|
},
|
|
{
|
|
heading: 'Messages',
|
|
links: [
|
|
{ label: 'Inbox', to: '/app/teacher/inbox' },
|
|
{ label: 'Sent', to: '/app/teacher/sent' },
|
|
{ label: 'Drafts', to: '/app/teacher/drafts' },
|
|
{ label: 'Trash', to: '/app/teacher/trash' },
|
|
{
|
|
label: 'Message thread',
|
|
to: '/app/teacher/inbox',
|
|
hint: '/app/teacher/messages/:messageId — open “View” from inbox.',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
heading: 'Help',
|
|
links: [
|
|
{ label: 'Contact us', to: '/app/teacher/teacher-contactus' },
|
|
{ label: 'Support', to: '/app/teacher/teacher-support' },
|
|
],
|
|
},
|
|
] as const
|
|
|
|
/** `landing_page/teacher_dashboard` — optional `GET /api/v1/teacher/dashboard` (JWT) plus full route index. */
|
|
export function TeacherDashboardPage() {
|
|
const apiEnabled = isTeacherDashboardApiEnabled()
|
|
const [dash, setDash] = useState<TeacherDashboardPayload | null>(null)
|
|
const [dashLoading, setDashLoading] = useState(apiEnabled)
|
|
|
|
useEffect(() => {
|
|
if (!apiEnabled) return
|
|
|
|
let cancelled = false
|
|
fetchTeacherDashboard()
|
|
.then((d) => {
|
|
if (!cancelled) setDash(d)
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setDash(null)
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setDashLoading(false)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [apiEnabled])
|
|
|
|
const termLine =
|
|
dash?.school_year || dash?.semester
|
|
? [dash.school_year, dash.semester].filter(Boolean).join(' · ')
|
|
: null
|
|
|
|
const defaultSubtitle =
|
|
'All teacher portal screens are listed below. The top bar shows shortcuts only.'
|
|
|
|
return (
|
|
<TeacherShell
|
|
title="Teacher Dashboard"
|
|
subtitle={defaultSubtitle}
|
|
flash={dash?.welcome?.trim() ? String(dash.welcome) : undefined}
|
|
>
|
|
{dashLoading ? <p className="text-muted small mb-3">Loading dashboard…</p> : null}
|
|
|
|
{termLine ? <p className="text-muted small mb-2">{termLine}</p> : null}
|
|
|
|
{dash?.counts && Object.keys(dash.counts).length > 0 ? (
|
|
<div className="row g-2 mb-4">
|
|
{Object.entries(dash.counts).map(([k, v]) => (
|
|
<div key={k} className="col-6 col-md-4 col-lg-2">
|
|
<div className="border rounded p-2 text-center small">
|
|
<div className="text-muted text-uppercase" style={{ fontSize: '0.7rem' }}>
|
|
{k.replace(/_/g, ' ')}
|
|
</div>
|
|
<div className="fw-semibold">{v != null && v !== '' ? String(v) : '—'}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{dash?.announcements && dash.announcements.length > 0 ? (
|
|
<div className="mb-4">
|
|
{dash.announcements.map((a, i) => (
|
|
<div key={i} className="alert alert-light border mb-2">
|
|
{a.title ? <div className="fw-semibold">{a.title}</div> : null}
|
|
{a.body ? <div className="small mt-1">{a.body}</div> : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
<p className="small text-muted mb-4">
|
|
CI view browser:{' '}
|
|
<Link to="/app/browse">/app/browse</Link> (registry links now map competition score views to this area).
|
|
</p>
|
|
{TEACHER_PAGE_GROUPS.map((group) => (
|
|
<section key={group.heading} className="mb-4" id={`teacher-${group.heading.replace(/\s+/g, '-').toLowerCase()}`}>
|
|
<h2 className="h6 text-uppercase text-muted mb-2">{group.heading}</h2>
|
|
<div className="row g-2">
|
|
{group.links.map((item) => (
|
|
<div className="col-6 col-md-4 col-lg-3" key={`${item.to}-${item.label}`}>
|
|
<Link className="btn btn-outline-primary w-100" to={item.to}>
|
|
{item.label}
|
|
</Link>
|
|
{'hint' in item && item.hint ? (
|
|
<p className="small text-muted mt-1 mb-0">{item.hint}</p>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</TeacherShell>
|
|
)
|
|
}
|
|
|
|
/** `teacher/no_classes.php` */
|
|
export function TeacherNoClassesPage() {
|
|
const { data, loading, error } = useTeacherResource<{ message?: string }>('/no-classes')
|
|
const msg = data?.message
|
|
return (
|
|
<TeacherShell title="Classes" loading={loading} error={error} flash={msg}>
|
|
<p className="text-muted mb-0">If you believe this is an error, contact the school office.</p>
|
|
</TeacherShell>
|
|
)
|
|
}
|
|
|
|
/** `teacher/select_semester.php` */
|
|
export function TeacherSelectSemesterPage() {
|
|
const [searchParams] = useSearchParams()
|
|
const { data, loading, error } = useTeacherResource<{
|
|
invalidChoice?: string
|
|
fallPath?: string
|
|
springPath?: string
|
|
}>('/select-semester')
|
|
const requestedSchoolYear = searchParams.get('school_year')?.trim() ?? ''
|
|
const { selectedYear } = useSchoolYearOptions({
|
|
preferredYear: requestedSchoolYear,
|
|
})
|
|
const activeSchoolYear = requestedSchoolYear || selectedYear
|
|
|
|
const fallTo = appendSchoolYearParam(data?.fallPath ?? '/app/teacher/scores?semester=Fall', activeSchoolYear)
|
|
const springTo = appendSchoolYearParam(data?.springPath ?? '/app/teacher/scores?semester=Spring', activeSchoolYear)
|
|
|
|
return (
|
|
<TeacherShell title="Choose a semester" loading={loading} error={error}>
|
|
<div className="row justify-content-center">
|
|
<div className="col-md-8 col-lg-6">
|
|
<div className="card shadow-sm border-0">
|
|
<div className="card-body text-center">
|
|
<p className="text-muted mb-4">
|
|
Before viewing scores, pick the semester you want to work with.
|
|
</p>
|
|
{data?.invalidChoice ? (
|
|
<div className="alert alert-warning">
|
|
<strong>Note:</strong> "{data.invalidChoice}" is not a valid semester selection.
|
|
</div>
|
|
) : null}
|
|
<div className="d-flex flex-wrap justify-content-center gap-3">
|
|
<Link to={fallTo} className="btn btn-primary btn-lg px-4">
|
|
Fall
|
|
</Link>
|
|
<Link to={springTo} className="btn btn-outline-primary btn-lg px-4">
|
|
Spring
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TeacherShell>
|
|
)
|
|
}
|
|
|
|
/** `teacher/trash.php` */
|
|
export function TeacherTrashPage() {
|
|
const { data, loading, error } = useTeacherResource<TrashPayload>('/trash')
|
|
const rows = data?.trashedMessages ?? []
|
|
return (
|
|
<TeacherShell title="Trash" loading={loading} error={error}>
|
|
{rows.length === 0 ? (
|
|
<p>No messages found in trash.</p>
|
|
) : (
|
|
<div className="table-responsive">
|
|
<table className="table table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th>Subject</th>
|
|
<th>Date</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((m) => (
|
|
<tr key={m.id}>
|
|
<td>{m.subject ?? '—'}</td>
|
|
<td>{m.created_at ?? ''}</td>
|
|
<td>
|
|
<span className="text-muted small">Restore / delete via API when wired.</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</TeacherShell>
|
|
)
|
|
}
|
|
|
|
/** `teacher/inbox.php` */
|
|
export function TeacherInboxPage() {
|
|
const { data, loading, error } = useTeacherResource<InboxPayload>('/inbox')
|
|
const rows = data?.receivedMessages ?? []
|
|
return (
|
|
<TeacherShell title="Inbox" loading={loading} error={error}>
|
|
{rows.length === 0 ? (
|
|
<p>No messages found.</p>
|
|
) : (
|
|
<div className="table-responsive">
|
|
<table className="table table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th>From</th>
|
|
<th>Subject</th>
|
|
<th>Date</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((m) => (
|
|
<tr key={m.id}>
|
|
<td>{String(m.sender_id ?? '')}</td>
|
|
<td>{m.subject ?? ''}</td>
|
|
<td>{m.created_at ?? ''}</td>
|
|
<td>
|
|
<Link to={`/app/teacher/messages/${m.id}`} className="me-2">
|
|
View
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</TeacherShell>
|
|
)
|
|
}
|
|
|
|
/** `teacher/sent.php` */
|
|
export function TeacherSentPage() {
|
|
const { data, loading, error } = useTeacherResource<SentPayload>('/sent')
|
|
const rows = data?.sentMessages ?? []
|
|
return (
|
|
<TeacherShell title="Sent" loading={loading} error={error}>
|
|
{rows.length === 0 ? (
|
|
<p>No sent messages.</p>
|
|
) : (
|
|
<div className="table-responsive">
|
|
<table className="table table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th>To</th>
|
|
<th>Subject</th>
|
|
<th>Date</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((m) => (
|
|
<tr key={m.id}>
|
|
<td>{String(m.sender_id ?? '')}</td>
|
|
<td>{m.subject ?? ''}</td>
|
|
<td>{m.created_at ?? ''}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</TeacherShell>
|
|
)
|
|
}
|
|
|
|
/** `teacher/teacher_message.php` — thread payload from `/api/v1/teacher/messages/:id`. */
|
|
export function TeacherMessagePage() {
|
|
const { messageId } = useParams()
|
|
const path = messageId ? `/messages/${messageId}` : '/message'
|
|
const { data, loading, error } = useTeacherResource<TeacherMessagePayload>(path)
|
|
return (
|
|
<TeacherShell title="Message" loading={loading} error={error}>
|
|
<pre className="small bg-light p-3 rounded overflow-auto" style={{ maxHeight: 420 }}>
|
|
{data ? JSON.stringify(data, null, 2) : null}
|
|
</pre>
|
|
</TeacherShell>
|
|
)
|
|
}
|