Add group-class scheduling, instructor assignment, and details/invite management
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m52s
CI / PHPStan (pull_request) Successful in 2m50s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m52s
CI / PHPStan (pull_request) Successful in 2m50s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped
Group classes now carry a specific class time (alongside date and duration) and an assigned instructor: - Schema: add `class_time` (TIME) to `us_offerings`; `Offering` gains `normalizeTime`/`sessionWindows`. (Rides the pending 1.0.0->1.1.0 dbDelta upgrade, so no version bump.) - Offering form: class-time field, plus a studio-admin instructor picker (plain instructors always own their own classes). - `ClassSlotReconciler`: assigning an instructor clears their open booking slots overlapping each session and flags already-booked lessons that clash (a booked lesson is never deleted). Uses new `AvailabilityRepository::findOverlapping`. - Front end: `GET /offerings` exposes `instructor_name`; the enrolment page shows who teaches each class and when it meets. Back-office group-class views redesigned: - Instructor **My Group Classes** and studio-admin **Group Classes** are now per-class summaries with enrolment counts, not flat student lists. - Each links through (`?class_id=<id>`) to a per-class **details page** (schedule panel, roster with payment status, and — for invite-only classes — the add/make-available/invite-by-email controls). Invite-only membership is managed entirely from this page. - Invite actions are allowed for the class's owning instructor or any `view_all_lessons` studio admin, so an owner-operator (studio admin who also teaches) can reach every class's roster and invites from the Group Classes page. Tests: composer test (508), composer lint, composer cs all pass. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -27,33 +27,76 @@ class GroupClassController {
|
||||
private RegistrationMailer $mailer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Studio-admin overview: every group class across instructors as a summary —
|
||||
* who teaches it, when it meets, and how full it is — rather than a flat list
|
||||
* of individual student enrolments. Selecting a class (`?class_id=<id>`) opens
|
||||
* the same per-class details page instructors use, so a studio admin (including
|
||||
* an owner-operator who also teaches) can view any class's roster and manage
|
||||
* invite-only membership from here.
|
||||
*/
|
||||
public function renderPage(): void {
|
||||
if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$notice = '';
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) {
|
||||
$notice = $this->handleFormAction( get_current_user_id() );
|
||||
}
|
||||
|
||||
$offerings = $this->offerings->findAll( 0, Offering::KIND_GROUP_CLASS );
|
||||
$baseUrl = admin_url( 'admin.php?page=us-group-classes' );
|
||||
|
||||
// View-state query param only (which class to drill into) — nothing is
|
||||
// mutated from it, so no nonce applies.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
$classId = absint( Val::int( $_GET['class_id'] ?? 0 ) );
|
||||
$current = null;
|
||||
foreach ( $offerings as $offering ) {
|
||||
if ( $offering->id === $classId ) {
|
||||
$current = $offering;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( null !== $current ) {
|
||||
// Enrolments are looked up by the class's own instructor; classDetail
|
||||
// filters them down to this offering.
|
||||
$class = $this->classDetail( $current, $this->enrollments->findByInstructor( $current->instructorId ) );
|
||||
$students = $this->studentOptions();
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/my-group-class-detail.php';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = array_map(
|
||||
function ( Enrollment $enrollment ): array {
|
||||
$offering = $this->offerings->findById( $enrollment->offeringId );
|
||||
$student = get_userdata( $enrollment->studentId );
|
||||
function ( Offering $offering ): array {
|
||||
$instructor = get_userdata( $offering->instructorId );
|
||||
|
||||
return [
|
||||
'student' => $student ? $student->display_name : (string) $enrollment->studentId,
|
||||
'offering' => $offering ? $offering->title : (string) $enrollment->offeringId,
|
||||
'status' => $enrollment->status,
|
||||
'id' => $offering->id,
|
||||
'title' => $offering->title,
|
||||
'instructor' => $instructor ? $instructor->display_name : (string) $offering->instructorId,
|
||||
'when' => $this->whenLabel( $offering ),
|
||||
'capacity' => $offering->capacity,
|
||||
'enrolled' => $this->enrollments->countActiveForOffering( (int) $offering->id ),
|
||||
'invite_only' => $offering->isInviteOnly(),
|
||||
];
|
||||
},
|
||||
$this->enrollments->findAllActive()
|
||||
$offerings
|
||||
);
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/group-classes.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Instructor view: their own group classes with per-class rosters. Each class
|
||||
* shows its enrolment count against capacity plus a roster of enrolled
|
||||
* students with enrolment and payment status. Invite-only classes also carry
|
||||
* controls to add, grant access to, or email-invite students.
|
||||
* Instructor view. By default a summary of the instructor's own group classes
|
||||
* — each with when it meets and how many are enrolled — rather than a dump of
|
||||
* every roster. A `class_id` query param drills into one class to show its
|
||||
* roster of enrolled students and, for invite-only classes, the controls to
|
||||
* add, grant access to, or email-invite students.
|
||||
*/
|
||||
public function renderInstructorPage(): void {
|
||||
if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) {
|
||||
@@ -67,50 +110,127 @@ class GroupClassController {
|
||||
$notice = $this->handleFormAction( $instructorId );
|
||||
}
|
||||
|
||||
$offerings = $this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS );
|
||||
$enrollments = $this->enrollments->findByInstructor( $instructorId );
|
||||
|
||||
// View-state query param only (which class to drill into) — nothing is
|
||||
// mutated from it, so no nonce applies.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
$classId = absint( Val::int( $_GET['class_id'] ?? 0 ) );
|
||||
$current = null;
|
||||
foreach ( $offerings as $offering ) {
|
||||
if ( $offering->id === $classId ) {
|
||||
$current = $offering;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( null !== $current ) {
|
||||
$baseUrl = admin_url( 'admin.php?page=us-my-group-classes' );
|
||||
$class = $this->classDetail( $current, $enrollments );
|
||||
$students = $this->studentOptions();
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/my-group-class-detail.php';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$classes = array_map(
|
||||
function ( Offering $offering ) use ( $enrollments ): array {
|
||||
$roster = [];
|
||||
$enrolled = 0;
|
||||
|
||||
foreach ( $enrollments as $enrollment ) {
|
||||
if ( $enrollment->offeringId !== $offering->id ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( Enrollment::STATUS_ACTIVE === $enrollment->status ) {
|
||||
++$enrolled;
|
||||
}
|
||||
|
||||
$student = get_userdata( $enrollment->studentId );
|
||||
$payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null;
|
||||
|
||||
$roster[] = [
|
||||
'student' => $student ? $student->display_name : (string) $enrollment->studentId,
|
||||
'status' => $enrollment->status,
|
||||
'payment' => $payment?->status,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $offering->id,
|
||||
'title' => $offering->title,
|
||||
'capacity' => $offering->capacity,
|
||||
'enrolled' => $enrolled,
|
||||
'invite_only' => $offering->isInviteOnly(),
|
||||
'roster' => $roster,
|
||||
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
|
||||
];
|
||||
},
|
||||
$this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS )
|
||||
fn( Offering $offering ): array => $this->classSummary( $offering, $enrollments ),
|
||||
$offerings
|
||||
);
|
||||
|
||||
$students = $this->studentOptions();
|
||||
$baseUrl = admin_url( 'admin.php?page=us-my-group-classes' );
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/my-group-classes.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary row for one class in the instructor overview: its identity, when it
|
||||
* meets, and how many active enrolments it holds against capacity.
|
||||
*
|
||||
* @param list<Enrollment> $enrollments
|
||||
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool}
|
||||
*/
|
||||
private function classSummary( Offering $offering, array $enrollments ): array {
|
||||
$enrolled = 0;
|
||||
foreach ( $enrollments as $enrollment ) {
|
||||
if ( $enrollment->offeringId === $offering->id && Enrollment::STATUS_ACTIVE === $enrollment->status ) {
|
||||
++$enrolled;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $offering->id,
|
||||
'title' => $offering->title,
|
||||
'when' => $this->whenLabel( $offering ),
|
||||
'capacity' => $offering->capacity,
|
||||
'enrolled' => $enrolled,
|
||||
'invite_only' => $offering->isInviteOnly(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Full details for one class: the summary fields, the class's own settings
|
||||
* (instructor, price, duration, description, schedule, active state), the
|
||||
* roster of enrolled students (with enrolment and payment status), and — for
|
||||
* invite-only classes — the list of people invited but not yet enrolled.
|
||||
*
|
||||
* @param list<Enrollment> $enrollments
|
||||
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}
|
||||
*/
|
||||
private function classDetail( Offering $offering, array $enrollments ): array {
|
||||
$roster = [];
|
||||
foreach ( $enrollments as $enrollment ) {
|
||||
if ( $enrollment->offeringId !== $offering->id ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$student = get_userdata( $enrollment->studentId );
|
||||
$payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null;
|
||||
|
||||
$roster[] = [
|
||||
'student' => $student ? $student->display_name : (string) $enrollment->studentId,
|
||||
'status' => $enrollment->status,
|
||||
'payment' => $payment?->status,
|
||||
];
|
||||
}
|
||||
|
||||
$instructor = get_userdata( $offering->instructorId );
|
||||
|
||||
return $this->classSummary( $offering, $enrollments ) + [
|
||||
'instructor' => $instructor ? $instructor->display_name : (string) $offering->instructorId,
|
||||
'price' => $offering->price,
|
||||
'currency' => $offering->currency,
|
||||
'duration' => $offering->durationMinutes,
|
||||
'description' => $offering->description,
|
||||
'schedule_note' => $offering->scheduleNote,
|
||||
'active' => $offering->isActive,
|
||||
'roster' => $roster,
|
||||
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable "when" label for a class: the class date (or weekly date
|
||||
* range) and, when set, the start time. Empty when the class has no date.
|
||||
*/
|
||||
private function whenLabel( Offering $offering ): string {
|
||||
if ( null === $offering->termStart ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$label = null === $offering->termEnd || $offering->termEnd === $offering->termStart
|
||||
? (string) mysql2date( 'M j, Y', $offering->termStart )
|
||||
: (string) mysql2date( 'M j, Y', $offering->termStart ) . ' – ' . (string) mysql2date( 'M j, Y', $offering->termEnd );
|
||||
|
||||
if ( null !== $offering->classTime ) {
|
||||
$label .= ' · ' . (string) mysql2date( 'g:i a', $offering->termStart . ' ' . $offering->classTime );
|
||||
}
|
||||
|
||||
return $label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending (not-yet-enrolled) access grants for an invite-only class, shown so
|
||||
* the instructor can see who has been invited but has not enrolled yet.
|
||||
@@ -143,7 +263,10 @@ class GroupClassController {
|
||||
|
||||
/**
|
||||
* Handle a posted management action, returning a status notice for display.
|
||||
* Every action is scoped to a group class the current instructor owns.
|
||||
* The action is scoped to a group class the current instructor owns, unless
|
||||
* the caller is a studio admin (`view_all_lessons`) — who may manage any
|
||||
* instructor's class, since the studio-admin Group Classes page reaches the
|
||||
* same controls for every class.
|
||||
*/
|
||||
private function handleFormAction( int $instructorId ): string {
|
||||
// Nonce is verified by the caller before this method runs.
|
||||
@@ -152,7 +275,10 @@ class GroupClassController {
|
||||
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
|
||||
$offering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null;
|
||||
|
||||
if ( null === $offering || $offering->instructorId !== $instructorId || Offering::KIND_GROUP_CLASS !== $offering->kind ) {
|
||||
$ownsOrManagesAll = null !== $offering
|
||||
&& ( $offering->instructorId === $instructorId || current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) );
|
||||
|
||||
if ( null === $offering || ! $ownsOrManagesAll || Offering::KIND_GROUP_CLASS !== $offering->kind ) {
|
||||
return esc_html__( 'That group class was not found.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user