fix teacher, parent and admin pages

This commit is contained in:
root
2026-04-25 00:00:10 -04:00
parent 7fe34dde0d
commit 3e77fc92c7
275 changed files with 46412 additions and 3325 deletions
+44
View File
@@ -0,0 +1,44 @@
import { Link, useSearchParams } from 'react-router-dom'
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
/** Landing for WhatsApp admin tools — detail screens call `/api/v1/administrator/whatsapp/*` via JWT (`api/whatsapp.ts`). */
export function WhatsappHubPage() {
const [searchParams] = useSearchParams()
const q = searchParams.toString()
const qs = q ? `?${q}` : ''
return (
<div className="container-fluid py-3">
<h2 className="text-center mt-3 mb-2">WhatsApp</h2>
<p className="text-muted text-center small mb-4">
Group links, parent contacts, and membership updates use the authenticated administrator WhatsApp API.
</p>
<AcademicFilterBar />
<div className="card mb-4">
<div className="card-header">Tools</div>
<div className="card-body row g-2">
<div className="col-md-6 col-lg-4">
<Link className="btn btn-outline-primary w-100" to={`/app/whatsapp/manage-links${qs}`}>
Manage group links
</Link>
</div>
<div className="col-md-6 col-lg-4">
<Link className="btn btn-outline-primary w-100" to={`/app/whatsapp/parent-contacts${qs}`}>
Parent contacts
</Link>
</div>
<div className="col-md-6 col-lg-4">
<Link
className="btn btn-outline-primary w-100"
to={`/app/whatsapp/parent-contacts-by-class${qs}`}
>
Parent contacts by class
</Link>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,273 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import {
fetchWhatsappManageLinks,
saveWhatsappLink,
sendWhatsappInvites,
type WhatsappSectionRow,
} from '../../api/whatsapp'
function linkForSection(
linksBySection: Record<string, { invite_link?: string | null; active?: number | boolean }> | undefined,
sid: number,
) {
if (!linksBySection) return undefined
return linksBySection[String(sid)] ?? linksBySection[sid]
}
export function WhatsappManageLinksPage() {
const [payload, setPayload] = useState<Awaited<ReturnType<typeof fetchWhatsappManageLinks>> | null>(null)
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [inviteMode, setInviteMode] = useState<'all' | 'class' | 'parents'>('all')
const [inviteClassId, setInviteClassId] = useState<number | ''>('')
const [inviteParentIds, setInviteParentIds] = useState<number[]>([])
useEffect(() => {
let cancelled = false
;(async () => {
try {
const data = await fetchWhatsappManageLinks()
if (cancelled) return
setPayload(data)
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load WhatsApp settings.')
}
})()
return () => {
cancelled = true
}
}, [])
const sections = payload?.sections ?? []
const linksBySection = payload?.linksBySection as
| Record<string, { invite_link?: string | null; active?: number | boolean }>
| undefined
const parents = payload?.parents ?? []
const parentOptions = useMemo(
() =>
parents.map((p) => {
let label = `${p.lastname ?? ''}, ${p.firstname ?? ''}`.trim()
if (p.email) label += `${p.email}`
label += p.source === 'parents' ? ' (Second Parent)' : ' (Primary)'
return { id: p.id, label }
}),
[parents],
)
async function onSaveSection(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const fd = new FormData(e.currentTarget)
const class_section_id = Number(fd.get('class_section_id'))
const class_section_name = String(fd.get('class_section_name') ?? '')
const invite_link = String(fd.get('invite_link') ?? '')
const active = fd.get('active') === 'on'
try {
await saveWhatsappLink({ class_section_id, class_section_name, invite_link, active })
setMessage('Link saved.')
const data = await fetchWhatsappManageLinks()
setPayload(data)
} catch (err) {
setMessage(err instanceof Error ? err.message : 'Save failed.')
}
}
async function onSendInvites(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
try {
const body: {
mode: 'all' | 'class' | 'parents'
class_section_id?: number | null
parent_ids?: number[]
} = { mode: inviteMode }
if (inviteMode === 'class') {
body.class_section_id = inviteClassId === '' ? null : Number(inviteClassId)
}
if (inviteMode === 'parents') {
body.parent_ids = inviteParentIds
}
await sendWhatsappInvites(body)
setMessage('Invite request submitted.')
} catch (err) {
setMessage(err instanceof Error ? err.message : 'Send failed.')
}
}
const schoolYear = payload?.schoolYear ?? ''
const semester = payload?.semester ?? ''
return (
<div className="container my-4">
<style>{`
#parentPicker .form-select[multiple] {
height: clamp(420px, 65vh, 820px);
min-height: 420px;
}
#parentPicker .form-select[multiple] option {
font-size: 0.95rem;
line-height: 1.25;
}
`}</style>
<h2 className="mb-3">WhatsApp Group Links</h2>
{message ? <div className="alert alert-info py-2">{message}</div> : null}
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="card-header d-flex justify-content-between align-items-center px-0 border-0 mb-3">
<Link className="btn btn-outline-secondary btn-sm" to="/app/whatsapp/parent-contacts-by-class">
<i className="bi bi-people" /> Parent Contacts by Class
</Link>
</div>
<div className="row g-4">
<div className="col-lg-7">
<div className="card">
<div className="card-header">
Set / Update Links ({schoolYear}
{semester ? ` - ${semester}` : ''})
</div>
<div className="card-body">
{sections.length === 0 ? (
<p className="text-muted">No sections found for this term.</p>
) : (
sections.map((sec: WhatsappSectionRow) => {
const sid = Number(sec.class_section_id ?? sec.id ?? 0)
const sname = sec.class_section_name ?? sec.name ?? `Section ${sid}`
const existing = linkForSection(linksBySection, sid)
return (
<form
key={sid}
className="row gy-2 align-items-end border-bottom pb-3 mb-3"
onSubmit={(e) => void onSaveSection(e)}
>
<input type="hidden" name="class_section_id" value={sid} />
<div className="col-12">
<label className="form-label">Class / Section</label>
<input type="text" className="form-control" name="class_section_name" value={sname} readOnly />
</div>
<div className="col-12">
<label className="form-label">Invite Link</label>
<input
type="url"
className="form-control"
name="invite_link"
placeholder="https://chat.whatsapp.com/..."
defaultValue={existing?.invite_link ?? ''}
required
/>
{existing?.invite_link ? (
<small className="text-success d-block mt-1">Link set.</small>
) : (
<small className="text-muted d-block mt-1">No link set yet.</small>
)}
</div>
<div className="col-md-6">
<div className="form-check mt-2">
<input
className="form-check-input"
type="checkbox"
name="active"
id={`active-${sid}`}
defaultChecked={Boolean(existing?.active === 1 || existing?.active === true)}
/>
<label className="form-check-label" htmlFor={`active-${sid}`}>
Active
</label>
</div>
</div>
<div className="col-md-6 text-end">
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</form>
)
})
)}
</div>
</div>
</div>
<div className="col-lg-5">
<div className="card">
<div className="card-header">Send Invites</div>
<div className="card-body">
<form id="sendInvitesForm" onSubmit={(e) => void onSendInvites(e)}>
<div className="mb-3">
<label className="form-label">Mode</label>
<select
className="form-select"
value={inviteMode}
onChange={(e) => setInviteMode(e.target.value as 'all' | 'class' | 'parents')}
required
>
<option value="all">All parents</option>
<option value="class">Parents in a class</option>
<option value="parents">Specific parent(s)</option>
</select>
</div>
<div className={`mb-3 ${inviteMode === 'class' ? '' : 'd-none'}`}>
<label className="form-label">Class / Section</label>
<select
className="form-select"
value={inviteClassId === '' ? '' : String(inviteClassId)}
onChange={(e) =>
setInviteClassId(e.target.value ? Number(e.target.value) : '')
}
required={inviteMode === 'class'}
>
<option value="">-- Select Class / Section --</option>
{sections.map((s: WhatsappSectionRow) => {
const cid = Number(s.class_section_id ?? s.id)
const hasLink = Boolean(linkForSection(linksBySection, cid)?.invite_link)
return (
<option key={cid} value={cid}>
{s.class_section_name}
{hasLink ? ' (has link)' : ''}
</option>
)
})}
</select>
</div>
<div className={`mb-3 ${inviteMode === 'parents' ? '' : 'd-none'}`} id="parentPicker">
<label className="form-label">Parents</label>
<select
className="form-select"
multiple
size={25}
value={inviteParentIds.map(String)}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions).map((o) => Number(o.value))
setInviteParentIds(selected)
}}
required={inviteMode === 'parents'}
>
{parentOptions.length === 0 ? (
<option disabled>No parents available</option>
) : (
parentOptions.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))
)}
</select>
<small className="text-muted">Hold Ctrl/Cmd to select multiple.</small>
</div>
<div className="text-end">
<button type="submit" className="btn btn-success">
Send Emails
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,227 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import {
fetchWhatsappParentContactsByClass,
updateWhatsappMembership,
type ParentContactsByClassParentRow,
type ParentContactsByClassSection,
} from '../../api/whatsapp'
function digitsOnly(phone: string): string {
return phone.replace(/\D+/g, '')
}
function memClass(v: string): string {
if (v === '1') return 'mem-yes'
if (v === '0') return 'mem-no'
return 'mem-none'
}
export function WhatsappParentContactsByClassPage() {
const [sections, setSections] = useState<ParentContactsByClassSection[]>([])
const [openIndex, setOpenIndex] = useState(0)
const [error, setError] = useState<string | null>(null)
const [statusByKey, setStatusByKey] = useState<Record<string, string>>({})
useEffect(() => {
let cancelled = false
;(async () => {
try {
const data = await fetchWhatsappParentContactsByClass()
if (cancelled) return
setSections(data.classSections ?? [])
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load data.')
}
})()
return () => {
cancelled = true
}
}, [])
async function saveRow(
section: ParentContactsByClassSection,
p: ParentContactsByClassParentRow,
primaryVal: string,
secondVal: string,
rowKey: string,
) {
const class_section_id = Number(p.class_section_id ?? section.class_section_id ?? 0)
try {
const res = await updateWhatsappMembership({
class_section_id,
school_year: section.school_year,
semester: section.semester,
primary_id: Number(p.primary_id ?? 0),
second_id: Number(p.second_id ?? 0),
primary_member: primaryVal,
second_member: secondVal,
})
const ok = res.status === 'ok'
setStatusByKey((prev) => ({
...prev,
[rowKey]: ok ? 'Saved' : res.message ?? 'Nothing to save',
}))
} catch {
setStatusByKey((prev) => ({ ...prev, [rowKey]: 'Save failed' }))
}
}
return (
<div className="container-fluid">
<style>{`
.mem-select.form-select.form-select-sm {
display: inline-block;
width: auto;
min-width: 52px;
font-size: 0.75rem;
padding-left: 4px;
padding-right: 12px;
line-height: 1;
border: 1px solid #adb5bd;
}
.mem-select.mem-yes {
background: #157347;
color: #fff;
border-color: #0f5132;
}
.mem-select.mem-no {
background: #bb2d3b;
color: #fff;
border-color: #842029;
}
.mem-select.mem-none {
background: #fff;
color: #212529;
border-color: #adb5bd;
}
`}</style>
<div className="wrapper py-3">
<h2 className="text-center mt-4 mb-3">Parent Contacts by Class</h2>
<Link className="btn btn-outline-secondary btn-sm mb-3" to="/app/whatsapp/manage-links">
<i className="bi bi-people" /> WhatsApp Group Links
</Link>
{error ? <div className="alert alert-danger">{error}</div> : null}
{sections.length === 0 && !error ? <p>No class sections available.</p> : null}
<div id="classAccordion">
{sections.map((classSection, index) => (
<div key={`${classSection.class_section_name}-${index}`} className="card mb-2">
<div className="card-header" id={`heading-${index}`}>
<h2 className="mb-0">
<button
className="btn btn-link w-100 text-start"
type="button"
aria-expanded={openIndex === index}
onClick={() => setOpenIndex(openIndex === index ? -1 : index)}
>
Grade : {classSection.class_section_name}
</button>
</h2>
</div>
{openIndex === index ? (
<div className="card-body">
{(classSection.parents ?? []).length === 0 ? (
<p>No parents found for this class section.</p>
) : (
<div className="table-responsive">
<table className="table table-striped table-hover">
<thead>
<tr>
<th>Primary Parent</th>
<th>Phone</th>
<th>In Group (Primary)</th>
<th>Second Parent</th>
<th>Phone</th>
<th>In Group (Second)</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{(classSection.parents ?? []).map((p, rIdx) => {
const rowKey = `${index}-${rIdx}`
const pm = (p.primary_member ?? '') as string
const sm = (p.second_member ?? '') as string
return (
<tr key={rowKey}>
<td>{p.primary_name}</td>
<td>
{p.primary_phone ? (
<a href={`tel:${digitsOnly(p.primary_phone)}`}>{p.primary_phone}</a>
) : (
<span className="text-muted"></span>
)}
</td>
<td className="text-center">
<select
className={`form-select form-select-sm mem-select ${memClass(pm)}`}
aria-label="Primary in group"
defaultValue={pm}
onChange={(e) => {
const tr = e.target.closest('tr')
const v = e.target.value
e.target.className = `form-select form-select-sm mem-select ${memClass(v)}`
const secondSel = tr?.querySelector<HTMLSelectElement>(
'select[aria-label="Second in group"]',
)
void saveRow(classSection, p, v, secondSel?.value ?? '', rowKey)
}}
>
<option value=""></option>
<option value="1">Yes</option>
<option value="0">No</option>
</select>
</td>
<td>{p.second_name}</td>
<td>
{p.second_phone ? (
<a href={`tel:${digitsOnly(p.second_phone)}`}>{p.second_phone}</a>
) : (
<span className="text-muted"></span>
)}
</td>
<td className="text-center">
{p.second_id ? (
<select
className={`form-select form-select-sm mem-select ${memClass(sm)}`}
aria-label="Second in group"
defaultValue={sm}
onChange={(e) => {
const tr = e.target.closest('tr')
const v = e.target.value
e.target.className = `form-select form-select-sm mem-select ${memClass(v)}`
const priSel = tr?.querySelector<HTMLSelectElement>(
'select[aria-label="Primary in group"]',
)
void saveRow(classSection, p, priSel?.value ?? '', v, rowKey)
}}
>
<option value=""></option>
<option value="1">Yes</option>
<option value="0">No</option>
</select>
) : (
<span className="text-muted"></span>
)}
</td>
<td className="text-center small text-muted">{statusByKey[rowKey] ?? ''}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
) : null}
</div>
))}
</div>
</div>
</div>
)
}
@@ -0,0 +1,112 @@
import { useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { fetchWhatsappParentContacts } from '../../api/whatsapp'
import type { ParentContactRow } from '../../api/whatsapp'
function digitsOnly(phone: string): string {
return phone.replace(/\D+/g, '')
}
export function WhatsappParentContactsPage() {
const [searchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? undefined
const semester = searchParams.get('semester') ?? undefined
const [contacts, setContacts] = useState<ParentContactRow[]>([])
const [termYear, setTermYear] = useState<string | null>(null)
const [termSem, setTermSem] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const data = await fetchWhatsappParentContacts({ school_year: schoolYear, semester })
if (cancelled) return
setContacts(data.contacts ?? [])
setTermYear(data.schoolYear ?? schoolYear ?? null)
setTermSem(data.semester ?? semester ?? null)
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load contacts.')
}
})()
return () => {
cancelled = true
}
}, [schoolYear, semester])
return (
<div className="container my-4">
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<h2 className="mb-0">Parent Contacts</h2>
<div className="d-flex align-items-center gap-2">
{termYear ? <span className="badge bg-primary">School Year: {termYear}</span> : null}
{termSem ? (
<span className="badge bg-info text-dark">
Semester: {termSem}
</span>
) : null}
<Link className="btn btn-outline-secondary btn-sm" to="/app/whatsapp/manage-links">
Back
</Link>
</div>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{contacts.length === 0 && !error ? (
<div className="alert alert-warning">No parent contacts found for the selected term.</div>
) : null}
{contacts.length > 0 ? (
<div className="card shadow-sm">
<div className="card-body">
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead className="table-light">
<tr>
<th style={{ width: '14rem' }}>Name</th>
<th style={{ width: '10rem' }}>Type</th>
<th style={{ width: '14rem' }}>Phone</th>
<th style={{ width: '18rem' }}>Email</th>
</tr>
</thead>
<tbody>
{contacts.map((row, i) => {
const name = `${row.lastname ?? ''}, ${row.firstname ?? ''}`.trim()
const isSecond = row.type === 'Second Parent'
return (
<tr key={`${name}-${i}`}>
<td>{name}</td>
<td>
<span className={`badge ${isSecond ? 'bg-secondary' : 'bg-success'}`}>
{row.type ?? ''}
</span>
</td>
<td>
{row.phone ? (
<a href={`tel:${digitsOnly(row.phone)}`}>{row.phone}</a>
) : (
<span className="text-muted"></span>
)}
</td>
<td>
{row.email ? (
<a href={`mailto:${row.email}`}>{row.email}</a>
) : (
<span className="text-muted"></span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="small text-muted mt-2">Tip: Click a phone to dial, email to compose.</div>
</div>
</div>
) : null}
</div>
)
}