Files
alrahma_web_client/src/components/CiPartials.tsx
T
2026-06-07 00:52:04 -04:00

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()}`
}