feat(admin): add Offerings operations UI

Offerings list + create; offering detail with overview (valid-transitions-only buttons),
server-computed launch readiness, required agreements, and registrations/roster;
registration detail with lifecycle transitions, agreement status, communication history
(masked recipients), and convert-to-enrollment. Reuses the existing Admin.module.css/
adminApi conventions exactly (ProgramsView.jsx's own list+detail pattern) — no new UI
framework or pattern introduced.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-26 23:59:20 -05:00
co-authored by Claude Sonnet 5
parent 24eee80354
commit 40362ec0c9
3 changed files with 448 additions and 0 deletions
+3
View File
@@ -10,6 +10,7 @@ import LearningView from './LearningView'
import AssessmentsView from './AssessmentsView'
import ClassroomView from './ClassroomView'
import ProgramsView from './ProgramsView'
import OfferingsView from './OfferingsView'
import NotificationsView from './NotificationsView'
import CurriculumView from './CurriculumView'
import styles from './Admin.module.css'
@@ -25,6 +26,7 @@ const NAV = [
{ key: 'cohorts', label: 'Cohorts' },
{ key: 'students', label: 'Students' },
{ key: 'programs', label: 'Programs' },
{ key: 'offerings', label: 'Offerings' },
{ key: 'curriculum', label: 'Curriculum' },
{ key: 'classroom', label: 'Classroom' },
{ key: 'learning', label: 'Learning' },
@@ -68,6 +70,7 @@ export default function AdminPortal() {
{view === 'cohorts' && <CohortsView role={instructor.role} />}
{view === 'students' && <StudentsView role={instructor.role} />}
{view === 'programs' && <ProgramsView role={instructor.role} />}
{view === 'offerings' && <OfferingsView role={instructor.role} />}
{view === 'curriculum' && <CurriculumView />}
{view === 'classroom' && <ClassroomView />}
{view === 'learning' && <LearningView />}
+412
View File
@@ -0,0 +1,412 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// Phase 8 §1-§5 — the Offerings operations console: list/create/detail, launch readiness,
// registrations/roster, and agreement requirements. Authoring (create/transition/agreement
// requirements) is admin/owner only, matching every other authoring surface in this app
// (ProgramsView/AssessmentsView's own precedent) — canAuthor gates writes only, not viewing.
const OFFERING_TRANSITIONS = {
draft: ['published', 'cancelled'],
published: ['enrollment_open', 'cancelled'],
enrollment_open: ['full', 'in_progress', 'cancelled'],
full: ['enrollment_open', 'in_progress', 'cancelled'],
in_progress: ['completed', 'cancelled'],
completed: [],
cancelled: [],
}
function statusPillClass(status) {
if (['enrollment_open', 'in_progress', 'completed'].includes(status)) return styles.pillOk
if (status === 'full') return styles.pillWarn
if (status === 'cancelled') return styles.pillErr
return styles.pillMuted
}
export default function OfferingsView({ role }) {
const [offerings, setOfferings] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const canAuthor = role === 'owner' || role === 'admin'
const reload = useCallback(() => {
adminApi.offerings().then(setOfferings).catch((e) => setError(e.message))
}, [])
useEffect(reload, [reload])
if (selected) return <OfferingDetail offeringId={selected} canAuthor={canAuthor} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
{canAuthor && (
<div className={styles.card}>
<h2>Create offering</h2>
<CreateOfferingForm onCreated={(id) => { reload(); setSelected(id) }} />
</div>
)}
<div className={styles.card}>
<h2>Offerings</h2>
{!offerings ? <p className={styles.muted}>Loading…</p> : offerings.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Name</th><th>Program</th><th>Delivery</th><th>Status</th><th>Seats</th><th /></tr></thead>
<tbody>
{offerings.map((o) => (
<tr key={o.offering_id}>
<td>{o.name}</td>
<td className={styles.muted}>{o.program_id}</td>
<td className={styles.muted}>{o.delivery_mode.replace('_', ' ')}</td>
<td><span className={`${styles.pill} ${statusPillClass(o.status)}`}>{o.status.replace('_', ' ')}</span></td>
<td className={styles.muted}>{o.capacity === null ? 'unlimited' : `${o.reserved_seats}/${o.capacity}`}</td>
<td><button className={styles.linkBtn} onClick={() => setSelected(o.offering_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function CreateOfferingForm({ onCreated }) {
const [programs, setPrograms] = useState([])
const [locations, setLocations] = useState([])
const [form, setForm] = useState({ offering_id: '', slug: '', name: '', program_id: '', delivery_mode: 'camp', capacity: '', location_id: '', recommended_min_age: '', recommended_max_age: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
adminApi.programs().then(setPrograms).catch(() => {})
adminApi.locations().then(setLocations).catch(() => {})
}, [])
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
const offering = await adminApi.createOffering({
offeringId: form.offering_id, slug: form.slug, name: form.name, programId: form.program_id, deliveryMode: form.delivery_mode,
capacity: form.capacity === '' ? undefined : Number(form.capacity),
locationId: form.location_id || undefined,
recommendedMinAge: form.recommended_min_age === '' ? undefined : Number(form.recommended_min_age),
recommendedMaxAge: form.recommended_max_age === '' ? undefined : Number(form.recommended_max_age),
})
setForm({ offering_id: '', slug: '', name: '', program_id: '', delivery_mode: 'camp', capacity: '', location_id: '', recommended_min_age: '', recommended_max_age: '' })
onCreated(offering.offering_id)
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit}>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.formRow}>
<div><label className={styles.label}>Offering ID (slug-like)</label>
<input className={styles.input} value={form.offering_id} onChange={(e) => setForm({ ...form, offering_id: e.target.value })} required /></div>
<div><label className={styles.label}>URL slug</label>
<input className={styles.input} value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} required /></div>
<div><label className={styles.label}>Name</label>
<input className={styles.input} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></div>
</div>
<div className={styles.formRow}>
<div><label className={styles.label}>Program</label>
<select className={styles.select} value={form.program_id} onChange={(e) => setForm({ ...form, program_id: e.target.value })} required>
<option value="">Select a program…</option>
{programs.map((p) => <option key={p.program_id} value={p.program_id}>{p.name}</option>)}
</select></div>
<div><label className={styles.label}>Delivery mode</label>
<select className={styles.select} value={form.delivery_mode} onChange={(e) => setForm({ ...form, delivery_mode: e.target.value })}>
{['self_paced', 'cohort', 'hybrid', 'camp', 'workshop', 'school', 'after_school', 'homeschool', 'community', 'one_to_one'].map((m) => <option key={m} value={m}>{m.replace('_', ' ')}</option>)}
</select></div>
<div><label className={styles.label}>Capacity (blank = unlimited)</label>
<input className={styles.input} type="number" min="1" value={form.capacity} onChange={(e) => setForm({ ...form, capacity: e.target.value })} /></div>
<div><label className={styles.label}>Location (optional)</label>
<select className={styles.select} value={form.location_id} onChange={(e) => setForm({ ...form, location_id: e.target.value })}>
<option value="">None / online</option>
{locations.map((l) => <option key={l.location_id} value={l.location_id}>{l.name}</option>)}
</select></div>
</div>
<div className={styles.formRow}>
<div><label className={styles.label}>Recommended min age (guidance only)</label>
<input className={styles.input} type="number" value={form.recommended_min_age} onChange={(e) => setForm({ ...form, recommended_min_age: e.target.value })} /></div>
<div><label className={styles.label}>Recommended max age (guidance only)</label>
<input className={styles.input} type="number" value={form.recommended_max_age} onChange={(e) => setForm({ ...form, recommended_max_age: e.target.value })} /></div>
</div>
<p className={styles.muted}>Age fields are display guidance only — never an access gate. Dates, registration window, and organization can be set later from the offering's own detail page.</p>
<button className={styles.btn} style={{ marginTop: '0.5rem' }} disabled={busy}>{busy ? 'Creating…' : 'Create draft offering'}</button>
</form>
)
}
function OfferingDetail({ offeringId, canAuthor, onBack }) {
const [offering, setOffering] = useState(null)
const [readiness, setReadiness] = useState(null)
const [registrations, setRegistrations] = useState(null)
const [agreementReqs, setAgreementReqs] = useState(null)
const [allAgreements, setAllAgreements] = useState([])
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const [selectedReg, setSelectedReg] = useState(null)
const reload = useCallback(() => {
adminApi.offering(offeringId).then(setOffering).catch((e) => setError(e.message))
adminApi.offeringReadiness(offeringId).then(setReadiness).catch(() => {})
adminApi.offeringRegistrations(offeringId).then(setRegistrations).catch(() => {})
adminApi.offeringAgreementRequirements(offeringId).then(setAgreementReqs).catch(() => {})
adminApi.agreements().then(setAllAgreements).catch(() => {})
}, [offeringId])
useEffect(reload, [reload])
const withBusy = (fn) => async () => {
setBusy(true); setError(null)
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
}
if (selectedReg) return <RegistrationDetail registrationId={selectedReg} canAuthor={canAuthor} onBack={() => { setSelectedReg(null); reload() }} />
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to offerings</button>
{!offering ? <p className={styles.muted}>Loading…</p> : (
<>
<h2 style={{ margin: '1rem 0' }}>{offering.name} <span className={`${styles.pill} ${statusPillClass(offering.status)}`}>{offering.status.replace('_', ' ')}</span></h2>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.card}>
<h3>Overview</h3>
<p className={styles.muted}>{offering.description || 'No description.'}</p>
<p>Program: {offering.program_id} — Delivery: {offering.delivery_mode.replace('_', ' ')} — Seats: {offering.capacity === null ? 'unlimited' : `${offering.reserved_seats} / ${offering.capacity}`}</p>
{canAuthor && (OFFERING_TRANSITIONS[offering.status] || []).length > 0 && (
<div style={{ marginTop: '0.5rem' }}>
{(OFFERING_TRANSITIONS[offering.status] || []).map((next) => (
<button key={next} className={styles.btnSecondary} style={{ marginRight: '0.5rem' }} disabled={busy}
onClick={withBusy(() => adminApi.transitionOffering(offeringId, next))}>
{next === 'cancelled' ? 'Cancel offering' : `Move to ${next.replace('_', ' ')}`}
</button>
))}
</div>
)}
</div>
<div className={styles.card}>
<h3>Launch readiness</h3>
{!readiness ? <p className={styles.muted}>Loading…</p> : (
<>
<p><span className={`${styles.pill} ${readiness.ready_to_open_registration ? styles.pillOk : styles.pillWarn}`}>
{readiness.ready_to_open_registration ? 'Ready to open registration' : 'Not yet ready'}
</span></p>
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
{readiness.checks.map((c) => (
<li key={c.key} className={styles.muted}>{c.ready ? '✓' : '✗'} {c.label}</li>
))}
</ul>
</>
)}
</div>
<div className={styles.card}>
<h3>Required agreements</h3>
{!agreementReqs ? <p className={styles.muted}>Loading…</p> : agreementReqs.length === 0 ? <p className={styles.muted}>None configured — registrations can convert without any agreement acceptance.</p> : (
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
{agreementReqs.map((a) => <li key={a.agreement_id} className={styles.muted}>{a.title} (v{a.version}) {a.requires_guardian_for_minor && <span className={styles.pill}>guardian required for minors</span>}</li>)}
</ul>
)}
{canAuthor && <AddAgreementRequirementForm offeringId={offeringId} allAgreements={allAgreements} onAdded={reload} />}
</div>
<div className={styles.card}>
<h3>Registrations / roster</h3>
{canAuthor && <CreateRegistrationForm offeringId={offeringId} onCreated={reload} />}
{!registrations ? <p className={styles.muted}>Loading…</p> : registrations.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Registrant</th><th>Type</th><th>Status</th><th /></tr></thead>
<tbody>
{registrations.map((r) => (
<tr key={r.registration_id}>
<td>{r.registrant_display_name}</td>
<td className={styles.muted}>{r.is_minor ? 'minor (guardian)' : 'adult'}</td>
<td><span className={`${styles.pill} ${r.status === 'enrollment_activated' ? styles.pillOk : r.status === 'cancelled' ? styles.pillErr : styles.pillMuted}`}>{r.status.replace('_', ' ')}</span></td>
<td><button className={styles.linkBtn} onClick={() => setSelectedReg(r.registration_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</>
)
}
function CreateRegistrationForm({ offeringId, onCreated }) {
const [form, setForm] = useState({ registrant_display_name: '', registrant_email: '', is_minor: false, guardian_display_name: '', guardian_email: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
let guardianId
if (form.is_minor) {
guardianId = `guardian-${Date.now()}`
await adminApi.createGuardian({ guardianId, displayName: form.guardian_display_name || form.guardian_email, email: form.guardian_email })
}
await adminApi.createRegistration(offeringId, {
registrantDisplayName: form.registrant_display_name, registrantEmail: form.registrant_email || undefined,
isMinor: form.is_minor, guardianId,
})
setForm({ registrant_display_name: '', registrant_email: '', is_minor: false, guardian_display_name: '', guardian_email: '' })
onCreated()
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit} style={{ marginBottom: '1rem', paddingBottom: '1rem', borderBottom: '1px solid var(--border)' }}>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.formRow}>
<div><label className={styles.label}>Registrant name</label>
<input className={styles.input} value={form.registrant_display_name} onChange={(e) => setForm({ ...form, registrant_display_name: e.target.value })} required /></div>
<div><label className={styles.label}>Registrant type</label>
<select className={styles.select} value={form.is_minor ? 'minor' : 'adult'} onChange={(e) => setForm({ ...form, is_minor: e.target.value === 'minor' })}>
<option value="adult">Adult (self-registering)</option>
<option value="minor">Minor (guardian registers)</option>
</select></div>
{!form.is_minor && (
<div><label className={styles.label}>Registrant email</label>
<input className={styles.input} type="email" value={form.registrant_email} onChange={(e) => setForm({ ...form, registrant_email: e.target.value })} /></div>
)}
</div>
{form.is_minor && (
<div className={styles.formRow}>
<div><label className={styles.label}>Guardian name</label>
<input className={styles.input} value={form.guardian_display_name} onChange={(e) => setForm({ ...form, guardian_display_name: e.target.value })} required /></div>
<div><label className={styles.label}>Guardian email</label>
<input className={styles.input} type="email" value={form.guardian_email} onChange={(e) => setForm({ ...form, guardian_email: e.target.value })} required /></div>
</div>
)}
<button className={styles.btn} disabled={busy || !form.registrant_display_name.trim()}>{busy ? 'Registering…' : 'Add registration'}</button>
</form>
)
}
function AddAgreementRequirementForm({ offeringId, allAgreements, onAdded }) {
const [agreementId, setAgreementId] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
if (!agreementId) return
setBusy(true); setError(null)
try { await adminApi.addOfferingAgreementRequirement(offeringId, agreementId); setAgreementId(''); onAdded() }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit} style={{ marginTop: '0.75rem', display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
{error && <div className={styles.errorBox}>{error}</div>}
<div><label className={styles.label}>Require an agreement</label>
<select className={styles.select} value={agreementId} onChange={(e) => setAgreementId(e.target.value)}>
<option value="">Select…</option>
{allAgreements.map((a) => <option key={a.agreement_id} value={a.agreement_id}>{a.title}</option>)}
</select></div>
<button className={styles.btnSecondary} disabled={busy || !agreementId}>Add requirement</button>
</form>
)
}
function RegistrationDetail({ registrationId, canAuthor, onBack }) {
const [reg, setReg] = useState(null)
const [agreementStatus, setAgreementStatus] = useState(null)
const [notifs, setNotifs] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const REG_TRANSITIONS = {
started: ['submitted', 'cancelled'], submitted: ['reviewed', 'agreements_pending', 'payment_pending', 'confirmed', 'cancelled'],
reviewed: ['agreements_pending', 'payment_pending', 'confirmed', 'cancelled'], agreements_pending: ['payment_pending', 'confirmed', 'cancelled'],
payment_pending: ['confirmed', 'cancelled'], confirmed: ['enrollment_activated', 'cancelled'], enrollment_activated: [], cancelled: [],
}
const reload = useCallback(() => {
adminApi.registration(registrationId).then(setReg).catch((e) => setError(e.message))
adminApi.registrationAgreementStatus(registrationId).then(setAgreementStatus).catch(() => {})
adminApi.registrationNotifications(registrationId).then(setNotifs).catch(() => {})
}, [registrationId])
useEffect(reload, [reload])
const withBusy = (fn) => async () => {
setBusy(true); setError(null); setNotice(null)
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to offering</button>
{!reg ? <p className={styles.muted}>Loading…</p> : (
<>
<h2 style={{ margin: '1rem 0' }}>{reg.registrant_display_name} <span className={styles.pill}>{reg.status.replace('_', ' ')}</span></h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
<div className={styles.card}>
<h3>Registration</h3>
<p>Type: {reg.is_minor ? 'Minor (guardian-registered)' : 'Adult (self-registered)'}</p>
{reg.registrant_email && <p className={styles.muted}>Contact: {reg.registrant_email}</p>}
{reg.readiness_notes && <p className={styles.muted}>Readiness notes: {reg.readiness_notes}</p>}
{canAuthor && (REG_TRANSITIONS[reg.status] || []).length > 0 && (
<div style={{ marginTop: '0.5rem' }}>
{(REG_TRANSITIONS[reg.status] || []).map((next) => (
<button key={next} className={styles.btnSecondary} style={{ marginRight: '0.5rem' }} disabled={busy}
onClick={withBusy(() => adminApi.transitionRegistration(registrationId, next))}>
{next === 'cancelled' ? 'Cancel' : next.replace('_', ' ')}
</button>
))}
{reg.status === 'confirmed' && (
<button className={styles.btn} disabled={busy} onClick={withBusy(async () => { const r = await adminApi.convertRegistration(registrationId); setNotice(`Enrolled as ${r.student.student_id}.`) })}>
Convert to enrollment
</button>
)}
</div>
)}
</div>
<div className={styles.card}>
<h3>Agreements</h3>
{!agreementStatus ? <p className={styles.muted}>Loading…</p> : agreementStatus.required.length === 0 ? <p className={styles.muted}>No agreements required for this offering.</p> : (
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
{agreementStatus.required.map((a) => (
<li key={a.agreement_id} className={styles.muted}>
{agreementStatus.missing.some((m) => m.agreement_id === a.agreement_id) ? '✗ missing' : '✓ accepted'} — {a.title}
</li>
))}
</ul>
)}
</div>
<div className={styles.card}>
<h3>Communications</h3>
{!notifs ? <p className={styles.muted}>Loading…</p> : notifs.length === 0 ? <p className={styles.muted}>None sent yet.</p> : (
<table>
<thead><tr><th>Type</th><th>To</th><th>Status</th><th>Sent</th></tr></thead>
<tbody>
{notifs.map((n) => (
<tr key={n.notification_id}>
<td className={styles.muted}>{n.notification_type.replace(/_/g, ' ')}</td>
<td className={styles.muted}>{n.recipient_email_masked || '—'}</td>
<td><span className={`${styles.pill} ${n.status === 'sent' ? styles.pillOk : n.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{n.status}</span></td>
<td className={styles.muted}>{n.sent_at ? new Date(n.sent_at).toLocaleString() : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</>
)
}
+33
View File
@@ -138,4 +138,37 @@ export const adminApi = {
// ── Phase 6A §7: course/module/activity curriculum view ──────────────────────────────────
programCourses: (programId) => get(`/api/admin/programs/${programId}/courses`),
studentCourses: (studentId, programId) => get(`/api/admin/students/${studentId}/courses?program_id=${encodeURIComponent(programId)}`),
// ── Phase 7/8: offerings, registrations, guardians, agreements ──────────────────────────
offerings: (status) => get(`/api/admin/offerings${status ? `?status=${status}` : ''}`),
offering: (id) => get(`/api/admin/offerings/${id}`),
createOffering: (fields) => post('/api/admin/offerings', fields),
transitionOffering: (id, status) => post(`/api/admin/offerings/${id}/transition`, { status }),
offeringReadiness: (id) => get(`/api/admin/offerings/${id}/readiness`),
assignOfferingInstructor: (id, instructor_id, role) => post(`/api/admin/offerings/${id}/instructors`, { instructor_id, role }),
offeringAgreementRequirements: (id) => get(`/api/admin/offerings/${id}/agreement-requirements`),
addOfferingAgreementRequirement: (id, agreement_id) => post(`/api/admin/offerings/${id}/agreement-requirements`, { agreement_id }),
organizations: () => get('/api/admin/organizations'),
createOrganization: (fields) => post('/api/admin/organizations', fields),
locations: () => get('/api/admin/locations'),
createLocation: (fields) => post('/api/admin/locations', fields),
curriculumPackage: (id) => get(`/api/admin/curriculum-packages/${id}`),
createCurriculumPackage: (fields) => post('/api/admin/curriculum-packages', fields),
addCurriculumPackageItem: (id, fields) => post(`/api/admin/curriculum-packages/${id}/items`, fields),
offeringRegistrations: (offeringId) => get(`/api/admin/offerings/${offeringId}/registrations`),
createRegistration: (offeringId, fields) => post(`/api/admin/offerings/${offeringId}/registrations`, fields),
registration: (id) => get(`/api/admin/registrations/${id}`),
transitionRegistration: (id, status) => post(`/api/admin/registrations/${id}/transition`, { status }),
convertRegistration: (id) => post(`/api/admin/registrations/${id}/convert`, {}),
registrationAgreementStatus: (id) => get(`/api/admin/registrations/${id}/agreement-status`),
registrationNotifications: (id) => get(`/api/admin/registrations/${id}/notifications`),
acceptAgreementForRegistration: (id, fields) => post(`/api/admin/registrations/${id}/agreements`, fields),
agreements: () => get('/api/admin/agreements'),
createAgreement: (fields) => post('/api/admin/agreements', fields),
createGuardian: (fields) => post('/api/admin/guardians', fields),
studentGuardians: (studentId) => get(`/api/admin/students/${studentId}/guardians`),
linkStudentGuardian: (studentId, fields) => post(`/api/admin/students/${studentId}/guardians`, fields),
}