Merge pull request 'Let a guardian enrol every child in the same group class' (#171) from fix/group-multi-student-enrolment into main
CI / No Debug Code (push) Successful in 4s
CI / Tests (PHP 8.1) (push) Successful in 1m4s
CI / Tests (PHP 8.2) (push) Successful in 1m10s
CI / Coding Standards (push) Successful in 3m35s
CI / PHPStan (push) Successful in 3m52s
CI / Tests (PHP 8.3) (push) Successful in 21m42s
CI / Build Plugin Zip (push) Successful in 14m57s
Release / Build and Publish Release (push) Successful in 3m11s
Release / Open next-version bump PR (push) Successful in 5s
CI / No Debug Code (push) Successful in 4s
CI / Tests (PHP 8.1) (push) Successful in 1m4s
CI / Tests (PHP 8.2) (push) Successful in 1m10s
CI / Coding Standards (push) Successful in 3m35s
CI / PHPStan (push) Successful in 3m52s
CI / Tests (PHP 8.3) (push) Successful in 21m42s
CI / Build Plugin Zip (push) Successful in 14m57s
Release / Build and Publish Release (push) Successful in 3m11s
Release / Open next-version bump PR (push) Successful in 5s
Reviewed-on: #171
This commit was merged in pull request #171.
This commit is contained in:
@@ -13,6 +13,9 @@ each change under the current top section as you work.
|
|||||||
|
|
||||||
## [1.5.1]
|
## [1.5.1]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **A parent can now enrol more than one child in the same group class.** Enrolling the first student worked, and then the class card switched to "You are enrolled in this class." with a **Withdraw** button — for the whole account. There was no way to sign up a second child short of withdrawing the first, even though nothing was ever actually full or forbidden: the class page was matching enrolments to the account rather than to the student, so one child's seat spoke for everybody. Each enrolled student now gets their own line on the card, named — "Ada is enrolled in this class." — with their own Withdraw button, and the Enrol button stays put, reading **Enrol another student**, until everyone on the account is in. The form's "Who is this for?" list offers only the students not yet enrolled, so the class cannot be double-booked for the same child by accident. Enrolments already recorded are unaffected; the seats were always separate on the studio's side, and this is the page catching up with that.
|
||||||
|
|
||||||
## [1.5.0]
|
## [1.5.0]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+115
-39
@@ -137,7 +137,78 @@
|
|||||||
return !o.withdrawal_deadline || todayYmd() <= o.withdrawal_deadline;
|
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');
|
let groups = offerings.filter((o) => o.kind === 'group_class');
|
||||||
if (singleOfferingId) {
|
if (singleOfferingId) {
|
||||||
groups = groups.filter((o) => Number(o.id) === singleOfferingId);
|
groups = groups.filter((o) => Number(o.id) === singleOfferingId);
|
||||||
@@ -149,44 +220,29 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = groups.map((o) => `
|
list.innerHTML = groups.map((o) => classCard(o, enrolled)).join('');
|
||||||
<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.querySelectorAll('.us-enrol-btn').forEach((btn) => {
|
list.querySelectorAll('.us-enrol-btn').forEach((btn) => {
|
||||||
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
|
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
hideConfirmation();
|
hideConfirmation();
|
||||||
openEnrolment(offering);
|
openEnrolment(offering, availableStudents(offering.id, enrolled));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
|
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();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
apiFetch(`enrollments/${enrollmentId}/withdraw`, { method: 'POST' })
|
apiFetch(`enrollments/${enrollmentId}/withdraw`, { method: 'POST' })
|
||||||
@@ -194,22 +250,45 @@
|
|||||||
.catch((err) => showError(err.message));
|
.catch((err) => showError(err.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEnrolment(offering) {
|
function openEnrolment(offering, available) {
|
||||||
clearError();
|
clearError();
|
||||||
Promise.all([
|
Promise.all([
|
||||||
apiFetch(`offerings/${offering.id}/questions`),
|
apiFetch(`offerings/${offering.id}/questions`),
|
||||||
apiFetch('policies?scope=booking'),
|
apiFetch('policies?scope=booking'),
|
||||||
])
|
])
|
||||||
.then(([questions, policies]) => renderEnrolment(offering, questions, policies))
|
.then(([questions, policies]) => renderEnrolment(offering, questions, policies, available))
|
||||||
.catch((err) => showError(err.message));
|
.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 = `
|
list.innerHTML = `
|
||||||
<div class="us-register">
|
<div class="us-register">
|
||||||
<h3>${escHtml(offering.title)}</h3>
|
<h3>${escHtml(offering.title)}</h3>
|
||||||
<form id="us-enrol-form">
|
<form id="us-enrol-form">
|
||||||
${window.usGuardian.selectorHtml(students, 'us-enrol-student')}
|
${studentFieldHtml(available)}
|
||||||
${questions.map(questionField).join('')}
|
${questions.map(questionField).join('')}
|
||||||
${policies.map(policyField).join('')}
|
${policies.map(policyField).join('')}
|
||||||
${window.usPricing.summaryHtml(offering)}
|
${window.usPricing.summaryHtml(offering)}
|
||||||
@@ -306,20 +385,17 @@
|
|||||||
function loadClasses() {
|
function loadClasses() {
|
||||||
clearError();
|
clearError();
|
||||||
hideConfirmation();
|
hideConfirmation();
|
||||||
// The student's own enrolments are fetched alongside the catalog so a
|
// The household's enrolments are fetched alongside the catalog so a
|
||||||
// class they already have an active enrolment in shows its status
|
// class a student already has an active enrolment in shows their status
|
||||||
// instead of offering to enrol them again (the API would reject the
|
// 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([
|
return Promise.all([
|
||||||
apiFetch('offerings?kind=group_class'),
|
apiFetch('offerings?kind=group_class'),
|
||||||
apiFetch('enrollments'),
|
apiFetch('enrollments'),
|
||||||
])
|
])
|
||||||
.then(([offerings, enrollments]) => renderClasses(
|
.then(([offerings, enrollments]) => renderClasses(offerings, activeByOffering(enrollments)))
|
||||||
offerings,
|
|
||||||
new Map(enrollments
|
|
||||||
.filter((e) => e.status === 'active')
|
|
||||||
.map((e) => [Number(e.offering_id), e.id]))
|
|
||||||
))
|
|
||||||
.catch((err) => showError(err.message));
|
.catch((err) => showError(err.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,11 +81,20 @@ student detail page. Only *upcoming* sessions are added there — the
|
|||||||
term's worth of past dates would bury the lessons under "Past lessons".
|
term's worth of past dates would bury the lessons under "Past lessons".
|
||||||
|
|
||||||
## Enrolment Flow
|
## Enrolment Flow
|
||||||
The class list is loaded together with the student's own enrolments
|
The class list is loaded together with the household's enrolments
|
||||||
(`GET /enrollments`); a class the student already has an `active` enrolment in
|
(`GET /enrollments`), and the two are matched up **per student**, not per account.
|
||||||
shows "You are enrolled in this class." instead of the Enrol button (the
|
Each active enrolment in a class adds its own line to the card — "Ada is enrolled
|
||||||
server would reject the duplicate with `409 already_enrolled` regardless — a
|
in this class." — with its own **Withdraw** button, and the Enrol button stays
|
||||||
cancelled enrolment does not block re-enrolling).
|
(reading "Enrol another student") for as long as anyone the account may enrol is
|
||||||
|
still out of the class. The enrolment form then offers only those students; when
|
||||||
|
exactly one is left the picker collapses to a hidden field carrying that student's
|
||||||
|
id, because an omitted `student_id` reads as "enrol the account holder" and would
|
||||||
|
sign up the parent instead of the last child. Only when the whole household is
|
||||||
|
enrolled does the Enrol button disappear.
|
||||||
|
|
||||||
|
The per-student matching mirrors the server, which rejects a duplicate with
|
||||||
|
`409 already_enrolled` for that `(offering, student)` pair alone — a sibling is
|
||||||
|
never a duplicate, and a cancelled enrolment does not block re-enrolling.
|
||||||
|
|
||||||
1. Student opens a group class from the offering catalog. Each class card shows its price with the **cadence** it is billed on — `120.00 CAD up front`, `40.00 CAD monthly`, and so on.
|
1. Student opens a group class from the offering catalog. Each class card shows its price with the **cadence** it is billed on — `120.00 CAD up front`, `40.00 CAD monthly`, and so on.
|
||||||
2. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
2. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
||||||
@@ -114,7 +123,8 @@ closed. Past the deadline the details page labels these as late enrolments. See
|
|||||||
|
|
||||||
## Withdrawal Flow
|
## Withdrawal Flow
|
||||||
A student may withdraw themselves from a class they are enrolled in through the same
|
A student may withdraw themselves from a class they are enrolled in through the same
|
||||||
group-class page: an active enrolment shows a **Withdraw** button.
|
group-class page: an active enrolment shows a **Withdraw** button. A guardian sees one
|
||||||
|
per enrolled child, labelled with the child's name, so the right seat is the one released.
|
||||||
`POST /enrollments/{id}/withdraw` marks the enrolment `cancelled` (freeing its
|
`POST /enrollments/{id}/withdraw` marks the enrolment `cancelled` (freeing its
|
||||||
capacity seat) and voids any still-pending payment. It **never issues an account
|
capacity seat) and voids any still-pending payment. It **never issues an account
|
||||||
credit** — a timely withdrawal is a clean exit, not a refund (credits are reserved
|
credit** — a timely withdrawal is a clean exit, not a refund (credits are reserved
|
||||||
|
|||||||
Reference in New Issue
Block a user