Let a guardian enrol every child in the same group class
CI / No Debug Code (pull_request) Successful in 26s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m5s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Coding Standards (pull_request) Successful in 3m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 6m9s
CI / Build Plugin Zip (pull_request) Skipped
CI / No Debug Code (pull_request) Successful in 26s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m5s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Coding Standards (pull_request) Successful in 3m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 6m9s
CI / Build Plugin Zip (pull_request) Skipped
The group-class page matched enrolments to the account instead of to the student: the lookup it built from GET /enrollments was keyed by offering id alone, so the first household enrolment marked the class as "yours" and took the Enrol button away from everyone else on the account. A parent could enrol one child and was then offered nothing but Withdraw. The server was never the constraint — hasActiveEnrollment() checks the (offering, student) pair and GET /enrollments deliberately returns the whole household — so the fix is to stop discarding student_id on the way in. Active enrolments are now grouped per class as a list, each student gets their own "… is enrolled in this class." line and their own named Withdraw button, and the Enrol button stays (as "Enrol another student") while anyone the account may enrol is still out. The enrolment form offers only the students not yet enrolled. When exactly one of them is left the picker collapses, and that case needed care: an omitted student_id reads as "enrol the account holder" server-side, so a hidden field carries the id rather than posting nothing and signing up the parent instead of the last child. Closes #170 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Jy9UPhmpLUAfimsyecHN2Z
This commit is contained in:
+115
-39
@@ -137,7 +137,78 @@
|
||||
return !o.withdrawal_deadline || todayYmd() <= o.withdrawal_deadline;
|
||||
}
|
||||
|
||||
function renderClasses(offerings, enrolledMap) {
|
||||
// Active enrolments grouped by class. A household can hold several in the
|
||||
// same class — one per student — so the value is a list, never a single id.
|
||||
function activeByOffering(enrollments) {
|
||||
const map = new Map();
|
||||
enrollments
|
||||
.filter((e) => e.status === 'active')
|
||||
.forEach((e) => {
|
||||
const key = Number(e.offering_id);
|
||||
const held = map.get(key) || [];
|
||||
held.push({ id: e.id, studentId: Number(e.student_id) });
|
||||
map.set(key, held);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
// Who on this account could still be enrolled in a class: everyone the
|
||||
// account may enrol, minus those already holding an active enrolment in it.
|
||||
// The per-student check is the point — the account used to be treated as a
|
||||
// single enrollee, so enrolling one child hid the Enrol button from the rest
|
||||
// of the household even though the server would have taken them happily.
|
||||
function availableStudents(offeringId, enrolled) {
|
||||
const held = enrolled.get(Number(offeringId)) || [];
|
||||
|
||||
// Degraded case: an unparseable student list leaves no id to compare
|
||||
// against, so any existing enrolment is read as covering the account.
|
||||
if (!students.length) return held.length ? [] : [{ id: 0, name: '', is_self: true }];
|
||||
|
||||
const taken = new Set(held.map((e) => e.studentId));
|
||||
return students.filter((s) => !taken.has(Number(s.id)));
|
||||
}
|
||||
|
||||
// The enrolled student's name, or '' when there is nobody to tell them apart
|
||||
// from: an account with a single student reads better in the second person.
|
||||
function studentName(studentId) {
|
||||
if (students.length < 2) return '';
|
||||
const s = students.find((st) => Number(st.id) === Number(studentId));
|
||||
return s && !s.is_self ? s.name : '';
|
||||
}
|
||||
|
||||
function enrolledRow(o, e) {
|
||||
const name = studentName(e.studentId);
|
||||
return `
|
||||
<p class="us-enrolled"><strong>${name ? `${escHtml(name)} is` : 'You are'} enrolled in this class.</strong></p>
|
||||
${isWithdrawalOpen(o)
|
||||
? `<button data-enrollment-id="${e.id}" data-student="${escHtml(name)}" class="us-withdraw-btn">Withdraw${name ? ` ${escHtml(name)}` : ''}</button>`
|
||||
: `<p class="us-withdraw-closed">Withdrawal${name ? ` for ${escHtml(name)}` : ''} has closed — contact the studio to withdraw.</p>`}`;
|
||||
}
|
||||
|
||||
function classCard(o, enrolled) {
|
||||
const held = enrolled.get(Number(o.id)) || [];
|
||||
const available = availableStudents(o.id, enrolled);
|
||||
const canEnrol = available.length > 0 && isEnrollmentOpen(o);
|
||||
|
||||
return `
|
||||
<div class="us-class">
|
||||
<h3>${escHtml(o.title)}</h3>
|
||||
${whenLabel(o) ? `<p class="us-class-when">${escHtml(whenLabel(o))}</p>` : ''}
|
||||
${o.instructor_name ? `<p class="us-class-instructor">With ${escHtml(o.instructor_name)}</p>` : ''}
|
||||
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
|
||||
${!singleOfferingId && o.description ? `<p>${escHtml(o.description)}</p>` : ''}
|
||||
<p class="us-class-price">${escHtml(window.usPricing.priceLabel(o))}</p>
|
||||
${canEnrol && enrolmentDeadline(o)
|
||||
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
|
||||
: ''}
|
||||
${held.map((e) => enrolledRow(o, e)).join('')}
|
||||
${canEnrol
|
||||
? `<button data-offering-id="${o.id}" class="us-enrol-btn">${held.length ? 'Enrol another student' : 'Enrol'}</button>`
|
||||
: (available.length ? '<p class="us-enrol-closed"><strong>Enrolment has closed.</strong></p>' : '')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderClasses(offerings, enrolled) {
|
||||
let groups = offerings.filter((o) => o.kind === 'group_class');
|
||||
if (singleOfferingId) {
|
||||
groups = groups.filter((o) => Number(o.id) === singleOfferingId);
|
||||
@@ -149,44 +220,29 @@
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = groups.map((o) => `
|
||||
<div class="us-class">
|
||||
<h3>${escHtml(o.title)}</h3>
|
||||
${whenLabel(o) ? `<p class="us-class-when">${escHtml(whenLabel(o))}</p>` : ''}
|
||||
${o.instructor_name ? `<p class="us-class-instructor">With ${escHtml(o.instructor_name)}</p>` : ''}
|
||||
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
|
||||
${!singleOfferingId && o.description ? `<p>${escHtml(o.description)}</p>` : ''}
|
||||
<p class="us-class-price">${escHtml(window.usPricing.priceLabel(o))}</p>
|
||||
${!enrolledMap.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
|
||||
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</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>')}
|
||||
</div>
|
||||
`).join('');
|
||||
list.innerHTML = groups.map((o) => classCard(o, enrolled)).join('');
|
||||
|
||||
list.querySelectorAll('.us-enrol-btn').forEach((btn) => {
|
||||
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
|
||||
btn.addEventListener('click', () => {
|
||||
hideConfirmation();
|
||||
openEnrolment(offering);
|
||||
openEnrolment(offering, availableStudents(offering.id, enrolled));
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => withdraw(btn.dataset.enrollmentId));
|
||||
btn.addEventListener('click', () => withdraw(btn.dataset.enrollmentId, btn.dataset.student || ''));
|
||||
});
|
||||
}
|
||||
|
||||
function withdraw(enrollmentId) {
|
||||
function withdraw(enrollmentId, studentName) {
|
||||
clearError();
|
||||
if (!window.confirm('Withdraw from this class? Your seat is released and any pending payment is cancelled.')) {
|
||||
// Named, because a household can hold more than one enrolment in the
|
||||
// same class and "this class" alone would not say whose seat is going.
|
||||
const prompt = studentName
|
||||
? `Withdraw ${studentName} from this class? Their seat is released and any pending payment is cancelled.`
|
||||
: 'Withdraw from this class? Your seat is released and any pending payment is cancelled.';
|
||||
if (!window.confirm(prompt)) {
|
||||
return;
|
||||
}
|
||||
apiFetch(`enrollments/${enrollmentId}/withdraw`, { method: 'POST' })
|
||||
@@ -194,22 +250,45 @@
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
function openEnrolment(offering) {
|
||||
function openEnrolment(offering, available) {
|
||||
clearError();
|
||||
Promise.all([
|
||||
apiFetch(`offerings/${offering.id}/questions`),
|
||||
apiFetch('policies?scope=booking'),
|
||||
])
|
||||
.then(([questions, policies]) => renderEnrolment(offering, questions, policies))
|
||||
.then(([questions, policies]) => renderEnrolment(offering, questions, policies, available))
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
function renderEnrolment(offering, questions, policies) {
|
||||
/**
|
||||
* The "who is this for?" control for one class, offering only the students
|
||||
* who are not already enrolled in it.
|
||||
*
|
||||
* When exactly one is left there is nothing to choose, but the id still has
|
||||
* to reach the server: an omitted picker posts no student_id, which the
|
||||
* server reads as "enrol the account holder" — and would enrol the parent
|
||||
* instead of the one child still to be signed up.
|
||||
*/
|
||||
function studentFieldHtml(available) {
|
||||
if (available.length > 1) {
|
||||
return window.usGuardian.selectorHtml(available, 'us-enrol-student');
|
||||
}
|
||||
|
||||
const only = available[0];
|
||||
if (!only) return '';
|
||||
|
||||
return `<input type="hidden" id="us-enrol-student" value="${Number(only.id)}">
|
||||
${students.length > 1
|
||||
? `<p class="us-student-picker">For ${only.is_self ? 'yourself' : escHtml(only.name)}.</p>`
|
||||
: ''}`;
|
||||
}
|
||||
|
||||
function renderEnrolment(offering, questions, policies, available) {
|
||||
list.innerHTML = `
|
||||
<div class="us-register">
|
||||
<h3>${escHtml(offering.title)}</h3>
|
||||
<form id="us-enrol-form">
|
||||
${window.usGuardian.selectorHtml(students, 'us-enrol-student')}
|
||||
${studentFieldHtml(available)}
|
||||
${questions.map(questionField).join('')}
|
||||
${policies.map(policyField).join('')}
|
||||
${window.usPricing.summaryHtml(offering)}
|
||||
@@ -306,20 +385,17 @@
|
||||
function loadClasses() {
|
||||
clearError();
|
||||
hideConfirmation();
|
||||
// The student's own enrolments are fetched alongside the catalog so a
|
||||
// class they already have an active enrolment in shows its status
|
||||
// The household's enrolments are fetched alongside the catalog so a
|
||||
// class a student already has an active enrolment in shows their status
|
||||
// instead of offering to enrol them again (the API would reject the
|
||||
// duplicate anyway). A cancelled enrolment does not block re-enrolling.
|
||||
// duplicate anyway). Each student is tracked separately: one child being
|
||||
// enrolled says nothing about their siblings, who can still be signed up
|
||||
// for the same class. A cancelled enrolment does not block re-enrolling.
|
||||
return Promise.all([
|
||||
apiFetch('offerings?kind=group_class'),
|
||||
apiFetch('enrollments'),
|
||||
])
|
||||
.then(([offerings, enrollments]) => renderClasses(
|
||||
offerings,
|
||||
new Map(enrollments
|
||||
.filter((e) => e.status === 'active')
|
||||
.map((e) => [Number(e.offering_id), e.id]))
|
||||
))
|
||||
.then(([offerings, enrollments]) => renderClasses(offerings, activeByOffering(enrollments)))
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user