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:
@@ -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 />}
|
||||
|
||||
@@ -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}>← 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}>← 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user