1035 lines
35 KiB
TypeScript
1035 lines
35 KiB
TypeScript
import { Link } from 'react-router-dom'
|
|
import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react'
|
|
import { useSchoolYearOptions } from '../hooks/useSchoolYearOptions'
|
|
import type { LegacySchoolYearValue } from '../lib/schoolYearOptions'
|
|
|
|
export type CiFlashMessage = {
|
|
id?: string
|
|
type: 'success' | 'error' | 'info' | 'warning'
|
|
message: ReactNode
|
|
}
|
|
|
|
const alertType = {
|
|
success: 'success',
|
|
error: 'danger',
|
|
info: 'info',
|
|
warning: 'warning',
|
|
} as const
|
|
|
|
export function CiFlashMessages({
|
|
messages,
|
|
autoHideMs = 4000,
|
|
}: {
|
|
messages: CiFlashMessage[]
|
|
autoHideMs?: number
|
|
}) {
|
|
const [visible, setVisible] = useState(messages)
|
|
|
|
useEffect(() => {
|
|
setVisible(messages)
|
|
}, [messages])
|
|
|
|
useEffect(() => {
|
|
if (autoHideMs <= 0 || visible.length === 0) return
|
|
const timeout = window.setTimeout(() => setVisible([]), autoHideMs)
|
|
return () => window.clearTimeout(timeout)
|
|
}, [autoHideMs, visible.length])
|
|
|
|
return (
|
|
<>
|
|
{visible.map((item, index) => (
|
|
<div
|
|
key={item.id ?? `${item.type}-${index}`}
|
|
className={`alert alert-${alertType[item.type]} alert-dismissible fade show`}
|
|
role="alert"
|
|
>
|
|
{item.message}
|
|
<button
|
|
type="button"
|
|
className="btn-close"
|
|
aria-label="Close"
|
|
onClick={() => setVisible((current) => current.filter((_, i) => i !== index))}
|
|
/>
|
|
</div>
|
|
))}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function CiAcademicFilter({
|
|
schoolYears,
|
|
selectedYear,
|
|
selectedSemester,
|
|
classSectionId,
|
|
onApply,
|
|
}: {
|
|
schoolYears?: LegacySchoolYearValue[]
|
|
selectedYear?: string | null
|
|
selectedSemester?: string | null
|
|
classSectionId?: string | number | null
|
|
onApply?: (values: { schoolYear: string; semester: string; classSectionId?: string }) => void
|
|
}) {
|
|
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
|
|
legacyYears: schoolYears,
|
|
preferredYear: selectedYear,
|
|
})
|
|
const [schoolYear, setSchoolYear] = useState(selectedYear ?? defaultYear)
|
|
const [semester, setSemester] = useState(selectedSemester ?? '')
|
|
|
|
useEffect(() => {
|
|
setSchoolYear(selectedYear ?? defaultYear)
|
|
}, [defaultYear, selectedYear])
|
|
|
|
useEffect(() => {
|
|
setSemester(selectedSemester ?? '')
|
|
}, [selectedSemester])
|
|
|
|
return (
|
|
<form
|
|
className="row g-2 align-items-center justify-content-center mb-3 ci-academic-filter"
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
onApply?.({
|
|
schoolYear,
|
|
semester,
|
|
classSectionId: classSectionId == null ? undefined : String(classSectionId),
|
|
})
|
|
}}
|
|
>
|
|
<div className="col-auto">
|
|
<label className="form-label mb-0" htmlFor="ci_school_year">
|
|
School year
|
|
</label>
|
|
</div>
|
|
<div className="col-auto">
|
|
<select
|
|
id="ci_school_year"
|
|
name="school_year"
|
|
className="form-select form-select-sm ci-academic-filter__year"
|
|
value={schoolYear}
|
|
onChange={(event) => setSchoolYear(event.target.value)}
|
|
>
|
|
{options.length > 0 ? (
|
|
options.map((year) => (
|
|
<option key={year.value} value={year.value}>
|
|
{year.label}
|
|
</option>
|
|
))
|
|
) : (
|
|
<option value={schoolYear}>{schoolYear || '-'}</option>
|
|
)}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="col-auto">
|
|
<label className="form-label mb-0" htmlFor="ci_semester">
|
|
Semester
|
|
</label>
|
|
</div>
|
|
<div className="col-auto">
|
|
<select
|
|
id="ci_semester"
|
|
name="semester"
|
|
className="form-select form-select-sm ci-academic-filter__semester"
|
|
value={semester}
|
|
onChange={(event) => setSemester(event.target.value)}
|
|
>
|
|
<option value="">-</option>
|
|
<option value="Fall">Fall</option>
|
|
<option value="Spring">Spring</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="col-auto">
|
|
<button type="submit" className="btn btn-secondary btn-sm">
|
|
Apply
|
|
</button>{' '}
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline-secondary btn-sm"
|
|
onClick={() => {
|
|
setSchoolYear(defaultYear)
|
|
setSemester('')
|
|
onApply?.({ schoolYear: defaultYear, semester: '', classSectionId: undefined })
|
|
}}
|
|
>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)
|
|
}
|
|
|
|
export function CiStandardStudentTable({
|
|
rows,
|
|
customHeaders = [],
|
|
customKeys = [],
|
|
emptyText = 'No rows found.',
|
|
}: {
|
|
rows: Record<string, unknown>[]
|
|
customHeaders?: string[]
|
|
customKeys?: string[]
|
|
emptyText?: string
|
|
}) {
|
|
return (
|
|
<div className="table-responsive">
|
|
<table className="table table-bordered table-striped align-middle">
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>School ID</th>
|
|
<th>First Name</th>
|
|
<th>Last Name</th>
|
|
<th>Gender</th>
|
|
<th>Age</th>
|
|
<th>Grade</th>
|
|
{customHeaders.map((header) => (
|
|
<th key={header}>{header}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7 + customHeaders.length} className="text-muted">
|
|
{emptyText}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
rows.map((row, index) => (
|
|
<tr key={String(row.id ?? row.school_id ?? index)}>
|
|
<td>{index + 1}</td>
|
|
<td>{display(row.school_id)}</td>
|
|
<td>{display(row.first_name ?? row.firstname)}</td>
|
|
<td>{display(row.last_name ?? row.lastname)}</td>
|
|
<td>{display(row.gender)}</td>
|
|
<td>{display(row.age)}</td>
|
|
<td>{display(row.Grade ?? row.grade ?? row.registration_grade)}</td>
|
|
{customKeys.map((key) => (
|
|
<td key={key}>{display(row[key])}</td>
|
|
))}
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const publicGradeOptions = [
|
|
['NA', 'Not enrolled in public school'],
|
|
['KG', 'Pre-K / Kindergarten'],
|
|
['1', 'Grade 1'],
|
|
['2', 'Grade 2'],
|
|
['3', 'Grade 3'],
|
|
['4', 'Grade 4'],
|
|
['5', 'Grade 5'],
|
|
['6', 'Grade 6'],
|
|
['7', 'Grade 7'],
|
|
['8', 'Grade 8'],
|
|
['9', 'Grade 9'],
|
|
['10', 'Grade 10'],
|
|
['11', 'Grade 11'],
|
|
['12', 'Grade 12'],
|
|
]
|
|
|
|
const alRahmaGradeOptions = [
|
|
['KG', 'Pre-K / Kindergarten'],
|
|
['1', 'Grade 1'],
|
|
['2', 'Grade 2'],
|
|
['3', 'Grade 3'],
|
|
['4', 'Grade 4'],
|
|
['5', 'Grade 5'],
|
|
['6', 'Grade 6'],
|
|
['7', 'Grade 7'],
|
|
['8', 'Grade 8'],
|
|
['9', 'Grade 9'],
|
|
['10', 'Youth'],
|
|
]
|
|
|
|
export const medicalConditionOptions = [
|
|
'None',
|
|
'ADHD (Attention-Deficit/Hyperactivity Disorder)',
|
|
'Anxiety or Emotional Disorders',
|
|
'Asthma',
|
|
'Autism Spectrum Disorder (ASD)',
|
|
'Behavioral or Conduct Disorders',
|
|
'Blindness / Vision Impairment',
|
|
'Celiac Disease (Gluten Intolerance)',
|
|
'Cerebral Palsy',
|
|
'Cystic Fibrosis',
|
|
'Depression',
|
|
'Diabetes (Type 1 or Type 2)',
|
|
'Down Syndrome',
|
|
'Dyslexia or Learning Disabilities',
|
|
'Eating Disorders',
|
|
'Eczema / Severe Skin Conditions',
|
|
'Epilepsy / Seizure Disorders',
|
|
'Hearing Impairments / Deafness',
|
|
'Heart Conditions (congenital or acquired)',
|
|
'Hemophilia / Bleeding Disorders',
|
|
'Kidney Disease',
|
|
'Migraines / Chronic Headaches',
|
|
'Obsessive-Compulsive Disorder (OCD)',
|
|
'Physical Disabilities / Mobility Impairments',
|
|
'PTSD (Post-Traumatic Stress Disorder)',
|
|
'Sickle Cell Anemia',
|
|
'Speech and Language Disorders',
|
|
'Thyroid Disorders',
|
|
'Tourette Syndrome',
|
|
'Traumatic Brain Injury (TBI)',
|
|
'Rheumatic diseases',
|
|
"Ulcerative Colitis / Crohn's Disease",
|
|
'Other',
|
|
]
|
|
|
|
export const allergyOptions = [
|
|
'None',
|
|
'Animal Dander (cats, dogs, etc.)',
|
|
'Antibiotics',
|
|
'Bee stings',
|
|
'Cockroach',
|
|
'Corn',
|
|
'Dust Mites',
|
|
'Egg',
|
|
'Fire ant stings',
|
|
'Fish',
|
|
'Fragrances / Perfumes',
|
|
'Latex',
|
|
'Milk / Dairy',
|
|
'Mold',
|
|
'Mosquito bites',
|
|
'Peanut',
|
|
'Pollen (grass, tree, weed)',
|
|
'Sesame',
|
|
'Shellfish (shrimp, crab, lobster, etc.)',
|
|
'Soy',
|
|
'Tree Nuts (almond, cashew, walnut, etc.)',
|
|
'Wasp stings',
|
|
'Wheat / Gluten',
|
|
'Other',
|
|
]
|
|
|
|
export function CiStudentFormPartial({
|
|
defaultValues,
|
|
showRemoveButton = false,
|
|
}: {
|
|
defaultValues?: {
|
|
firstname?: string | null
|
|
lastname?: string | null
|
|
dob?: string | null
|
|
gender?: string | null
|
|
registration_grade?: string | null
|
|
photo_consent?: boolean | string | null
|
|
last_year?: 'yes' | 'no' | string | null
|
|
allergies?: string[]
|
|
medical_conditions?: string[]
|
|
}
|
|
showRemoveButton?: boolean
|
|
}) {
|
|
const [lastYear, setLastYear] = useState(defaultValues?.last_year ?? (defaultValues?.registration_grade ? 'yes' : ''))
|
|
const [selectedGrade, setSelectedGrade] = useState(defaultValues?.registration_grade ?? '')
|
|
const gradeOptions = lastYear === 'yes' ? alRahmaGradeOptions : lastYear === 'no' ? publicGradeOptions : []
|
|
const gradeLabel =
|
|
lastYear === 'yes'
|
|
? 'Al Rahma School Last Year Grade'
|
|
: lastYear === 'no'
|
|
? 'Public School Last Year Grade'
|
|
: 'Last Year Grade'
|
|
|
|
const defaultMedical = useMemo(() => new Set(defaultValues?.medical_conditions ?? []), [defaultValues?.medical_conditions])
|
|
const defaultAllergies = useMemo(() => new Set(defaultValues?.allergies ?? []), [defaultValues?.allergies])
|
|
|
|
return (
|
|
<div className="student-form border rounded p-3 mb-4 bg-light position-relative">
|
|
{showRemoveButton ? (
|
|
<button type="button" className="btn-close position-absolute end-0 top-0 m-2" aria-label="Close" />
|
|
) : null}
|
|
<h5 className="mb-3 text-primary">Student Information</h5>
|
|
|
|
<div className="col-md-12 mb-3">
|
|
<label className="form-label fw-bold mb-2 d-block">
|
|
Was your child enrolled in Al Rahma Sunday School last year? <span className="text-danger">*</span>
|
|
</label>
|
|
<div className="d-flex align-items-center flex-wrap gap-3">
|
|
{['yes', 'no'].map((value) => (
|
|
<div className="form-check ps-3" key={value}>
|
|
<input
|
|
className="form-check-input"
|
|
type="radio"
|
|
name="last_year"
|
|
value={value}
|
|
checked={lastYear === value}
|
|
onChange={(event) => {
|
|
setLastYear(event.target.value)
|
|
setSelectedGrade('')
|
|
}}
|
|
required
|
|
/>
|
|
<label className="form-check-label">{value === 'yes' ? 'Yes' : 'No'}</label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="row">
|
|
<div className="col-md-6 mb-3">
|
|
<label className="form-label">
|
|
First Name <span className="text-danger">*</span>
|
|
</label>
|
|
<input name="firstname" className="form-control" maxLength={30} defaultValue={defaultValues?.firstname ?? ''} required />
|
|
</div>
|
|
<div className="col-md-6 mb-3">
|
|
<label className="form-label">
|
|
Last Name <span className="text-danger">*</span>
|
|
</label>
|
|
<input name="lastname" className="form-control" maxLength={30} defaultValue={defaultValues?.lastname ?? ''} required />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="row">
|
|
<div className="col-md-6 mb-3">
|
|
<label className="form-label">
|
|
Date of Birth <span className="text-danger">*</span>
|
|
</label>
|
|
<input type="date" className="form-control" name="dob" defaultValue={defaultValues?.dob ?? ''} required />
|
|
</div>
|
|
<div className="col-md-6 mb-3">
|
|
<label className="form-label">
|
|
{gradeLabel} <span className="text-danger">*</span>
|
|
</label>
|
|
<select
|
|
className="form-select"
|
|
name="registration_grade"
|
|
value={selectedGrade}
|
|
onChange={(event) => setSelectedGrade(event.target.value)}
|
|
disabled={gradeOptions.length === 0}
|
|
required
|
|
>
|
|
<option value="">Select Grade</option>
|
|
{gradeOptions.map(([value, label]) => (
|
|
<option key={value} value={value}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="row">
|
|
<div className="col-md-6 mb-3">
|
|
<label className="form-label">
|
|
Gender <span className="text-danger">*</span>
|
|
</label>
|
|
<select name="gender" className="form-select" defaultValue={defaultValues?.gender ?? ''} required>
|
|
<option value="">Select Gender</option>
|
|
<option value="Male">Male</option>
|
|
<option value="Female">Female</option>
|
|
</select>
|
|
</div>
|
|
<div className="col-md-6 mb-3">
|
|
<label className="form-label">
|
|
Photo Consent <span className="text-danger">*</span>
|
|
</label>
|
|
<select
|
|
name="photo_consent"
|
|
className="form-select"
|
|
defaultValue={defaultValues?.photo_consent === true || defaultValues?.photo_consent === 'Yes' ? 'Yes' : ''}
|
|
required
|
|
>
|
|
<option value="">Select Answer</option>
|
|
<option value="Yes">Yes</option>
|
|
<option value="No">No</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="row">
|
|
<Checklist title="Medical Conditions" name="medical_conditions" options={medicalConditionOptions} defaultSelected={defaultMedical} />
|
|
<Checklist title="Allergies" name="allergies" options={allergyOptions} defaultSelected={defaultAllergies} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function CiEmergencyContactFormPartial({
|
|
defaultValues,
|
|
}: {
|
|
defaultValues?: {
|
|
name?: string | null
|
|
cellphone?: string | null
|
|
email?: string | null
|
|
relation?: string | null
|
|
}
|
|
}) {
|
|
return (
|
|
<div className="emergency-contact-form border rounded p-3 mb-3 bg-white shadow-sm">
|
|
<h5 className="mb-3 text-primary">Emergency Contact Information</h5>
|
|
|
|
<div className="row">
|
|
<div className="col-md-6 mb-3">
|
|
<label className="form-label">
|
|
Name <span className="text-danger">*</span>
|
|
</label>
|
|
<input type="text" className="form-control" name="name" required maxLength={60} defaultValue={defaultValues?.name ?? ''} />
|
|
</div>
|
|
<div className="col-md-6 mb-3">
|
|
<label className="form-label">
|
|
Phone Number <span className="text-danger">*</span>
|
|
</label>
|
|
<input
|
|
type="tel"
|
|
className="form-control"
|
|
name="cellphone"
|
|
required
|
|
maxLength={12}
|
|
defaultValue={defaultValues?.cellphone ?? ''}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="row">
|
|
<div className="col-md-6 mb-3">
|
|
<label className="form-label">Email</label>
|
|
<input type="email" className="form-control" name="email" defaultValue={defaultValues?.email ?? ''} />
|
|
</div>
|
|
<div className="col-md-6 mb-3">
|
|
<label className="form-label">
|
|
Relationship to Student(s) <span className="text-danger">*</span>
|
|
</label>
|
|
<select className="form-select" name="relation" required defaultValue={defaultValues?.relation ?? ''}>
|
|
<option value="">Select Relationship</option>
|
|
<option value="Parent">Parent</option>
|
|
<option value="Sibling">Sibling</option>
|
|
<option value="Relative">Relative</option>
|
|
<option value="Friend">Friend</option>
|
|
<option value="Neighbor">Neighbor</option>
|
|
<option value="Guardian">Guardian</option>
|
|
<option value="Other">Other</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function CiNoInvoiceFound({ message = 'No invoice found.' }: { message?: string }) {
|
|
return (
|
|
<div className="modal d-block ci-static-modal" tabIndex={-1} role="dialog" aria-modal="true">
|
|
<div className="modal-dialog">
|
|
<div className="modal-content">
|
|
<div className="modal-header">
|
|
<h5 className="modal-title">Error</h5>
|
|
</div>
|
|
<div className="modal-body">
|
|
<p className="mb-0">{message}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function CiPublicFooter({ year = new Date().getFullYear() }: { year?: number }) {
|
|
const footerRef = useRef<HTMLElement>(null)
|
|
|
|
useEffect(() => {
|
|
const root = footerRef.current
|
|
if (!root) return
|
|
const links = root.querySelectorAll<HTMLAnchorElement>('a.pdf-link')
|
|
const controllers: AbortController[] = []
|
|
|
|
links.forEach((link) => {
|
|
const pdfUrl = link.getAttribute('href')
|
|
if (!pdfUrl) return
|
|
const ac = new AbortController()
|
|
controllers.push(ac)
|
|
fetch(pdfUrl, { method: 'HEAD', signal: ac.signal })
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
link.style.opacity = '0.7'
|
|
link.title = 'File might be temporarily unavailable'
|
|
console.warn('PDF might be missing:', pdfUrl)
|
|
link.addEventListener('click', function onClick(e) {
|
|
if (!confirm('The PDF file might be temporarily unavailable. Try to open it anyway?')) {
|
|
e.preventDefault()
|
|
}
|
|
})
|
|
}
|
|
})
|
|
.catch(() => {
|
|
link.style.opacity = '0.7'
|
|
link.title = 'File check failed'
|
|
})
|
|
})
|
|
|
|
return () => controllers.forEach((c) => c.abort())
|
|
}, [])
|
|
|
|
return (
|
|
<footer ref={footerRef} className="footer mt-auto custom-footer custom-footer--bar text-white px-3 shadow-sm">
|
|
<div className="custom-footer__glow" aria-hidden />
|
|
<div className="container-fluid custom-footer__container">
|
|
<div className="custom-footer__bar">
|
|
<div className="custom-footer__meta" aria-label="Contact">
|
|
<span className="custom-footer__meta-item">
|
|
<i className="fa fa-map-marker-alt custom-footer__meta-ico" aria-hidden />
|
|
<span>5 Courthouse Lane, Chelmsford, MA 01824</span>
|
|
</span>
|
|
<a className="custom-footer__meta-link" href="tel:+19783640219">
|
|
<i className="fa fa-phone-alt custom-footer__meta-ico" aria-hidden />
|
|
+1 978-364-0219
|
|
</a>
|
|
<a className="custom-footer__meta-link" href="mailto:alrahma.isgl@gmail.com">
|
|
<i className="fa fa-envelope custom-footer__meta-ico" aria-hidden />
|
|
alrahma.isgl@gmail.com
|
|
</a>
|
|
</div>
|
|
<nav className="custom-footer__docs" aria-label="Policies and guides">
|
|
<a
|
|
href="/privacy_policy.pdf"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="pdf-link custom-footer__doc-link"
|
|
data-filename="privacy_policy.pdf"
|
|
>
|
|
<i className="fas fa-file-pdf me-1" aria-hidden />
|
|
Privacy Policy
|
|
</a>
|
|
<a
|
|
href="/terms_of_service.pdf"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="pdf-link custom-footer__doc-link"
|
|
data-filename="terms_of_service.pdf"
|
|
>
|
|
<i className="fas fa-file-pdf me-1" aria-hidden />
|
|
Terms of Service
|
|
</a>
|
|
<a
|
|
href="/account_creation_guide.pdf"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="pdf-link custom-footer__doc-link"
|
|
data-filename="account_creation_guide.pdf"
|
|
>
|
|
<i className="fas fa-file-pdf me-1" aria-hidden />
|
|
How To Create An Account
|
|
</a>
|
|
</nav>
|
|
</div>
|
|
<div className="custom-footer__copyright-row">
|
|
<p className="custom-footer__copyright mb-0">
|
|
© {year}{' '}
|
|
<span className="custom-footer__copyright-brand">Al Rahma Sunday School</span> by ISGL. All Rights Reserved.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
)
|
|
}
|
|
|
|
export function CiBackFooter({ year = new Date().getFullYear() }: { year?: number }) {
|
|
return (
|
|
<footer className="footer bg-white text-muted border-top">
|
|
<div className="container py-3">
|
|
<p className="text-center mb-0">
|
|
© {year} Al Rahma Sunday School by{' '}
|
|
<a className="text-decoration-underline" href="https://isgl.org" target="_blank" rel="noopener noreferrer">ISGL</a>.
|
|
{' '}All Rights Reserved.
|
|
</p>
|
|
</div>
|
|
</footer>
|
|
)
|
|
}
|
|
|
|
export function CiAvatarMenu({
|
|
name,
|
|
email,
|
|
currentRole,
|
|
roles = [],
|
|
onSwitchRole,
|
|
onLogout,
|
|
}: {
|
|
name?: string | null
|
|
email?: string | null
|
|
currentRole?: string | null
|
|
roles?: string[]
|
|
onSwitchRole?: (role: string) => void
|
|
onLogout?: () => void
|
|
}) {
|
|
const [open, setOpen] = useState(false)
|
|
const [roleOpen, setRoleOpen] = useState(false)
|
|
const [styleOpen, setStyleOpen] = useState(false)
|
|
const initials = userInitials(name, email)
|
|
const normalizedRole = currentRole || 'guest'
|
|
const otherRoles = roles.filter((role) => role && role !== normalizedRole)
|
|
|
|
return (
|
|
<div className="dropdown ci-avatar-menu">
|
|
<button
|
|
type="button"
|
|
className="btn p-0 bg-transparent border-0"
|
|
aria-label="Open user menu"
|
|
aria-expanded={open}
|
|
onClick={() => setOpen((value) => !value)}
|
|
>
|
|
<div className="rounded-circle text-white fw-semibold d-flex align-items-center justify-content-center ci-avatar-menu__avatar">
|
|
{initials}
|
|
</div>
|
|
</button>
|
|
<div className={`dropdown-menu dropdown-menu-end quick-menu p-0 ${open ? 'show' : ''}`}>
|
|
<div className="px-3 py-3 d-flex align-items-center">
|
|
<div className="rounded-circle text-white fw-semibold d-flex align-items-center justify-content-center me-3 ci-avatar-menu__avatar-sm">
|
|
{initials}
|
|
</div>
|
|
<div>
|
|
<div className="fw-semibold mb-0 lh-sm">{name || 'User'}</div>
|
|
<div className="text-muted small lh-sm">
|
|
{normalizedRole}
|
|
{email ? ` · ${email}` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="dropdown-divider" />
|
|
|
|
{otherRoles.length > 0 ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="dropdown-item d-flex justify-content-between align-items-center"
|
|
onClick={() => setRoleOpen((value) => !value)}
|
|
>
|
|
Switch Role <i className="bi bi-chevron-down ms-2" aria-hidden />
|
|
</button>
|
|
{roleOpen ? (
|
|
<div className="px-2 pb-2">
|
|
{otherRoles.map((role) => (
|
|
<button key={role} type="button" className="dropdown-item" onClick={() => onSwitchRole?.(role)}>
|
|
Switch to {humanRole(role)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
<div className="dropdown-divider" />
|
|
</>
|
|
) : null}
|
|
|
|
<button
|
|
type="button"
|
|
className="dropdown-item d-flex justify-content-between align-items-center"
|
|
onClick={() => setStyleOpen((value) => !value)}
|
|
>
|
|
Style <i className="bi bi-chevron-down ms-2" aria-hidden />
|
|
</button>
|
|
{styleOpen ? (
|
|
<div className="px-2 pb-2">
|
|
<div className="px-2 small text-muted">Menu</div>
|
|
<div className="d-grid gap-2 px-2 pb-2">
|
|
{['emerald', 'white', 'dark'].map((menu) => (
|
|
<Link key={menu} className="btn btn-outline-secondary btn-sm" to={`/app/preferences?menu=${menu}`}>
|
|
{humanRole(menu)}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="dropdown-divider" />
|
|
<button type="button" className="dropdown-item" onClick={onLogout}>Log Out</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function CiParentSidebar() {
|
|
const links = [
|
|
['/app/parent/home', 'bi bi-house-door', 'Dashboard'],
|
|
['/app/parent/register-student', 'bi bi-person-plus', 'Register Kids'],
|
|
['/app/parent/enroll-classes', 'bi bi-pencil-square', 'Enroll in Classes'],
|
|
['/app/parent/attendance', 'bi bi-calendar-check', 'Attendance'],
|
|
['/app/parent/scores', 'bi bi-clipboard-data', 'Scores'],
|
|
['/app/parent/payment', 'bi bi-credit-card', 'Payment'],
|
|
['/app/parent/message', 'bi bi-chat-dots', 'Messages'],
|
|
['/app/parent/calendar', 'bi bi-calendar', 'Calendar'],
|
|
['/app/parent/contact', 'bi bi-telephone', 'Contact Us'],
|
|
] as const
|
|
|
|
return (
|
|
<nav className="bg-light sidebar ci-parent-sidebar">
|
|
<div className="sidebar-sticky pt-3">
|
|
<ul className="nav flex-column">
|
|
{links.map(([to, icon, label]) => (
|
|
<li className="nav-item" key={to}>
|
|
<Link className="nav-link" to={to}>
|
|
<i className={icon} aria-hidden /> {label}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</nav>
|
|
)
|
|
}
|
|
|
|
export function CiAssignClassTeacherModal({
|
|
teacherName = '',
|
|
teacherRole = '',
|
|
classes,
|
|
onSubmit,
|
|
}: {
|
|
teacherName?: string
|
|
teacherRole?: string
|
|
classes: Array<{ id: number | string; name: string }>
|
|
onSubmit?: (classSectionId: string) => void
|
|
}) {
|
|
const [classSectionId, setClassSectionId] = useState('')
|
|
|
|
return (
|
|
<div className="modal d-block ci-static-modal" tabIndex={-1} role="dialog" aria-modal="true">
|
|
<div className="modal-dialog">
|
|
<div className="modal-content">
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
if (classSectionId) onSubmit?.(classSectionId)
|
|
}}
|
|
>
|
|
<div className="modal-header"><h5 className="modal-title">Assign Class</h5></div>
|
|
<div className="modal-body">
|
|
<ReadonlyInput label="Name" value={teacherName} />
|
|
<ReadonlyInput label="Role" value={teacherRole} />
|
|
<div className="mb-3">
|
|
<label className="form-label">Select Class</label>
|
|
<select className="form-control" value={classSectionId} onChange={(event) => setClassSectionId(event.target.value)} required>
|
|
<option value="">Select a class</option>
|
|
{classes.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="modal-footer">
|
|
<button type="submit" className="btn btn-success">Save</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function CiAssignClassStudentModal({
|
|
studentName = '',
|
|
classes,
|
|
onSubmit,
|
|
}: {
|
|
studentName?: string
|
|
classes: Array<{ id: number | string; name: string }>
|
|
onSubmit?: (classSectionId: string) => void
|
|
}) {
|
|
const [classSectionId, setClassSectionId] = useState('')
|
|
|
|
return (
|
|
<div className="modal d-block ci-static-modal" tabIndex={-1} role="dialog" aria-modal="true">
|
|
<div className="modal-dialog">
|
|
<div className="modal-content">
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
if (classSectionId) onSubmit?.(classSectionId)
|
|
}}
|
|
>
|
|
<div className="modal-header"><h5 className="modal-title">Assign Class to Student</h5></div>
|
|
<div className="modal-body">
|
|
<ReadonlyInput label="Student Name" value={studentName} />
|
|
<div className="mb-3">
|
|
<label className="form-label">Select Class</label>
|
|
<select className="form-control" value={classSectionId} onChange={(event) => setClassSectionId(event.target.value)} required>
|
|
<option value="">Select a class</option>
|
|
{classes.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="modal-footer">
|
|
<button type="submit" className="btn btn-primary">Assign Class</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type AttendanceNoticePayload = {
|
|
student_name?: string
|
|
class_section_name?: string
|
|
date?: string
|
|
date_fmt?: string
|
|
reason?: string
|
|
teacher_name?: string
|
|
school_name?: string
|
|
}
|
|
|
|
export function buildAttendanceNoticeSubject(subjectType: string, payload: AttendanceNoticePayload) {
|
|
const base = payload.student_name ? ` - ${payload.student_name}` : ''
|
|
const dateLabel = payload.date_fmt || formatNoticeDate(payload.date)
|
|
const date = dateLabel ? ` (${dateLabel})` : ''
|
|
if (subjectType === 'Absent') return `Attendance Notice: Absent${base}${date}`
|
|
if (subjectType === 'Late') return `Attendance Notice: Late${base}${date}`
|
|
return `Attendance Notice${base}${date}`
|
|
}
|
|
|
|
export function buildAttendanceNoticeMessage(subjectType: string, payload: AttendanceNoticePayload) {
|
|
const name = payload.student_name || 'your child'
|
|
const date = payload.date_fmt || formatNoticeDate(payload.date) || ''
|
|
const cls = payload.class_section_name ? ` (${payload.class_section_name})` : ''
|
|
const reason = payload.reason ? ` Reason: ${payload.reason}.` : ''
|
|
const teacher = payload.teacher_name ? `\nTeacher: ${payload.teacher_name}` : ''
|
|
const school = payload.school_name || 'Our School'
|
|
|
|
if (subjectType === 'Absent') {
|
|
return `Dear Parent/Guardian,\n\nThis is to inform you that ${name}${cls} was marked Absent on ${date}.${reason}\n\nIf this was an error or there is additional context you would like to share, please reply to this email.\n\nThank you,\n${school}${teacher}\n`
|
|
}
|
|
if (subjectType === 'Late') {
|
|
return `Dear Parent/Guardian,\n\nThis is to notify you that ${name}${cls} was marked Late on ${date}.${reason}\n\nPlease help ensure timely arrival. If you have questions, reply to this email.\n\nThank you,\n${school}${teacher}\n`
|
|
}
|
|
return `Dear Parent/Guardian,\n\nWe are reaching out regarding ${name}${cls} on ${date}. Please see attendance details in the subject line.\n\nIf you have any questions, reply to this email.\n\nThank you,\n${school}${teacher}\n`
|
|
}
|
|
|
|
export function CiAttendanceNotificationModal({
|
|
payload = {},
|
|
onSubmit,
|
|
}: {
|
|
payload?: AttendanceNoticePayload & { student_id?: number | string; parent_email?: string }
|
|
onSubmit?: (values: { to: string; subjectType: string; subject: string; message: string }) => Promise<void> | void
|
|
}) {
|
|
const [subjectType, setSubjectType] = useState('Absent')
|
|
const [to, setTo] = useState(payload.parent_email ?? '')
|
|
const [subject, setSubject] = useState(() => buildAttendanceNoticeSubject('Absent', payload))
|
|
const [message, setMessage] = useState(() => buildAttendanceNoticeMessage('Absent', payload))
|
|
|
|
function updateType(nextType: string) {
|
|
setSubjectType(nextType)
|
|
setSubject(buildAttendanceNoticeSubject(nextType, payload))
|
|
setMessage(buildAttendanceNoticeMessage(nextType, payload))
|
|
}
|
|
|
|
async function submit(event: FormEvent) {
|
|
event.preventDefault()
|
|
await onSubmit?.({ to, subjectType, subject, message })
|
|
}
|
|
|
|
return (
|
|
<div className="modal d-block ci-static-modal" tabIndex={-1} role="dialog" aria-modal="true">
|
|
<div className="modal-dialog modal-lg">
|
|
<div className="modal-content">
|
|
<form onSubmit={submit}>
|
|
<div className="modal-header"><h5 className="modal-title">Notify Parent</h5></div>
|
|
<div className="modal-body">
|
|
<div className="mb-3">
|
|
<label className="form-label">To (Parent Email)</label>
|
|
<input type="email" className="form-control" value={to} onChange={(event) => setTo(event.target.value)} required />
|
|
</div>
|
|
<div className="row g-3">
|
|
<div className="col-md-5">
|
|
<label className="form-label">Subject Type</label>
|
|
<select className="form-select" value={subjectType} onChange={(event) => updateType(event.target.value)} required>
|
|
<option value="Absent">Absent Notice</option>
|
|
<option value="Late">Late/Tardy Notice</option>
|
|
<option value="General">General Attendance Message</option>
|
|
</select>
|
|
</div>
|
|
<div className="col-md-7">
|
|
<label className="form-label">Subject</label>
|
|
<input className="form-control" value={subject} onChange={(event) => setSubject(event.target.value)} required />
|
|
</div>
|
|
</div>
|
|
<div className="mt-3">
|
|
<label className="form-label">Message</label>
|
|
<textarea className="form-control" rows={8} value={message} onChange={(event) => setMessage(event.target.value)} required />
|
|
</div>
|
|
</div>
|
|
<div className="modal-footer">
|
|
<button type="submit" className="btn btn-primary">Send Notification</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Checklist({
|
|
title,
|
|
name,
|
|
options,
|
|
defaultSelected,
|
|
}: {
|
|
title: string
|
|
name: string
|
|
options: string[]
|
|
defaultSelected: Set<string>
|
|
}) {
|
|
const [showOther, setShowOther] = useState(defaultSelected.has('Other'))
|
|
|
|
return (
|
|
<div className="col-md-6 mb-3">
|
|
<label className="form-label">
|
|
{title} <span className="text-danger">*</span>
|
|
</label>
|
|
<div className="border rounded p-2 bg-white ps-3 ci-checklist">
|
|
{options.map((option) => (
|
|
<div className="form-check" key={option}>
|
|
<input
|
|
className="form-check-input"
|
|
type="checkbox"
|
|
name={name}
|
|
value={option}
|
|
defaultChecked={defaultSelected.has(option)}
|
|
onChange={(event) => {
|
|
if (option === 'Other') setShowOther(event.target.checked)
|
|
}}
|
|
/>
|
|
<label className="form-check-label ms-1">{option}</label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{showOther ? <input type="text" className="form-control mt-2" name={`${name}_other`} placeholder="Please specify" /> : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function display(value: unknown) {
|
|
return value == null || value === '' ? '-' : String(value)
|
|
}
|
|
|
|
function ReadonlyInput({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="mb-3">
|
|
<label className="form-label">{label}</label>
|
|
<input type="text" className="form-control" value={value} readOnly />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function userInitials(name?: string | null, email?: string | null) {
|
|
const cleanName = name?.trim()
|
|
if (cleanName) {
|
|
const parts = cleanName.split(/\s+/)
|
|
return `${parts[0]?.[0] ?? ''}${parts.length > 1 ? parts[parts.length - 1]?.[0] ?? '' : ''}`.toUpperCase() || 'U'
|
|
}
|
|
return email?.trim()?.[0]?.toUpperCase() || 'U'
|
|
}
|
|
|
|
function humanRole(value: string) {
|
|
return value.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())
|
|
}
|
|
|
|
function formatNoticeDate(value?: string) {
|
|
if (!value) return ''
|
|
const date = new Date(String(value).replace(' ', 'T'))
|
|
if (Number.isNaN(date.valueOf())) return String(value)
|
|
const pad = (num: number) => String(num).padStart(2, '0')
|
|
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${date.getFullYear()}`
|
|
}
|