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
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:
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user