add parent pages
This commit is contained in:
@@ -0,0 +1,965 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
|
||||
|
||||
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?: Array<string | { school_year: string }>
|
||||
selectedYear?: string | null
|
||||
selectedSemester?: string | null
|
||||
classSectionId?: string | number | null
|
||||
onApply?: (values: { schoolYear: string; semester: string; classSectionId?: string }) => void
|
||||
}) {
|
||||
const years = (schoolYears ?? []).map((year) => (typeof year === 'string' ? year : year.school_year))
|
||||
const [schoolYear, setSchoolYear] = useState(selectedYear ?? years[0] ?? '')
|
||||
const [semester, setSemester] = useState(selectedSemester ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
setSchoolYear(selectedYear ?? years[0] ?? '')
|
||||
}, [selectedYear, years.join('|')])
|
||||
|
||||
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)}
|
||||
>
|
||||
{years.length > 0 ? (
|
||||
years.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</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(years[0] ?? '')
|
||||
setSemester('')
|
||||
onApply?.({ schoolYear: years[0] ?? '', 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 }) {
|
||||
return (
|
||||
<footer className="footer mt-auto custom-footer text-white py-4">
|
||||
<div className="container">
|
||||
<div className="row align-items-start justify-content-between">
|
||||
<div className="col-md-6 col-12 mb-3 mb-md-0 text-md-start">
|
||||
<ul className="list-unstyled mb-0 info-list">
|
||||
<li>
|
||||
<i className="fa fa-map-marker-alt me-2" aria-hidden />
|
||||
5 Courthouse Lane, Chelmsford, MA 01824
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa fa-phone-alt me-2" aria-hidden />
|
||||
+1 978-364-0219
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa fa-envelope me-2" aria-hidden />
|
||||
alrahma.isgl@gmail.com
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="col-md-4 col-12 text-center text-md-end">
|
||||
<ul className="list-unstyled mb-0 info-list">
|
||||
<li><a href="/privacy_policy.pdf" target="_blank" rel="noopener noreferrer" className="pdf-link"><i className="fas fa-file-pdf me-1" aria-hidden />Privacy Policy</a></li>
|
||||
<li><a href="/terms_of_service.pdf" target="_blank" rel="noopener noreferrer" className="pdf-link"><i className="fas fa-file-pdf me-1" aria-hidden />Terms of Service</a></li>
|
||||
<li><a href="/account_creation_guide.pdf" target="_blank" rel="noopener noreferrer" className="pdf-link"><i className="fas fa-file-pdf me-1" aria-hidden />How To Create An Account</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-white mb-0 mt-3">© {year} Al Rahma Sunday School by ISGL. All Rights Reserved.</p>
|
||||
</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()}`
|
||||
}
|
||||
Reference in New Issue
Block a user