0 ? $this->availability->findById( $slotId ) : null; if ( null === $slot || ( $onlyInstructorId > 0 && $slot->instructorId !== $onlyInstructorId ) ) { return new \WP_Error( 'invalid_slot', __( 'Choose a time to book.', 'unsupervised-schedular' ) ); } if ( $slot->isBooked ) { return new \WP_Error( 'slot_taken', __( 'That time has already been booked.', 'unsupervised-schedular' ) ); } $offering = $this->booker->resolveOffering( $slot, $offeringId ); if ( $offering instanceof \WP_Error ) { return $offering; } // A weekly reservation needs a weekly time to reserve. The student-facing // flow quietly books a single lesson when the slot does not repeat; here the // staff member asked for a term and must be told they are not getting one, // rather than discovering it later on the roster. $weekly = Lesson::RECURRENCE_WEEKLY === $recurrence; if ( $weekly && null === $slot->recurrenceGroup ) { return new \WP_Error( 'not_weekly', __( 'That time does not repeat weekly, so it cannot be reserved for the term.', 'unsupervised-schedular' ) ); } $reservation = $this->booker->reserve( $slot, $offering, $studentId, $weekly ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE, $notes, // Stamped on the lesson so it can be told apart later: only a lesson the // studio booked may have its intake recorded after the fact. get_current_user_id() ); if ( $reservation instanceof \WP_Error ) { return $reservation; } $settlement = $this->booker->settle( $reservation['ids'], $reservation['anchor_id'], $slot, $offering, $studentId, $noCharge ); return $this->notice( $studentId, $offering, $slot, count( $reservation['ids'] ), $settlement['status'] ); } /** * What was booked and what it left owing, so the notice answers the two things * the person who booked it needs to know. */ private function notice( int $studentId, Offering $offering, AvailabilitySlot $slot, int $count, string $status ): string { $who = $this->studentName( $studentId ); $what = $count > 1 ? sprintf( /* translators: 1: student name, 2: lesson type, 3: number of weekly occurrences, 4: first lesson date and time. */ __( 'Booked %1$s into %2$s — %3$d weekly lessons from %4$s.', 'unsupervised-schedular' ), $who, $offering->title, $count, Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) ) ) : sprintf( /* translators: 1: student name, 2: lesson type, 3: lesson date and time. */ __( 'Booked %1$s into %2$s on %3$s.', 'unsupervised-schedular' ), $who, $offering->title, Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) ) ); $owing = Lesson::STATUS_CONFIRMED === $status ? __( 'Nothing is owed, so it is confirmed.', 'unsupervised-schedular' ) : __( 'A pending payment has been raised; the lesson is confirmed once it settles.', 'unsupervised-schedular' ); return $what . ' ' . $owing; } /** * Everything the form's three selects need. `$onlyInstructorId` scopes both the * open times and the lesson types to one instructor's, the same way `book()` * scopes what may be booked. * * @return array{students: list, offerings: list, slots: list} */ public function formData( int $onlyInstructorId = 0 ): array { $slots = $this->openSlots( $onlyInstructorId ); $offerings = array_values( array_filter( $this->offerings->findAll( $onlyInstructorId, Offering::KIND_PRIVATE_LESSON, true ), static fn( Offering $o ): bool => null !== $o->id ) ); // Whose lesson type / whose time only needs saying when the page spans more // than one instructor — on an instructor's own page it is noise. $named = 0 === $onlyInstructorId; return [ 'students' => $this->studentOptions(), 'offerings' => array_map( fn( Offering $o ): array => [ 'id' => (int) $o->id, 'label' => $this->offeringLabel( $o, $named ), ], $offerings ), 'slots' => array_map( fn( AvailabilitySlot $s ): array => [ 'id' => (int) $s->id, 'label' => $this->slotLabel( $s, $named ), 'weekly' => null !== $s->recurrenceGroup, ], $slots ), ]; } /** * Open slots inside the booking horizon, newest last. * * @return list */ private function openSlots( int $instructorId ): array { $until = ( new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) ) ) ->modify( '+' . self::HORIZON_DAYS . ' days' ) ->format( 'Y-m-d H:i:s' ); return array_values( array_filter( $this->availability->findAvailable( $instructorId, 0, 0, '', $until ), static fn( AvailabilitySlot $s ): bool => null !== $s->id ) ); } /** * A lesson type as "60 min piano (60 min) — Jane Doe", the instructor named * only when the list spans several. */ private function offeringLabel( Offering $offering, bool $withInstructor ): string { $label = $offering->title; if ( null !== $offering->durationMinutes ) { /* translators: %d: lesson length in minutes. */ $label .= ' (' . sprintf( __( '%d min', 'unsupervised-schedular' ), $offering->durationMinutes ) . ')'; } return $withInstructor ? $label . ' — ' . $this->instructorName( $offering->instructorId ) : $label; } /** * An open time as "Mon Sep 2, 4:00 PM (30 min) — Jane Doe — 30 min piano — * repeats weekly": when it is, how long, whose, and what it is already tied to, * since all four decide whether a given student can be booked into it. */ private function slotLabel( AvailabilitySlot $slot, bool $withInstructor ): string { /* translators: %d: lesson length in minutes. */ $label = Val::string( mysql2date( 'D M j, Y g:i A', $slot->startDt ) ) . ' (' . sprintf( __( '%d min', 'unsupervised-schedular' ), $slot->durationMinutes ) . ')'; if ( $withInstructor ) { $label .= ' — ' . $this->instructorName( $slot->instructorId ); } $tied = null !== $slot->offeringId ? $this->offerings->findById( $slot->offeringId ) : null; if ( null !== $tied ) { $label .= ' — ' . $tied->title; } if ( null !== $slot->recurrenceGroup ) { $label .= ' — ' . __( 'repeats weekly', 'unsupervised-schedular' ); } return $label; } private function instructorName( int $instructorId ): string { return $this->studentName( $instructorId ); } /** A person's display name, however little the account has on file. */ private function studentName( int $userId ): string { $user = get_userdata( $userId ); return UserName::format( $user instanceof \WP_User ? $user : null, $userId ); } /** * Everyone who can be booked for, by name — every holder of the student role, * which is exactly the set {@see book()} accepts. That deliberately includes * the children a guardian books for and students still awaiting approval: * neither may book in their own name, both may be booked for. * * @return list */ private function studentOptions(): array { $users = array_filter( get_users( [ 'role' => RoleManager::STUDENT, 'orderby' => 'display_name', 'order' => 'ASC', ] ), static fn( mixed $u ): bool => $u instanceof \WP_User ); return array_values( array_map( static fn( \WP_User $u ): array => [ 'id' => (int) $u->ID, 'name' => UserName::format( $u, (int) $u->ID ), ], $users ) ); } }