fix teacher, parent and admin pages
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
fetchClassProgressGroups,
|
||||
fetchClassProgressMeta,
|
||||
fetchClassSections,
|
||||
} from '../../api/session'
|
||||
import type { ClassProgressGroupRow } from '../../api/types'
|
||||
import { ClassProgressUnitDisplay } from './ClassProgressUnitDisplay'
|
||||
import { formatProgressDate } from './classProgressUtils'
|
||||
|
||||
/** CI `admin/class_progress_list.php` — accordion by class section, filters, submission badges when API provides stats. */
|
||||
export function ClassProgressListPage() {
|
||||
const [filters, setFilters] = useState({ weekStart: '', weekEnd: '', classSectionId: '', status: '' })
|
||||
const [subjectSections, setSubjectSections] = useState<Record<string, { label?: string; db_subject?: string }>>({})
|
||||
const [statusOptions, setStatusOptions] = useState<Record<string, string>>({})
|
||||
const [classSections, setClassSections] = useState<
|
||||
Array<{ class_section_id?: number | null; class_section_name?: string | null }>
|
||||
>([])
|
||||
const [items, setItems] = useState<ClassProgressGroupRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
/** Server may later expose `low_progress_section_ids`; until then keep empty (CI disables button). */
|
||||
const lowProgressSectionIds: number[] = []
|
||||
|
||||
const lowProgressQuery = lowProgressSectionIds.join(',')
|
||||
const lowProgressUrl =
|
||||
lowProgressQuery === ''
|
||||
? '/app/administrator/teacher-submissions'
|
||||
: `/app/administrator/teacher-submissions?low_progress_sections=${encodeURIComponent(lowProgressQuery)}`
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [meta, sections, groups] = await Promise.all([
|
||||
fetchClassProgressMeta(),
|
||||
fetchClassSections({ perPage: 200 }),
|
||||
fetchClassProgressGroups({
|
||||
classSectionId: filters.classSectionId ? Number(filters.classSectionId) : null,
|
||||
status: filters.status || null,
|
||||
weekStart: filters.weekStart || null,
|
||||
weekEnd: filters.weekEnd || null,
|
||||
perPage: 200,
|
||||
}),
|
||||
])
|
||||
setSubjectSections(meta.data?.subject_sections ?? {})
|
||||
setStatusOptions(meta.data?.status_options ?? {})
|
||||
setClassSections(sections.data?.sections ?? [])
|
||||
setItems(groups.data?.items ?? [])
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to load class progress.')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
const groupsBySection = useMemo(() => {
|
||||
const m = new Map<number, ClassProgressGroupRow[]>()
|
||||
for (const item of items) {
|
||||
const sid = Number(item.class_section_id ?? 0)
|
||||
if (!sid) continue
|
||||
const arr = m.get(sid) ?? []
|
||||
arr.push(item)
|
||||
m.set(sid, arr)
|
||||
}
|
||||
return m
|
||||
}, [items])
|
||||
|
||||
const subjectCount = Object.keys(subjectSections).length
|
||||
|
||||
function teacherLabelForGroup(group: ClassProgressGroupRow): string {
|
||||
const reports = group.reports ?? {}
|
||||
const teacherCounts: Record<string, number> = {}
|
||||
const teacherLatest: Record<string, string> = {}
|
||||
for (const report of Object.values(reports)) {
|
||||
const name = String(report?.teacher_name ?? '').trim()
|
||||
if (!name) continue
|
||||
teacherCounts[name] = (teacherCounts[name] ?? 0) + 1
|
||||
const stamp = String(report?.updated_at ?? report?.created_at ?? '')
|
||||
if (stamp !== '' && (!teacherLatest[name] || stamp > teacherLatest[name])) {
|
||||
teacherLatest[name] = stamp
|
||||
}
|
||||
}
|
||||
if (!Object.keys(teacherCounts).length) return '—'
|
||||
let bestName = ''
|
||||
let bestCount = -1
|
||||
let bestStamp = ''
|
||||
for (const [name, count] of Object.entries(teacherCounts)) {
|
||||
const stamp = teacherLatest[name] ?? ''
|
||||
if (count > bestCount || (count === bestCount && stamp > bestStamp)) {
|
||||
bestName = name
|
||||
bestCount = count
|
||||
bestStamp = stamp
|
||||
}
|
||||
}
|
||||
return bestName || '—'
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.admin-progress-table thead th {
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
z-index: auto !important;
|
||||
background: #f8f9fa;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
padding-top: .75rem;
|
||||
padding-bottom: .75rem;
|
||||
}
|
||||
.admin-progress-table td { vertical-align: top; }
|
||||
.bg-orange { background-color: #fd7e14 !important; }
|
||||
`}</style>
|
||||
<div className="container py-4">
|
||||
<div className="mb-4 pb-3 border-bottom">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h3 className="mb-0">Class Progress Reports</h3>
|
||||
<div className="text-muted">Filter by week, class, and status</div>
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
className={`btn btn-sm btn-outline-warning${lowProgressSectionIds.length === 0 ? ' disabled' : ''}`}
|
||||
to={lowProgressUrl}
|
||||
aria-disabled={lowProgressSectionIds.length === 0}
|
||||
tabIndex={lowProgressSectionIds.length === 0 ? -1 : undefined}
|
||||
onClick={(e) => {
|
||||
if (lowProgressSectionIds.length === 0) e.preventDefault()
|
||||
}}
|
||||
>
|
||||
Teachers < 50%
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<form
|
||||
className="card shadow-sm mb-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void load()
|
||||
}}
|
||||
>
|
||||
<div className="card-body">
|
||||
<div className="row g-3">
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">From</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-control"
|
||||
value={filters.weekStart}
|
||||
onChange={(e) => setFilters((c) => ({ ...c, weekStart: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">To</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-control"
|
||||
value={filters.weekEnd}
|
||||
onChange={(e) => setFilters((c) => ({ ...c, weekEnd: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Class/Section</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={filters.classSectionId}
|
||||
onChange={(e) => setFilters((c) => ({ ...c, classSectionId: e.target.value }))}
|
||||
>
|
||||
<option value="">All</option>
|
||||
{classSections.map((section) => (
|
||||
<option key={String(section.class_section_id ?? '')} value={section.class_section_id ?? ''}>
|
||||
{section.class_section_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Status</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters((c) => ({ ...c, status: e.target.value }))}
|
||||
>
|
||||
<option value="">All</option>
|
||||
{Object.entries(statusOptions).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex gap-2 mt-3">
|
||||
<button className="btn btn-primary" type="submit">
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFilters({ weekStart: '', weekEnd: '', classSectionId: '', status: '' })
|
||||
window.setTimeout(() => void load(), 0)
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="card shadow-sm mt-5">
|
||||
<div className="card-body pt-4">
|
||||
{classSections.length === 0 ? (
|
||||
<div className="text-center text-muted py-4">No class sections found.</div>
|
||||
) : (
|
||||
<div className="accordion" id="adminProgressAccordion">
|
||||
{classSections.map((cs) => {
|
||||
const sectionId = Number(cs.class_section_id ?? 0)
|
||||
const sectionName = cs.class_section_name ?? 'Unknown Section'
|
||||
const sectionGroups = groupsBySection.get(sectionId) ?? []
|
||||
const hasReports = sectionGroups.length > 0
|
||||
const collapseId = `progress-section-${sectionId}`
|
||||
const headingId = `progress-heading-${sectionId}`
|
||||
const submissionLabel = 'Submitted: N/A'
|
||||
const badgeClass = 'bg-secondary'
|
||||
return (
|
||||
<div className="accordion-item mb-2" key={sectionId || sectionName}>
|
||||
<h2 className="accordion-header" id={headingId}>
|
||||
<button
|
||||
className="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target={`#${collapseId}`}
|
||||
aria-expanded="false"
|
||||
aria-controls={collapseId}
|
||||
>
|
||||
{sectionName}
|
||||
<span className={`badge ${badgeClass} ms-2`}>{submissionLabel}</span>
|
||||
<span className="badge bg-info text-dark ms-2">Units: {subjectCount}</span>
|
||||
{hasReports ? (
|
||||
<span className="badge bg-secondary ms-2">{sectionGroups.length} weeks</span>
|
||||
) : null}
|
||||
</button>
|
||||
</h2>
|
||||
<div
|
||||
id={collapseId}
|
||||
className="accordion-collapse collapse"
|
||||
aria-labelledby={headingId}
|
||||
data-bs-parent="#adminProgressAccordion"
|
||||
>
|
||||
<div className="accordion-body">
|
||||
{!hasReports ? (
|
||||
<div className="text-muted">No reports found for this section.</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table
|
||||
className="table table-hover align-middle mb-0 admin-progress-table no-mgmt-sticky"
|
||||
data-no-mgmt-sticky
|
||||
>
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Week</th>
|
||||
<th>Subjects</th>
|
||||
<th>Teacher</th>
|
||||
<th className="text-end">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sectionGroups.map((group, groupIndex) => {
|
||||
const reports = group.reports ?? {}
|
||||
const reportValues = Object.values(reports)
|
||||
const firstReport = reportValues[0]
|
||||
const exampleId = firstReport?.id
|
||||
const reportKey = reportValues
|
||||
.map((report) => String(report?.id ?? report?.subject ?? report?.teacher_name ?? ''))
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join('-')
|
||||
const weekLabel = `${formatProgressDate(group.week_start)} – ${formatProgressDate(group.week_end)}`
|
||||
return (
|
||||
<tr
|
||||
key={`${sectionId}-${group.week_start}-${group.week_end}-${reportKey || exampleId || 'group'}-${groupIndex}`}
|
||||
>
|
||||
<td>
|
||||
<div className="fw-semibold">{weekLabel}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="d-flex flex-column gap-2">
|
||||
{Object.entries(subjectSections).map(([slug, section]) => {
|
||||
const subjectName = section.db_subject ?? section.label ?? slug
|
||||
const report = reports[subjectName]
|
||||
const statusTag = report
|
||||
? (report.status_label ?? 'Unknown')
|
||||
: 'No entry'
|
||||
const badgeClassInner = report ? 'bg-secondary' : 'bg-light text-muted'
|
||||
return (
|
||||
<div key={slug} className="border rounded-3 p-2">
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<strong className="small mb-0">
|
||||
{section.label ?? subjectName}
|
||||
</strong>
|
||||
<span className={`badge ${badgeClassInner}`}>{statusTag}</span>
|
||||
</div>
|
||||
<div className="small">
|
||||
{report ? (
|
||||
<ClassProgressUnitDisplay
|
||||
unitTitle={report.unit_title}
|
||||
isQuran={subjectName === 'Quran/Arabic'}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted">No submission</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td>{teacherLabelForGroup(group)}</td>
|
||||
<td className="text-end">
|
||||
{exampleId ? (
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
type="button"
|
||||
onClick={() => navigate(`/app/admin/progress/view/${exampleId}`)}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { splitUnitTitle } from './classProgressUtils'
|
||||
|
||||
/** CI `admin/partials/class_progress_unit_display.php` */
|
||||
export function ClassProgressUnitDisplay({
|
||||
unitTitle,
|
||||
isQuran,
|
||||
compact,
|
||||
}: {
|
||||
unitTitle?: string | null
|
||||
isQuran?: boolean
|
||||
compact?: boolean
|
||||
}) {
|
||||
const { curriculum, custom } = splitUnitTitle(unitTitle)
|
||||
const labelCurriculum = isQuran ? 'Surah / curriculum' : 'Unit / chapter'
|
||||
const labelCustom = isQuran ? 'Custom Surah / Arabic' : 'Custom subject(s)'
|
||||
|
||||
if (!String(unitTitle ?? '').trim()) return <span className="text-muted">-</span>
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<>
|
||||
{custom.length > 0 ? (
|
||||
<div className="small">
|
||||
<span className="badge text-bg-info me-1">{isQuran ? 'Custom' : 'Custom subject'}</span>
|
||||
{custom.join(', ')}
|
||||
</div>
|
||||
) : null}
|
||||
{curriculum.length > 0 ? (
|
||||
<div className={`small text-muted${custom.length > 0 ? ' mt-1' : ''}`}>{curriculum.join(' · ')}</div>
|
||||
) : null}
|
||||
{custom.length === 0 && curriculum.length === 0 ? (
|
||||
<div className="small text-muted">{String(unitTitle)}</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{curriculum.length > 0 ? (
|
||||
<div className="mb-2">
|
||||
<strong>{labelCurriculum}:</strong> {curriculum.join(' ; ')}
|
||||
</div>
|
||||
) : null}
|
||||
{custom.length > 0 ? (
|
||||
<div className="mb-2">
|
||||
<strong>{labelCustom}:</strong> {custom.join(' ; ')}
|
||||
</div>
|
||||
) : null}
|
||||
{curriculum.length === 0 && custom.length === 0 ? (
|
||||
<div className="mb-2">
|
||||
<strong>{labelCurriculum}:</strong> {String(unitTitle)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { apiUrl } from '../../lib/apiOrigin'
|
||||
import { fetchClassProgressDetail, fetchClassProgressMeta } from '../../api/session'
|
||||
import type { ClassProgressReportRow } from '../../api/types'
|
||||
import { ClassProgressUnitDisplay } from './ClassProgressUnitDisplay'
|
||||
import { FLAG_LABELS, formatProgressDate } from './classProgressUtils'
|
||||
|
||||
function attachmentHref(att: {
|
||||
id?: number
|
||||
download_url?: string | null
|
||||
legacy?: boolean | number | null
|
||||
}) {
|
||||
if (att.download_url) return att.download_url
|
||||
const id = att.id
|
||||
if (!id) return '#'
|
||||
const legacy = Boolean(att.legacy)
|
||||
return legacy
|
||||
? apiUrl(`/admin/progress/attachment/${id}`)
|
||||
: apiUrl(`/admin/progress/attachment-file/${id}`)
|
||||
}
|
||||
|
||||
/** CI `admin/class_progress_view.php` */
|
||||
export function ClassProgressViewPage() {
|
||||
const { id } = useParams()
|
||||
const reportId = Number(id)
|
||||
const [report, setReport] = useState<ClassProgressReportRow | null>(null)
|
||||
const [weeklyReports, setWeeklyReports] = useState<ClassProgressReportRow[]>([])
|
||||
const [subjectSections, setSubjectSections] = useState<Record<string, { label?: string; db_subject?: string }>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!reportId) return
|
||||
;(async () => {
|
||||
try {
|
||||
const [detail, meta] = await Promise.all([
|
||||
fetchClassProgressDetail(reportId),
|
||||
fetchClassProgressMeta(),
|
||||
])
|
||||
setReport(detail.data?.report ?? null)
|
||||
setWeeklyReports(detail.data?.weekly_reports ?? [])
|
||||
setSubjectSections(meta.data?.subject_sections ?? {})
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to load progress report.')
|
||||
}
|
||||
})()
|
||||
}, [reportId])
|
||||
|
||||
const reportsBySubject = useMemo(
|
||||
() => Object.fromEntries(weeklyReports.map((entry) => [String(entry.subject ?? ''), entry])),
|
||||
[weeklyReports],
|
||||
)
|
||||
|
||||
const attachments = weeklyReports.flatMap((entry) =>
|
||||
(entry.attachments ?? []).map((attachment) => ({
|
||||
subject: entry.subject ?? 'Attachment',
|
||||
attachment,
|
||||
report_id: entry.id,
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="container py-4">
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{report ? (
|
||||
<>
|
||||
<div className="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h3 className="mb-0">Progress Report</h3>
|
||||
<div className="text-muted">
|
||||
{report.class_section_name ?? '-'} • {formatProgressDate(report.week_start)} →{' '}
|
||||
{formatProgressDate(report.week_end)}
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/app/admin/progress" className="btn btn-outline-secondary">
|
||||
Back
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="row g-3">
|
||||
<div className="col-lg-8">
|
||||
{Object.entries(subjectSections).map(([slug, section]) => {
|
||||
const subjectName = section.db_subject ?? section.label ?? slug
|
||||
const isQuran = subjectName === 'Quran/Arabic'
|
||||
const homeworkLabel = isQuran ? 'Arabic Practice / Homework' : 'Assigned Homework'
|
||||
const entry = reportsBySubject[subjectName]
|
||||
return (
|
||||
<div className="card shadow-sm mb-3" key={slug}>
|
||||
<div className="card-header bg-white d-flex align-items-center justify-content-between">
|
||||
<strong className="mb-0">{section.label ?? subjectName}</strong>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{!entry ? (
|
||||
<div className="text-muted">No entry submitted for this subject this week.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<ClassProgressUnitDisplay
|
||||
unitTitle={entry.unit_title}
|
||||
isQuran={isQuran}
|
||||
compact={false}
|
||||
/>
|
||||
</div>
|
||||
{String(entry.materials ?? '').trim() ? (
|
||||
<div className="mb-2">
|
||||
<strong>Materials:</strong> {entry.materials}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-3">
|
||||
<strong>Activities</strong>
|
||||
<div className="mt-1" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{entry.covered || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<strong>{homeworkLabel}:</strong>
|
||||
<div className="mt-1" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{entry.homework || '-'}
|
||||
</div>
|
||||
</div>
|
||||
{(entry.attachments ?? []).length > 0 ? (
|
||||
<div className="mt-3">
|
||||
<strong>Attachments:</strong>
|
||||
<div className="d-flex flex-column gap-2 mt-2">
|
||||
{(entry.attachments ?? []).map((att) => {
|
||||
const linkId = att.id ?? entry.id
|
||||
return (
|
||||
<a
|
||||
key={String(att.id ?? att.name ?? '')}
|
||||
className="btn btn-sm btn-outline-secondary text-start"
|
||||
href={attachmentHref({ ...att, id: linkId })}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{att.name ?? 'Attachment'}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="col-lg-4">
|
||||
<div className="card shadow-sm mb-3">
|
||||
<div className="card-header bg-white">
|
||||
<strong>Summary</strong>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="mb-2">
|
||||
<strong>Teacher:</strong> {String(report.teacher_name ?? '').trim() || '-'}
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<strong>Submitted:</strong> {formatProgressDate(report.created_at, true)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Status:</strong> {report.status_label ?? 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(report.flags ?? []).length > 0 ? (
|
||||
<div className="card shadow-sm mb-3">
|
||||
<div className="card-header bg-white">
|
||||
<strong>Checklist</strong>
|
||||
</div>
|
||||
<div className="card-body d-flex flex-wrap gap-2">
|
||||
{(report.flags ?? []).map((flag) => (
|
||||
<span key={flag} className="badge bg-light text-dark border">
|
||||
{FLAG_LABELS[flag] ?? flag.replace(/_/g, ' ')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{attachments.length > 0 ? (
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header bg-white">
|
||||
<strong>Attachments</strong>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{attachments.map((item, index) => {
|
||||
const att = item.attachment
|
||||
const linkId = att.id ?? item.report_id
|
||||
if (!linkId) return null
|
||||
const label = `${item.subject} • ${att.name ?? 'Attachment'}`
|
||||
return (
|
||||
<a
|
||||
key={`${linkId}-${index}`}
|
||||
className="btn btn-outline-primary w-100 text-start mb-2"
|
||||
href={attachmentHref({ ...att, id: linkId })}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Mirrors `ClassProgressController::splitUnitTitleForDisplay` / CI `class_progress_unit_display.php`. */
|
||||
|
||||
export function formatProgressDate(value?: string | null, withTime = false) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return withTime ? date.toLocaleString() : date.toLocaleDateString()
|
||||
}
|
||||
|
||||
export function splitUnitTitle(unitTitle?: string | null) {
|
||||
const raw = String(unitTitle ?? '').trim()
|
||||
if (!raw) return { curriculum: [] as string[], custom: [] as string[] }
|
||||
const segments = raw
|
||||
.split(/\s*[;|]\s*/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
const curriculum: string[] = []
|
||||
const custom: string[] = []
|
||||
for (const segment of segments) {
|
||||
if (/custom/i.test(segment)) custom.push(segment.replace(/^custom[:\s-]*/i, '').trim() || segment)
|
||||
else curriculum.push(segment)
|
||||
}
|
||||
if (curriculum.length === 0 && custom.length === 0) curriculum.push(raw)
|
||||
return { curriculum, custom }
|
||||
}
|
||||
|
||||
export const FLAG_LABELS: Record<string, string> = {
|
||||
homework_assigned: 'Homework assigned',
|
||||
quiz_done: 'Quiz / check-in completed',
|
||||
materials_missing: 'Materials needed',
|
||||
}
|
||||
Reference in New Issue
Block a user