fix parent pages and teachers and admin
This commit is contained in:
@@ -1003,11 +1003,6 @@ const ParentPaymentInvoicesPage = lazy(() =>
|
|||||||
default: m.ParentPaymentInvoicesPage,
|
default: m.ParentPaymentInvoicesPage,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
const ParentStatementPage = lazy(() =>
|
|
||||||
import('./pages/parent/ParentStatementPage').then((m) => ({
|
|
||||||
default: m.ParentStatementPage,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
const ParentRegisterStudentPage = lazy(() =>
|
const ParentRegisterStudentPage = lazy(() =>
|
||||||
import('./pages/parent/WiredParentScreens').then((m) => ({
|
import('./pages/parent/WiredParentScreens').then((m) => ({
|
||||||
default: m.ParentRegisterStudentPage,
|
default: m.ParentRegisterStudentPage,
|
||||||
@@ -1539,7 +1534,6 @@ export default function App() {
|
|||||||
<Route path="parent/enroll-classes" element={<ParentEnrollClassesPage />} />
|
<Route path="parent/enroll-classes" element={<ParentEnrollClassesPage />} />
|
||||||
<Route path="parent/events" element={<ParentEventsPage />} />
|
<Route path="parent/events" element={<ParentEventsPage />} />
|
||||||
<Route path="parent/payment" element={<ParentPaymentInvoicesPage />} />
|
<Route path="parent/payment" element={<ParentPaymentInvoicesPage />} />
|
||||||
<Route path="parent/statements" element={<ParentStatementPage />} />
|
|
||||||
<Route path="parent/register-student" element={<ParentRegisterStudentPage />} />
|
<Route path="parent/register-student" element={<ParentRegisterStudentPage />} />
|
||||||
<Route path="parent/report-attendance" element={<ParentReportAttendancePage />} />
|
<Route path="parent/report-attendance" element={<ParentReportAttendancePage />} />
|
||||||
<Route path="parent/attendance-reports" element={<ParentAttendanceReportsHistoryPage />} />
|
<Route path="parent/attendance-reports" element={<ParentAttendanceReportsHistoryPage />} />
|
||||||
|
|||||||
+68
-11
@@ -222,8 +222,7 @@ export async function fetchReportCardCompleteness(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Build absolute API URL for iframe / window.open (report PDF HTML). */
|
function reportCardStudentPath(
|
||||||
export function reportCardStudentUrl(
|
|
||||||
studentId: number | string,
|
studentId: number | string,
|
||||||
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
|
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
|
||||||
): string {
|
): string {
|
||||||
@@ -233,10 +232,10 @@ export function reportCardStudentUrl(
|
|||||||
if (opts.download) qs.set('download', '1')
|
if (opts.download) qs.set('download', '1')
|
||||||
if (opts.report_date) qs.set('report_date', opts.report_date)
|
if (opts.report_date) qs.set('report_date', opts.report_date)
|
||||||
qs.set('cb', String(Date.now()))
|
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,
|
classSectionId: number | string,
|
||||||
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
|
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
|
||||||
): string {
|
): string {
|
||||||
@@ -246,7 +245,22 @@ export function reportCardClassUrl(
|
|||||||
if (opts.download) qs.set('download', '1')
|
if (opts.download) qs.set('download', '1')
|
||||||
if (opts.report_date) qs.set('report_date', opts.report_date)
|
if (opts.report_date) qs.set('report_date', opts.report_date)
|
||||||
qs.set('cb', String(Date.now()))
|
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). */
|
/** HTML document for iframe preview (Bearer auth; unlike raw iframe src). */
|
||||||
@@ -288,15 +302,58 @@ export async function fetchReportCardClassHtml(
|
|||||||
return res.text()
|
return res.text()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opens PDF download in new tab (authenticated GET). */
|
async function fetchReportCardPdf(path: string): Promise<Blob> {
|
||||||
export async function openReportCardDownload(url: string): Promise<void> {
|
|
||||||
const headers = new Headers({ Accept: 'application/pdf,*/*' })
|
const headers = new Headers({ Accept: 'application/pdf,*/*' })
|
||||||
const token = getStoredToken()
|
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}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
applyStoredSchoolYearHeaders(headers)
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(url, { headers })
|
const res = await fetch(apiUrl(path), { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
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()
|
const raw = await res.blob()
|
||||||
const blob = new Blob([await raw.arrayBuffer()], { type: 'application/pdf' })
|
return new Blob([await raw.arrayBuffer()], { type: 'application/pdf' })
|
||||||
window.open(URL.createObjectURL(blob), '_blank', 'noopener')
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -448,7 +448,7 @@ export async function fetchParentStatement(params: {
|
|||||||
if (params.parentId != null) query.set('parent_id', String(params.parentId))
|
if (params.parentId != null) query.set('parent_id', String(params.parentId))
|
||||||
|
|
||||||
const response = await apiFetch<ApiParentStatementResponse>(
|
const response = await apiFetch<ApiParentStatementResponse>(
|
||||||
`/api/v1/parent/statements${query.toString() ? `?${query}` : ''}`,
|
`/api/v1/parents/statements${query.toString() ? `?${query}` : ''}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
|
|||||||
@@ -1974,6 +1974,8 @@ export async function fetchClassProgressGroups(params?: {
|
|||||||
weekStart?: string | null
|
weekStart?: string | null
|
||||||
weekEnd?: string | null
|
weekEnd?: string | null
|
||||||
subject?: string | null
|
subject?: string | null
|
||||||
|
schoolYear?: string | null
|
||||||
|
semester?: string | null
|
||||||
page?: number
|
page?: number
|
||||||
perPage?: number
|
perPage?: number
|
||||||
}): Promise<ClassProgressGroupsResponse> {
|
}): Promise<ClassProgressGroupsResponse> {
|
||||||
@@ -1984,6 +1986,8 @@ export async function fetchClassProgressGroups(params?: {
|
|||||||
if (params?.weekStart) query.set('week_start', params.weekStart)
|
if (params?.weekStart) query.set('week_start', params.weekStart)
|
||||||
if (params?.weekEnd) query.set('week_end', params.weekEnd)
|
if (params?.weekEnd) query.set('week_end', params.weekEnd)
|
||||||
if (params?.subject) query.set('subject', params.subject)
|
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?.page) query.set('page', String(params.page))
|
||||||
if (params?.perPage) query.set('per_page', String(params.perPage))
|
if (params?.perPage) query.set('per_page', String(params.perPage))
|
||||||
return apiFetch<ClassProgressGroupsResponse>(`/api/v1/class-progress?${query.toString()}`)
|
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?: {
|
export async function fetchClassProgressMeta(params?: {
|
||||||
classId?: number | null
|
classId?: number | null
|
||||||
sundayCount?: number | null
|
sundayCount?: number | null
|
||||||
|
schoolYear?: string | null
|
||||||
|
semester?: string | null
|
||||||
}): Promise<ClassProgressMetaResponse> {
|
}): Promise<ClassProgressMetaResponse> {
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
if (params?.classId) query.set('class_id', String(params.classId))
|
if (params?.classId) query.set('class_id', String(params.classId))
|
||||||
if (params?.sundayCount) query.set('sunday_count', String(params.sundayCount))
|
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()}` : ''
|
const qs = query.size > 0 ? `?${query.toString()}` : ''
|
||||||
return apiFetch<ClassProgressMetaResponse>(`/api/v1/class-progress/meta${qs}`)
|
return apiFetch<ClassProgressMetaResponse>(`/api/v1/class-progress/meta${qs}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -349,9 +349,23 @@ export type ParentProfileResponse = {
|
|||||||
export type ParentInvoiceRow = {
|
export type ParentInvoiceRow = {
|
||||||
id: number
|
id: number
|
||||||
invoice_number?: string | null
|
invoice_number?: string | null
|
||||||
|
total_amount?: number
|
||||||
|
paid_amount?: number
|
||||||
balance?: number
|
balance?: number
|
||||||
status?: string | null
|
status?: string | null
|
||||||
due_date?: 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 = {
|
export type ParentInvoicesResponse = {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const parentNavItems = [
|
|||||||
{ to: '/app/parent/enroll-classes', icon: 'bi bi-pencil-square', label: 'Enroll in Classes' },
|
{ 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/events', icon: 'bi bi-bell', label: 'Events' },
|
||||||
{ to: '/app/parent/payment', icon: 'bi bi-credit-card', label: 'Invoice' },
|
{ 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/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-attendance', icon: 'bi bi-megaphone', label: 'Report Absence' },
|
||||||
{ to: '/app/parent/report-cards', icon: 'bi bi-file-earmark-text', label: 'Report Cards' },
|
{ 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">
|
<div className="container-fluid">
|
||||||
<Link className="navbar-brand fw-semibold d-inline-flex align-items-center gap-2" to={dashboardPath}>
|
<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" />
|
<img className="main-layout-logo" src="/logo.png" alt="Al Rahma Sunday School logo" />
|
||||||
Al Rahma Sunday School
|
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
className="navbar-toggler"
|
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() {
|
export function SchoolYearsManagementPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [years, setYears] = useState<SchoolYearRecord[]>([])
|
const [years, setYears] = useState<SchoolYearRecord[]>([])
|
||||||
@@ -141,6 +221,14 @@ export function SchoolYearsManagementPage() {
|
|||||||
const [busyEdit, setBusyEdit] = useState(false)
|
const [busyEdit, setBusyEdit] = useState(false)
|
||||||
const [busyOpenId, setBusyOpenId] = useState<number | null>(null)
|
const [busyOpenId, setBusyOpenId] = useState<number | null>(null)
|
||||||
const [busyCloseAction, setBusyCloseAction] = useState<'validate' | 'preview' | 'close' | 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 selectedYearName = searchParams.get('school_year')?.trim() || null
|
||||||
|
|
||||||
const activeYear = useMemo(
|
const activeYear = useMemo(
|
||||||
@@ -346,6 +434,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
suggestNextName(selectedYear.name),
|
suggestNextName(selectedYear.name),
|
||||||
)
|
)
|
||||||
setPromotionPreview(preview)
|
setPromotionPreview(preview)
|
||||||
|
setPromotionPage(1)
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
setError(normalizeApiError(loadError, 'Unable to load promotion preview.'))
|
setError(normalizeApiError(loadError, 'Unable to load promotion preview.'))
|
||||||
}
|
}
|
||||||
@@ -361,6 +450,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
suggestNextName(selectedYear.name),
|
suggestNextName(selectedYear.name),
|
||||||
)
|
)
|
||||||
setParentBalances(balances)
|
setParentBalances(balances)
|
||||||
|
setBalancePage(1)
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
setError(normalizeApiError(loadError, 'Unable to load parent balance preview.'))
|
setError(normalizeApiError(loadError, 'Unable to load parent balance preview.'))
|
||||||
}
|
}
|
||||||
@@ -428,6 +518,10 @@ export function SchoolYearsManagementPage() {
|
|||||||
const balanceRows = parentBalances?.rows ?? []
|
const balanceRows = parentBalances?.rows ?? []
|
||||||
const closePromotionRows = closePreview?.promotion_preview.rows ?? []
|
const closePromotionRows = closePreview?.promotion_preview.rows ?? []
|
||||||
const closeBalanceRows = closePreview?.parent_balances.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 selectedYearReadOnly = isReadOnlySchoolYear(selectedYear)
|
||||||
const selectedYearIsActive = Boolean(selectedYear?.is_current)
|
const selectedYearIsActive = Boolean(selectedYear?.is_current)
|
||||||
const requiredConfirmation = selectedYearIsActive && selectedYear ? `CLOSE ${selectedYear.name}` : ''
|
const requiredConfirmation = selectedYearIsActive && selectedYear ? `CLOSE ${selectedYear.name}` : ''
|
||||||
@@ -783,6 +877,20 @@ export function SchoolYearsManagementPage() {
|
|||||||
{promotionPreview.summary.total_students} students scanned,{' '}
|
{promotionPreview.summary.total_students} students scanned,{' '}
|
||||||
{promotionPreview.summary.hold} hold rows.
|
{promotionPreview.summary.hold} hold rows.
|
||||||
</div>
|
</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">
|
<div className="table-responsive">
|
||||||
<table className="table table-sm align-middle mb-0">
|
<table className="table table-sm align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -801,7 +909,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
promotionRows.slice(0, 8).map((row) => (
|
pagedPromotionRows.rows.map((row) => (
|
||||||
<tr key={row.student_id}>
|
<tr key={row.student_id}>
|
||||||
<td>
|
<td>
|
||||||
<div>{row.student_name}</div>
|
<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)},{' '}
|
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)}.
|
credits ${formatMoney(parentBalances.summary.total_credit_balance)}, net ${formatMoney(parentBalances.summary.net_balance_impact ?? parentBalances.summary.net_balance_to_transfer)}.
|
||||||
</div>
|
</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">
|
<div className="table-responsive">
|
||||||
<table className="table table-sm align-middle mb-0">
|
<table className="table table-sm align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -858,7 +980,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
balanceRows.slice(0, 8).map((row) => (
|
pagedBalanceRows.rows.map((row) => (
|
||||||
<tr key={row.parent_id}>
|
<tr key={row.parent_id}>
|
||||||
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
|
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
|
||||||
<td>{row.student_names?.join(', ') || '—'}</td>
|
<td>{row.student_names?.join(', ') || '—'}</td>
|
||||||
@@ -1092,6 +1214,20 @@ export function SchoolYearsManagementPage() {
|
|||||||
<div className="col-lg-6">
|
<div className="col-lg-6">
|
||||||
<div className="border rounded p-3 h-100">
|
<div className="border rounded p-3 h-100">
|
||||||
<h3 className="h6">Closure promotion preview</h3>
|
<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">
|
<div className="table-responsive">
|
||||||
<table className="table table-sm align-middle mb-0">
|
<table className="table table-sm align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -1102,7 +1238,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{closePromotionRows.slice(0, 8).map((row) => (
|
{pagedClosePromotionRows.rows.map((row) => (
|
||||||
<tr key={`close-${row.student_id}`}>
|
<tr key={`close-${row.student_id}`}>
|
||||||
<td>{row.student_name}</td>
|
<td>{row.student_name}</td>
|
||||||
<td>{row.promotion_action}</td>
|
<td>{row.promotion_action}</td>
|
||||||
@@ -1120,6 +1256,20 @@ export function SchoolYearsManagementPage() {
|
|||||||
<div className="small text-muted mb-2">
|
<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.
|
Source year: {closePreview.parent_balances.from_school_year}; target year: {closePreview.parent_balances.to_school_year}. Transfer mode: unpaid balances only.
|
||||||
</div>
|
</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">
|
<div className="table-responsive">
|
||||||
<table className="table table-sm align-middle mb-0">
|
<table className="table table-sm align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -1133,7 +1283,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{closeBalanceRows.slice(0, 8).map((row) => (
|
{pagedCloseBalanceRows.rows.map((row) => (
|
||||||
<tr key={`balance-${row.parent_id}`}>
|
<tr key={`balance-${row.parent_id}`}>
|
||||||
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
|
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
|
||||||
<td>{row.student_names?.join(', ') || '—'}</td>
|
<td>{row.student_names?.join(', ') || '—'}</td>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
fetchClassProgressGroups,
|
fetchClassProgressGroups,
|
||||||
fetchClassProgressMeta,
|
fetchClassProgressMeta,
|
||||||
@@ -20,6 +20,9 @@ export function ClassProgressListPage() {
|
|||||||
const [items, setItems] = useState<ClassProgressGroupRow[]>([])
|
const [items, setItems] = useState<ClassProgressGroupRow[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const navigate = useNavigate()
|
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). */
|
/** Server may later expose `low_progress_section_ids`; until then keep empty (CI disables button). */
|
||||||
const lowProgressSectionIds: number[] = []
|
const lowProgressSectionIds: number[] = []
|
||||||
@@ -33,10 +36,12 @@ export function ClassProgressListPage() {
|
|||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [meta, sections, groups] = await Promise.all([
|
const [meta, sections, groups] = await Promise.all([
|
||||||
fetchClassProgressMeta(),
|
fetchClassProgressMeta({ schoolYear: selectedSchoolYear, semester: selectedSemester }),
|
||||||
fetchClassSections({ perPage: 200, withStudents: true }),
|
fetchClassSections({ perPage: 200, withStudents: true, schoolYear: selectedSchoolYear, semester: selectedSemester }),
|
||||||
fetchClassProgressGroups({
|
fetchClassProgressGroups({
|
||||||
classSectionId: filters.classSectionId ? Number(filters.classSectionId) : null,
|
classSectionId: filters.classSectionId ? Number(filters.classSectionId) : null,
|
||||||
|
schoolYear: selectedSchoolYear,
|
||||||
|
semester: selectedSemester,
|
||||||
status: filters.status || null,
|
status: filters.status || null,
|
||||||
weekStart: filters.weekStart || null,
|
weekStart: filters.weekStart || null,
|
||||||
weekEnd: filters.weekEnd || null,
|
weekEnd: filters.weekEnd || null,
|
||||||
@@ -55,7 +60,7 @@ export function ClassProgressListPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load()
|
void load()
|
||||||
}, [])
|
}, [selectedSchoolYear, selectedSemester])
|
||||||
|
|
||||||
const groupsBySection = useMemo(() => {
|
const groupsBySection = useMemo(() => {
|
||||||
const m = new Map<number, ClassProgressGroupRow[]>()
|
const m = new Map<number, ClassProgressGroupRow[]>()
|
||||||
@@ -69,6 +74,26 @@ export function ClassProgressListPage() {
|
|||||||
return m
|
return m
|
||||||
}, [items])
|
}, [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
|
const subjectCount = Object.keys(subjectSections).length
|
||||||
|
|
||||||
function teacherLabelForGroup(group: ClassProgressGroupRow): string {
|
function teacherLabelForGroup(group: ClassProgressGroupRow): string {
|
||||||
@@ -218,11 +243,11 @@ export function ClassProgressListPage() {
|
|||||||
|
|
||||||
<div className="card shadow-sm mt-5">
|
<div className="card shadow-sm mt-5">
|
||||||
<div className="card-body pt-4">
|
<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="text-center text-muted py-4">No class sections found.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="accordion" id="adminProgressAccordion">
|
<div className="accordion" id="adminProgressAccordion">
|
||||||
{classSections.map((cs) => {
|
{displaySections.map((cs) => {
|
||||||
const sectionId = Number(cs.class_section_id ?? 0)
|
const sectionId = Number(cs.class_section_id ?? 0)
|
||||||
const sectionName = cs.class_section_name ?? 'Unknown Section'
|
const sectionName = cs.class_section_name ?? 'Unknown Section'
|
||||||
const sectionGroups = groupsBySection.get(sectionId) ?? []
|
const sectionGroups = groupsBySection.get(sectionId) ?? []
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { ParentParityShell } from './ParentParityShell'
|
import { ParentParityShell } from './ParentParityShell'
|
||||||
import {
|
import {
|
||||||
fetchAttendanceReportForm,
|
fetchAttendanceReportForm,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
submitContactMessage,
|
submitContactMessage,
|
||||||
updateParentEnrollments,
|
updateParentEnrollments,
|
||||||
} from '../../api/session'
|
} from '../../api/session'
|
||||||
|
import { fetchParentStatement, type ParentStatement } from '../../api/schoolYears'
|
||||||
import type {
|
import type {
|
||||||
ParentAttendanceReportFormResponse,
|
ParentAttendanceReportFormResponse,
|
||||||
ParentEnrollmentStudent,
|
ParentEnrollmentStudent,
|
||||||
@@ -19,6 +21,7 @@ import type {
|
|||||||
ParentRegistrationOverviewResponse,
|
ParentRegistrationOverviewResponse,
|
||||||
} from '../../api/types'
|
} from '../../api/types'
|
||||||
import { useAuth } from '../../auth/AuthProvider'
|
import { useAuth } from '../../auth/AuthProvider'
|
||||||
|
import { useSchoolYear } from '../../context/SchoolYearContext'
|
||||||
|
|
||||||
/** `Views/parent/contact.php` — POST /api/v1/contact */
|
/** `Views/parent/contact.php` — POST /api/v1/contact */
|
||||||
export function ParentContactPage() {
|
export function ParentContactPage() {
|
||||||
@@ -480,18 +483,59 @@ function choiceBadge(action: 'enroll' | 'withdraw' | null) {
|
|||||||
return <span className="text-muted small">No change</span>
|
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 */
|
/** `Views/parent/payment_view.php` — GET /api/v1/parents/invoices */
|
||||||
export function ParentPaymentInvoicesPage() {
|
export function ParentPaymentInvoicesPage() {
|
||||||
|
const { selectedSchoolYearName } = useSchoolYear()
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [statementError, setStatementError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [statementLoading, setStatementLoading] = useState(false)
|
||||||
const [rows, setRows] = useState<ParentInvoiceRow[]>([])
|
const [rows, setRows] = useState<ParentInvoiceRow[]>([])
|
||||||
|
const [statement, setStatement] = useState<ParentStatement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!selectedSchoolYearName) return
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await fetchParentInvoices(null)
|
const data = await fetchParentInvoices(selectedSchoolYearName)
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setRows(data.invoices ?? [])
|
setRows(data.invoices ?? [])
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -504,53 +548,287 @@ export function ParentPaymentInvoicesPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
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 (
|
return (
|
||||||
<ParentParityShell
|
<ParentParityShell
|
||||||
title="Payments & invoices"
|
title="Payments & invoices"
|
||||||
ciViewFile="payment_view.php"
|
ciViewFile="payment_view.php"
|
||||||
legacyCiRoutes={['/parent/payment']}
|
legacyCiRoutes={['/parent/payment']}
|
||||||
|
fullWidth
|
||||||
>
|
>
|
||||||
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
{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 ? (
|
{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">
|
<div className="table-responsive">
|
||||||
<table className="table table-sm table-striped">
|
<table className="table table-sm mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>Date</th>
|
||||||
<th>Invoice</th>
|
<th>Amount</th>
|
||||||
<th>Balance</th>
|
<th>Method</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Due</th>
|
<th>Transaction</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
<tr>
|
||||||
<td colSpan={5} className="text-muted">
|
<th>Description</th>
|
||||||
No invoices.
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
rows.map((inv) => (
|
statementLines.map((line, index) => (
|
||||||
<tr key={inv.id}>
|
<tr key={`${line.label}-${line.invoice_id ?? index}`}>
|
||||||
<td>{inv.id}</td>
|
<td>{line.label}</td>
|
||||||
<td>{inv.invoice_number ?? '—'}</td>
|
<td>{line.source_school_year ?? '—'}</td>
|
||||||
<td>
|
<td>{line.invoice_id ?? '—'}</td>
|
||||||
{typeof inv.balance === 'number' ? inv.balance.toFixed(2) : '—'}
|
<td className={`text-end ${paymentTone(line.amount)}`}>${formatCurrency(line.amount)}</td>
|
||||||
</td>
|
|
||||||
<td>{inv.status ?? '—'}</td>
|
|
||||||
<td>{inv.due_date ?? '—'}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
</ParentParityShell>
|
</ParentParityShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
import {
|
import {
|
||||||
@@ -49,23 +49,32 @@ export function ManualPayPage() {
|
|||||||
const [previewBlobUrl, setPreviewBlobUrl] = useState<string | null>(null)
|
const [previewBlobUrl, setPreviewBlobUrl] = useState<string | null>(null)
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const [editPaymentId, setEditPaymentId] = useState<number | string | null>(null)
|
const [editPaymentId, setEditPaymentId] = useState<number | string | null>(null)
|
||||||
|
const loadSeq = useRef(0)
|
||||||
|
|
||||||
const activeTerm = termFromUrl
|
const activeTerm = termFromUrl
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
(term: string) => {
|
(term: string) => {
|
||||||
|
const requestId = ++loadSeq.current
|
||||||
if (!term.trim()) {
|
if (!term.trim()) {
|
||||||
setData(null)
|
setData(null)
|
||||||
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
fetchManualPayPage({ search_term: term.trim() })
|
fetchManualPayPage({ search_term: term.trim() })
|
||||||
.then(setData)
|
.then((payload) => {
|
||||||
.catch((e: unknown) =>
|
if (requestId === loadSeq.current) setData(payload)
|
||||||
setError(e instanceof ApiHttpError ? e.message : 'Could not load manual pay data.'),
|
})
|
||||||
)
|
.catch((e: unknown) => {
|
||||||
.finally(() => setLoading(false))
|
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)}` })
|
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) {
|
function pickSuggestion(text: string) {
|
||||||
setSearchInput(text)
|
setSearchInput(text)
|
||||||
setSuggestOpen(false)
|
setSuggestOpen(false)
|
||||||
@@ -183,6 +207,14 @@ export function ManualPayPage() {
|
|||||||
<button className="btn btn-primary" type="submit" disabled={loading}>
|
<button className="btn btn-primary" type="submit" disabled={loading}>
|
||||||
Search
|
Search
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={clearSearch}
|
||||||
|
disabled={loading && !searchInput && !data}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{suggestOpen && suggestions.length > 0 ? (
|
{suggestOpen && suggestions.length > 0 ? (
|
||||||
<div className="list-group position-absolute w-100 shadow-sm" style={{ zIndex: 10 }}>
|
<div className="list-group position-absolute w-100 shadow-sm" style={{ zIndex: 10 }}>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
fetchReportCardCompleteness,
|
fetchReportCardCompleteness,
|
||||||
fetchReportCardMeta,
|
fetchReportCardMeta,
|
||||||
fetchReportCardStudentHtml,
|
fetchReportCardStudentHtml,
|
||||||
openReportCardDownload,
|
openReportCardClassDownload,
|
||||||
|
openReportCardStudentDownload,
|
||||||
reportCardClassUrl,
|
reportCardClassUrl,
|
||||||
reportCardStudentUrl,
|
reportCardStudentUrl,
|
||||||
type ReportCardMeta,
|
type ReportCardMeta,
|
||||||
@@ -118,14 +119,12 @@ export function ReportCardManagementPage() {
|
|||||||
|
|
||||||
async function downloadStudent() {
|
async function downloadStudent() {
|
||||||
if (!studentId || !schoolYear) return
|
if (!studentId || !schoolYear) return
|
||||||
const url = reportCardStudentUrl(studentId, {
|
try {
|
||||||
|
await openReportCardStudentDownload(studentId, {
|
||||||
school_year: schoolYear,
|
school_year: schoolYear,
|
||||||
semester,
|
semester,
|
||||||
download: true,
|
|
||||||
report_date: reportDate || undefined,
|
report_date: reportDate || undefined,
|
||||||
})
|
})
|
||||||
try {
|
|
||||||
await openReportCardDownload(url)
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
alert(e instanceof ApiHttpError ? e.message : 'Download failed.')
|
alert(e instanceof ApiHttpError ? e.message : 'Download failed.')
|
||||||
}
|
}
|
||||||
@@ -133,14 +132,12 @@ export function ReportCardManagementPage() {
|
|||||||
|
|
||||||
async function downloadClass() {
|
async function downloadClass() {
|
||||||
if (!classId || !schoolYear) return
|
if (!classId || !schoolYear) return
|
||||||
const url = reportCardClassUrl(classId, {
|
try {
|
||||||
|
await openReportCardClassDownload(classId, {
|
||||||
school_year: schoolYear,
|
school_year: schoolYear,
|
||||||
semester,
|
semester,
|
||||||
download: true,
|
|
||||||
report_date: reportDate || undefined,
|
report_date: reportDate || undefined,
|
||||||
})
|
})
|
||||||
try {
|
|
||||||
await openReportCardDownload(url)
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
alert(e instanceof ApiHttpError ? e.message : 'Download failed.')
|
alert(e instanceof ApiHttpError ? e.message : 'Download failed.')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ function buildHistoryQueryString(qs: string): string {
|
|||||||
if (v === '' || v == null) emptyKeys.push(k)
|
if (v === '' || v == null) emptyKeys.push(k)
|
||||||
})
|
})
|
||||||
emptyKeys.forEach((k) => p.delete(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('per_page')) p.set('per_page', '100')
|
||||||
if (!p.has('sort_by')) p.set('sort_by', 'week_start')
|
if (!p.has('sort_by')) p.set('sort_by', 'week_start')
|
||||||
if (!p.has('sort_dir')) p.set('sort_dir', 'desc')
|
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 [draftWeekStart, setDraftWeekStart] = useState(() => search.get('week_start') ?? '')
|
||||||
const [draftWeekEnd, setDraftWeekEnd] = useState(() => search.get('week_end') ?? '')
|
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 [draftStatus, setDraftStatus] = useState(() => search.get('status') ?? '')
|
||||||
const [draftSortDir, setDraftSortDir] = useState(() => search.get('sort_dir') || 'desc')
|
const [draftSortDir, setDraftSortDir] = useState(() => search.get('sort_dir') || 'desc')
|
||||||
|
|
||||||
@@ -114,22 +114,10 @@ export function TeacherClassProgressHistoryPage() {
|
|||||||
const p = new URLSearchParams(buildHistoryQueryString(qs))
|
const p = new URLSearchParams(buildHistoryQueryString(qs))
|
||||||
setDraftWeekStart(p.get('week_start') ?? '')
|
setDraftWeekStart(p.get('week_start') ?? '')
|
||||||
setDraftWeekEnd(p.get('week_end') ?? '')
|
setDraftWeekEnd(p.get('week_end') ?? '')
|
||||||
setDraftSection(p.get('class_section_id') ?? '')
|
|
||||||
setDraftStatus(p.get('status') ?? '')
|
setDraftStatus(p.get('status') ?? '')
|
||||||
setDraftSortDir(p.get('sort_dir') || 'desc')
|
setDraftSortDir(p.get('sort_dir') || 'desc')
|
||||||
}, [qs])
|
}, [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) {
|
function applyFilters(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const next = new URLSearchParams()
|
const next = new URLSearchParams()
|
||||||
@@ -138,7 +126,6 @@ export function TeacherClassProgressHistoryPage() {
|
|||||||
next.set('sort_dir', draftSortDir === 'asc' ? 'asc' : 'desc')
|
next.set('sort_dir', draftSortDir === 'asc' ? 'asc' : 'desc')
|
||||||
if (draftWeekStart.trim()) next.set('week_start', draftWeekStart.trim())
|
if (draftWeekStart.trim()) next.set('week_start', draftWeekStart.trim())
|
||||||
if (draftWeekEnd.trim()) next.set('week_end', draftWeekEnd.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())
|
if (draftStatus.trim()) next.set('status', draftStatus.trim())
|
||||||
next.delete('page')
|
next.delete('page')
|
||||||
setSearch(next, { replace: true })
|
setSearch(next, { replace: true })
|
||||||
@@ -147,7 +134,6 @@ export function TeacherClassProgressHistoryPage() {
|
|||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
setDraftWeekStart('')
|
setDraftWeekStart('')
|
||||||
setDraftWeekEnd('')
|
setDraftWeekEnd('')
|
||||||
setDraftSection('')
|
|
||||||
setDraftStatus('')
|
setDraftStatus('')
|
||||||
const next = new URLSearchParams()
|
const next = new URLSearchParams()
|
||||||
next.set('per_page', '100')
|
next.set('per_page', '100')
|
||||||
@@ -220,24 +206,6 @@ export function TeacherClassProgressHistoryPage() {
|
|||||||
onChange={(e) => setDraftWeekEnd(e.target.value)}
|
onChange={(e) => setDraftWeekEnd(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="col-md-3 col-lg-2">
|
||||||
<label className="form-label small mb-0" htmlFor="hist-st">
|
<label className="form-label small mb-0" htmlFor="hist-st">
|
||||||
Status
|
Status
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
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 { ApiHttpError, apiFetch } from '../../api/http'
|
||||||
import { TeacherShell } from './TeacherShell'
|
import { TeacherShell } from './TeacherShell'
|
||||||
import { useTeacherResource } from './useTeacherResource'
|
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`. */
|
/** `teacher/class_progress_submit.php` — weekly progress form; POST `/api/v1/teacher/class-progress-submit`. */
|
||||||
export function TeacherClassProgressSubmitPage() {
|
export function TeacherClassProgressSubmitPage() {
|
||||||
const [search] = useSearchParams()
|
const { data: raw, loading, error, reload } = useTeacherResource<unknown>('/class-progress-submit')
|
||||||
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 = useMemo(() => unwrapSubmitForm(raw), [raw])
|
const data = useMemo(() => unwrapSubmitForm(raw), [raw])
|
||||||
|
|
||||||
const subjectSections = data?.subject_sections ?? {}
|
const subjectSections = data?.subject_sections ?? {}
|
||||||
@@ -132,15 +123,6 @@ export function TeacherClassProgressSubmitPage() {
|
|||||||
|
|
||||||
const unitPickList = useMemo(() => unitChoices(curriculumIslamic), [curriculumIslamic])
|
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 [weekStart, setWeekStart] = useState('')
|
||||||
const [classSectionId, setClassSectionId] = useState<number | ''>('')
|
const [classSectionId, setClassSectionId] = useState<number | ''>('')
|
||||||
const [covered, setCovered] = useState<Record<string, string>>({})
|
const [covered, setCovered] = useState<Record<string, string>>({})
|
||||||
@@ -172,31 +154,20 @@ export function TeacherClassProgressSubmitPage() {
|
|||||||
setSubmitOk(null)
|
setSubmitOk(null)
|
||||||
}, [data, defaults.class_section_id, defaults.week_start, sundayOptions])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (classSectionId === '' && sectionsForClass.length === 1) {
|
if (classSectionId === '' && classes.length === 1) {
|
||||||
const only = Number(sectionsForClass[0].class_section_id ?? 0)
|
const only = Number(classes[0].class_section_id ?? 0)
|
||||||
if (only > 0) setClassSectionId(only)
|
if (only > 0) setClassSectionId(only)
|
||||||
}
|
}
|
||||||
}, [sectionsForClass, classSectionId])
|
}, [classes, classSectionId])
|
||||||
|
|
||||||
const canSubmit = useMemo(() => {
|
const canSubmit = useMemo(() => {
|
||||||
if (!weekStart || !classSectionId) return false
|
if (!weekStart) return false
|
||||||
if (!islamicSelectionValid(islamicRows)) return false
|
if (!islamicSelectionValid(islamicRows)) return false
|
||||||
const covIslamic = (covered.islamic ?? '').trim()
|
const covIslamic = (covered.islamic ?? '').trim()
|
||||||
if (covIslamic === '') return false
|
if (covIslamic === '') return false
|
||||||
return true
|
return true
|
||||||
}, [weekStart, classSectionId, islamicRows, covered])
|
}, [weekStart, islamicRows, covered])
|
||||||
|
|
||||||
const updateIslamicRow = useCallback((idx: number, patch: Partial<UnitRow>) => {
|
const updateIslamicRow = useCallback((idx: number, patch: Partial<UnitRow>) => {
|
||||||
setIslamicRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)))
|
setIslamicRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)))
|
||||||
@@ -209,7 +180,7 @@ export function TeacherClassProgressSubmitPage() {
|
|||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
setSubmitOk(null)
|
setSubmitOk(null)
|
||||||
if (!canSubmit || classSectionId === '') return
|
if (!canSubmit) return
|
||||||
|
|
||||||
const unitIslamic: string[] = []
|
const unitIslamic: string[] = []
|
||||||
const chapterIslamic: string[] = []
|
const chapterIslamic: string[] = []
|
||||||
@@ -240,8 +211,9 @@ export function TeacherClassProgressSubmitPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('class_section_id', String(classSectionId))
|
|
||||||
fd.append('week_start', weekStart)
|
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')
|
if (confirmOverwrite) fd.append('confirm_overwrite', '1')
|
||||||
|
|
||||||
for (const slug of slugs) {
|
for (const slug of slugs) {
|
||||||
@@ -282,8 +254,9 @@ export function TeacherClassProgressSubmitPage() {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
canSubmit,
|
canSubmit,
|
||||||
classSectionId,
|
|
||||||
weekStart,
|
weekStart,
|
||||||
|
defaults.school_year,
|
||||||
|
defaults.semester,
|
||||||
confirmOverwrite,
|
confirmOverwrite,
|
||||||
slugs,
|
slugs,
|
||||||
covered,
|
covered,
|
||||||
@@ -328,26 +301,6 @@ export function TeacherClassProgressSubmitPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 ? (
|
{submitOk ? (
|
||||||
<div className="alert alert-success py-2 mb-0" role="status">
|
<div className="alert alert-success py-2 mb-0" role="status">
|
||||||
{submitOk}
|
{submitOk}
|
||||||
@@ -382,29 +335,6 @@ export function TeacherClassProgressSubmitPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -671,7 +601,7 @@ export function TeacherClassProgressSubmitPage() {
|
|||||||
</button>
|
</button>
|
||||||
{!canSubmit ? (
|
{!canSubmit ? (
|
||||||
<span className="small text-muted">
|
<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>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
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 { fetchAdministratorCalendarEvents, openProtectedApiFile } from '../../api/session'
|
||||||
import type { ExamDraftRow, SchoolCalendarEventRow } from '../../api/types'
|
import type { ExamDraftRow, SchoolCalendarEventRow } from '../../api/types'
|
||||||
import { TeacherShell } from './TeacherShell'
|
import { TeacherShell } from './TeacherShell'
|
||||||
@@ -128,8 +128,18 @@ type FrontendMeResponse = {
|
|||||||
const EXAM_DRAFT_MAX_BYTES = 12 * 1024 * 1024
|
const EXAM_DRAFT_MAX_BYTES = 12 * 1024 * 1024
|
||||||
const EXAM_DRAFT_ALLOWED_EXTENSIONS = ['doc', 'docx']
|
const EXAM_DRAFT_ALLOWED_EXTENSIONS = ['doc', 'docx']
|
||||||
|
|
||||||
function teacherExamFilePath(kind: 'teacher' | 'final', filename?: string | null) {
|
function teacherExamStoredFileName(filename?: string | null): string | null {
|
||||||
return filename ? `/api/v1/files/exams/${kind}/${encodeURIComponent(filename)}` : 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 = {
|
type TeacherAbsenceRow = {
|
||||||
@@ -332,7 +342,9 @@ export function TeacherCompetitionScoresIndexPage() {
|
|||||||
<td>{dateLabel}</td>
|
<td>{dateLabel}</td>
|
||||||
<td>{isLocked ? 'Yes' : 'No'}</td>
|
<td>{isLocked ? 'Yes' : 'No'}</td>
|
||||||
<td>
|
<td>
|
||||||
{isLocked ? (
|
{compId <= 0 ? (
|
||||||
|
<span className="text-muted">Unavailable</span>
|
||||||
|
) : isLocked ? (
|
||||||
<span className="text-muted">Locked</span>
|
<span className="text-muted">Locked</span>
|
||||||
) : (
|
) : (
|
||||||
<Link to={`/app/teacher/competition-scores/${compId}`}>Enter scores</Link>
|
<Link to={`/app/teacher/competition-scores/${compId}`}>Enter scores</Link>
|
||||||
@@ -353,10 +365,12 @@ export function TeacherCompetitionScoresIndexPage() {
|
|||||||
/** `teacher/competition_scores/scores.php` */
|
/** `teacher/competition_scores/scores.php` */
|
||||||
export function TeacherCompetitionScoresDetailPage() {
|
export function TeacherCompetitionScoresDetailPage() {
|
||||||
const { competitionId } = useParams()
|
const { competitionId } = useParams()
|
||||||
const path =
|
const id = Number(competitionId ?? 0)
|
||||||
competitionId && competitionId !== 'undefined'
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
? `/competition-scores/${competitionId}`
|
return <Navigate to="/app/teacher/competition-scores" replace />
|
||||||
: '/competition-scores/0'
|
}
|
||||||
|
|
||||||
|
const path = `/competition-scores/${id}`
|
||||||
return <TeacherApiDumpPage title="Competition scores entry" path={path} />
|
return <TeacherApiDumpPage title="Competition scores entry" path={path} />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1279,7 +1293,7 @@ export function TeacherExamDraftsPage() {
|
|||||||
) : (
|
) : (
|
||||||
(payload?.drafts ?? []).map((draft) => {
|
(payload?.drafts ?? []).map((draft) => {
|
||||||
const teacherFile = teacherExamFilePath('teacher', draft.teacher_file)
|
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 (
|
return (
|
||||||
<tr key={draft.id}>
|
<tr key={draft.id}>
|
||||||
<td>
|
<td>
|
||||||
@@ -1340,7 +1354,7 @@ export function TeacherExamDraftsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
{(payload?.legacy_exams ?? []).map((draft) => {
|
{(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 (
|
return (
|
||||||
<div key={draft.id} className="col-12 col-xl-6">
|
<div key={draft.id} className="col-12 col-xl-6">
|
||||||
<article className="teacher-calendar-agenda-item h-100">
|
<article className="teacher-calendar-agenda-item h-100">
|
||||||
|
|||||||
Reference in New Issue
Block a user