fix parent pages and teachers and admin
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 29s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 23s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s

This commit is contained in:
root
2026-07-09 13:56:26 -04:00
parent 4b78aafa65
commit b895c06dc1
15 changed files with 726 additions and 372 deletions
-6
View File
@@ -1003,11 +1003,6 @@ const ParentPaymentInvoicesPage = lazy(() =>
default: m.ParentPaymentInvoicesPage,
})),
)
const ParentStatementPage = lazy(() =>
import('./pages/parent/ParentStatementPage').then((m) => ({
default: m.ParentStatementPage,
})),
)
const ParentRegisterStudentPage = lazy(() =>
import('./pages/parent/WiredParentScreens').then((m) => ({
default: m.ParentRegisterStudentPage,
@@ -1539,7 +1534,6 @@ export default function App() {
<Route path="parent/enroll-classes" element={<ParentEnrollClassesPage />} />
<Route path="parent/events" element={<ParentEventsPage />} />
<Route path="parent/payment" element={<ParentPaymentInvoicesPage />} />
<Route path="parent/statements" element={<ParentStatementPage />} />
<Route path="parent/register-student" element={<ParentRegisterStudentPage />} />
<Route path="parent/report-attendance" element={<ParentReportAttendancePage />} />
<Route path="parent/attendance-reports" element={<ParentAttendanceReportsHistoryPage />} />
+69 -12
View File
@@ -222,8 +222,7 @@ export async function fetchReportCardCompleteness(params: {
}
/** Build absolute API URL for iframe / window.open (report PDF HTML). */
export function reportCardStudentUrl(
function reportCardStudentPath(
studentId: number | string,
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
): string {
@@ -233,10 +232,10 @@ export function reportCardStudentUrl(
if (opts.download) qs.set('download', '1')
if (opts.report_date) qs.set('report_date', opts.report_date)
qs.set('cb', String(Date.now()))
return apiUrl(`/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}`)
return `/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}`
}
export function reportCardClassUrl(
function reportCardClassPath(
classSectionId: number | string,
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
): string {
@@ -246,7 +245,22 @@ export function reportCardClassUrl(
if (opts.download) qs.set('download', '1')
if (opts.report_date) qs.set('report_date', opts.report_date)
qs.set('cb', String(Date.now()))
return apiUrl(`/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}`)
return `/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}`
}
/** Build absolute API URL for iframe / window.open (report PDF HTML). */
export function reportCardStudentUrl(
studentId: number | string,
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
): string {
return apiUrl(reportCardStudentPath(studentId, opts))
}
export function reportCardClassUrl(
classSectionId: number | string,
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
): string {
return apiUrl(reportCardClassPath(classSectionId, opts))
}
/** HTML document for iframe preview (Bearer auth; unlike raw iframe src). */
@@ -288,15 +302,58 @@ export async function fetchReportCardClassHtml(
return res.text()
}
/** Opens PDF download in new tab (authenticated GET). */
export async function openReportCardDownload(url: string): Promise<void> {
async function fetchReportCardPdf(path: string): Promise<Blob> {
const headers = new Headers({ Accept: 'application/pdf,*/*' })
const token = getStoredToken()
if (!token) {
throw new ApiHttpError('Your session has expired. Please sign in again.', 401, null)
}
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(url, { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const raw = await res.blob()
const blob = new Blob([await raw.arrayBuffer()], { type: 'application/pdf' })
window.open(URL.createObjectURL(blob), '_blank', 'noopener')
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) {
const errBody: unknown = await res.json().catch(() => null)
const msg =
typeof errBody === 'object' && errBody !== null && 'message' in errBody
? String((errBody as { message?: unknown }).message ?? '')
: ''
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, errBody)
}
const raw = await res.blob()
return new Blob([await raw.arrayBuffer()], { type: 'application/pdf' })
}
function openBlob(blob: Blob): void {
const objectUrl = URL.createObjectURL(blob)
window.open(objectUrl, '_blank', 'noopener')
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
}
/** Opens PDF download in new tab (authenticated GET). */
export async function openReportCardStudentDownload(
studentId: number | string,
opts: { school_year: string; semester?: string; report_date?: string },
): Promise<void> {
openBlob(
await fetchReportCardPdf(
reportCardStudentPath(studentId, {
...opts,
download: true,
}),
),
)
}
export async function openReportCardClassDownload(
classSectionId: number | string,
opts: { school_year: string; semester?: string; report_date?: string },
): Promise<void> {
openBlob(
await fetchReportCardPdf(
reportCardClassPath(classSectionId, {
...opts,
download: true,
}),
),
)
}
+1 -1
View File
@@ -448,7 +448,7 @@ export async function fetchParentStatement(params: {
if (params.parentId != null) query.set('parent_id', String(params.parentId))
const response = await apiFetch<ApiParentStatementResponse>(
`/api/v1/parent/statements${query.toString() ? `?${query}` : ''}`,
`/api/v1/parents/statements${query.toString() ? `?${query}` : ''}`,
)
if (!response.data) {
+8
View File
@@ -1974,6 +1974,8 @@ export async function fetchClassProgressGroups(params?: {
weekStart?: string | null
weekEnd?: string | null
subject?: string | null
schoolYear?: string | null
semester?: string | null
page?: number
perPage?: number
}): Promise<ClassProgressGroupsResponse> {
@@ -1984,6 +1986,8 @@ export async function fetchClassProgressGroups(params?: {
if (params?.weekStart) query.set('week_start', params.weekStart)
if (params?.weekEnd) query.set('week_end', params.weekEnd)
if (params?.subject) query.set('subject', params.subject)
if (params?.schoolYear) query.set('school_year', params.schoolYear)
if (params?.semester) query.set('semester', params.semester)
if (params?.page) query.set('page', String(params.page))
if (params?.perPage) query.set('per_page', String(params.perPage))
return apiFetch<ClassProgressGroupsResponse>(`/api/v1/class-progress?${query.toString()}`)
@@ -1996,10 +2000,14 @@ export async function fetchClassProgressDetail(id: number): Promise<ClassProgres
export async function fetchClassProgressMeta(params?: {
classId?: number | null
sundayCount?: number | null
schoolYear?: string | null
semester?: string | null
}): Promise<ClassProgressMetaResponse> {
const query = new URLSearchParams()
if (params?.classId) query.set('class_id', String(params.classId))
if (params?.sundayCount) query.set('sunday_count', String(params.sundayCount))
if (params?.schoolYear) query.set('school_year', params.schoolYear)
if (params?.semester) query.set('semester', params.semester)
const qs = query.size > 0 ? `?${query.toString()}` : ''
return apiFetch<ClassProgressMetaResponse>(`/api/v1/class-progress/meta${qs}`)
}
+14
View File
@@ -349,9 +349,23 @@ export type ParentProfileResponse = {
export type ParentInvoiceRow = {
id: number
invoice_number?: string | null
total_amount?: number
paid_amount?: number
balance?: number
status?: string | null
due_date?: string | null
last_paid_amount?: number
last_payment_date?: string | null
payments?: Array<{
id: number
invoice_id?: number
paid_amount?: number
balance?: number
payment_method?: string | null
payment_date?: string | null
transaction_id?: string | null
status?: string | null
}>
}
export type ParentInvoicesResponse = {
-2
View File
@@ -14,7 +14,6 @@ const parentNavItems = [
{ to: '/app/parent/enroll-classes', icon: 'bi bi-pencil-square', label: 'Enroll in Classes' },
{ to: '/app/parent/events', icon: 'bi bi-bell', label: 'Events' },
{ to: '/app/parent/payment', icon: 'bi bi-credit-card', label: 'Invoice' },
{ to: '/app/parent/statements', icon: 'bi bi-receipt', label: 'Statements' },
{ to: '/app/parent/register-student', icon: 'bi bi-person-plus', label: 'Register Students' },
{ to: '/app/parent/report-attendance', icon: 'bi bi-megaphone', label: 'Report Absence' },
{ to: '/app/parent/report-cards', icon: 'bi bi-file-earmark-text', label: 'Report Cards' },
@@ -171,7 +170,6 @@ export function MainLayout() {
<div className="container-fluid">
<Link className="navbar-brand fw-semibold d-inline-flex align-items-center gap-2" to={dashboardPath}>
<img className="main-layout-logo" src="/logo.png" alt="Al Rahma Sunday School logo" />
Al Rahma Sunday School
</Link>
<button
className="navbar-toggler"
@@ -121,6 +121,86 @@ function closePayloadFrom(state: CloseFormState): CloseSchoolYearPayload {
}
}
const PAGE_SIZE_OPTIONS = [10, 20, 100] as const
type PageSize = (typeof PAGE_SIZE_OPTIONS)[number]
function paginateRows<T>(rows: T[], page: number, pageSize: PageSize) {
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize))
const currentPage = Math.min(Math.max(page, 1), totalPages)
const start = (currentPage - 1) * pageSize
return {
currentPage,
totalPages,
rows: rows.slice(start, start + pageSize),
start: rows.length === 0 ? 0 : start + 1,
end: Math.min(start + pageSize, rows.length),
}
}
function PaginationControls({
label,
totalRows,
page,
pageSize,
totalPages,
start,
end,
onPageChange,
onPageSizeChange,
}: {
label: string
totalRows: number
page: number
pageSize: PageSize
totalPages: number
start: number
end: number
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: PageSize) => void
}) {
if (totalRows === 0) return null
return (
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 small text-muted mb-2">
<div>
Showing {start}-{end} of {totalRows} {label}
</div>
<div className="d-flex align-items-center gap-2">
<label className="d-flex align-items-center gap-1 mb-0">
<span>Rows</span>
<select
className="form-select form-select-sm"
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value) as PageSize)}
>
{PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</label>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
Previous
</button>
<span>Page {page} of {totalPages}</span>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Next
</button>
</div>
</div>
)
}
export function SchoolYearsManagementPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [years, setYears] = useState<SchoolYearRecord[]>([])
@@ -141,6 +221,14 @@ export function SchoolYearsManagementPage() {
const [busyEdit, setBusyEdit] = useState(false)
const [busyOpenId, setBusyOpenId] = useState<number | null>(null)
const [busyCloseAction, setBusyCloseAction] = useState<'validate' | 'preview' | 'close' | null>(null)
const [promotionPage, setPromotionPage] = useState(1)
const [promotionPageSize, setPromotionPageSize] = useState<PageSize>(10)
const [balancePage, setBalancePage] = useState(1)
const [balancePageSize, setBalancePageSize] = useState<PageSize>(10)
const [closePromotionPage, setClosePromotionPage] = useState(1)
const [closePromotionPageSize, setClosePromotionPageSize] = useState<PageSize>(10)
const [closeBalancePage, setCloseBalancePage] = useState(1)
const [closeBalancePageSize, setCloseBalancePageSize] = useState<PageSize>(10)
const selectedYearName = searchParams.get('school_year')?.trim() || null
const activeYear = useMemo(
@@ -346,6 +434,7 @@ export function SchoolYearsManagementPage() {
suggestNextName(selectedYear.name),
)
setPromotionPreview(preview)
setPromotionPage(1)
} catch (loadError) {
setError(normalizeApiError(loadError, 'Unable to load promotion preview.'))
}
@@ -361,6 +450,7 @@ export function SchoolYearsManagementPage() {
suggestNextName(selectedYear.name),
)
setParentBalances(balances)
setBalancePage(1)
} catch (loadError) {
setError(normalizeApiError(loadError, 'Unable to load parent balance preview.'))
}
@@ -428,6 +518,10 @@ export function SchoolYearsManagementPage() {
const balanceRows = parentBalances?.rows ?? []
const closePromotionRows = closePreview?.promotion_preview.rows ?? []
const closeBalanceRows = closePreview?.parent_balances.rows ?? []
const pagedPromotionRows = paginateRows(promotionRows, promotionPage, promotionPageSize)
const pagedBalanceRows = paginateRows(balanceRows, balancePage, balancePageSize)
const pagedClosePromotionRows = paginateRows(closePromotionRows, closePromotionPage, closePromotionPageSize)
const pagedCloseBalanceRows = paginateRows(closeBalanceRows, closeBalancePage, closeBalancePageSize)
const selectedYearReadOnly = isReadOnlySchoolYear(selectedYear)
const selectedYearIsActive = Boolean(selectedYear?.is_current)
const requiredConfirmation = selectedYearIsActive && selectedYear ? `CLOSE ${selectedYear.name}` : ''
@@ -783,6 +877,20 @@ export function SchoolYearsManagementPage() {
{promotionPreview.summary.total_students} students scanned,{' '}
{promotionPreview.summary.hold} hold rows.
</div>
<PaginationControls
label="students"
totalRows={promotionRows.length}
page={pagedPromotionRows.currentPage}
pageSize={promotionPageSize}
totalPages={pagedPromotionRows.totalPages}
start={pagedPromotionRows.start}
end={pagedPromotionRows.end}
onPageChange={setPromotionPage}
onPageSizeChange={(pageSize) => {
setPromotionPageSize(pageSize)
setPromotionPage(1)
}}
/>
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
@@ -801,7 +909,7 @@ export function SchoolYearsManagementPage() {
</td>
</tr>
) : (
promotionRows.slice(0, 8).map((row) => (
pagedPromotionRows.rows.map((row) => (
<tr key={row.student_id}>
<td>
<div>{row.student_name}</div>
@@ -837,6 +945,20 @@ export function SchoolYearsManagementPage() {
Unpaid ${formatMoney(parentBalances.summary.total_positive_unpaid_balance ?? parentBalances.summary.total_old_unpaid_balance)},{' '}
credits ${formatMoney(parentBalances.summary.total_credit_balance)}, net ${formatMoney(parentBalances.summary.net_balance_impact ?? parentBalances.summary.net_balance_to_transfer)}.
</div>
<PaginationControls
label="parents"
totalRows={balanceRows.length}
page={pagedBalanceRows.currentPage}
pageSize={balancePageSize}
totalPages={pagedBalanceRows.totalPages}
start={pagedBalanceRows.start}
end={pagedBalanceRows.end}
onPageChange={setBalancePage}
onPageSizeChange={(pageSize) => {
setBalancePageSize(pageSize)
setBalancePage(1)
}}
/>
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
@@ -858,7 +980,7 @@ export function SchoolYearsManagementPage() {
</td>
</tr>
) : (
balanceRows.slice(0, 8).map((row) => (
pagedBalanceRows.rows.map((row) => (
<tr key={row.parent_id}>
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
<td>{row.student_names?.join(', ') || '—'}</td>
@@ -1092,6 +1214,20 @@ export function SchoolYearsManagementPage() {
<div className="col-lg-6">
<div className="border rounded p-3 h-100">
<h3 className="h6">Closure promotion preview</h3>
<PaginationControls
label="students"
totalRows={closePromotionRows.length}
page={pagedClosePromotionRows.currentPage}
pageSize={closePromotionPageSize}
totalPages={pagedClosePromotionRows.totalPages}
start={pagedClosePromotionRows.start}
end={pagedClosePromotionRows.end}
onPageChange={setClosePromotionPage}
onPageSizeChange={(pageSize) => {
setClosePromotionPageSize(pageSize)
setClosePromotionPage(1)
}}
/>
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
@@ -1102,7 +1238,7 @@ export function SchoolYearsManagementPage() {
</tr>
</thead>
<tbody>
{closePromotionRows.slice(0, 8).map((row) => (
{pagedClosePromotionRows.rows.map((row) => (
<tr key={`close-${row.student_id}`}>
<td>{row.student_name}</td>
<td>{row.promotion_action}</td>
@@ -1120,6 +1256,20 @@ export function SchoolYearsManagementPage() {
<div className="small text-muted mb-2">
Source year: {closePreview.parent_balances.from_school_year}; target year: {closePreview.parent_balances.to_school_year}. Transfer mode: unpaid balances only.
</div>
<PaginationControls
label="parents"
totalRows={closeBalanceRows.length}
page={pagedCloseBalanceRows.currentPage}
pageSize={closeBalancePageSize}
totalPages={pagedCloseBalanceRows.totalPages}
start={pagedCloseBalanceRows.start}
end={pagedCloseBalanceRows.end}
onPageChange={setCloseBalancePage}
onPageSizeChange={(pageSize) => {
setCloseBalancePageSize(pageSize)
setCloseBalancePage(1)
}}
/>
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
@@ -1133,7 +1283,7 @@ export function SchoolYearsManagementPage() {
</tr>
</thead>
<tbody>
{closeBalanceRows.slice(0, 8).map((row) => (
{pagedCloseBalanceRows.rows.map((row) => (
<tr key={`balance-${row.parent_id}`}>
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
<td>{row.student_names?.join(', ') || '—'}</td>
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import {
fetchClassProgressGroups,
fetchClassProgressMeta,
@@ -20,6 +20,9 @@ export function ClassProgressListPage() {
const [items, setItems] = useState<ClassProgressGroupRow[]>([])
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const selectedSchoolYear = searchParams.get('school_year')?.trim() || null
const selectedSemester = searchParams.get('semester')?.trim() || null
/** Server may later expose `low_progress_section_ids`; until then keep empty (CI disables button). */
const lowProgressSectionIds: number[] = []
@@ -33,10 +36,12 @@ export function ClassProgressListPage() {
async function load() {
try {
const [meta, sections, groups] = await Promise.all([
fetchClassProgressMeta(),
fetchClassSections({ perPage: 200, withStudents: true }),
fetchClassProgressMeta({ schoolYear: selectedSchoolYear, semester: selectedSemester }),
fetchClassSections({ perPage: 200, withStudents: true, schoolYear: selectedSchoolYear, semester: selectedSemester }),
fetchClassProgressGroups({
classSectionId: filters.classSectionId ? Number(filters.classSectionId) : null,
schoolYear: selectedSchoolYear,
semester: selectedSemester,
status: filters.status || null,
weekStart: filters.weekStart || null,
weekEnd: filters.weekEnd || null,
@@ -55,7 +60,7 @@ export function ClassProgressListPage() {
useEffect(() => {
void load()
}, [])
}, [selectedSchoolYear, selectedSemester])
const groupsBySection = useMemo(() => {
const m = new Map<number, ClassProgressGroupRow[]>()
@@ -69,6 +74,26 @@ export function ClassProgressListPage() {
return m
}, [items])
const displaySections = useMemo(() => {
const byId = new Map<number, { class_section_id?: number | null; class_section_name?: string | null }>()
for (const section of classSections) {
const id = Number(section.class_section_id ?? 0)
if (id > 0) byId.set(id, section)
}
for (const item of items) {
const id = Number(item.class_section_id ?? 0)
if (id > 0 && !byId.has(id)) {
byId.set(id, {
class_section_id: id,
class_section_name: item.class_section_name ?? `Section ${id}`,
})
}
}
return Array.from(byId.values()).sort((a, b) =>
String(a.class_section_name ?? '').localeCompare(String(b.class_section_name ?? '')),
)
}, [classSections, items])
const subjectCount = Object.keys(subjectSections).length
function teacherLabelForGroup(group: ClassProgressGroupRow): string {
@@ -218,11 +243,11 @@ export function ClassProgressListPage() {
<div className="card shadow-sm mt-5">
<div className="card-body pt-4">
{classSections.length === 0 ? (
{displaySections.length === 0 ? (
<div className="text-center text-muted py-4">No class sections found.</div>
) : (
<div className="accordion" id="adminProgressAccordion">
{classSections.map((cs) => {
{displaySections.map((cs) => {
const sectionId = Number(cs.class_section_id ?? 0)
const sectionName = cs.class_section_name ?? 'Unknown Section'
const sectionGroups = groupsBySection.get(sectionId) ?? []
-111
View File
@@ -1,111 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { ApiHttpError } from '../../api/http'
import { fetchParentStatement, type ParentStatement } from '../../api/schoolYears'
import { SchoolYearSelector } from '../../components/schoolYear'
import { useSchoolYear } from '../../context/SchoolYearContext'
function normalizeApiError(error: unknown, fallback: string): string {
if (error instanceof ApiHttpError) return error.message || fallback
if (error instanceof Error) return error.message
return fallback
}
function formatMoney(value: number | null | undefined): string {
return Number(value ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
export function ParentStatementPage() {
const { selectedSchoolYearName } = useSchoolYear()
const [statement, setStatement] = useState<ParentStatement | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!selectedSchoolYearName) return
let cancelled = false
setLoading(true)
setError(null)
;(async () => {
try {
const nextStatement = await fetchParentStatement({ schoolYear: selectedSchoolYearName })
if (!cancelled) setStatement(nextStatement)
} catch (loadError) {
if (!cancelled) setError(normalizeApiError(loadError, 'Unable to load parent statement.'))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [selectedSchoolYearName])
const lines = useMemo(() => statement?.lines ?? [], [statement])
return (
<div className="container py-4">
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 className="h3 mb-1">Parent Statement</h1>
<p className="text-muted mb-0">
Opening balances from closed years are shown separately from new-year charges so they are not double-counted.
</p>
</div>
<SchoolYearSelector />
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading statement</p> : null}
<div className="card shadow-sm">
<div className="card-body">
<div className="d-flex flex-wrap justify-content-between gap-3 mb-3">
<div>
<h2 className="h5 mb-1">{statement?.parent_name ?? 'Parent account'}</h2>
<div className="text-muted small">School year: {selectedSchoolYearName ?? '—'}</div>
</div>
<div className="text-end">
<div className="small text-muted">Current balance</div>
<div className="h4 mb-0">${formatMoney(statement?.current_balance)}</div>
</div>
</div>
<div className="alert alert-info small">
Opening balance from prior year: <strong>${formatMoney(statement?.opening_balance)}</strong>. The old-balance invoice is the collectible item; this opening-balance label is audit context, not a second charge.
</div>
<div className="table-responsive">
<table className="table align-middle">
<thead>
<tr>
<th>Description</th>
<th>Source year</th>
<th>Invoice</th>
<th className="text-end">Amount</th>
</tr>
</thead>
<tbody>
{lines.length === 0 ? (
<tr><td colSpan={4} className="text-muted">No statement lines found.</td></tr>
) : lines.map((line, index) => (
<tr key={`${line.label}-${index}`}>
<td>{line.label}</td>
<td>{line.source_school_year ?? '—'}</td>
<td>{line.invoice_id ?? '—'}</td>
<td className="text-end">${formatMoney(line.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}
+298 -20
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { ParentParityShell } from './ParentParityShell'
import {
fetchAttendanceReportForm,
@@ -11,6 +12,7 @@ import {
submitContactMessage,
updateParentEnrollments,
} from '../../api/session'
import { fetchParentStatement, type ParentStatement } from '../../api/schoolYears'
import type {
ParentAttendanceReportFormResponse,
ParentEnrollmentStudent,
@@ -19,6 +21,7 @@ import type {
ParentRegistrationOverviewResponse,
} from '../../api/types'
import { useAuth } from '../../auth/AuthProvider'
import { useSchoolYear } from '../../context/SchoolYearContext'
/** `Views/parent/contact.php` — POST /api/v1/contact */
export function ParentContactPage() {
@@ -480,18 +483,59 @@ function choiceBadge(action: 'enroll' | 'withdraw' | null) {
return <span className="text-muted small">No change</span>
}
function formatCurrency(value: number | null | undefined): string {
return Number(value ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
function parseCurrency(value: number | string | null | undefined): number {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value !== 'string') return 0
const parsed = Number(value.replace(/[$,]/g, ''))
return Number.isFinite(parsed) ? parsed : 0
}
function invoiceStatusClass(status?: string | null): string {
const normalized = String(status ?? '').trim().toLowerCase()
if (normalized === 'paid') return 'bg-success-subtle text-success-emphasis border border-success-subtle'
if (normalized.includes('partial')) return 'bg-warning-subtle text-warning-emphasis border border-warning-subtle'
if (normalized === 'pending' || normalized === 'unpaid') return 'bg-danger-subtle text-danger-emphasis border border-danger-subtle'
return 'bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle'
}
function paymentTone(balance: number): string {
if (balance > 0) return 'text-danger'
if (balance < 0) return 'text-success'
return 'text-success'
}
function formatDateLabel(value?: string | null): string {
if (!value) return 'No due date'
const date = new Date(`${value}T00:00:00`)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
/** `Views/parent/payment_view.php` — GET /api/v1/parents/invoices */
export function ParentPaymentInvoicesPage() {
const { selectedSchoolYearName } = useSchoolYear()
const [error, setError] = useState<string | null>(null)
const [statementError, setStatementError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [statementLoading, setStatementLoading] = useState(false)
const [rows, setRows] = useState<ParentInvoiceRow[]>([])
const [statement, setStatement] = useState<ParentStatement | null>(null)
useEffect(() => {
if (!selectedSchoolYearName) return
let cancelled = false
;(async () => {
setLoading(true)
try {
const data = await fetchParentInvoices(null)
const data = await fetchParentInvoices(selectedSchoolYearName)
if (cancelled) return
setRows(data.invoices ?? [])
setError(null)
@@ -504,53 +548,287 @@ export function ParentPaymentInvoicesPage() {
return () => {
cancelled = true
}
}, [])
}, [selectedSchoolYearName])
useEffect(() => {
if (!selectedSchoolYearName) return
let cancelled = false
;(async () => {
setStatementLoading(true)
setStatement(null)
try {
const data = await fetchParentStatement({ schoolYear: selectedSchoolYearName })
if (cancelled) return
setStatement(data)
setStatementError(null)
} catch (e) {
if (!cancelled) {
setStatement(null)
setStatementError(e instanceof Error ? e.message : 'Failed to load statement.')
}
} finally {
if (!cancelled) setStatementLoading(false)
}
})()
return () => {
cancelled = true
}
}, [selectedSchoolYearName])
const statementLines = useMemo(() => statement?.lines ?? [], [statement])
const totalBalance = useMemo(
() => rows.reduce((sum, inv) => sum + parseCurrency(inv.balance), 0),
[rows],
)
const totalPaid = useMemo(
() => rows.reduce((sum, inv) => sum + parseCurrency(inv.paid_amount), 0),
[rows],
)
const openInvoices = useMemo(
() => rows.filter((inv) => parseCurrency(inv.balance) > 0),
[rows],
)
const paidInvoices = rows.length - openInvoices.length
const nextDueInvoice = useMemo(() => {
return [...openInvoices]
.filter((inv) => inv.due_date)
.sort((a, b) => String(a.due_date).localeCompare(String(b.due_date)))[0]
}, [openInvoices])
const statementTotal = useMemo(
() => statementLines.reduce((sum, line) => sum + parseCurrency(line.amount), 0),
[statementLines],
)
return (
<ParentParityShell
title="Payments & invoices"
ciViewFile="payment_view.php"
legacyCiRoutes={['/parent/payment']}
fullWidth
>
{error ? <div className="alert alert-warning small">{error}</div> : null}
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<div className="text-uppercase text-success fw-semibold small mb-1">Family account</div>
<h3 className="h4 mb-1">Payment overview</h3>
<p className="text-muted mb-0">
{selectedSchoolYearName ? `School year ${selectedSchoolYearName}` : 'School year not selected'}
</p>
</div>
<Link className="btn btn-success" to="/app/parent/payment-redirect">
Make a payment
</Link>
</div>
<div className="row g-3 mb-4">
<div className="col-md-4">
<div className="border rounded-3 p-3 h-100 bg-white">
<div className="small text-muted">Current balance</div>
<div className={`display-6 fw-semibold ${paymentTone(statement?.current_balance ?? totalBalance)}`}>
${formatCurrency(statement?.current_balance ?? totalBalance)}
</div>
<div className="small text-muted">Across all visible invoices and statement items</div>
</div>
</div>
<div className="col-md-4">
<div className="border rounded-3 p-3 h-100 bg-white">
<div className="small text-muted">Invoices</div>
<div className="display-6 fw-semibold text-dark">{rows.length}</div>
<div className="small text-muted">
{openInvoices.length} open, {paidInvoices} paid · ${formatCurrency(totalPaid)} received
</div>
</div>
</div>
<div className="col-md-4">
<div className="border rounded-3 p-3 h-100 bg-white">
<div className="small text-muted">Next due date</div>
<div className="h3 fw-semibold mb-1">{formatDateLabel(nextDueInvoice?.due_date)}</div>
<div className="small text-muted">
{nextDueInvoice ? nextDueInvoice.invoice_number ?? `Invoice #${nextDueInvoice.id}` : 'No open due dates'}
</div>
</div>
</div>
</div>
<section className="mb-4">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h3 className="h5 mb-1">Invoices</h3>
<p className="text-muted small mb-0">Review balances and payment status for each invoice.</p>
</div>
<span className="badge bg-light text-dark border">${formatCurrency(totalBalance)} visible balance</span>
</div>
{loading ? (
<p className="text-muted small">Loading</p>
<div className="border rounded-3 p-4 text-muted small bg-white">Loading invoices</div>
) : rows.length === 0 ? (
<div className="border rounded-3 p-4 text-center bg-white">
<div className="fw-semibold">No invoices found</div>
<div className="text-muted small">There are no invoices for this school year.</div>
</div>
) : (
<div className="d-grid gap-3">
{rows.map((inv) => {
const balance = parseCurrency(inv.balance)
const totalAmount = parseCurrency(inv.total_amount)
const paidAmount = parseCurrency(inv.paid_amount)
const invoiceLabel = inv.invoice_number ?? `Invoice #${inv.id}`
const payments = inv.payments ?? []
return (
<div className="border rounded-3 p-3 bg-white" key={inv.id}>
<div className="row g-3 align-items-center">
<div className="col-lg-3">
<div className="small text-muted">Invoice</div>
<div className="fw-semibold">{invoiceLabel}</div>
<div className="small text-muted">Record #{inv.id}</div>
</div>
<div className="col-6 col-lg-2">
<div className="small text-muted">Invoice total</div>
<div className="h5 mb-0 text-dark">${formatCurrency(totalAmount)}</div>
</div>
<div className="col-6 col-lg-2">
<div className="small text-muted">Paid</div>
<div className="h5 mb-0 text-success">${formatCurrency(paidAmount)}</div>
</div>
<div className="col-6 col-lg-2">
<div className="small text-muted">Balance</div>
<div className={`h5 mb-0 ${paymentTone(balance)}`}>${formatCurrency(balance)}</div>
</div>
<div className="col-6 col-lg-1">
<div className="small text-muted">Due</div>
<div className="fw-semibold">{formatDateLabel(inv.due_date)}</div>
</div>
<div className="col-md-6 col-lg-1">
<span className={`badge rounded-pill px-3 py-2 ${invoiceStatusClass(inv.status)}`}>
{inv.status ?? 'Unknown'}
</span>
</div>
<div className="col-md-6 col-lg-1 text-md-end">
{balance > 0 ? (
<Link
className="btn btn-outline-success btn-sm"
to={`/app/parent/payment-redirect?invoice_id=${encodeURIComponent(String(inv.id))}`}
>
Pay invoice
</Link>
) : (
<span className="text-success small fw-semibold">Paid</span>
)}
</div>
</div>
<div className="border-top mt-3 pt-3">
<div className="small fw-semibold mb-2">Payments from payment records</div>
{payments.length === 0 ? (
<div className="small text-muted">No posted payments found for this invoice.</div>
) : (
<div className="table-responsive">
<table className="table table-sm table-striped">
<table className="table table-sm mb-0">
<thead>
<tr>
<th>#</th>
<th>Invoice</th>
<th>Balance</th>
<th>Date</th>
<th>Amount</th>
<th>Method</th>
<th>Status</th>
<th>Due</th>
<th>Transaction</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
{payments.map((payment) => (
<tr key={payment.id}>
<td>{payment.payment_date ? formatDateLabel(payment.payment_date) : 'No payment date'}</td>
<td>${formatCurrency(payment.paid_amount)}</td>
<td>{payment.payment_method ?? '—'}</td>
<td>{payment.status ?? '—'}</td>
<td>{payment.transaction_id ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
})}
</div>
)}
</section>
<section className="border-top mt-4 pt-4">
<div className="d-flex flex-wrap justify-content-between gap-3 mb-3">
<div>
<h3 className="h5 mb-1">Parent statement</h3>
<p className="text-muted small mb-0">
Opening balances from closed years are shown separately from new-year charges so they are not double-counted.
</p>
</div>
<div className="text-md-end">
<div className="small text-muted">Statement total</div>
<div className="h4 mb-0">${formatCurrency(statementTotal)}</div>
</div>
</div>
{statementError ? <div className="alert alert-warning small">{statementError}</div> : null}
{statementLoading ? (
<div className="border rounded-3 p-4 text-muted small bg-white">Loading statement</div>
) : (
<>
<div className="row g-3 mb-3">
<div className="col-md-6">
<div className="border rounded-3 p-3 bg-light h-100">
<div className="small text-muted">Opening balance from prior year</div>
<div className="h4 mb-1">${formatCurrency(statement?.opening_balance)}</div>
<div className="small text-muted">
The old-balance invoice is the collectible item. This label is audit context, not a second charge.
</div>
</div>
</div>
<div className="col-md-6">
<div className="border rounded-3 p-3 bg-light h-100">
<div className="small text-muted">Current balance</div>
<div className={`h4 mb-1 ${paymentTone(statement?.current_balance ?? 0)}`}>
${formatCurrency(statement?.current_balance)}
</div>
<div className="small text-muted">Includes current statement activity for this account.</div>
</div>
</div>
</div>
<div className="table-responsive">
<table className="table table-sm align-middle bg-white border">
<thead>
<tr>
<td colSpan={5} className="text-muted">
No invoices.
<th>Description</th>
<th>Source year</th>
<th>Invoice</th>
<th className="text-end">Amount</th>
</tr>
</thead>
<tbody>
{statementLines.length === 0 ? (
<tr>
<td colSpan={4} className="text-muted">
No statement lines found.
</td>
</tr>
) : (
rows.map((inv) => (
<tr key={inv.id}>
<td>{inv.id}</td>
<td>{inv.invoice_number ?? '—'}</td>
<td>
{typeof inv.balance === 'number' ? inv.balance.toFixed(2) : '—'}
</td>
<td>{inv.status ?? '—'}</td>
<td>{inv.due_date ?? '—'}</td>
statementLines.map((line, index) => (
<tr key={`${line.label}-${line.invoice_id ?? index}`}>
<td>{line.label}</td>
<td>{line.source_school_year ?? '—'}</td>
<td>{line.invoice_id ?? '—'}</td>
<td className={`text-end ${paymentTone(line.amount)}`}>${formatCurrency(line.amount)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</>
)}
</section>
</ParentParityShell>
)
}
+38 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
@@ -49,23 +49,32 @@ export function ManualPayPage() {
const [previewBlobUrl, setPreviewBlobUrl] = useState<string | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [editPaymentId, setEditPaymentId] = useState<number | string | null>(null)
const loadSeq = useRef(0)
const activeTerm = termFromUrl
const load = useCallback(
(term: string) => {
const requestId = ++loadSeq.current
if (!term.trim()) {
setData(null)
setLoading(false)
return
}
setLoading(true)
setError(null)
fetchManualPayPage({ search_term: term.trim() })
.then(setData)
.catch((e: unknown) =>
setError(e instanceof ApiHttpError ? e.message : 'Could not load manual pay data.'),
)
.finally(() => setLoading(false))
.then((payload) => {
if (requestId === loadSeq.current) setData(payload)
})
.catch((e: unknown) => {
if (requestId === loadSeq.current) {
setError(e instanceof ApiHttpError ? e.message : 'Could not load manual pay data.')
}
})
.finally(() => {
if (requestId === loadSeq.current) setLoading(false)
})
},
[],
)
@@ -103,6 +112,21 @@ export function ManualPayPage() {
navigate({ pathname: '/app/administrator/payment/manual-pay', search: `?search_term=${encodeURIComponent(q)}` })
}
function clearSearch() {
loadSeq.current += 1
setSearchInput('')
setData(null)
setError(null)
setSuggestions([])
setSuggestOpen(false)
setLoading(false)
setShowAddModal(false)
setEditPaymentId(null)
if (previewBlobUrl) URL.revokeObjectURL(previewBlobUrl)
setPreviewBlobUrl(null)
navigate({ pathname: '/app/administrator/payment/manual-pay', search: '' })
}
function pickSuggestion(text: string) {
setSearchInput(text)
setSuggestOpen(false)
@@ -183,6 +207,14 @@ export function ManualPayPage() {
<button className="btn btn-primary" type="submit" disabled={loading}>
Search
</button>
<button
className="btn btn-outline-secondary"
type="button"
onClick={clearSearch}
disabled={loading && !searchInput && !data}
>
Clear
</button>
</div>
{suggestOpen && suggestions.length > 0 ? (
<div className="list-group position-absolute w-100 shadow-sm" style={{ zIndex: 10 }}>
@@ -6,7 +6,8 @@ import {
fetchReportCardCompleteness,
fetchReportCardMeta,
fetchReportCardStudentHtml,
openReportCardDownload,
openReportCardClassDownload,
openReportCardStudentDownload,
reportCardClassUrl,
reportCardStudentUrl,
type ReportCardMeta,
@@ -118,14 +119,12 @@ export function ReportCardManagementPage() {
async function downloadStudent() {
if (!studentId || !schoolYear) return
const url = reportCardStudentUrl(studentId, {
try {
await openReportCardStudentDownload(studentId, {
school_year: schoolYear,
semester,
download: true,
report_date: reportDate || undefined,
})
try {
await openReportCardDownload(url)
} catch (e: unknown) {
alert(e instanceof ApiHttpError ? e.message : 'Download failed.')
}
@@ -133,14 +132,12 @@ export function ReportCardManagementPage() {
async function downloadClass() {
if (!classId || !schoolYear) return
const url = reportCardClassUrl(classId, {
try {
await openReportCardClassDownload(classId, {
school_year: schoolYear,
semester,
download: true,
report_date: reportDate || undefined,
})
try {
await openReportCardDownload(url)
} catch (e: unknown) {
alert(e instanceof ApiHttpError ? e.message : 'Download failed.')
}
@@ -70,6 +70,7 @@ function buildHistoryQueryString(qs: string): string {
if (v === '' || v == null) emptyKeys.push(k)
})
emptyKeys.forEach((k) => p.delete(k))
p.delete('class_section_id')
if (!p.has('per_page')) p.set('per_page', '100')
if (!p.has('sort_by')) p.set('sort_by', 'week_start')
if (!p.has('sort_dir')) p.set('sort_dir', 'desc')
@@ -106,7 +107,6 @@ export function TeacherClassProgressHistoryPage() {
const [draftWeekStart, setDraftWeekStart] = useState(() => search.get('week_start') ?? '')
const [draftWeekEnd, setDraftWeekEnd] = useState(() => search.get('week_end') ?? '')
const [draftSection, setDraftSection] = useState(() => search.get('class_section_id') ?? '')
const [draftStatus, setDraftStatus] = useState(() => search.get('status') ?? '')
const [draftSortDir, setDraftSortDir] = useState(() => search.get('sort_dir') || 'desc')
@@ -114,22 +114,10 @@ export function TeacherClassProgressHistoryPage() {
const p = new URLSearchParams(buildHistoryQueryString(qs))
setDraftWeekStart(p.get('week_start') ?? '')
setDraftWeekEnd(p.get('week_end') ?? '')
setDraftSection(p.get('class_section_id') ?? '')
setDraftStatus(p.get('status') ?? '')
setDraftSortDir(p.get('sort_dir') || 'desc')
}, [qs])
const sectionOptions = useMemo(() => {
const m = new Map<number, string>()
for (const r of items) {
const id = Number(r.class_section_id ?? 0)
if (!id) continue
const name = String(r.class_section_name ?? '').trim() || `Section ${id}`
if (!m.has(id)) m.set(id, name)
}
return [...m.entries()].sort((a, b) => a[0] - b[0])
}, [items])
function applyFilters(e: FormEvent) {
e.preventDefault()
const next = new URLSearchParams()
@@ -138,7 +126,6 @@ export function TeacherClassProgressHistoryPage() {
next.set('sort_dir', draftSortDir === 'asc' ? 'asc' : 'desc')
if (draftWeekStart.trim()) next.set('week_start', draftWeekStart.trim())
if (draftWeekEnd.trim()) next.set('week_end', draftWeekEnd.trim())
if (draftSection.trim()) next.set('class_section_id', draftSection.trim())
if (draftStatus.trim()) next.set('status', draftStatus.trim())
next.delete('page')
setSearch(next, { replace: true })
@@ -147,7 +134,6 @@ export function TeacherClassProgressHistoryPage() {
function resetFilters() {
setDraftWeekStart('')
setDraftWeekEnd('')
setDraftSection('')
setDraftStatus('')
const next = new URLSearchParams()
next.set('per_page', '100')
@@ -220,24 +206,6 @@ export function TeacherClassProgressHistoryPage() {
onChange={(e) => setDraftWeekEnd(e.target.value)}
/>
</div>
<div className="col-md-3 col-lg-2">
<label className="form-label small mb-0" htmlFor="hist-sec">
Section
</label>
<select
id="hist-sec"
className="form-select form-select-sm"
value={draftSection}
onChange={(e) => setDraftSection(e.target.value)}
>
<option value="">All</option>
{sectionOptions.map(([id, name]) => (
<option key={id} value={String(id)}>
{name}
</option>
))}
</select>
</div>
<div className="col-md-3 col-lg-2">
<label className="form-label small mb-0" htmlFor="hist-st">
Status
@@ -1,5 +1,5 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { Link } from 'react-router-dom'
import { ApiHttpError, apiFetch } from '../../api/http'
import { TeacherShell } from './TeacherShell'
import { useTeacherResource } from './useTeacherResource'
@@ -110,16 +110,7 @@ function appendArray(fd: FormData, key: string, values: string[]) {
/** `teacher/class_progress_submit.php` — weekly progress form; POST `/api/v1/teacher/class-progress-submit`. */
export function TeacherClassProgressSubmitPage() {
const [search] = useSearchParams()
const classIdQ = search.get('class_id')
const path = useMemo(() => {
const params = new URLSearchParams()
if (classIdQ) params.set('class_id', classIdQ)
const q = params.toString()
return `/class-progress-submit${q ? `?${q}` : ''}`
}, [classIdQ])
const { data: raw, loading, error, reload } = useTeacherResource<unknown>(path)
const { data: raw, loading, error, reload } = useTeacherResource<unknown>('/class-progress-submit')
const data = useMemo(() => unwrapSubmitForm(raw), [raw])
const subjectSections = data?.subject_sections ?? {}
@@ -132,15 +123,6 @@ export function TeacherClassProgressSubmitPage() {
const unitPickList = useMemo(() => unitChoices(curriculumIslamic), [curriculumIslamic])
const classIds = useMemo(() => {
const s = new Set<number>()
for (const c of classes) {
const id = Number(c.class_id ?? 0)
if (id > 0) s.add(id)
}
return [...s].sort((a, b) => a - b)
}, [classes])
const [weekStart, setWeekStart] = useState('')
const [classSectionId, setClassSectionId] = useState<number | ''>('')
const [covered, setCovered] = useState<Record<string, string>>({})
@@ -172,31 +154,20 @@ export function TeacherClassProgressSubmitPage() {
setSubmitOk(null)
}, [data, defaults.class_section_id, defaults.week_start, sundayOptions])
const resolvedClassId = useMemo(() => {
const fromQ = classIdQ ? Number(classIdQ) : 0
if (fromQ > 0) return fromQ
return Number(defaults.class_id ?? 0)
}, [classIdQ, defaults.class_id])
const sectionsForClass = useMemo(() => {
if (!resolvedClassId) return classes
return classes.filter((c) => Number(c.class_id ?? 0) === resolvedClassId)
}, [classes, resolvedClassId])
useEffect(() => {
if (classSectionId === '' && sectionsForClass.length === 1) {
const only = Number(sectionsForClass[0].class_section_id ?? 0)
if (classSectionId === '' && classes.length === 1) {
const only = Number(classes[0].class_section_id ?? 0)
if (only > 0) setClassSectionId(only)
}
}, [sectionsForClass, classSectionId])
}, [classes, classSectionId])
const canSubmit = useMemo(() => {
if (!weekStart || !classSectionId) return false
if (!weekStart) return false
if (!islamicSelectionValid(islamicRows)) return false
const covIslamic = (covered.islamic ?? '').trim()
if (covIslamic === '') return false
return true
}, [weekStart, classSectionId, islamicRows, covered])
}, [weekStart, islamicRows, covered])
const updateIslamicRow = useCallback((idx: number, patch: Partial<UnitRow>) => {
setIslamicRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)))
@@ -209,7 +180,7 @@ export function TeacherClassProgressSubmitPage() {
const handleSubmit = useCallback(async () => {
setSubmitError(null)
setSubmitOk(null)
if (!canSubmit || classSectionId === '') return
if (!canSubmit) return
const unitIslamic: string[] = []
const chapterIslamic: string[] = []
@@ -240,8 +211,9 @@ export function TeacherClassProgressSubmitPage() {
}
const fd = new FormData()
fd.append('class_section_id', String(classSectionId))
fd.append('week_start', weekStart)
if (defaults.school_year) fd.append('school_year', defaults.school_year)
if (defaults.semester) fd.append('semester', defaults.semester)
if (confirmOverwrite) fd.append('confirm_overwrite', '1')
for (const slug of slugs) {
@@ -282,8 +254,9 @@ export function TeacherClassProgressSubmitPage() {
}
}, [
canSubmit,
classSectionId,
weekStart,
defaults.school_year,
defaults.semester,
confirmOverwrite,
slugs,
covered,
@@ -328,26 +301,6 @@ export function TeacherClassProgressSubmitPage() {
</div>
) : null}
{classes.length > 0 && classIds.length > 1 ? (
<div className="d-flex flex-wrap align-items-center gap-2">
<span className="small text-muted me-1">Class:</span>
{classIds.map((id) => {
const label =
classes.find((c) => Number(c.class_id) === id)?.class_name?.trim() || `Class ${id}`
const active = resolvedClassId === id
return (
<Link
key={id}
to={`/app/teacher/class-progress-submit?class_id=${encodeURIComponent(String(id))}`}
className={`btn btn-sm ${active ? 'btn-primary' : 'btn-outline-primary'}`}
>
{label}
</Link>
)
})}
</div>
) : null}
{submitOk ? (
<div className="alert alert-success py-2 mb-0" role="status">
{submitOk}
@@ -382,29 +335,6 @@ export function TeacherClassProgressSubmitPage() {
))}
</select>
</div>
<div className="col-md-6">
<label className="form-label" htmlFor="cp-section">
Class section
</label>
<select
id="cp-section"
className="form-select form-select-sm"
value={classSectionId === '' ? '' : String(classSectionId)}
onChange={(e) => setClassSectionId(e.target.value ? Number(e.target.value) : '')}
>
<option value="">Select section</option>
{sectionsForClass.map((c) => {
const id = Number(c.class_section_id ?? 0)
if (!id) return null
const name = String(c.class_section_name ?? '').trim() || `Section ${id}`
return (
<option key={id} value={id}>
{name}
</option>
)
})}
</select>
</div>
</div>
</section>
@@ -671,7 +601,7 @@ export function TeacherClassProgressSubmitPage() {
</button>
{!canSubmit ? (
<span className="small text-muted">
Choose section, week, Islamic unit/chapter, and material covered for Islamic Studies.
Choose week, Islamic unit/chapter, and material covered for Islamic Studies.
</span>
) : null}
</div>
+24 -10
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { Link, Navigate, useParams, useSearchParams } from 'react-router-dom'
import { fetchAdministratorCalendarEvents, openProtectedApiFile } from '../../api/session'
import type { ExamDraftRow, SchoolCalendarEventRow } from '../../api/types'
import { TeacherShell } from './TeacherShell'
@@ -128,8 +128,18 @@ type FrontendMeResponse = {
const EXAM_DRAFT_MAX_BYTES = 12 * 1024 * 1024
const EXAM_DRAFT_ALLOWED_EXTENSIONS = ['doc', 'docx']
function teacherExamFilePath(kind: 'teacher' | 'final', filename?: string | null) {
return filename ? `/api/v1/files/exams/${kind}/${encodeURIComponent(filename)}` : null
function teacherExamStoredFileName(filename?: string | null): string | null {
const value = String(filename ?? '').trim()
return /\.(docx?|pdf)$/i.test(value) ? value : null
}
function teacherExamFilePath(kind: 'teacher' | 'final', filename?: string | null, draftId?: number | null) {
const stored = teacherExamStoredFileName(filename)
if (stored) return `/api/v1/files/exams/${kind}/${encodeURIComponent(stored)}`
if (kind === 'final' && draftId && draftId > 0) {
return `/api/v1/files/exams/final/${encodeURIComponent(String(draftId))}`
}
return null
}
type TeacherAbsenceRow = {
@@ -332,7 +342,9 @@ export function TeacherCompetitionScoresIndexPage() {
<td>{dateLabel}</td>
<td>{isLocked ? 'Yes' : 'No'}</td>
<td>
{isLocked ? (
{compId <= 0 ? (
<span className="text-muted">Unavailable</span>
) : isLocked ? (
<span className="text-muted">Locked</span>
) : (
<Link to={`/app/teacher/competition-scores/${compId}`}>Enter scores</Link>
@@ -353,10 +365,12 @@ export function TeacherCompetitionScoresIndexPage() {
/** `teacher/competition_scores/scores.php` */
export function TeacherCompetitionScoresDetailPage() {
const { competitionId } = useParams()
const path =
competitionId && competitionId !== 'undefined'
? `/competition-scores/${competitionId}`
: '/competition-scores/0'
const id = Number(competitionId ?? 0)
if (!Number.isInteger(id) || id <= 0) {
return <Navigate to="/app/teacher/competition-scores" replace />
}
const path = `/competition-scores/${id}`
return <TeacherApiDumpPage title="Competition scores entry" path={path} />
}
@@ -1279,7 +1293,7 @@ export function TeacherExamDraftsPage() {
) : (
(payload?.drafts ?? []).map((draft) => {
const teacherFile = teacherExamFilePath('teacher', draft.teacher_file)
const finalFile = teacherExamFilePath('final', draft.final_pdf_file ?? draft.final_file)
const finalFile = teacherExamFilePath('final', draft.final_pdf_file ?? draft.final_file, draft.id)
return (
<tr key={draft.id}>
<td>
@@ -1340,7 +1354,7 @@ export function TeacherExamDraftsPage() {
) : (
<div className="row g-3">
{(payload?.legacy_exams ?? []).map((draft) => {
const finalFile = teacherExamFilePath('final', draft.final_pdf_file ?? draft.final_file)
const finalFile = teacherExamFilePath('final', draft.final_pdf_file ?? draft.final_file, draft.id)
return (
<div key={draft.id} className="col-12 col-xl-6">
<article className="teacher-calendar-agenda-item h-100">