Files
unsupervised-scheduler/templates/admin/offerings.php
T
thatguygriffandClaude Opus 4.8 2c4b481077
CI / Tests (PHP 8.1) (pull_request) Successful in 44s
CI / Tests (PHP 8.2) (pull_request) Successful in 59s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Add group-class withdrawal deadline and kind-aware offering form
Group classes now carry an optional per-class withdrawal deadline. Up to
that day a student may withdraw themselves from the class; the withdrawal
frees the seat and voids any pending payment but never issues an account
credit. After the deadline self-withdrawal closes and a studio admin must
withdraw the student by hand (the admin path is never subject to the
deadline). A blank deadline keeps self-withdrawal open indefinitely.

Also make the Add/Edit Offering form show only the fields relevant to the
selected kind: group settings for group classes, weekly reservation for
private lessons. Progressive enhancement — without JS every field renders.

- New nullable us_offerings.withdrawal_deadline column; Offering model gains
  $withdrawalDeadline + isWithdrawalOpen().
- New student endpoint POST /enrollments/{id}/withdraw, gated by the deadline
  (403 withdrawal_closed), ownership-checked, idempotent.
- Front-end group-class page shows a Withdraw button while open.
- No USC_VERSION bump: 1.2.0 is unreleased and accumulates schema changes
  under its section, matching the scheduled-billing and credit features.

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

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 15:56:40 -03:00

260 lines
19 KiB
PHP
Raw 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 echo $editing && Offering::BILLING_ONE_TIME === $editing->billingMode ? 'selected' : ''; ?>><?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>
<option value="<?php echo esc_attr(Offering::BILLING_WEEKLY); ?>" <?php echo $editing && Offering::BILLING_WEEKLY === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('Weekly — due 24h before each lesson', 'unsupervised-schedular'); ?></option>
<option value="<?php echo esc_attr(Offering::BILLING_MONTHLY); ?>" <?php echo $editing && Offering::BILLING_MONTHLY === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('Monthly — billed on the 1st for that month\'s lessons', 'unsupervised-schedular'); ?></option>
</select>
</td>
</tr>
<tr class="us-private-only">
<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 class="us-group-only">
<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 class="us-group-only">
<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 class="us-group-only">
<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 class="us-group-only">
<th><label for="enrollment_deadline"><?php esc_html_e('Enrolment deadline', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="date" name="enrollment_deadline" id="enrollment_deadline" value="<?php echo esc_attr($editing->enrollmentDeadline ?? ''); ?>">
<p class="description"><?php esc_html_e('Group classes only — the last day students may enrol. Leave blank to default to the first day of the class.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<tr class="us-group-only">
<th><label for="withdrawal_deadline"><?php esc_html_e('Withdrawal deadline', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="date" name="withdrawal_deadline" id="withdrawal_deadline" value="<?php echo esc_attr($editing->withdrawalDeadline ?? ''); ?>">
<p class="description"><?php esc_html_e('Group classes only — the last day a student may withdraw themselves. A withdrawal on or before this day frees the seat and voids any pending payment without crediting the student; after it, students can no longer withdraw online. Leave blank to allow withdrawal any time.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<tr class="us-group-only">
<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 class="us-group-only">
<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 class="us-group-only">
<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>
<?php // Progressive enhancement: only show the fields relevant to the chosen
// kind. Without JS every row stays visible (the pre-toggle behaviour), so
// the form is fully usable either way. ?>
<script>
(function () {
var kind = document.getElementById('kind');
if (!kind) return;
var groupOnly = document.querySelectorAll('.us-group-only');
var privateOnly = document.querySelectorAll('.us-private-only');
function sync() {
var isGroup = kind.value === '<?php echo esc_js(Offering::KIND_GROUP_CLASS); ?>';
groupOnly.forEach(function (row) { row.style.display = isGroup ? '' : 'none'; });
privateOnly.forEach(function (row) { row.style.display = isGroup ? 'none' : ''; });
}
kind.addEventListener('change', sync);
sync();
}());
</script>
<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>