maybeRenderDetail( 'us-scheduler', false ) ) { return; } [ $notice, $error ] = $this->handleFormAction( false, 0 ); $rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() ); $this->renderLessonsPage( $rows, 'us-scheduler', 0, $notice, $error ); } public function renderInstructorLessons(): void { if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) { wp_die( esc_html__( 'You do not have permission to view lessons.', 'unsupervised-schedular' ) ); } if ( $this->maybeRenderDetail( 'us-my-lessons', true ) ) { return; } $instructorId = get_current_user_id(); [ $notice, $error ] = $this->handleFormAction( true, $instructorId ); $rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( $instructorId ) ); $this->renderLessonsPage( $rows, 'us-my-lessons', $instructorId, $notice, $error ); } /** * When the request targets a single lesson (`?lesson_id=`), render its detail * view and report that the page has been handled. Instructors may only open * their own lessons; the studio dashboard ($onlyOwn = false) may open any. */ private function maybeRenderDetail( string $pageSlug, bool $onlyOwn ): bool { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only lesson selector. $lessonId = absint( Val::int( $_GET['lesson_id'] ?? 0 ) ); if ( $lessonId <= 0 ) { return false; } $lesson = $this->repository->findById( $lessonId ); $backUrl = admin_url( 'admin.php?page=' . $pageSlug ); $notice = ''; $error = ''; if ( null === $lesson || ( $onlyOwn && get_current_user_id() !== $lesson->instructorId ) ) { $row = null; $answers = []; $accepts = []; $intake = $this->emptyIntake(); } else { // Recorded before the tables are read, so what was just entered appears // on the page that reports it. [ $notice, $error ] = $this->recordIntake( $lesson ); $row = $this->row( $lesson ); $answers = $this->detail->answers( $lesson ); $accepts = $this->detail->acceptances( $lesson ); $intake = $this->intakeForm( $lesson ); } include USC_PLUGIN_DIR . 'templates/admin/lesson-detail.php'; return true; } /** * Handle a submitted "record intake collected elsewhere" form. * * @return array{string, string} Success notice and error message. */ private function recordIntake( Lesson $lesson ): array { if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) { return [ '', '' ]; } // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above. if ( 'record_intake' !== sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) { return [ '', '' ]; } $answers = []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each value is unslashed and sanitized below. foreach ( (array) ( $_POST['answers'] ?? [] ) as $questionId => $value ) { $answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each element is coerced to a positive int; slashes cannot survive integer coercion. $rawVersionIds = (array) ( $_POST['accepted_policy_version_ids'] ?? [] ); $versionIds = array_values( array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $rawVersionIds ) ) ); $result = $this->intake->record( $lesson, $answers, $versionIds, sanitize_key( Val::string( wp_unslash( $_POST['collected_via'] ?? '' ) ) ), sanitize_text_field( Val::string( wp_unslash( $_POST['collected_note'] ?? '' ) ) ), get_current_user_id() ); // phpcs:enable WordPress.Security.NonceVerification.Missing return $result instanceof \WP_Error ? [ '', $result->get_error_message() ] : [ $result, '' ]; } /** * What the detail template needs to offer the recording form: whether this * lesson qualifies at all, what is still missing, and the collection methods * to choose between. * * @return array{recordable: bool, questions: list, policies: list, methods: array} */ private function intakeForm( Lesson $lesson ): array { if ( ! $lesson->isStaffRegistered() ) { return $this->emptyIntake(); } return [ 'recordable' => true ] + $this->intake->pending( $lesson ) + [ 'methods' => IntakeProvenance::choices() ]; } /** * The form data for a lesson that cannot be recorded against — one the student * booked, or one that could not be opened at all. * * @return array{recordable: bool, questions: list, policies: list, methods: array} */ private function emptyIntake(): array { return [ 'recordable' => false, 'questions' => [], 'policies' => [], 'methods' => [], ]; } /** * Render the lessons template with its calendar view state: week (default) * or list, plus which week the week view shows, and the choices the * book-for-a-student form offers — scoped to one instructor's own schedule on * **My Lessons**, studio-wide (0) on the **Scheduler**. * * @param list> $rows */ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- $notice is read by the included template. private function renderLessonsPage( array $rows, string $pageSlug, int $onlyInstructorId, string $notice, string $error ): void { // View-state query params only (which view, which week) — nothing is // mutated from them, so no nonce applies. // phpcs:disable WordPress.Security.NonceVerification.Recommended $view = 'list' === sanitize_key( Val::string( wp_unslash( $_GET['usc_view'] ?? '' ) ) ) ? 'list' : 'week'; $requestedWeek = sanitize_text_field( Val::string( wp_unslash( $_GET['usc_week'] ?? '' ) ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended $weekStart = WeekCalendar::weekStart( $requestedWeek, Val::int( get_option( 'start_of_week', 1 ) ), current_time( 'Y-m-d' ) ); $weekDays = WeekCalendar::bucket( $weekStart, $rows, static fn( array $row ): string => Val::string( $row['day'] ) ); $prevWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '-7 days' )->format( 'Y-m-d' ); $nextWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '+7 days' )->format( 'Y-m-d' ); $baseUrl = admin_url( 'admin.php?page=' . $pageSlug ); $bookForm = $this->adminBooking->formData( $onlyInstructorId ); // A refused booking is shown again as it was typed — losing five fields to a // single mistake is what made the panel infuriating to correct. A successful // one starts empty, so the next booking does not inherit the last one's. $bookValues = '' !== $error ? $this->submittedBooking() : $this->emptyBooking(); include USC_PLUGIN_DIR . 'templates/admin/lessons.php'; } /** * Run the submitted action and report what happened: a per-lesson payment * override (e-transfer email or HST rate), or a lesson booked for a student. * When $onlyOwn, the payment or slot must belong to the current instructor. * * @return array{string, string} Success notice and error message; each is * empty when it does not apply. */ private function handleFormAction( bool $onlyOwn, int $instructorId ): array { if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) { return [ '', '' ]; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above. $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ); if ( 'book_for_student' === $action ) { return $this->bookForStudent( $onlyOwn ? $instructorId : 0 ); } $this->updatePayment( $action, $onlyOwn ); return [ '', '' ]; } /** * Book a lesson on a student's behalf from the submitted form. The slot is * scoped to the instructor's own schedule on **My Lessons** ($onlyInstructorId * non-zero) and studio-wide on the **Scheduler**. * * @return array{string, string} */ private function bookForStudent( int $onlyInstructorId ): array { $submitted = $this->submittedBooking(); $result = $this->adminBooking->book( $submitted['student_id'], $submitted['slot_id'], $submitted['offering_id'], $submitted['weekly'] ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE, $submitted['no_charge'], $submitted['notes'], $onlyInstructorId ); return $result instanceof \WP_Error ? [ '', $result->get_error_message() ] : [ $result, '' ]; } /** * The book-for-a-student form exactly as submitted. Read in one place so what * gets booked and what the form shows again after a refusal cannot drift apart * on a field name. * * @return array{student_id: int, slot_id: int, offering_id: int, weekly: bool, no_charge: bool, notes: string} */ private function submittedBooking(): array { // phpcs:disable WordPress.Security.NonceVerification.Missing -- read only after handleFormAction() has verified the nonce: to book, or to re-render (escaped) a form it refused. return [ 'student_id' => absint( Val::int( $_POST['student_id'] ?? 0 ) ), 'slot_id' => absint( Val::int( $_POST['slot_id'] ?? 0 ) ), 'offering_id' => absint( Val::int( $_POST['offering_id'] ?? 0 ) ), 'weekly' => isset( $_POST['recurrence_weekly'] ), 'no_charge' => isset( $_POST['no_charge'] ), 'notes' => sanitize_text_field( Val::string( wp_unslash( $_POST['notes'] ?? '' ) ) ), ]; // phpcs:enable WordPress.Security.NonceVerification.Missing } /** * An untouched book-for-a-student form. * * @return array{student_id: int, slot_id: int, offering_id: int, weekly: bool, no_charge: bool, notes: string} */ private function emptyBooking(): array { return [ 'student_id' => 0, 'slot_id' => 0, 'offering_id' => 0, 'weekly' => false, 'no_charge' => false, 'notes' => '', ]; } /** * Apply a per-lesson payment override. When $onlyOwn, the payment must belong * to the current instructor. */ private function updatePayment( string $action, bool $onlyOwn ): void { // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked by the caller. $paymentId = absint( Val::int( $_POST['payment_id'] ?? 0 ) ); $email = sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::float() coerces to float; slashes cannot survive numeric coercion. $taxRate = isset( $_POST['tax_rate'] ) ? max( 0.0, Val::float( $_POST['tax_rate'] ) ) : 0.0; // phpcs:enable WordPress.Security.NonceVerification.Missing if ( $paymentId <= 0 || ! in_array( $action, [ 'set_etransfer', 'set_tax' ], true ) ) { return; } $payment = $this->payments->findById( $paymentId ); if ( null === $payment || ( $onlyOwn && get_current_user_id() !== $payment->instructorId ) ) { return; } if ( 'set_tax' === $action ) { $this->payments->updateTax( $paymentId, $taxRate ); return; } $this->payments->updateEtransferEmail( $paymentId, '' !== $email ? $email : null ); } /** * Build a display row for a lesson, including its e-transfer and HST payment * overrides. * * @return array */ private function row( Lesson $lesson ): array { $student = get_userdata( $lesson->studentId ); $instructor = get_userdata( $lesson->instructorId ); $payment = null !== $lesson->paymentId ? $this->payments->findById( $lesson->paymentId ) : null; $slot = $this->availability->findById( $lesson->slotId ); $offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null; return [ 'lesson_id' => (int) $lesson->id, 'student' => $student ? $student->display_name : (string) $lesson->studentId, 'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId, 'offering' => $offering ? $offering->title : '—', 'duration' => null !== $offering && null !== $offering->durationMinutes ? $offering->durationMinutes : 0, 'recurrence' => $lesson->recurrence, 'time' => $slot ? $this->formatSlotTime( $slot ) : '—', 'day' => $slot ? substr( $slot->startDt, 0, 10 ) : '', 'time_short' => $slot ? Val::string( mysql2date( 'g:i A', $slot->startDt ) ) : '—', 'status' => $lesson->status, 'notes' => $lesson->notes ?? '', 'payment_id' => $payment ? (int) $payment->id : 0, 'currency' => $payment ? (string) $payment->currency : '', 'amount' => $payment ? (float) $payment->amount : 0.0, 'tax_rate' => $payment ? (float) $payment->taxRate : 0.0, 'tax_amount' => $payment ? (float) $payment->taxAmount : 0.0, 'total' => $payment ? $payment->total() : 0.0, 'etransfer_email' => $payment ? (string) $payment->etransferEmail : '', 'etransfer_editable' => null !== $payment && Payment::METHOD_ETRANSFER === $payment->method && ! $payment->isPaid(), 'tax_editable' => null !== $payment && ! $payment->isPaid(), ]; } /** * Format a slot's window as e.g. "Jul 6, 2026 9:00 AM–10:00 AM", repeating the * date on the end time only when the slot crosses midnight. */ private function formatSlotTime( AvailabilitySlot $slot ): string { $sameDay = substr( $slot->startDt, 0, 10 ) === substr( $slot->endDt, 0, 10 ); return Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) ) . '–' . Val::string( mysql2date( $sameDay ? 'g:i A' : 'M j, Y g:i A', $slot->endDt ) ); } }