Files
unsupervised-scheduler/templates/admin/offerings.php
T
thatguygriffandClaude Opus 4.8 b066bef353
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
Add group-class scheduling, instructor assignment, and details/invite management
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]>
2026-07-23 17:29:48 -03:00

225 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use Unsupervised\Schedular\Offering\Offering;
if (! defined('ABSPATH')) {
exit;
}
/**
* @var list<\Unsupervised\Schedular\Offering\Offering> $offerings
* @var \Unsupervised\Schedular\Offering\Offering|null $editing Offering loaded into the form, or null when adding.
* @var list<array{id: int, name: string}> $instructors Instructors offered in the assignment picker (studio admins only).
* @var bool $manageAll Whether the current user may assign classes to other instructors.
* @var string $notice Status message from the last save (slot-clearing / conflicts).
*/
$baseUrl = admin_url('admin.php?page=us-offerings');
// Prefill the sessions control from the stored term dates: a term longer than
// one day was created as weekly sessions one week apart.
$termRecurrence = 'single';
$termSessions = 10;
if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $editing->termEnd !== $editing->termStart) {
$termRecurrence = 'weekly';
$termSessions = (int) round(((int) strtotime($editing->termEnd) - (int) strtotime($editing->termStart)) / 604800) + 1;
}
?>
<div class="wrap">
<h1><?php esc_html_e('Offerings', 'unsupervised-schedular'); ?></h1>
<?php if ('' !== $notice) : ?>
<div class="notice notice-info is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
<?php endif; ?>
<h2><?php $editing ? esc_html_e('Edit Offering', 'unsupervised-schedular') : esc_html_e('Add Offering', 'unsupervised-schedular'); ?></h2>
<form method="post">
<?php wp_nonce_field('usc_offering_action'); ?>
<?php if ($editing) : ?>
<input type="hidden" name="usc_action" value="update">
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $editing->id); ?>">
<?php else : ?>
<input type="hidden" name="usc_action" value="add">
<?php endif; ?>
<table class="form-table">
<tr>
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th>
<td><input type="text" name="title" id="title" class="regular-text" required value="<?php echo esc_attr($editing->title ?? ''); ?>"></td>
</tr>
<tr>
<th><label for="kind"><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></label></th>
<td>
<select name="kind" id="kind">
<option value="<?php echo esc_attr(Offering::KIND_PRIVATE_LESSON); ?>"><?php esc_html_e('Private lesson', 'unsupervised-schedular'); ?></option>
<option value="<?php echo esc_attr(Offering::KIND_GROUP_CLASS); ?>" <?php echo $editing && Offering::KIND_GROUP_CLASS === $editing->kind ? 'selected' : ''; ?>><?php esc_html_e('Group class', 'unsupervised-schedular'); ?></option>
</select>
</td>
</tr>
<?php if ($manageAll) : ?>
<tr>
<th><label for="class_instructor_id"><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></label></th>
<td>
<select name="class_instructor_id" id="class_instructor_id">
<?php foreach ($instructors as $instructor) : ?>
<option value="<?php echo esc_attr((string) $instructor['id']); ?>" <?php echo $editing && $editing->instructorId === $instructor['id'] ? 'selected' : ''; ?>><?php echo esc_html($instructor['name']); ?></option>
<?php endforeach; ?>
</select>
<p class="description"><?php esc_html_e('Who teaches this. Assigning a group class clears that instructors open booking slots at the class time.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<?php endif; ?>
<tr>
<th><label for="description"><?php esc_html_e('Description', 'unsupervised-schedular'); ?></label></th>
<td><textarea name="description" id="description" class="large-text" rows="4"><?php echo esc_textarea($editing->description ?? ''); ?></textarea></td>
</tr>
<tr>
<th><label for="duration_minutes"><?php esc_html_e('Duration (minutes)', 'unsupervised-schedular'); ?></label></th>
<td><input type="number" name="duration_minutes" id="duration_minutes" min="0" step="1" value="<?php echo esc_attr((string) ($editing->durationMinutes ?? '')); ?>"></td>
</tr>
<tr>
<th><label for="price"><?php esc_html_e('Price (dollars)', 'unsupervised-schedular'); ?></label></th>
<td><input type="number" name="price" id="price" min="0" step="0.01" value="<?php echo esc_attr(number_format($editing->price ?? 0.0, 2, '.', '')); ?>"></td>
</tr>
<tr>
<th><label for="billing_mode"><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></label></th>
<td>
<select name="billing_mode" id="billing_mode">
<option value="<?php echo esc_attr(Offering::BILLING_ONE_TIME); ?>"><?php esc_html_e('One-time at booking', 'unsupervised-schedular'); ?></option>
<option value="<?php echo esc_attr(Offering::BILLING_FULL_TERM); ?>" <?php echo $editing && Offering::BILLING_FULL_TERM === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('Full term upfront', 'unsupervised-schedular'); ?></option>
</select>
</td>
</tr>
<tr>
<th><?php esc_html_e('Weekly reservation', 'unsupervised-schedular'); ?></th>
<td><label><input type="checkbox" name="allow_weekly" value="1" <?php echo $editing && $editing->allowWeekly ? 'checked' : ''; ?>> <?php esc_html_e('Allow weekly recurring reservation (private)', 'unsupervised-schedular'); ?></label></td>
</tr>
<tr>
<th><label for="capacity"><?php esc_html_e('Capacity', 'unsupervised-schedular'); ?></label></th>
<td><input type="number" name="capacity" id="capacity" min="0" step="1" value="<?php echo esc_attr((string) ($editing->capacity ?? '')); ?>"> <span class="description"><?php esc_html_e('Group classes only', 'unsupervised-schedular'); ?></span></td>
</tr>
<tr>
<th><label for="term_start"><?php esc_html_e('Start date', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="date" name="term_start" id="term_start" value="<?php echo esc_attr($editing->termStart ?? ''); ?>">
<span class="description"><?php esc_html_e('Group classes only — date of the first class', 'unsupervised-schedular'); ?></span>
</td>
</tr>
<tr>
<th><label for="class_time"><?php esc_html_e('Class time', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="time" name="class_time" id="class_time" value="<?php echo esc_attr(null === ($editing->classTime ?? null) ? '' : substr((string) $editing->classTime, 0, 5)); ?>">
<span class="description"><?php esc_html_e('Group classes only — the time each session starts. Combined with the duration to block the instructors availability.', 'unsupervised-schedular'); ?></span>
</td>
</tr>
<tr>
<th><?php esc_html_e('Sessions', 'unsupervised-schedular'); ?></th>
<td>
<label><input type="radio" name="term_recurrence" value="single" <?php echo 'single' === $termRecurrence ? 'checked' : ''; ?>> <?php esc_html_e('One-off', 'unsupervised-schedular'); ?></label>
&nbsp;
<label><input type="radio" name="term_recurrence" value="weekly" <?php echo 'weekly' === $termRecurrence ? 'checked' : ''; ?>> <?php esc_html_e('Weekly for', 'unsupervised-schedular'); ?></label>
<input type="number" name="term_sessions" min="1" max="52" value="<?php echo esc_attr((string) $termSessions); ?>" style="width:5em;"> <?php esc_html_e('sessions', 'unsupervised-schedular'); ?>
<p class="description"><?php esc_html_e('The end date is calculated from the start date and the number of weekly sessions.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<tr>
<th><label for="schedule_note"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></label></th>
<td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" placeholder="<?php esc_attr_e('e.g. Tuesdays 4:00pm', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->scheduleNote ?? ''); ?>"></td>
</tr>
<tr>
<th><label for="etransfer_email"><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></label></th>
<td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->etransferEmail ?? ''); ?>"></td>
</tr>
<tr>
<th><label for="cancellation_cutoff_hours"><?php esc_html_e('Cancellation cutoff (hours)', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="number" name="cancellation_cutoff_hours" id="cancellation_cutoff_hours" min="0" step="1" placeholder="<?php esc_attr_e('Uses the studio default', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr(null === ($editing->cancellationCutoffHours ?? null) ? '' : (string) $editing->cancellationCutoffHours); ?>">
<p class="description"><?php esc_html_e('How many hours before a lesson a student may still cancel it. Leave blank to use the studio default; 0 lets students cancel any time.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<tr>
<th><?php esc_html_e('Invite only', 'unsupervised-schedular'); ?></th>
<td>
<label><input type="checkbox" name="invite_only" value="1" <?php echo $editing && $editing->isInviteOnly() ? 'checked' : ''; ?>> <?php esc_html_e('Hide from the booking list — students join by invitation only (group classes)', 'unsupervised-schedular'); ?></label>
<p class="description"><?php esc_html_e('Invite-only classes never appear in the student catalog. Add or invite students from My Lessons → My Group Classes.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<tr>
<th><?php esc_html_e('Active', 'unsupervised-schedular'); ?></th>
<td><label><input type="checkbox" name="is_active" value="1" <?php echo null === $editing || $editing->isActive ? 'checked' : ''; ?>> <?php esc_html_e('Open for registration', 'unsupervised-schedular'); ?></label></td>
</tr>
</table>
<?php submit_button($editing ? esc_html__('Update Offering', 'unsupervised-schedular') : esc_html__('Add Offering', 'unsupervised-schedular')); ?>
<?php if ($editing) : ?>
<p><a href="<?php echo esc_url($baseUrl); ?>"><?php esc_html_e('Cancel editing', 'unsupervised-schedular'); ?></a></p>
<?php endif; ?>
</form>
<h2><?php esc_html_e('Current Offerings', 'unsupervised-schedular'); ?></h2>
<?php if (empty($offerings)) : ?>
<p><?php esc_html_e('No offerings configured.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th style="width:4em;"><?php esc_html_e('ID', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Title', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Duration', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Price', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Term', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Active', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($offerings as $offering) : ?>
<tr>
<td><?php echo esc_html((string) $offering->id); ?></td>
<td>
<?php echo esc_html($offering->title); ?>
<?php if ($offering->isInviteOnly()) : ?>
<span class="dashicons dashicons-lock" title="<?php esc_attr_e('Invite only', 'unsupervised-schedular'); ?>"></span>
<?php endif; ?>
</td>
<td><?php echo esc_html($offering->kind); ?></td>
<td><?php echo $offering->durationMinutes ? esc_html((string) $offering->durationMinutes . ' min') : '&mdash;'; ?></td>
<td><?php echo esc_html(number_format($offering->price, 2) . ' ' . $offering->currency); ?></td>
<td><?php echo esc_html($offering->billingMode); ?></td>
<td>
<?php if (null === $offering->termStart) : ?>
&mdash;
<?php else : ?>
<?php
if (null === $offering->termEnd || $offering->termEnd === $offering->termStart) {
echo esc_html((string) mysql2date('M j, Y', $offering->termStart));
} else {
echo esc_html((string) mysql2date('M j, Y', $offering->termStart) . ' ' . (string) mysql2date('M j, Y', $offering->termEnd));
}
if (null !== $offering->classTime) {
echo '<br><span class="description">' . esc_html((string) mysql2date('g:i a', $offering->termStart . ' ' . $offering->classTime)) . '</span>';
}
?>
<?php endif; ?>
</td>
<td><?php echo $offering->isActive ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?></td>
<td>
<a class="button button-small" href="<?php echo esc_url(add_query_arg('usc_edit', (int) $offering->id, $baseUrl)); ?>"><?php esc_html_e('Edit', 'unsupervised-schedular'); ?></a>
<form method="post" style="display:inline;">
<?php wp_nonce_field('usc_offering_action'); ?>
<input type="hidden" name="usc_action" value="delete">
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $offering->id); ?>">
<button type="submit" class="button button-small button-link-delete">
<?php esc_html_e('Delete', 'unsupervised-schedular'); ?>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>