Files
unsupervised-scheduler/src/AdminMenu.php
T
thatguygriffandClaude Opus 5 cb347ffca0
CI / Tests (PHP 8.1) (pull_request) Successful in 1m0s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m0s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 3m8s
CI / Build Plugin Zip (pull_request) Skipped
CI / PHPStan (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m44s
Demo follow-ups: editable policy name, one-page signup, group classes in upcoming lessons, deletion cleanup
Five items from the latest demo pass:

- A policy's title can be edited from the Policies screen. Only the title
  moves; the slug is what the gates resolve policies by, so a rename can
  never detach a policy from acceptances already recorded against it.
- Signup is one page again. The studio's registration questions move from
  a second step behind "Next" onto the main form, in an "About you" panel
  above the students being added, and that panel also asks an adult
  student for their birth year (the same us_birth_year meta a child's
  uses). register.js disables and hides the whole panel for a pure
  guardian, since the questions describe a student.
- The password is re-scored on submit, not only as it is typed. zxcvbn's
  dictionary arrives after page load, so a password typed straight away
  was never scored at all and the first the student heard of it was the
  server rejecting the whole form.
- Group-class sessions appear alongside lessons wherever upcoming lessons
  are listed: the [us_scheduler] panel (students and instructors) and the
  admin student detail page. GroupClass\SessionSchedule derives them from
  Offering::sessionWindows(), the same derivation the billing scan uses.
  They carry kind = 'group_class' and no Cancel action - a session is one
  date in a term, not a booked slot.
- Deleting a user releases what the account was holding: each upcoming
  lesson is cancelled, its slot freed for rebooking, its pending payment
  voided, and active class enrolments cancelled. Past lessons and paid
  history are left alone.

Tests: composer test (851), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-30 11:45:04 -03:00

360 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Availability\AvailabilityController;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\WindowValidator;
use Unsupervised\Schedular\Auth\AccessSettings;
use Unsupervised\Schedular\Auth\InstructorController;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationApprovalController;
use Unsupervised\Schedular\Auth\RegistrationController;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\StudentActions;
use Unsupervised\Schedular\Auth\StudentController;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Auth\StudentHistory;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\LessonController;
use Unsupervised\Schedular\Booking\LessonDetail;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController;
use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\CreditRepository;
use Unsupervised\Schedular\Payment\PaymentController;
use Unsupervised\Schedular\Payment\PaymentReportController;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyController;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyService;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\QuestionController;
use Unsupervised\Schedular\Registration\QuestionRepository;
class AdminMenu {
/**
* Hook suffix of the availability screen, captured when the page is added so
* its script loads on that screen only.
*/
private string $availabilityHook = '';
private AvailabilityController $availabilityController;
private LessonController $lessonController;
private OfferingController $offeringController;
private QuestionController $questionController;
private PolicyController $policyController;
private RegistrationController $registrationController;
private RegistrationApprovalController $registrationApprovalController;
private GroupClassController $groupClassController;
private StudentController $studentController;
private InstructorController $instructorController;
private StudioSettings $settings;
private AccessSettings $accessSettings;
private PaymentController $paymentController;
private PaymentReportController $paymentReportController;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits, GuardianService $guardians ) {
$this->availabilityController = new AvailabilityController( $availability, $offerings, new WindowValidator( $offerings ) );
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) );
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) );
$this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites );
$this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer );
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments, $credits ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ), $guardians, new SessionSchedule( $enrollments, $offerings ) );
$this->instructorController = new InstructorController();
$this->settings = $settings;
$this->accessSettings = new AccessSettings();
$this->paymentController = new PaymentController( $payments, $paymentService );
$this->paymentReportController = new PaymentReportController( $payments );
}
public function register(): void {
add_action( 'admin_menu', [ $this, 'addPages' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'enqueueAssets' ] );
add_action( 'admin_post_' . PaymentReportController::EXPORT_ACTION, [ $this->paymentReportController, 'export' ] );
}
/**
* Load a screen's script on that screen only.
*
* @param string $hookSuffix Screen the enqueue is running for.
*/
public function enqueueAssets( string $hookSuffix ): void {
if ( '' === $this->availabilityHook || $hookSuffix !== $this->availabilityHook ) {
return;
}
wp_enqueue_script(
'us-scheduler-availability-admin',
USC_PLUGIN_URL . 'assets/js/availability-admin.js',
[],
USC_VERSION,
true
);
}
public function addPages(): void {
$this->addStudioSeparators();
// Studio-wide dashboard: all upcoming lessons across instructors.
add_menu_page(
__( 'Scheduler', 'unsupervised-schedular' ),
__( 'Scheduler', 'unsupervised-schedular' ),
RoleManager::CAP_VIEW_ALL_LESSONS,
'us-scheduler',
[ $this->lessonController, 'renderAdminDashboard' ],
'dashicons-calendar-alt',
40
);
// Instructor: manage their own availability.
$this->availabilityHook = (string) add_menu_page(
__( 'My Availability', 'unsupervised-schedular' ),
__( 'My Availability', 'unsupervised-schedular' ),
RoleManager::CAP_MANAGE_AVAILABILITY,
'us-availability',
[ $this->availabilityController, 'renderPage' ],
'dashicons-clock',
41
);
// Studio admin / instructor: manage offerings.
add_menu_page(
__( 'Offerings', 'unsupervised-schedular' ),
__( 'Offerings', 'unsupervised-schedular' ),
RoleManager::CAP_MANAGE_OFFERINGS,
'us-offerings',
[ $this->offeringController, 'renderPage' ],
'dashicons-tag',
33
);
// Studio admin / instructor: manage per-offering intake questions.
add_submenu_page(
'us-offerings',
__( 'Questions', 'unsupervised-schedular' ),
__( 'Questions', 'unsupervised-schedular' ),
RoleManager::CAP_MANAGE_QUESTIONS,
'us-questions',
[ $this->questionController, 'renderPage' ]
);
// Studio admin: draft, version, and publish policies.
add_menu_page(
__( 'Policies', 'unsupervised-schedular' ),
__( 'Policies', 'unsupervised-schedular' ),
RoleManager::CAP_MANAGE_POLICIES,
'us-policies',
[ $this->policyController, 'renderPage' ],
'dashicons-text-page',
31
);
// Studio admin: invite students to register.
add_menu_page(
__( 'Invites', 'unsupervised-schedular' ),
__( 'Invites', 'unsupervised-schedular' ),
RoleManager::CAP_MANAGE_STUDENTS,
'us-invites',
[ $this->registrationController, 'renderPage' ],
'dashicons-email',
32
);
// Studio admin: all group-class enrolments.
add_menu_page(
__( 'Group Classes', 'unsupervised-schedular' ),
__( 'Group Classes', 'unsupervised-schedular' ),
RoleManager::CAP_VIEW_ALL_LESSONS,
'us-group-classes',
[ $this->groupClassController, 'renderPage' ],
'dashicons-groups',
36
);
// Studio admin: create instructors and manage their capabilities.
add_menu_page(
__( 'Instructors', 'unsupervised-schedular' ),
__( 'Instructors', 'unsupervised-schedular' ),
RoleManager::CAP_MANAGE_INSTRUCTORS,
InstructorController::PAGE_SLUG,
[ $this->instructorController, 'renderPage' ],
'dashicons-businessperson',
34.5
);
// Studio admin: browse students and their activity.
add_menu_page(
__( 'Students', 'unsupervised-schedular' ),
__( 'Students', 'unsupervised-schedular' ),
RoleManager::CAP_MANAGE_STUDENTS,
'us-students',
[ $this->studentController, 'renderPage' ],
'dashicons-id',
35
);
// Studio admin: approve or reject self-signup students (open registration).
add_submenu_page(
'us-students',
__( 'Pending Students', 'unsupervised-schedular' ),
__( 'Pending Students', 'unsupervised-schedular' ),
RoleManager::CAP_MANAGE_STUDENTS,
RegistrationApprovalController::PAGE_SLUG,
[ $this->registrationApprovalController, 'renderPage' ]
);
// Studio admin: confirm pending (e-transfer) payments.
add_menu_page(
__( 'Payments', 'unsupervised-schedular' ),
__( 'Payments', 'unsupervised-schedular' ),
RoleManager::CAP_MANAGE_BILLING,
'us-payments',
[ $this->paymentController, 'renderPage' ],
'dashicons-money-alt',
37
);
// Studio admin (all) / instructor (own): monthly payments report with HST.
// Gated on export so instructors — who lack manage_billing — can still see it.
add_menu_page(
__( 'Payment Report', 'unsupervised-schedular' ),
__( 'Payment Report', 'unsupervised-schedular' ),
RoleManager::CAP_EXPORT_PAYMENTS,
'us-reports',
[ $this->paymentReportController, 'renderPage' ],
'dashicons-chart-bar',
38
);
// Studio admin: Stripe credentials and billing settings.
add_menu_page(
__( 'Studio Settings', 'unsupervised-schedular' ),
__( 'Studio Settings', 'unsupervised-schedular' ),
RoleManager::CAP_MANAGE_BILLING,
'us-settings',
[ $this->settings, 'renderPage' ],
'dashicons-admin-settings',
30
);
// Site owner: whether WordPress administrators are studio admins / instructors.
// Gated on the core manage_options capability — never the plugin's own grants —
// so an administrator can always reach it to re-enable a disabled grant.
add_menu_page(
__( 'Access', 'unsupervised-schedular' ),
__( 'Access', 'unsupervised-schedular' ),
'manage_options',
'us-access',
[ $this->accessSettings, 'renderPage' ],
'dashicons-admin-network',
30.5
);
// Instructor: view their upcoming lessons. Hidden for anyone who can
// already see the Scheduler — it shows every instructor's lessons
// (including their own, with the same payment edit forms), so the two
// menu items would just duplicate each other for an owner-operator.
if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
add_menu_page(
__( 'My Lessons', 'unsupervised-schedular' ),
__( 'My Lessons', 'unsupervised-schedular' ),
RoleManager::CAP_VIEW_LESSONS,
'us-my-lessons',
[ $this->lessonController, 'renderInstructorLessons' ],
'dashicons-welcome-learn-more',
42
);
// Instructor: their own group classes with per-class rosters.
add_submenu_page(
'us-my-lessons',
__( 'My Group Classes', 'unsupervised-schedular' ),
__( 'My Group Classes', 'unsupervised-schedular' ),
RoleManager::CAP_VIEW_LESSONS,
'us-my-group-classes',
[ $this->groupClassController, 'renderInstructorPage' ]
);
}
}
/**
* Insert sidebar separators around the studio menus so they sit visually
* apart from the core WordPress items and split into three sections —
* mirroring the dividers core uses. Each separator is only added when the user
* can see a menu in the following section, to avoid orphaned dividers.
*
* Layout: [29] · setup (Settings/Policies/Invites/Offerings) · [34] ·
* people & money (Students/Group Classes/Payments/Payment Report) · [39] ·
* operations (Scheduler) and instructor menus.
*/
private function addStudioSeparators(): void {
$this->addSeparatorAt( 29, $this->userSeesStudioMenu() );
$this->addSeparatorAt( 34, $this->userSeesPeopleSection() );
$this->addSeparatorAt( 39, current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) );
}
private function addSeparatorAt( int $position, bool $visible ): void {
if ( ! $visible ) {
return;
}
global $menu;
if ( ! is_array( $menu ) || isset( $menu[ $position ] ) ) {
return;
}
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- writing to $menu is the supported way to add an admin sidebar separator.
$menu[ $position ] = [ '', 'read', 'us-studio-separator-' . $position, '', 'wp-menu-separator' ];
}
private function userSeesPeopleSection(): bool {
return current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS )
|| current_user_can( RoleManager::CAP_MANAGE_STUDENTS )
|| current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS )
|| current_user_can( RoleManager::CAP_MANAGE_BILLING );
}
private function userSeesStudioMenu(): bool {
// Administrators always see the Access page, so the leading separator
// should show for them even if both capability grants are disabled.
if ( current_user_can( 'manage_options' ) ) {
return true;
}
$caps = [
RoleManager::CAP_VIEW_ALL_LESSONS,
RoleManager::CAP_VIEW_LESSONS,
RoleManager::CAP_MANAGE_AVAILABILITY,
RoleManager::CAP_MANAGE_OFFERINGS,
RoleManager::CAP_MANAGE_QUESTIONS,
RoleManager::CAP_MANAGE_POLICIES,
RoleManager::CAP_MANAGE_STUDENTS,
RoleManager::CAP_MANAGE_BILLING,
];
foreach ( $caps as $cap ) {
if ( current_user_can( $cap ) ) {
return true;
}
}
return false;
}
}