diff --git a/frontend/src/admin/AdminPortal.jsx b/frontend/src/admin/AdminPortal.jsx index ca4e494..e6c2ebc 100644 --- a/frontend/src/admin/AdminPortal.jsx +++ b/frontend/src/admin/AdminPortal.jsx @@ -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' && } {view === 'students' && } {view === 'programs' && } + {view === 'offerings' && } {view === 'curriculum' && } {view === 'classroom' && } {view === 'learning' && } diff --git a/frontend/src/admin/OfferingsView.jsx b/frontend/src/admin/OfferingsView.jsx new file mode 100644 index 0000000..d2cb4ef --- /dev/null +++ b/frontend/src/admin/OfferingsView.jsx @@ -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 { setSelected(null); reload() }} /> + + return ( + <> + {error &&
{error}
} + {canAuthor && ( +
+

Create offering

+ { reload(); setSelected(id) }} /> +
+ )} +
+

Offerings

+ {!offerings ?

Loading…

: offerings.length === 0 ?

None yet.

: ( + + + + {offerings.map((o) => ( + + + + + + + + + ))} + +
NameProgramDeliveryStatusSeats
{o.name}{o.program_id}{o.delivery_mode.replace('_', ' ')}{o.status.replace('_', ' ')}{o.capacity === null ? 'unlimited' : `${o.reserved_seats}/${o.capacity}`}
+ )} +
+ + ) +} + +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 ( +
+ {error &&
{error}
} +
+
+ setForm({ ...form, offering_id: e.target.value })} required />
+
+ setForm({ ...form, slug: e.target.value })} required />
+
+ setForm({ ...form, name: e.target.value })} required />
+
+
+
+
+
+
+
+ setForm({ ...form, capacity: e.target.value })} />
+
+
+
+
+
+ setForm({ ...form, recommended_min_age: e.target.value })} />
+
+ setForm({ ...form, recommended_max_age: e.target.value })} />
+
+

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.

+ +
+ ) +} + +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 { setSelectedReg(null); reload() }} /> + + return ( + <> + + {!offering ?

Loading…

: ( + <> +

{offering.name} {offering.status.replace('_', ' ')}

+ {error &&
{error}
} + +
+

Overview

+

{offering.description || 'No description.'}

+

Program: {offering.program_id} — Delivery: {offering.delivery_mode.replace('_', ' ')} — Seats: {offering.capacity === null ? 'unlimited' : `${offering.reserved_seats} / ${offering.capacity}`}

+ {canAuthor && (OFFERING_TRANSITIONS[offering.status] || []).length > 0 && ( +
+ {(OFFERING_TRANSITIONS[offering.status] || []).map((next) => ( + + ))} +
+ )} +
+ +
+

Launch readiness

+ {!readiness ?

Loading…

: ( + <> +

+ {readiness.ready_to_open_registration ? 'Ready to open registration' : 'Not yet ready'} +

+
    + {readiness.checks.map((c) => ( +
  • {c.ready ? '✓' : '✗'} {c.label}
  • + ))} +
+ + )} +
+ +
+

Required agreements

+ {!agreementReqs ?

Loading…

: agreementReqs.length === 0 ?

None configured — registrations can convert without any agreement acceptance.

: ( +
    + {agreementReqs.map((a) =>
  • {a.title} (v{a.version}) {a.requires_guardian_for_minor && guardian required for minors}
  • )} +
+ )} + {canAuthor && } +
+ +
+

Registrations / roster

+ {canAuthor && } + {!registrations ?

Loading…

: registrations.length === 0 ?

None yet.

: ( + + + + {registrations.map((r) => ( + + + + + + + ))} + +
RegistrantTypeStatus
{r.registrant_display_name}{r.is_minor ? 'minor (guardian)' : 'adult'}{r.status.replace('_', ' ')}
+ )} +
+ + )} + + ) +} + +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 ( +
+ {error &&
{error}
} +
+
+ setForm({ ...form, registrant_display_name: e.target.value })} required />
+
+
+ {!form.is_minor && ( +
+ setForm({ ...form, registrant_email: e.target.value })} />
+ )} +
+ {form.is_minor && ( +
+
+ setForm({ ...form, guardian_display_name: e.target.value })} required />
+
+ setForm({ ...form, guardian_email: e.target.value })} required />
+
+ )} + +
+ ) +} + +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 ( +
+ {error &&
{error}
} +
+
+ +
+ ) +} + +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 ( + <> + + {!reg ?

Loading…

: ( + <> +

{reg.registrant_display_name} {reg.status.replace('_', ' ')}

+ {error &&
{error}
} + {notice &&

{notice}

} + +
+

Registration

+

Type: {reg.is_minor ? 'Minor (guardian-registered)' : 'Adult (self-registered)'}

+ {reg.registrant_email &&

Contact: {reg.registrant_email}

} + {reg.readiness_notes &&

Readiness notes: {reg.readiness_notes}

} + {canAuthor && (REG_TRANSITIONS[reg.status] || []).length > 0 && ( +
+ {(REG_TRANSITIONS[reg.status] || []).map((next) => ( + + ))} + {reg.status === 'confirmed' && ( + + )} +
+ )} +
+ +
+

Agreements

+ {!agreementStatus ?

Loading…

: agreementStatus.required.length === 0 ?

No agreements required for this offering.

: ( +
    + {agreementStatus.required.map((a) => ( +
  • + {agreementStatus.missing.some((m) => m.agreement_id === a.agreement_id) ? '✗ missing' : '✓ accepted'} — {a.title} +
  • + ))} +
+ )} +
+ +
+

Communications

+ {!notifs ?

Loading…

: notifs.length === 0 ?

None sent yet.

: ( + + + + {notifs.map((n) => ( + + + + + + + ))} + +
TypeToStatusSent
{n.notification_type.replace(/_/g, ' ')}{n.recipient_email_masked || '—'}{n.status}{n.sent_at ? new Date(n.sent_at).toLocaleString() : '—'}
+ )} +
+ + )} + + ) +} diff --git a/frontend/src/admin/api.js b/frontend/src/admin/api.js index 8b7e5a0..d2727dd 100644 --- a/frontend/src/admin/api.js +++ b/frontend/src/admin/api.js @@ -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), }