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
+30 -6
View File
@@ -123,7 +123,14 @@
return !deadline || todayYmd() <= deadline;
}
function renderClasses(offerings, enrolledOfferingIds) {
// Self-withdrawal closes at the end of the withdrawal-deadline day. Unlike
// enrolment there is no implicit default: an unset deadline keeps withdrawal
// open. Mirrors the server-side Offering::isWithdrawalOpen() gate.
function isWithdrawalOpen(o) {
return !o.withdrawal_deadline || todayYmd() <= o.withdrawal_deadline;
}
function renderClasses(offerings, enrolledMap) {
let groups = offerings.filter((o) => o.kind === 'group_class');
if (singleOfferingId) {
groups = groups.filter((o) => Number(o.id) === singleOfferingId);
@@ -143,11 +150,14 @@
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
${o.description ? `<p>${escHtml(o.description)}</p>` : ''}
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
${!enrolledOfferingIds.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
${!enrolledMap.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
: ''}
${enrolledOfferingIds.has(Number(o.id))
? '<p class="us-enrolled"><strong>You are enrolled in this class.</strong></p>'
${enrolledMap.has(Number(o.id))
? `<p class="us-enrolled"><strong>You are enrolled in this class.</strong></p>
${isWithdrawalOpen(o)
? `<button data-enrollment-id="${enrolledMap.get(Number(o.id))}" class="us-withdraw-btn">Withdraw</button>`
: '<p class="us-withdraw-closed">Withdrawal has closed — contact the studio to withdraw.</p>'}`
: (isEnrollmentOpen(o)
? `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`
: '<p class="us-enrol-closed"><strong>Enrolment has closed.</strong></p>')}
@@ -158,6 +168,20 @@
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
btn.addEventListener('click', () => openEnrolment(offering));
});
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
btn.addEventListener('click', () => withdraw(btn.dataset.enrollmentId));
});
}
function withdraw(enrollmentId) {
clearError();
if (!window.confirm('Withdraw from this class? Your seat is released and any pending payment is cancelled.')) {
return;
}
apiFetch(`enrollments/${enrollmentId}/withdraw`, { method: 'POST' })
.then(loadClasses)
.catch((err) => showError(err.message));
}
function openEnrolment(offering) {
@@ -240,9 +264,9 @@
])
.then(([offerings, enrollments]) => renderClasses(
offerings,
new Set(enrollments
new Map(enrollments
.filter((e) => e.status === 'active')
.map((e) => Number(e.offering_id)))
.map((e) => [Number(e.offering_id), e.id]))
))
.catch((err) => showError(err.message));
}