diff --git a/app/assets/sass/components/app-card.scss b/app/assets/sass/components/app-card.scss index 79ae51a..d88c122 100644 --- a/app/assets/sass/components/app-card.scss +++ b/app/assets/sass/components/app-card.scss @@ -4,6 +4,8 @@ border: 1px solid $nhsuk-border-colour; background-color: nhsuk-colour("white"); @include nhsuk-responsive-margin(4, bottom); + //NHS looking shadow + box-shadow: 0 2px 0 0 nhsuk-colour("grey-4"); &__header { display: flex; @@ -13,6 +15,7 @@ padding: nhsuk-spacing(3); background-color: nhsuk-colour("grey-5"); border-bottom: 1px solid $nhsuk-border-colour; + } &__title { @@ -22,45 +25,29 @@ } &__action { - @include nhsuk-font-size(14); + @include nhsuk-font-size(16); white-space: nowrap; } &__body { - padding: 0 nhsuk-spacing(3); - } - - &__table { - width: 100%; - margin: 0; - border-collapse: collapse; + padding: nhsuk-spacing(3); } - &__row { - border-bottom: 1px solid nhsuk-colour("grey-5"); + &__summary-list { + margin-bottom: 0; - &:last-child th, - &:last-child td { - border-bottom: 0; + .nhsuk-summary-list__key, + .nhsuk-summary-list__value, + .nhsuk-summary-list__actions { + @include nhsuk-font-size(16); + vertical-align: top; } - } - &__key, - &__value { - @include nhsuk-font-size(16); - padding: nhsuk-spacing(3) 0; - vertical-align: top; - } - - &__key { - width: 34%; - padding-right: nhsuk-spacing(3); - font-weight: $nhsuk-font-bold; - text-align: left; - } + .nhsuk-summary-list__row:last-child { + border-bottom: none; + } - &__value { - > :last-child { + .nhsuk-summary-list__value > :last-child { margin-bottom: 0; } } diff --git a/app/data/session-data-defaults.js b/app/data/session-data-defaults.js index 383863e..e8f6a31 100644 --- a/app/data/session-data-defaults.js +++ b/app/data/session-data-defaults.js @@ -54,7 +54,7 @@ const serviceDefinitions = [ { id: 'RSV:Adult', name: 'RSV Adult', vaccine: vaccineTypes.RSV, group: 'RSV', age: '18+', type: 'Adult' }, { id: 'RSV_COVID:12-17', name: 'RSV and COVID 12 to 17', vaccine: [vaccineTypes.RSV, vaccineTypes.COVID], group: 'RSV_AND_COVID', age: '12-17', type: 'Child' }, { id: 'RSV_COVID:18+', name: 'RSV and COVID 18+', vaccine: [vaccineTypes.RSV, vaccineTypes.COVID], group: 'RSV_AND_COVID', age: '18+', type: 'Adult' }, - { id: 'MENB:16-18', name: 'MenB 17 to 18', vaccine: vaccineTypes.MENB, group: 'MENB', age: '17-18', type: null } + { id: 'MENB:16-18', name: 'MenB Young People', vaccine: vaccineTypes.MENB, group: 'MENB', age: '17-18', type: null } ]; const SERVICES = Object.fromEntries( @@ -120,15 +120,10 @@ const base = { hideCard: true }, { - text: 'View appointments', - description: 'View and manage appointments for your site', + text: 'Clinics', + description: 'View appointments for your site', hrefTemplate: '/site/:id/availability/day' }, - { - text: 'Manage clinics', - description: 'Create clinics and review recently created clinic series', - hrefTemplate: '/site/:id/clinics' - }, { text: 'Change site details', description: 'Change site details and accessibility information', diff --git a/app/data/site1.config.js b/app/data/site1.config.js index a42963c..a3050a9 100644 --- a/app/data/site1.config.js +++ b/app/data/site1.config.js @@ -11,6 +11,10 @@ const ongoingSeriesEnd = today.plus({ months: 2 }).toISODate(); const futureSingleDate = today.plus({ days: 3 }).toISODate(); const childSessionIn2Days = today.plus({ days: 2 }).toISODate(); const childSessionIn3Days = today.plus({ days: 3 }).toISODate(); +const childSessionIn4Days = today.plus({ days: 4 }).toISODate(); +const childSessionIn5Days = today.plus({ days: 5 }).toISODate(); +const childSessionIn6Days = today.plus({ days: 6 }).toISODate(); +const childSessionIn7Days = today.plus({ days: 7 }).toISODate(); const nextTuesdayDate = today.plus({ days: (2 - today.weekday + 7) % 7 }).toISODate(); const SERVICE_IDS = { @@ -102,10 +106,30 @@ const clinics = [ label: 'Adult Flu and Covid clinics (extended)' }, { - date: childSessionIn3Days, + date: childSessionIn4Days, from: '10:00', until: '16:30', label: 'Adult Flu and Covid clinics (extended)' + }, + { + date: childSessionIn6Days, + services: [ + { + operation: 'add', + service: SERVICE_IDS.RSV_ADULT + } + ], + label: 'Adult Flu and Covid clinics (service add)' + }, + { + date: childSessionIn7Days, + services: [ + { + operation: 'remove', + service: SERVICE_IDS.FLU_65_PLUS + } + ], + label: 'Adult Flu and Covid clinics (service remove)' } ], closures: [ diff --git a/app/routes/base.js b/app/routes/base.js index 15911d5..e4554fd 100644 --- a/app/routes/base.js +++ b/app/routes/base.js @@ -15,46 +15,46 @@ function getToday() { return override_today || DateTime.now().toFormat('yyyy-MM-dd'); } -function asArray(value) { - if (Array.isArray(value)) return value; - if (value === undefined || value === null || value === '') return []; - return [value]; +function getRelativeDayLabel(dateISO, todayISO) { + const target = DateTime.fromISO(dateISO || '').startOf('day'); + const today = DateTime.fromISO(todayISO || '').startOf('day'); + if (!target.isValid || !today.isValid) return null; + + const diffDays = Math.round(target.diff(today, 'days').days); + if (diffDays === 0) return 'Today'; + if (diffDays === -1) return 'Yesterday'; + if (diffDays === 1) return 'Tomorrow'; + return null; } -function applyServiceOperations(baseServices = [], operations = []) { - let services = [...asArray(baseServices)]; - - for (const change of asArray(operations)) { - if (!change || typeof change !== 'object') continue; - - if (change.operation === 'replace') { - services = asArray(change.values); - continue; - } +function getRelativeWeekLabel(weekStartISO, todayISO) { + const targetWeek = DateTime.fromISO(weekStartISO || '').startOf('week'); + const thisWeek = DateTime.fromISO(todayISO || '').startOf('week'); + if (!targetWeek.isValid || !thisWeek.isValid) return null; - if (change.operation === 'add' && change.service) { - if (!services.includes(change.service)) { - services.push(change.service); - } - continue; - } - - if (change.operation === 'remove' && change.service) { - services = services.filter((service) => service !== change.service); - } - } - - return services; + const diffWeeks = Math.round(targetWeek.diff(thisWeek, 'weeks').weeks); + if (diffWeeks === 0) return 'This week'; + if (diffWeeks === -1) return 'Last week'; + if (diffWeeks === 1) return 'Next week'; + return null; } -function sortedStringArray(values = []) { - return asArray(values) - .map((value) => String(value)) - .sort((a, b) => a.localeCompare(b)); +function getRelativeMonthLabel(monthDateISO, todayISO) { + const targetMonth = DateTime.fromISO(monthDateISO || '').startOf('month'); + const thisMonth = DateTime.fromISO(todayISO || '').startOf('month'); + if (!targetMonth.isValid || !thisMonth.isValid) return null; + + const diffMonths = Math.round(targetMonth.diff(thisMonth, 'months').months); + if (diffMonths === 0) return 'This month'; + if (diffMonths === -1) return 'Last month'; + if (diffMonths === 1) return 'Next month'; + return null; } -function servicesEqual(left = [], right = []) { - return JSON.stringify(sortedStringArray(left)) === JSON.stringify(sortedStringArray(right)); +function asArray(value) { + if (Array.isArray(value)) return value; + if (value === undefined || value === null || value === '') return []; + return [value]; } function normalizeSessionType(type) { @@ -368,7 +368,6 @@ function clone(value) { function editFieldOptions(isSeries) { const options = [ - { value: 'name', text: 'Name' }, { value: 'date', text: isSeries ? 'Dates' : 'Date' }, ...(isSeries ? [{ value: 'days', text: 'Days' }] : []), { value: 'time', text: 'Time' }, @@ -408,14 +407,12 @@ function bookingCountText(count) { function resetEditOutcome(state) { state.bookingAction = null; state.affectedBookingIds = []; - state.childOverrideWarning = null; - state.postWarningPath = null; } function editFieldsForStep(step, isSeries) { switch (step) { case 'details': - return ['name', 'date']; + return ['date']; case 'days': return isSeries ? ['days'] : ['date']; case 'clinic-times': @@ -472,7 +469,6 @@ function editCaptionText(draft) { function editStepForField(field, isSeries) { switch (field) { - case 'name': case 'date': return 'details'; case 'days': @@ -501,7 +497,7 @@ function setEditTemplateData(res, data, state) { function updateDraftFromDetails(state, newSession = {}) { const editableFields = currentEditableFields(state); - if (editableFields.length === 0 || editableFields.includes('name')) { + if ((editableFields.length === 0 || editableFields.includes('name')) && newSession.name !== undefined) { state.draft.name = String(newSession.name || '').trim(); } @@ -731,11 +727,6 @@ function buildSummaryRowMap(draft, siteId, sessionId, data) { const closuresText = draft.closures?.length ? `${draft.closures.length} added` : 'None added'; return { - name: { - key: { text: 'Name' }, - value: { text: draft.name || 'Not provided' }, - actions: { items: [{ href: `/site/${siteId}/clinics/edit/${sessionId}/change/name`, text: 'Change', visuallyHiddenText: ' name' }] } - }, date: { key: { text: isSeries ? 'Dates' : 'Date' }, value: { text: dateText }, @@ -779,7 +770,6 @@ function buildSummaryRowMap(draft, siteId, sessionId, data) { function buildSummaryRowsForEdit(draft, siteId, sessionId, data) { const rowsByField = buildSummaryRowMap(draft, siteId, sessionId, data); const orderedFields = [ - 'name', 'date', 'days', 'time', @@ -796,8 +786,6 @@ function buildSummaryRowsForEdit(draft, siteId, sessionId, data) { function hasEditFieldChanged(original, draft, field) { switch (field) { - case 'name': - return String(original?.label || '') !== String(draft?.name || ''); case 'date': if (draft?.type === 'Clinic series') { return String(original?.startDate || '') !== String(draft?.startDate || '') @@ -835,28 +823,64 @@ function hasEditFieldChanged(original, draft, field) { } } -function buildChangedRowsForEdit(original, draft, state, siteId, sessionId, data) { - const rowsByField = buildSummaryRowMap(draft, siteId, sessionId, data); +function buildChangedFieldKeysForEdit(original, draft, state) { const editableFields = currentEditableFields(state); const candidateFields = editableFields.length > 0 ? editableFields - : ['name', 'date', 'days', 'time', 'capacity', 'duration', 'services', 'closures']; + : ['date', 'days', 'time', 'capacity', 'duration', 'services', 'closures']; - const changedRows = candidateFields - .filter((field) => rowsByField[field] && hasEditFieldChanged(original, draft, field)) - .map((field) => rowsByField[field]); + const changedFields = candidateFields + .filter((field) => hasEditFieldChanged(original, draft, field)); - if (changedRows.length > 0) { - return changedRows; + if (changedFields.length > 0) { + return changedFields; } if (editableFields.length > 0) { - return editableFields - .filter((field) => rowsByField[field]) - .map((field) => rowsByField[field]); + return editableFields; + } + + return candidateFields; +} + +function childSessionUnaffectedByField(childSession, field) { + switch (field) { + case 'time': + return Boolean(childSession?.from && childSession?.until); + case 'capacity': + return childSession?.capacity !== undefined && childSession?.capacity !== null; + case 'services': + return asArray(childSession?.services).length > 0; + default: + return false; } +} - return buildSummaryRowsForEdit(draft, siteId, sessionId, data); +function formatShortDate(dateISO) { + const dt = DateTime.fromISO(dateISO || ''); + return dt.isValid ? dt.toFormat('d MMM yyyy') : String(dateISO || ''); +} + +function buildUnaffectedChildClinicDates(model, changedFields, siteId) { + const childSessions = asArray(model?.childSessions).filter((childSession) => childSession?.date); + if (childSessions.length === 0) return []; + + const changed = asArray(changedFields).filter(Boolean); + if (changed.length === 0) return []; + + const merged = mergeDailyAvailability({}, String(siteId || ''), { [model.id]: model }); + + const unaffectedDates = childSessions + .filter((childSession) => changed.every((field) => childSessionUnaffectedByField(childSession, field))) + .map((childSession) => childSession.date) + .filter((dateISO) => { + const sessions = merged?.[dateISO]?.sessions || []; + return sessions.some((session) => String(session?.recurringId) === String(model?.id)); + }); + + return Array.from(new Set(unaffectedDates)) + .sort((a, b) => String(a).localeCompare(String(b))) + .map((dateISO) => formatShortDate(dateISO)); } function reviewBackPath(siteId, sessionId, state) { @@ -889,107 +913,8 @@ function prepareReviewAfterEdit(data, siteId, state) { ? `${editSummaryPath(siteId, state.sessionId)}/affected-bookings` : `${editSummaryPath(siteId, state.sessionId)}/check-answers`; - const warning = buildChildOverrideWarningContext( - state, - originalModel, - updatedModel - ); - - state.childOverrideWarning = warning; - state.postWarningPath = warning ? nextPath : null; - setEditState(data, state); - return warning - ? `${editSummaryPath(siteId, state.sessionId)}/child-clinic-overrides` - : nextPath; -} - -function buildChildOverrideWarningContext(state, originalModel, updatedModel) { - if (state?.draft?.type !== 'Clinic series') return null; - - const editableFields = currentEditableFields(state); - const modelChanges = { - time: String(originalModel?.from || '') !== String(updatedModel?.from || '') - || String(originalModel?.until || '') !== String(updatedModel?.until || ''), - capacity: (Number(originalModel?.capacity) || 1) !== (Number(updatedModel?.capacity) || 1), - services: !servicesEqual(originalModel?.services, updatedModel?.services) - }; - - let targetedFields = ['time', 'capacity', 'services'].filter((field) => editableFields.includes(field)); - if (targetedFields.length === 0) { - targetedFields = Object.entries(modelChanges) - .filter(([, changed]) => changed) - .map(([field]) => field); - } - - if (targetedFields.length === 0) return null; - - const changedFields = targetedFields.filter((field) => modelChanges[field]); - - if (changedFields.length === 0) return null; - - const parentServices = asArray(updatedModel?.services); - const parentLabel = String(updatedModel?.label || '').trim() || 'Clinic series'; - const rows = []; - - for (const child of asArray(updatedModel?.childSessions)) { - if (!child?.date) continue; - - const childHasPairedTime = Boolean(child?.from && child?.until); - const resolvedFrom = childHasPairedTime ? child.from : updatedModel?.from; - const resolvedUntil = childHasPairedTime ? child.until : updatedModel?.until; - const hasTimeOverride = childHasPairedTime - && (String(resolvedFrom) !== String(updatedModel?.from || '') - || String(resolvedUntil) !== String(updatedModel?.until || '')); - - const hasCapacityOverride = child?.capacity !== undefined - && child?.capacity !== null; - - const effectiveServices = child?.services - ? applyServiceOperations(parentServices, child.services) - : [...parentServices]; - const hasServicesOverride = asArray(child?.services).length > 0; - - const include = ( - (changedFields.includes('time') && hasTimeOverride) - || (changedFields.includes('capacity') && hasCapacityOverride) - || (changedFields.includes('services') && hasServicesOverride) - ); - - if (!include) continue; - - rows.push({ - dateISO: child.date, - parentLabel, - childLabel: child?.label || '', - from: resolvedFrom, - until: resolvedUntil, - capacity: Number(child?.capacity ?? updatedModel?.capacity) || 1, - effectiveServiceIds: asArray(effectiveServices) - }); - } - - if (rows.length === 0) return null; - - rows.sort((a, b) => String(a.dateISO).localeCompare(String(b.dateISO))); - - const originalParentServiceIds = sortedStringArray(originalModel?.services); - const updatedParentServiceIds = sortedStringArray(updatedModel?.services); - const parentServicesAddedIds = updatedParentServiceIds - .filter((serviceId) => !originalParentServiceIds.includes(serviceId)); - const parentServicesRemovedIds = originalParentServiceIds - .filter((serviceId) => !updatedParentServiceIds.includes(serviceId)); - - return { - count: rows.length, - hasTimeChange: changedFields.includes('time'), - hasCapacityChange: changedFields.includes('capacity'), - hasServicesChange: changedFields.includes('services'), - parentServicesAddedIds, - parentServicesRemovedIds, - rows, - changedFields - }; + return nextPath; } function calculateAffectedBookings(originalModel, updatedModel, siteBookings) { @@ -1211,7 +1136,7 @@ function slotMatchesSession(slot, session) { && (!slot?.recurringSessionId || !session?.recurringId || String(slot.recurringSessionId) === String(session.recurringId)); } -function buildWeekAvailabilitySummary(week, dailyAvailability, slotsByDate, servicesById, siteBookings, siteId, today, recurringSessionsById = {}) { +function buildWeekAvailabilitySummary(week, dailyAvailability, slotsByDate, servicesById, siteBookings, siteId, today, recurringSessionsById = {}, backHref = null) { return week.map((day) => { const sessions = sortSessionsForAvailability(dailyAvailability?.[day]?.sessions); const dateSlots = asArray(slotsByDate?.[day]); @@ -1222,6 +1147,10 @@ function buildWeekAvailabilitySummary(week, dailyAvailability, slotsByDate, serv const totalSlots = sessionSlots.length; const resolvedLabel = session.label || recurringSessionsById?.[session?.recurringId]?.label || ''; + const changeHrefBase = day < today || !session?.recurringId + ? null + : `/site/${siteId}/change/session/${session.id}`; + return { id: session.id, label: resolvedLabel, @@ -1238,7 +1167,9 @@ function buildWeekAvailabilitySummary(week, dailyAvailability, slotsByDate, serv })), bookedTotal, unbookedTotal: Math.max(0, totalSlots - bookedTotal), - actionHref: day < today || !session?.recurringId ? null : `/site/${siteId}/change/session/${session.id}` + actionHref: changeHrefBase + ? `${changeHrefBase}${backHref ? `?back=${encodeURIComponent(backHref)}` : ''}` + : null }; }); @@ -1299,7 +1230,8 @@ function buildMonthAvailabilitySummary(weekRanges, dailyAvailability, slotsByDat siteBookings, siteId, today, - recurringSessionsById + recurringSessionsById, + null ); const services = new Map(); @@ -1822,41 +1754,6 @@ router.all('/site/:id/clinics/edit/:sessionId/affected-bookings', (req, res) => }); }); -router.all('/site/:id/clinics/edit/:sessionId/child-clinic-overrides', (req, res) => { - const data = req.session.data; - const state = ensureEditStateForSession(data, req.site_id, req.params.sessionId); - if (!state) { - return res.redirect(`/site/${req.site_id}/clinics`); - } - - const warning = state.childOverrideWarning; - const nextPath = state.postWarningPath - || (asArray(state.affectedBookingIds).length > 0 - ? `${editSummaryPath(req.site_id, req.params.sessionId)}/affected-bookings` - : `${editSummaryPath(req.site_id, req.params.sessionId)}/check-answers`); - - if (!warning || !asArray(warning.rows).length) { - return res.redirect(nextPath); - } - - if (req.method === 'POST') { - return res.redirect(nextPath); - } - - const step = state.currentEditStep || editStepForField(state.currentEditField, state?.draft?.type === 'Clinic series'); - const backHref = step - ? editStepPath(req.site_id, req.params.sessionId, step) - : editSummaryPath(req.site_id, req.params.sessionId); - - return res.render('site/clinics/edit/child-clinic-overrides', { - sessionId: req.params.sessionId, - backHref, - formAction: `${editSummaryPath(req.site_id, req.params.sessionId)}/child-clinic-overrides`, - warning: warning, - rows: warning.rows - }); -}); - router.all('/site/:id/clinics/edit/:sessionId/check-answers', (req, res) => { const data = req.session.data; const state = ensureEditStateForSession(data, req.site_id, req.params.sessionId); @@ -1866,6 +1763,10 @@ router.all('/site/:id/clinics/edit/:sessionId/check-answers', (req, res) => { if (req.method === 'POST') { const updatedModel = draftToModel(state.draft); + const changedFields = buildChangedFieldKeysForEdit(state.original, state.draft, state); + const unaffectedChildClinics = state.draft.type === 'Clinic series' + ? buildUnaffectedChildClinicDates(updatedModel, changedFields, req.site_id) + : []; persistRecurringSession(data, req.site_id, updatedModel); const siteBookings = data?.bookings?.[req.site_id] || {}; @@ -1881,7 +1782,8 @@ router.all('/site/:id/clinics/edit/:sessionId/check-answers', (req, res) => { siteId: req.site_id, sessionId: req.params.sessionId, isSeries: state.draft.type === 'Clinic series', - cancelledBookingsSummary + cancelledBookingsSummary, + unaffectedChildClinics }); applyAffectedBookingAction(siteBookings, state.affectedBookingIds, state.bookingAction); @@ -1892,7 +1794,20 @@ router.all('/site/:id/clinics/edit/:sessionId/check-answers', (req, res) => { return res.render('site/clinics/edit/check-answers', { sessionId: req.params.sessionId, isSeries: state.draft.type === 'Clinic series', - rows: buildChangedRowsForEdit(state.original, state.draft, state, req.site_id, req.params.sessionId, data), + rowFields: buildChangedFieldKeysForEdit(state.original, state.draft, state), + draft: state.draft, + previous: { + startDate: state.original?.startDate, + endDate: state.original?.endDate, + days: asArray(state.original?.recurrencePattern?.byDay), + from: state.original?.from, + until: state.original?.until, + capacity: String(Number(state.original?.capacity) || 1), + duration: String(Number(state.original?.slotLength) || 10), + services: asArray(state.original?.services), + closuresCount: asArray(state.original?.closures).length + }, + checkAnswersMode: 'clinic-edit', affectedCount: asArray(state.affectedBookingIds).length, bookingAction: state.bookingAction, backHref: reviewBackPath(req.site_id, req.params.sessionId, state) @@ -1928,7 +1843,8 @@ router.get('/site/:id/clinics/edit/:sessionId/success', (req, res) => { return res.render('site/clinics/edit/success', { sessionId: req.params.sessionId, - cancelSummary + cancelSummary, + unaffectedChildClinics: matchingSuccessState?.unaffectedChildClinics || [] }); }); @@ -2273,13 +2189,91 @@ router.get('/site/:id/debug/recurring-expansion', (req, res) => { // VIEW AVAILABILITY // ----------------------------------------------------------------------------- router.get('/site/:id/availability/day', (req, res) => { + const data = req.session.data; + const site_id = req.site_id; const date = req.query.date || getToday(); + const today = getToday(); + const tomorrow = DateTime.fromISO(date).plus({ days: 1 }).toISODate(); + const yesterday = DateTime.fromISO(date).minus({ days: 1 }).toISODate(); + const daySummary = buildWeekAvailabilitySummary( + [date], + res.locals.dailyAvailability, + res.locals.slots, + data.services || {}, + data?.bookings?.[site_id] || {}, + site_id, + today, + data?.recurring_sessions?.[site_id] || {}, + `/site/${site_id}/availability/day?date=${date}` + )[0] || { + date, + sessions: [], + totalAppointments: 0, + bookedAppointments: 0, + unbookedAppointments: 0, + isToday: date === today, + isPast: date < today, + dayViewHref: `/site/${site_id}/availability/day?date=${date}` + }; + + if ((daySummary.sessions || []).length === 0 && (res.locals.dailyAvailability?.[date]?.sessions || []).length > 0) { + const dateSlots = asArray(res.locals.slots?.[date]); + const sessions = sortSessionsForAvailability(res.locals.dailyAvailability?.[date]?.sessions); + const siteBookings = data?.bookings?.[site_id] || {}; + const servicesById = data.services || {}; + + const fallbackSessions = sessions.map((session) => { + const sessionSlots = dateSlots.filter((slot) => ( + (slot?.sessionId && session?.id && String(slot.sessionId) === String(session.id)) + || ( + !slot?.sessionId + && slot?.group?.start === session?.from + && slot?.group?.end === session?.until + && (!slot?.recurringSessionId || !session?.recurringId || String(slot.recurringSessionId) === String(session.recurringId)) + ) + )); + + const bookedTotal = sessionSlots.filter((slot) => slot?.booking_status === 'scheduled').length; + const totalSlots = sessionSlots.length; + const resolvedLabel = session.label || data?.recurring_sessions?.[site_id]?.[session?.recurringId]?.label || ''; + + return { + id: session.id, + label: resolvedLabel, + from: session.from, + until: session.until, + services: asArray(session.services).map((serviceId) => ({ + id: serviceId, + name: servicesById?.[serviceId]?.name || serviceId, + bookedCount: sessionSlots.filter((slot) => ( + slot?.booking_status === 'scheduled' + && slot?.booking_id + && siteBookings?.[slot.booking_id]?.service === serviceId + )).length + })), + bookedTotal, + unbookedTotal: Math.max(0, totalSlots - bookedTotal), + actionHref: date < today || !session?.recurringId + ? null + : `/site/${site_id}/change/session/${session.id}?back=${encodeURIComponent(`/site/${site_id}/availability/day?date=${date}`)}` + }; + }); + + daySummary.sessions = fallbackSessions; + daySummary.totalAppointments = fallbackSessions.reduce((sum, session) => sum + session.bookedTotal + session.unbookedTotal, 0); + daySummary.bookedAppointments = fallbackSessions.reduce((sum, session) => sum + session.bookedTotal, 0); + daySummary.unbookedAppointments = Math.max(0, daySummary.totalAppointments - daySummary.bookedAppointments); + } res.render('site/availability/day', { date, - today: getToday(), - tomorrow: DateTime.fromISO(date).plus({ days: 1 }).toISODate(), - yesterday: DateTime.fromISO(date).minus({ days: 1 }).toISODate() + today, + tomorrow, + yesterday, + daySummary, + dayHeading: getRelativeDayLabel(date, today), + previousDayLabel: getRelativeDayLabel(yesterday, today), + nextDayLabel: getRelativeDayLabel(tomorrow, today) }); }); @@ -2315,7 +2309,8 @@ router.get('/site/:id/availability/week', (req, res) => { data?.bookings?.[site_id] || {}, site_id, today, - data?.recurring_sessions?.[site_id] || {} + data?.recurring_sessions?.[site_id] || {}, + `/site/${site_id}/availability/week?date=${startFromDate}` ); res.render('site/availability/week', { @@ -2324,7 +2319,10 @@ router.get('/site/:id/availability/week', (req, res) => { week, weekDays, previousWeek, - nextWeek + nextWeek, + weekHeading: getRelativeWeekLabel(week[0], today), + previousWeekLabel: getRelativeWeekLabel(previousWeek.start, today), + nextWeekLabel: getRelativeWeekLabel(nextWeek.start, today) }); }); @@ -2349,7 +2347,10 @@ router.get('/site/:id/availability/month', (req, res) => { currentDate: monthData.currentDate, previousMonthDate: monthData.previousMonthDate, nextMonthDate: monthData.nextMonthDate, - monthWeeks + monthWeeks, + monthHeading: getRelativeMonthLabel(monthData.currentDate, today), + previousMonthLabel: getRelativeMonthLabel(monthData.previousMonthDate, today), + nextMonthLabel: getRelativeMonthLabel(monthData.nextMonthDate, today) }); }); diff --git a/app/routes/change-session.js b/app/routes/change-session.js index 6a1c027..0e23807 100644 --- a/app/routes/change-session.js +++ b/app/routes/change-session.js @@ -107,6 +107,14 @@ function changeStepPath(siteId, itemId, step) { return `${changeSummaryPath(siteId, itemId)}/${step}`; } +function normalizeBackHref(back) { + if (typeof back !== 'string') return null; + const value = back.trim(); + if (!value.startsWith('/')) return null; + if (value.startsWith('//')) return null; + return value; +} + function changeFieldToStep(field) { switch (field) { case 'name': @@ -365,20 +373,16 @@ function hasFieldChanged(state, field) { } } -function buildChangedRowsForCheckAnswers(state, siteId, itemId, data) { - const rowMap = buildSummaryRowMap(state.draft, siteId, itemId, data); +function buildChangedFieldKeysForCheckAnswers(state) { const editableFields = currentEditableFields(state); - const rows = editableFields - .filter((field) => rowMap[field] && hasFieldChanged(state, field)) - .map((field) => rowMap[field]); + const changedFields = editableFields + .filter((field) => hasFieldChanged(state, field)); - if (rows.length > 0) { - return rows; + if (changedFields.length > 0) { + return changedFields; } - return editableFields - .filter((field) => rowMap[field]) - .map((field) => rowMap[field]); + return editableFields; } function normalizeIsoMinute(datetimeISO) { @@ -549,9 +553,14 @@ function ensureChangeStateForSession(req, res) { const data = req.session.data; const siteId = req.site_id; const itemId = req.params.itemId; + const requestedBackHref = normalizeBackHref(req.query?.back); const existing = getChangeState(data); if (existing && existing.siteId === siteId && existing.itemId === itemId) { + if (requestedBackHref) { + existing.returnTo = requestedBackHref; + setChangeState(data, existing); + } return existing; } @@ -570,6 +579,7 @@ function ensureChangeStateForSession(req, res) { originalParent: clone(parentModel), originalChild: clone(originalChild), draft: buildChildDraft(parentModel, target.session, target.date), + returnTo: requestedBackHref || null, currentEditStep: null, bookingAction: null, affectedBookingIds: [] @@ -601,7 +611,7 @@ router.get('/site/:id/change/:type/:itemId', (req, res) => { draft: state.draft, date: state.date, displayDate: formatDisplayDate(state.date), - weekHref: weekViewHref(req.site_id, state.date) + weekHref: state.returnTo || weekViewHref(req.site_id, state.date) }); }); @@ -800,7 +810,16 @@ router.all('/site/:id/change/session/:itemId/check-answers', (req, res) => { pageName: 'Check your answers', sessionId: req.params.itemId, isSeries: false, - rows: buildChangedRowsForCheckAnswers(state, req.site_id, req.params.itemId, data), + rowFields: buildChangedFieldKeysForCheckAnswers(state), + draft: state.draft, + previous: { + name: state.draft.parentName, + from: state.draft.parentFrom, + until: state.draft.parentUntil, + capacity: String(state.draft.parentCapacity), + services: asArray(state.draft.parentServices) + }, + checkAnswersMode: 'session-change', affectedCount: asArray(state.affectedBookingIds).length, bookingAction: state.bookingAction, formAction: `${changeSummaryPath(req.site_id, req.params.itemId)}/check-answers`, diff --git a/app/views/components/action-control-bar.njk b/app/views/components/action-control-bar.njk new file mode 100644 index 0000000..107aae4 --- /dev/null +++ b/app/views/components/action-control-bar.njk @@ -0,0 +1,29 @@ +{% from 'nhsuk/components/button/macro.njk' import button %} + +{% macro appActionControlBar(params) %} + {% set siteId = params.siteId %} + {% set createClinicHref = params.createClinicHref or ('/site/' ~ siteId ~ '/clinics/type-of-clinc?new=1') %} + {% set cancelDateRangeHref = params.cancelDateRangeHref or ('/site/' ~ siteId ~ '/cancel-availability/dates') %} + +
+{% endmacro %} \ No newline at end of file diff --git a/app/views/components/app-card.njk b/app/views/components/app-card.njk index 683bc8c..9576df4 100644 --- a/app/views/components/app-card.njk +++ b/app/views/components/app-card.njk @@ -1,4 +1,5 @@ {%- from "nhsuk/macros/attributes.njk" import nhsukAttributes -%} +{%- from "nhsuk/components/summary-list/macro.njk" import summaryList -%} {%- macro appCard(params) -%}| - {%- if item.keyHtml %} - {{ item.keyHtml | safe }} - {%- else %} - {{ item.key }} - {%- endif %} - | -- {%- if item.html %} - {{ item.html | safe }} - {%- else %} - {{ item.value }} - {%- endif %} - | -
|---|
| {{ booking.datetime | formatDate('h:mm a') | lower }} |
@@ -125,10 +109,67 @@ {{ opts.title }}{% endmacro %} + {% macro clinicsRunningToday() %} + {% set sessions = daySummary.sessions or [] %} + + {% if sessions | length %} +
No clinics running today. + {% endif %} + {% endmacro %} + + {% set daySlots = slots[date] or [] %} + {% set scheduledCount = 0 %} + {% set cancelledCount = 0 %} + {% set clinicsRunningCount = (daySummary.sessions or []) | length %} + {% for slot in daySlots %} + {% if slot.booking_status == 'scheduled' or slot.booking_status == 'orphaned' %} + {% set scheduledCount = scheduledCount + 1 %} + {% elif slot.booking_status == 'cancelled' %} + {% set cancelledCount = cancelledCount + 1 %} + {% endif %} + {% endfor %} + {{ tabs({ items: [ { - label: "Scheduled", + label: "Scheduled (" ~ scheduledCount ~ ")", id: "scheduled", panel: { html: appointmentForToday({ @@ -139,7 +180,7 @@{{ opts.title }}} }, { - label: "Cancelled", + label: "Cancelled (" ~ cancelledCount ~ ")", id: "cancelled", panel: { html: appointmentForToday({ @@ -147,6 +188,13 @@{{ opts.title }}title: 'Cancelled appointments' }) } + }, + { + label: "Clinics running today (" ~ clinicsRunningCount ~ ")", + id: "clinics-running-today", + panel: { + html: clinicsRunningToday() + } } ] }) }} diff --git a/app/views/site/availability/month.html b/app/views/site/availability/month.html index 6030c7b..7501d00 100644 --- a/app/views/site/availability/month.html +++ b/app/views/site/availability/month.html @@ -1,8 +1,9 @@ {% extends '../../layouts/layout.html' %} {% from '../../components/secondary-navigation.njk' import appSecondaryNavigation %} +{% from '../../components/action-control-bar.njk' import appActionControlBar %} {% from 'nhsuk/components/card/macro.njk' import card %} -{% set pageName = currentDate | formatDate("LLLL yyyy") %} +{% set pageName = monthHeading or (currentDate | formatDate("LLLL yyyy")) %} {% block content %}
@@ -24,27 +25,23 @@
current: true
},
{
- text: 'Clinics',
- href: '/site/' ~ site_id ~ '/clinics',
- primary: true
- } if features.allAvailability
+ text: 'Clinics list',
+ href: '/site/' ~ site_id ~ '/clinics'
+ }
]
})
}}
- {% if features.cancelDateRange %}
- {% include "../../includes/addCancelButtons.njk" %}
- {% endif %}
-
- {{ pageName }}+{{ pageName }}+ {{ appActionControlBar({ siteId: site_id }) }} {{ pagination({ previous: { - labelText: previousMonthDate | formatDate('LLLL yyyy'), + labelText: previousMonthLabel or (previousMonthDate | formatDate('LLLL yyyy')), href: "/site/" ~ site_id ~ "/availability/month?date=" ~ previousMonthDate }, next: { - labelText: nextMonthDate | formatDate('LLLL yyyy'), + labelText: nextMonthLabel or (nextMonthDate | formatDate('LLLL yyyy')), href: "/site/" ~ site_id ~ "/availability/month?date=" ~ nextMonthDate } }) }} diff --git a/app/views/site/availability/week.html b/app/views/site/availability/week.html index 71e6460..f5417f3 100644 --- a/app/views/site/availability/week.html +++ b/app/views/site/availability/week.html @@ -1,8 +1,9 @@ {% extends '../../layouts/layout.html' %} {% from '../../components/secondary-navigation.njk' import appSecondaryNavigation %} +{% from '../../components/action-control-bar.njk' import appActionControlBar %} {% from 'nhsuk/components/card/macro.njk' import card %} -{% set pageName = week[0] | formatDate('d LLLL') ~ ' to ' ~ week[6] | formatDate('d LLLL yyyy') %} +{% set pageName = weekHeading or (week[0] | formatDate('d LLLL') ~ ' to ' ~ week[6] | formatDate('d LLLL yyyy')) %} {% block content %}
@@ -24,27 +25,24 @@
href: '/site/' ~ site_id ~ '/availability/month'
},
{
- text: 'Clinics',
- href: '/site/' ~ site_id ~ '/clinics',
- primary: true
- } if features.allAvailability
+ text: 'Clinics list',
+ href: '/site/' ~ site_id ~ '/clinics'
+ }
]
})
}}
+ {{ pageName }}+ {{ appActionControlBar({ siteId: site_id }) }} - {% if features.cancelDateRange %} - {% include "../../includes/addCancelButtons.njk" %} - {% endif %} - -{{ pageName }}+ {{ pagination({ previous: { - labelText: previousWeek.start | formatDate('d') ~ " to " ~ previousWeek.end | formatDate('d LLL'), + labelText: previousWeekLabel or (previousWeek.start | formatDate('d') ~ " to " ~ previousWeek.end | formatDate('d LLL')), href: "/site/" ~ site_id ~ "/availability/week?date=" ~ previousWeek.start }, next: { - labelText: nextWeek.start | formatDate('d') ~ " to " ~ nextWeek.end | formatDate('d LLL'), + labelText: nextWeekLabel or (nextWeek.start | formatDate('d') ~ " to " ~ nextWeek.end | formatDate('d LLL')), href: "/site/" ~ site_id ~ "/availability/week?date=" ~ nextWeek.start } }) }} @@ -62,7 +60,7 @@{{ pageName }}
|