fix parent pages and teachers and admin
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
+68
-11
@@ -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 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()
|
||||
const blob = new Blob([await raw.arrayBuffer()], { type: 'application/pdf' })
|
||||
window.open(URL.createObjectURL(blob), '_blank', 'noopener')
|
||||
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,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) ?? []
|
||||
|
||||
@@ -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 { 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}
|
||||
{loading ? (
|
||||
<p className="text-muted small">Loading…</p>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Invoice</th>
|
||||
<th>Balance</th>
|
||||
<th>Status</th>
|
||||
<th>Due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-muted">
|
||||
No invoices.
|
||||
</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>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<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 ? (
|
||||
<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 mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Amount</th>
|
||||
<th>Method</th>
|
||||
<th>Status</th>
|
||||
<th>Transaction</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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>
|
||||
<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>
|
||||
) : (
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
school_year: schoolYear,
|
||||
semester,
|
||||
download: true,
|
||||
report_date: reportDate || undefined,
|
||||
})
|
||||
try {
|
||||
await openReportCardDownload(url)
|
||||
await openReportCardStudentDownload(studentId, {
|
||||
school_year: schoolYear,
|
||||
semester,
|
||||
report_date: reportDate || undefined,
|
||||
})
|
||||
} 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, {
|
||||
school_year: schoolYear,
|
||||
semester,
|
||||
download: true,
|
||||
report_date: reportDate || undefined,
|
||||
})
|
||||
try {
|
||||
await openReportCardDownload(url)
|
||||
await openReportCardClassDownload(classId, {
|
||||
school_year: schoolYear,
|
||||
semester,
|
||||
report_date: reportDate || undefined,
|
||||
})
|
||||
} 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user