Merge pull request 'Add student administration view (studio-admin)' (#24) from feature/student-administration into main
CI / No Debug Code (push) Successful in 34s
CI / Coding Standards (push) Successful in 55s
CI / PHPStan (push) Successful in 2m9s
CI / Tests (PHP 8.3) (push) Successful in 1m4s
CI / Tests (PHP 8.1) (push) Successful in 3m1s
CI / Tests (PHP 8.2) (push) Successful in 3m6s
CI / Build Plugin Zip (push) Successful in 41s
CI / No Debug Code (push) Successful in 34s
CI / Coding Standards (push) Successful in 55s
CI / PHPStan (push) Successful in 2m9s
CI / Tests (PHP 8.3) (push) Successful in 1m4s
CI / Tests (PHP 8.1) (push) Successful in 3m1s
CI / Tests (PHP 8.2) (push) Successful in 3m6s
CI / Build Plugin Zip (push) Successful in 41s
Reviewed-on: #24
This commit was merged in pull request #24.
This commit is contained in:
@@ -34,7 +34,7 @@ model, REST API, classes, and tests. For contributor/architecture guidance see
|
|||||||
| Account registration (invite-only, signup policy acceptance) | [account-registration.md](docs/features/account-registration.md) | ✅ Implemented |
|
| Account registration (invite-only, signup policy acceptance) | [account-registration.md](docs/features/account-registration.md) | ✅ Implemented |
|
||||||
| Lesson booking (offering → questions → policies) | [lesson-booking.md](docs/features/lesson-booking.md) | ✅ Implemented |
|
| Lesson booking (offering → questions → policies) | [lesson-booking.md](docs/features/lesson-booking.md) | ✅ Implemented |
|
||||||
| Group classes (capacity-enforced enrolment) | [group-classes.md](docs/features/group-classes.md) | ✅ Implemented |
|
| Group classes (capacity-enforced enrolment) | [group-classes.md](docs/features/group-classes.md) | ✅ Implemented |
|
||||||
| Student administration (studio-admin view) | [student-administration.md](docs/features/student-administration.md) | 🟡 Planned |
|
| Student administration (studio-admin view) | [student-administration.md](docs/features/student-administration.md) | ✅ Implemented |
|
||||||
| Payments (Stripe, e-transfer/comp, receipts) | [payments.md](docs/features/payments.md) | 🟡 Planned |
|
| Payments (Stripe, e-transfer/comp, receipts) | [payments.md](docs/features/payments.md) | 🟡 Planned |
|
||||||
| Payment reporting (monthly per-instructor + CSV) | [payment-reporting.md](docs/features/payment-reporting.md) | 🟡 Planned |
|
| Payment reporting (monthly per-instructor + CSV) | [payment-reporting.md](docs/features/payment-reporting.md) | 🟡 Planned |
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Overview
|
## Overview
|
||||||
A read-only studio-admin area to browse students and drill into one student's
|
A read-only studio-admin area to browse students and drill into one student's
|
||||||
history and upcoming activity — lessons and group-class enrolments — without
|
history and upcoming activity — lessons and group-class enrolments — without
|
||||||
digging through individual records. (Status: planned — issue #22.)
|
digging through individual records.
|
||||||
|
|
||||||
## Data Model
|
## Data Model
|
||||||
No new tables. The views are composed from existing data:
|
No new tables. The views are composed from existing data:
|
||||||
@@ -34,10 +34,11 @@ Read-only in this iteration; cancel/edit actions are a possible follow-up.
|
|||||||
## Implementation
|
## Implementation
|
||||||
- Admin controller: `Unsupervised\Schedular\Auth\StudentController` (list + detail)
|
- Admin controller: `Unsupervised\Schedular\Auth\StudentController` (list + detail)
|
||||||
- Templates: `templates/admin/students.php`, `templates/admin/student-detail.php`
|
- Templates: `templates/admin/students.php`, `templates/admin/student-detail.php`
|
||||||
- Reuses `Booking\BookingRepository::findByStudent`,
|
- Reuses `Booking\BookingRepository::findByStudent` + `countUpcomingForStudent`,
|
||||||
`Availability\AvailabilityRepository::findById`,
|
`Availability\AvailabilityRepository::findById`,
|
||||||
`Offering\OfferingRepository::findById`,
|
`Offering\OfferingRepository::findById`,
|
||||||
`GroupClass\EnrollmentRepository::findByStudent`
|
`GroupClass\EnrollmentRepository::findByStudent` + `countActiveForStudent`
|
||||||
|
- Upcoming/past split: `Auth\StudentSchedule::partition()` (pure, unit-tested)
|
||||||
- The upcoming/past split is extracted into a small pure helper so it is
|
- The upcoming/past split is extracted into a small pure helper so it is
|
||||||
unit-testable (the controller itself follows the repo convention of not being
|
unit-testable (the controller itself follows the repo convention of not being
|
||||||
unit-tested).
|
unit-tested).
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
|||||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||||
use Unsupervised\Schedular\Auth\RegistrationController;
|
use Unsupervised\Schedular\Auth\RegistrationController;
|
||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
|
use Unsupervised\Schedular\Auth\StudentController;
|
||||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||||
use Unsupervised\Schedular\Booking\LessonController;
|
use Unsupervised\Schedular\Booking\LessonController;
|
||||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||||
@@ -30,6 +31,7 @@ class AdminMenu {
|
|||||||
private PolicyController $policyController;
|
private PolicyController $policyController;
|
||||||
private RegistrationController $registrationController;
|
private RegistrationController $registrationController;
|
||||||
private GroupClassController $groupClassController;
|
private GroupClassController $groupClassController;
|
||||||
|
private StudentController $studentController;
|
||||||
|
|
||||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites, EnrollmentRepository $enrollments ) {
|
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites, EnrollmentRepository $enrollments ) {
|
||||||
$this->availabilityController = new AvailabilityController( $availability, $offerings );
|
$this->availabilityController = new AvailabilityController( $availability, $offerings );
|
||||||
@@ -39,6 +41,7 @@ class AdminMenu {
|
|||||||
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
|
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
|
||||||
$this->registrationController = new RegistrationController( $invites );
|
$this->registrationController = new RegistrationController( $invites );
|
||||||
$this->groupClassController = new GroupClassController( $enrollments, $offerings );
|
$this->groupClassController = new GroupClassController( $enrollments, $offerings );
|
||||||
|
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments );
|
||||||
}
|
}
|
||||||
|
|
||||||
public function register(): void {
|
public function register(): void {
|
||||||
@@ -122,6 +125,17 @@ class AdminMenu {
|
|||||||
36
|
36
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
37
|
||||||
|
);
|
||||||
|
|
||||||
// Instructor: view their upcoming lessons.
|
// Instructor: view their upcoming lessons.
|
||||||
add_menu_page(
|
add_menu_page(
|
||||||
__( 'My Lessons', 'unsupervised-schedular' ),
|
__( 'My Lessons', 'unsupervised-schedular' ),
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Auth;
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||||
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||||
|
use Unsupervised\Schedular\Booking\Lesson;
|
||||||
|
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||||
|
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||||
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
|
|
||||||
|
class StudentController {
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private BookingRepository $bookings,
|
||||||
|
private AvailabilityRepository $availability,
|
||||||
|
private OfferingRepository $offerings,
|
||||||
|
private EnrollmentRepository $enrollments,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function renderPage(): void {
|
||||||
|
if ( ! current_user_can( RoleManager::CAP_MANAGE_STUDENTS ) ) {
|
||||||
|
wp_die( esc_html__( 'You do not have permission to view students.', 'unsupervised-schedular' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only student selector.
|
||||||
|
$studentId = absint( $_GET['student_id'] ?? 0 );
|
||||||
|
$student = $studentId > 0 ? get_userdata( $studentId ) : false;
|
||||||
|
|
||||||
|
if ( $student && in_array( RoleManager::STUDENT, (array) $student->roles, true ) ) {
|
||||||
|
$this->renderDetail( $student );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$students = array_map(
|
||||||
|
fn( \WP_User $user ): array => [
|
||||||
|
'id' => (int) $user->ID,
|
||||||
|
'name' => $user->display_name,
|
||||||
|
'email' => $user->user_email,
|
||||||
|
'registered' => $user->user_registered,
|
||||||
|
'upcoming' => $this->bookings->countUpcomingForStudent( (int) $user->ID ),
|
||||||
|
'enrolments' => $this->enrollments->countActiveForStudent( (int) $user->ID ),
|
||||||
|
],
|
||||||
|
get_users(
|
||||||
|
[
|
||||||
|
'role' => RoleManager::STUDENT,
|
||||||
|
'orderby' => 'display_name',
|
||||||
|
'order' => 'ASC',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$pageSlug = 'us-students';
|
||||||
|
include USC_PLUGIN_DIR . 'templates/admin/students.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderDetail( \WP_User $student ): void {
|
||||||
|
$now = current_time( 'mysql' );
|
||||||
|
$rows = array_map(
|
||||||
|
fn( Lesson $lesson ): array => $this->lessonRow( $lesson ),
|
||||||
|
$this->bookings->findByStudent( (int) $student->ID )
|
||||||
|
);
|
||||||
|
|
||||||
|
$schedule = StudentSchedule::partition( $rows, $now );
|
||||||
|
$upcoming = $schedule['upcoming'];
|
||||||
|
$past = $schedule['past'];
|
||||||
|
|
||||||
|
$enrolments = array_map(
|
||||||
|
function ( Enrollment $enrollment ): array {
|
||||||
|
$offering = $this->offerings->findById( $enrollment->offeringId );
|
||||||
|
|
||||||
|
return [
|
||||||
|
'offering' => $offering ? $offering->title : (string) $enrollment->offeringId,
|
||||||
|
'status' => $enrollment->status,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
$this->enrollments->findByStudent( (int) $student->ID )
|
||||||
|
);
|
||||||
|
|
||||||
|
$backUrl = admin_url( 'admin.php?page=us-students' );
|
||||||
|
include USC_PLUGIN_DIR . 'templates/admin/student-detail.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a display row for a lesson (slot time, offering, instructor, status).
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function lessonRow( Lesson $lesson ): array {
|
||||||
|
$slot = $this->availability->findById( $lesson->slotId );
|
||||||
|
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
|
||||||
|
$instructor = get_userdata( $lesson->instructorId );
|
||||||
|
|
||||||
|
return [
|
||||||
|
'start_dt' => $slot ? $slot->startDt : '',
|
||||||
|
'end_dt' => $slot ? $slot->endDt : '',
|
||||||
|
'offering' => $offering ? $offering->title : '—',
|
||||||
|
'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId,
|
||||||
|
'status' => $lesson->status,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helper for splitting a student's dated rows into upcoming and past.
|
||||||
|
*/
|
||||||
|
class StudentSchedule {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partition rows (each with a `start_dt` string in `Y-m-d H:i:s`) relative to
|
||||||
|
* `$now`. Upcoming rows are sorted ascending, past rows descending. Rows
|
||||||
|
* without a usable `start_dt` fall into `past`.
|
||||||
|
*
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
* @return array{upcoming: list<array<string, mixed>>, past: list<array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
public static function partition( array $rows, string $now ): array {
|
||||||
|
$upcoming = [];
|
||||||
|
$past = [];
|
||||||
|
|
||||||
|
foreach ( $rows as $row ) {
|
||||||
|
$start = (string) ( $row['start_dt'] ?? '' );
|
||||||
|
if ( '' !== $start && $start >= $now ) {
|
||||||
|
$upcoming[] = $row;
|
||||||
|
} else {
|
||||||
|
$past[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort( $upcoming, static fn( array $a, array $b ): int => strcmp( (string) ( $a['start_dt'] ?? '' ), (string) ( $b['start_dt'] ?? '' ) ) );
|
||||||
|
usort( $past, static fn( array $a, array $b ): int => strcmp( (string) ( $b['start_dt'] ?? '' ), (string) ( $a['start_dt'] ?? '' ) ) );
|
||||||
|
|
||||||
|
return [
|
||||||
|
'upcoming' => array_values( $upcoming ),
|
||||||
|
'past' => array_values( $past ),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,6 +111,26 @@ class BookingRepository {
|
|||||||
return array_map( Lesson::fromRow( ... ), $rows ?? [] );
|
return array_map( Lesson::fromRow( ... ), $rows ?? [] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count a student's upcoming, non-cancelled lessons (slot in the future).
|
||||||
|
*/
|
||||||
|
public function countUpcomingForStudent( int $studentId ): int {
|
||||||
|
$avTable = str_replace( 'us_lessons', 'us_availability', $this->table );
|
||||||
|
|
||||||
|
return (int) $this->db->get_var(
|
||||||
|
$this->db->prepare(
|
||||||
|
"SELECT COUNT(*) FROM {$this->table} l
|
||||||
|
JOIN {$avTable} a ON a.id = l.slot_id
|
||||||
|
WHERE l.student_id = %d
|
||||||
|
AND l.status != %s
|
||||||
|
AND a.start_dt >= %s",
|
||||||
|
$studentId,
|
||||||
|
Lesson::STATUS_CANCELLED,
|
||||||
|
current_time( 'mysql' )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All lessons for a student.
|
* All lessons for a student.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ class EnrollmentRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count a student's active group-class enrolments.
|
||||||
|
*/
|
||||||
|
public function countActiveForStudent( int $studentId ): int {
|
||||||
|
return (int) $this->db->get_var(
|
||||||
|
$this->db->prepare(
|
||||||
|
"SELECT COUNT(*) FROM {$this->table} WHERE student_id = %d AND status = %s",
|
||||||
|
$studentId,
|
||||||
|
Enrollment::STATUS_ACTIVE
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a student already holds an active enrolment in an offering.
|
* Whether a student already holds an active enrolment in an offering.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (! defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \WP_User $student
|
||||||
|
* @var list<array{start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $upcoming
|
||||||
|
* @var list<array{start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $past
|
||||||
|
* @var list<array{offering: string, status: string}> $enrolments
|
||||||
|
* @var string $backUrl
|
||||||
|
*/
|
||||||
|
|
||||||
|
$renderLessons = static function (array $rows): void {
|
||||||
|
if (empty($rows)) {
|
||||||
|
echo '<p>' . esc_html__('None.', 'unsupervised-schedular') . '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<table class="wp-list-table widefat fixed striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e('When', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Offering', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($rows as $row) : ?>
|
||||||
|
<tr>
|
||||||
|
<td><?php echo esc_html($row['start_dt'] !== '' ? $row['start_dt'] : '—'); ?></td>
|
||||||
|
<td><?php echo esc_html($row['offering']); ?></td>
|
||||||
|
<td><?php echo esc_html($row['instructor']); ?></td>
|
||||||
|
<td><?php echo esc_html($row['status']); ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>
|
||||||
|
<?php echo esc_html($student->display_name); ?>
|
||||||
|
<a href="<?php echo esc_url($backUrl); ?>" class="page-title-action"><?php esc_html_e('Back to students', 'unsupervised-schedular'); ?></a>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e('Account', 'unsupervised-schedular'); ?></h2>
|
||||||
|
<table class="form-table">
|
||||||
|
<tr><th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th><td><?php echo esc_html($student->user_email); ?></td></tr>
|
||||||
|
<tr><th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th><td><?php echo esc_html($student->user_registered); ?></td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e('Upcoming lessons', 'unsupervised-schedular'); ?></h2>
|
||||||
|
<?php $renderLessons($upcoming); ?>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e('Past lessons', 'unsupervised-schedular'); ?></h2>
|
||||||
|
<?php $renderLessons($past); ?>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e('Group-class enrolments', 'unsupervised-schedular'); ?></h2>
|
||||||
|
<?php if (empty($enrolments)) : ?>
|
||||||
|
<p><?php esc_html_e('None.', 'unsupervised-schedular'); ?></p>
|
||||||
|
<?php else : ?>
|
||||||
|
<table class="wp-list-table widefat fixed striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e('Class', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($enrolments as $enrolment) : ?>
|
||||||
|
<tr>
|
||||||
|
<td><?php echo esc_html($enrolment['offering']); ?></td>
|
||||||
|
<td><?php echo esc_html($enrolment['status']); ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (! defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<array{id: int, name: string, email: string, registered: string, upcoming: int, enrolments: int}> $students
|
||||||
|
* @var string $pageSlug
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1><?php esc_html_e('Students', 'unsupervised-schedular'); ?></h1>
|
||||||
|
|
||||||
|
<?php if (empty($students)) : ?>
|
||||||
|
<p><?php esc_html_e('No students yet.', 'unsupervised-schedular'); ?></p>
|
||||||
|
<?php else : ?>
|
||||||
|
<table class="wp-list-table widefat fixed striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e('Name', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Upcoming lessons', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Active enrolments', 'unsupervised-schedular'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($students as $student) : ?>
|
||||||
|
<?php $detailUrl = add_query_arg(['page' => $pageSlug, 'student_id' => $student['id']], admin_url('admin.php')); ?>
|
||||||
|
<tr>
|
||||||
|
<td><a href="<?php echo esc_url($detailUrl); ?>"><?php echo esc_html($student['name']); ?></a></td>
|
||||||
|
<td><?php echo esc_html($student['email']); ?></td>
|
||||||
|
<td><?php echo esc_html($student['registered']); ?></td>
|
||||||
|
<td><?php echo esc_html((string) $student['upcoming']); ?></td>
|
||||||
|
<td><?php echo esc_html((string) $student['enrolments']); ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Auth\StudentSchedule;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class StudentScheduleTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testPartitionSplitsByNow(): void
|
||||||
|
{
|
||||||
|
$rows = [
|
||||||
|
['start_dt' => '2026-06-10 09:00:00', 'label' => 'future-a'],
|
||||||
|
['start_dt' => '2026-06-01 09:00:00', 'label' => 'past-a'],
|
||||||
|
['start_dt' => '2026-06-20 09:00:00', 'label' => 'future-b'],
|
||||||
|
['start_dt' => '2026-05-15 09:00:00', 'label' => 'past-b'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = StudentSchedule::partition($rows, '2026-06-08 12:00:00');
|
||||||
|
|
||||||
|
self::assertSame(['future-a', 'future-b'], array_column($result['upcoming'], 'label'));
|
||||||
|
self::assertSame(['past-a', 'past-b'], array_column($result['past'], 'label'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpcomingSortedAscendingAndPastDescending(): void
|
||||||
|
{
|
||||||
|
$rows = [
|
||||||
|
['start_dt' => '2026-06-20 09:00:00'],
|
||||||
|
['start_dt' => '2026-06-10 09:00:00'],
|
||||||
|
['start_dt' => '2026-05-01 09:00:00'],
|
||||||
|
['start_dt' => '2026-05-30 09:00:00'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = StudentSchedule::partition($rows, '2026-06-08 00:00:00');
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
['2026-06-10 09:00:00', '2026-06-20 09:00:00'],
|
||||||
|
array_column($result['upcoming'], 'start_dt')
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
['2026-05-30 09:00:00', '2026-05-01 09:00:00'],
|
||||||
|
array_column($result['past'], 'start_dt')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRowsWithoutStartDateFallIntoPast(): void
|
||||||
|
{
|
||||||
|
$rows = [
|
||||||
|
['start_dt' => '', 'label' => 'no-slot'],
|
||||||
|
['start_dt' => '2026-06-10 09:00:00', 'label' => 'future'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = StudentSchedule::partition($rows, '2026-06-08 00:00:00');
|
||||||
|
|
||||||
|
self::assertSame(['future'], array_column($result['upcoming'], 'label'));
|
||||||
|
self::assertSame(['no-slot'], array_column($result['past'], 'label'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmptyInput(): void
|
||||||
|
{
|
||||||
|
$result = StudentSchedule::partition([], '2026-06-08 00:00:00');
|
||||||
|
|
||||||
|
self::assertSame([], $result['upcoming']);
|
||||||
|
self::assertSame([], $result['past']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,6 +132,20 @@ class BookingRepositoryTest extends TestCase
|
|||||||
self::assertFalse($this->repo->updateStatus(1, Lesson::STATUS_CONFIRMED));
|
self::assertFalse($this->repo->updateStatus(1, Lesson::STATUS_CONFIRMED));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCountUpcomingForStudent(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_time')->justReturn('2026-06-08 12:00:00');
|
||||||
|
|
||||||
|
$this->db->shouldReceive('prepare')
|
||||||
|
->once()
|
||||||
|
->with(Mockery::pattern('/COUNT\(\*\).*l.student_id = %d.*a.start_dt >= %s/s'), 5, Lesson::STATUS_CANCELLED, '2026-06-08 12:00:00')
|
||||||
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
|
$this->db->shouldReceive('get_var')->andReturn('3');
|
||||||
|
|
||||||
|
self::assertSame(3, $this->repo->countUpcomingForStudent(5));
|
||||||
|
}
|
||||||
|
|
||||||
public function testFindByStudentReturnsLessons(): void
|
public function testFindByStudentReturnsLessons(): void
|
||||||
{
|
{
|
||||||
$row = (object) [
|
$row = (object) [
|
||||||
|
|||||||
@@ -56,6 +56,18 @@ class EnrollmentRepositoryTest extends TestCase
|
|||||||
self::assertSame(4, $this->repo->countActiveForOffering(7));
|
self::assertSame(4, $this->repo->countActiveForOffering(7));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCountActiveForStudent(): void
|
||||||
|
{
|
||||||
|
$this->db->shouldReceive('prepare')
|
||||||
|
->once()
|
||||||
|
->with(Mockery::pattern('/student_id = %d AND status = %s/'), 5, Enrollment::STATUS_ACTIVE)
|
||||||
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
|
$this->db->shouldReceive('get_var')->andReturn('2');
|
||||||
|
|
||||||
|
self::assertSame(2, $this->repo->countActiveForStudent(5));
|
||||||
|
}
|
||||||
|
|
||||||
public function testHasActiveEnrollmentTrueWhenCountPositive(): void
|
public function testHasActiveEnrollmentTrueWhenCountPositive(): void
|
||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
|
|||||||
Reference in New Issue
Block a user