Add group-class withdrawal deadline and kind-aware offering form
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

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]>
This commit is contained in:
2026-07-24 15:56:40 -03:00
co-authored by Claude Opus 4.8
parent f552c3952a
commit 2c4b481077
14 changed files with 347 additions and 21 deletions
+34 -8
View File
@@ -92,36 +92,43 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
</select>
</td>
</tr>
<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>
<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>
<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>
<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>
<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>
<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>
@@ -131,7 +138,7 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<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>
<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>
@@ -146,7 +153,7 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<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>
<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>
@@ -164,6 +171,25 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<?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)) : ?>