diff --git a/app/controllers/download.js b/app/controllers/download.js index 2c9accbe9..4b840b2a7 100644 --- a/app/controllers/download.js +++ b/app/controllers/download.js @@ -1,8 +1,66 @@ import programmesData from '../datasets/programmes.js' import { AcademicYear, DownloadFormat, ProgrammeType } from '../enums.js' import { Download, Programme, Team } from '../models.js' +import { getDateValueDifference } from '../utils/date.js' +import { getResults, getPagination } from '../utils/pagination.js' export const downloadController = { + read(request, response, next, download_id) { + response.locals.download = Download.findOne( + download_id, + request.session.data + ) + + next() + }, + + readAll(request, response, next) { + response.locals.downloads = Download.findAll(request.session.data) + + next() + }, + + list(request, response) { + const { type } = request.query + const { data } = request.session + const { downloads } = response.locals + + let results = downloads + + // Filter by type + if (type && type !== 'none') { + results = results.filter((download) => download.type === type) + } + + // Sort + results = results.sort((a, b) => + getDateValueDifference(b.createdAt, a.createdAt) + ) + + // Results + response.locals.results = getResults(results, request.query, 40) + response.locals.pages = getPagination(results, request.query, 40) + + // Clean up session data + delete data.type + + response.render(`download/list`) + }, + + filterList(request, response) { + const params = new URLSearchParams() + + // Radios and text inputs + for (const key of ['type']) { + const value = request.body[key] + if (value) { + params.append(key, String(value)) + } + } + + response.redirect(`/downloads?${params}`) + }, + form(request, response) { const { data } = request.session @@ -44,24 +102,46 @@ export const downloadController = { create(request, response) { const { account } = request.app.locals - const { data } = request.session + const { programmeType, session_id, type } = request.body.download + const { data, referrer } = request.session + const { __ } = response.locals - const { type } = request.body.download - const programme_id = programmesData[type].id - const programme = Programme.findOne(programme_id, data) - - const createdDownload = Download.create( - { - ...request.body.download, - programme_id, - vaccination_uuids: programme.vaccinations.map(({ uuid }) => uuid), - createdBy_uid: account.uid - }, - data - ) + let createdDownload + if (type) { + createdDownload = Download.create( + { + createdBy_uid: account.uid, + session_id, + type + }, + data + ) + } else { + const programme_id = programmesData[programmeType].id + const programme = Programme.findOne(programme_id, data) + + createdDownload = Download.create( + { + ...request.body.download, + programme_id, + vaccination_uuids: programme.vaccinations.map(({ uuid }) => uuid), + createdBy_uid: account.uid + }, + data + ) + } const download = new Download(createdDownload, data) + request.flash('success', __(`download.new.success`, { download })) + + response.redirect(referrer) + }, + + download(request, response) { + const { data } = request.session + const { download } = response.locals + // Generate and return file const { buffer, fileName, mimetype } = download.createFile(data) diff --git a/app/controllers/interchange.js b/app/controllers/interchange.js new file mode 100644 index 000000000..afe624bf1 --- /dev/null +++ b/app/controllers/interchange.js @@ -0,0 +1,5 @@ +export const interchangeController = { + list(request, response) { + response.redirect('/uploads') + } +} diff --git a/app/controllers/session.js b/app/controllers/session.js index ab4a01116..65e92a0f2 100644 --- a/app/controllers/session.js +++ b/app/controllers/session.js @@ -636,18 +636,6 @@ export const sessionController = { response.redirect(paths.next) }, - downloadFile(request, response) { - const { data } = request.session - const { session } = response.locals - - const { buffer, fileName, mimetype } = session.createFile(data) - - response.header('Content-Type', mimetype) - response.header('Content-disposition', `attachment; filename=${fileName}`) - - response.end(buffer) - }, - giveInstructions(request, response) { const { account } = request.app.locals const { __, session } = response.locals diff --git a/app/data.js b/app/data.js index 8416831e8..267bca655 100644 --- a/app/data.js +++ b/app/data.js @@ -33,6 +33,7 @@ const data = { clinics, counts: {}, defaultBatches: {}, + downloads: {}, features: {}, instructions, moves, diff --git a/app/enums.js b/app/enums.js index fadb39b68..6d02d86cd 100644 --- a/app/enums.js +++ b/app/enums.js @@ -105,6 +105,25 @@ export const DownloadFormat = { SystmOne: 'XLSX for SystmOne (TPP)' } +/** + * @readonly + * @enum {string} + */ +export const DownloadStatus = { + Processing: 'Processing', + Ready: 'Ready' +} + +/** + * @readonly + * @enum {string} + */ +export const DownloadType = { + Report: 'Vaccination records', + Moves: 'School moves', + Session: 'Offline session' +} + /** * @readonly * @enum {string} diff --git a/app/locales/en.js b/app/locales/en.js index a2b30b97b..8bb4b230c 100644 --- a/app/locales/en.js +++ b/app/locales/en.js @@ -712,9 +712,27 @@ export const en = { } }, download: { + label: 'Exports', + list: { + label: 'Exports', + title: 'Exports' + }, + search: { + label: 'Find export' + }, + results: + '{count, plural, =0 {No exports matching your search criteria were found} one {Showing {from} to {to} of {count} export} other {Showing {from} to {to} of {count} exports}}', new: { label: 'Download vaccination report', - confirm: 'Download vaccination data' + confirm: 'Download vaccination data', + success: + 'It will take some time to prepare the records. You’ll be able to download them soon in [Exports](/downloads)' + }, + createdAt: { + label: 'Requested at' + }, + createdBy: { + label: 'Requested by' }, startAt: { label: 'Get vaccination data from' @@ -726,10 +744,16 @@ export const en = { title: 'Select file format', label: 'File format' }, + status: { + label: 'Status' + }, teams: { title: 'Select providers', label: 'Providers' }, + type: { + label: 'Type' + }, vaccinations: { label: 'Records' } @@ -860,6 +884,14 @@ export const en = { title: 'Home' } }, + interchange: { + label: 'Manage data', + list: { + label: 'Manage data', + title: 'Manage data', + description: 'Import and export child and vaccination records' + } + }, notice: { label: 'Notice', list: { @@ -1918,7 +1950,7 @@ export const en = { title: 'Record offline', description: 'If the internet connection at the vaccination session is unreliable, you can record offline using a spreadsheet.\n\nYou need to download the blank spreadsheet ahead of the session while you still have internet access.\n\nTo upload a completed spreadsheet, go to the ‘Vaccinations’ area. You also need an internet connection to upload the spreadsheet.', - confirm: 'Download spreadsheet', + confirm: 'Download offline spreadsheet', vaccinator: { label: 'Vaccinator', firstName: 'First name', @@ -2142,7 +2174,7 @@ export const en = { 'Use this page to upload and import child, class list and vaccination records.\n\nUpload times can vary. Refresh the page to see the latest status.' }, search: { - label: 'Find upload' + label: 'Find import' }, results: '{count, plural, =0 {No imports matching your search criteria were found} one {Showing {from} to {to} of {count} import} other {Showing {from} to {to} of {count} imports}}', diff --git a/app/models/download.js b/app/models/download.js index ea9d1e94b..35bb17260 100644 --- a/app/models/download.js +++ b/app/models/download.js @@ -1,15 +1,17 @@ import { fakerEN_GB as faker } from '@faker-js/faker' +import { addSeconds } from 'date-fns' import xlsx from 'json-as-xlsx' -import { DownloadFormat } from '../enums.js' -import { Programme, Team, Vaccination } from '../models.js' +import { DownloadFormat, DownloadStatus, DownloadType } from '../enums.js' +import { Programme, Session, Team, Vaccination, User } from '../models.js' import { convertIsoDateToObject, convertObjectToIsoDate, formatDate, today } from '../utils/date.js' -import { formatList } from '../utils/string.js' +import { getDownloadStatus } from '../utils/status.js' +import { formatList, formatProgress, formatTag } from '../utils/string.js' /** * @class Vaccination report download @@ -25,6 +27,7 @@ import { formatList } from '../utils/string.js' * @property {Date} [endAt] - Date to end report * @property {object} [endAt_] - Date to end report (from `dateInput`) * @property {DownloadFormat} [format] - Downloaded file format + * @property {DownloadType} [type] - Download type * @property {string} [programme_id] - Programme ID * @property {Array} [team_ids] - Team IDs * @property {Array} [vaccination_uuids] - Vaccination UUIDs @@ -41,11 +44,28 @@ export class Download { this.endAt = options?.endAt && new Date(options.endAt) this.endAt_ = options?.endAt_ this.format = options?.format || DownloadFormat.CSV + this.type = options?.type || DownloadType.Report this.programme_id = options?.programme_id + this.session_id = options?.session_id this.team_ids = options?.team_ids this.vaccination_uuids = options?.vaccination_uuids || [] } + /** + * Get user who created upload + * + * @returns {User} User + */ + get createdBy() { + try { + if (this.createdBy_uid) { + return User.findOne(this.createdBy_uid, this.context) + } + } catch (error) { + console.error('Upload.createdBy', error.message) + } + } + /** * Get start date for `dateInput` * @@ -92,11 +112,18 @@ export class Download { * @returns {string} Name */ get name() { - if (this.programme) { - return this.programme.name + switch (true) { + case this.type === DownloadType.Moves: + return `School moves (${this.formatted.createdAt})` + case this.type === DownloadType.Report: + return `${this.programme.name} vaccination records` + case this.type === DownloadType.Session && this.session_id: + return `Offline spreadsheet for ${this.session.name}` + case this.type === DownloadType.Session: + return `Offline spreadsheet for no known school (including home-schooled children)` + default: + return 'Download' } - - return 'Download' } /** @@ -115,6 +142,19 @@ export class Download { } } + /** + * Get session + * + * @returns {Session|undefined} Session + */ + get session() { + try { + return Session.findOne(this.session_id, this.context) + } catch (error) { + console.error('Download.session', error.message) + } + } + /** * Get teams * @@ -284,6 +324,23 @@ export class Download { return [headers.join(','), ...rows].join('\n') } + get progress() { + return 50 + } + + get status() { + if (this.createdAt) { + const completedAt = addSeconds(this.createdAt, 30) + const now = today() + + if (completedAt < now) { + return DownloadStatus.Ready + } + } + + return DownloadStatus.Processing + } + /** * Get formatted values * @@ -291,12 +348,25 @@ export class Download { */ get formatted() { return { + createdAt: formatDate(this.createdAt, { + day: 'numeric', + month: 'long', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }), + createdBy: this.createdBy?.fullName, startAt: this.startAt ? formatDate(this.startAt, { dateStyle: 'long' }) : 'Earliest recorded vaccination', endAt: this.endAt ? formatDate(this.endAt, { dateStyle: 'long' }) : 'Latest recorded vaccination', + status: + this.status === DownloadStatus.Processing + ? formatProgress(this.progress) + : formatTag(getDownloadStatus(this.status)), teams: this.teams.length > 0 ? formatList(this.teams.map(({ name }) => name)) @@ -320,7 +390,20 @@ export class Download { * @returns {string} URI */ get uri() { - return `/reports/${this.programme_id}/download/${this.id}` + return `/downloads/${this.id}` + } + + /** + * Find all + * + * @param {object} context - Context + * @returns {Array|undefined} Downloads + * @static + */ + static findAll(context) { + return Object.values(context.downloads).map( + (upload) => new Download(upload, context) + ) } /** diff --git a/app/models/session.js b/app/models/session.js index 5202e7edd..e2c01c260 100644 --- a/app/models/session.js +++ b/app/models/session.js @@ -826,23 +826,6 @@ export class Session { return createdSession } - /** - * Create file - * - * @param {object} context - Context - * @returns {object} File buffer, name and mime type - * @todo Create download using Mavis offline XLSX schema - */ - createFile(context) { - const { name } = new Session(this, context) - - return { - buffer: Buffer.from(''), - fileName: `${name}.csv`, - mimetype: 'text/csv' - } - } - /** * Update * diff --git a/app/routes.js b/app/routes.js index d6c706e8e..4e5a03c1a 100644 --- a/app/routes.js +++ b/app/routes.js @@ -19,6 +19,7 @@ import { consentRoutes } from './routes/consent.js' import { defaultBatchRoutes } from './routes/default-batch.js' import { downloadRoutes } from './routes/download.js' import { homeRoutes } from './routes/home.js' +import { interchangeRoutes } from './routes/interchange.js' import { moveRoutes } from './routes/move.js' import { noticeRoutes } from './routes/notice.js' import { parentRoutes } from './routes/parent.js' @@ -47,7 +48,9 @@ router.use('/', homeRoutes) router.use('/account', accountRoutes) router.use('/activity', activityRoutes) router.use('/consents', consentRoutes) +router.use('/downloads', downloadRoutes) router.use('/give-or-refuse-consent', parentRoutes) +router.use('/interchange', interchangeRoutes) router.use('/moves', moveRoutes) router.use('/notices', noticeRoutes) router.use('/teams', teamRoutes) diff --git a/app/routes/download.js b/app/routes/download.js index 8376eaf2e..02e0506c3 100644 --- a/app/routes/download.js +++ b/app/routes/download.js @@ -4,7 +4,14 @@ import { downloadController as download } from '../controllers/download.js' const router = express.Router({ strict: true }) +router.get('/', download.readAll, download.list) +router.post('/', download.filterList) + router.get('/new', download.form) router.post('/new', download.create) +router.param('download_id', download.read) + +router.get('/:download_id/download', download.download) + export const downloadRoutes = router diff --git a/app/routes/interchange.js b/app/routes/interchange.js new file mode 100644 index 000000000..ebe4551e5 --- /dev/null +++ b/app/routes/interchange.js @@ -0,0 +1,9 @@ +import express from 'express' + +import { interchangeController as interchange } from '../controllers/interchange.js' + +const router = express.Router({ strict: true, mergeParams: true }) + +router.get('/', interchange.list) + +export const interchangeRoutes = router diff --git a/app/routes/session.js b/app/routes/session.js index 1f65d929a..f61d67f79 100644 --- a/app/routes/session.js +++ b/app/routes/session.js @@ -25,7 +25,6 @@ router.post('/:session_id/edit/:view', session.updateForm) router.post('/:session_id/invite-to-clinic', session.inviteToClinic) router.post('/:session_id/instructions', session.giveInstructions) -router.post('/:session_id/offline', session.downloadFile) router.post('/:session_id/reminders', session.sendReminders) router.all('/:session_id/:view', session.readPatientSessions) diff --git a/app/utils/status.js b/app/utils/status.js index 90026ad07..059276f48 100644 --- a/app/utils/status.js +++ b/app/utils/status.js @@ -1,5 +1,6 @@ import { ConsentOutcome, + DownloadStatus, GillickCompetent, InstructionOutcome, PatientConsentStatus, @@ -58,6 +59,28 @@ export function getConsentOutcomeStatus(consent) { } } +/** + * Get download status properties + * + * @param {DownloadStatus} status - Download status + * @returns {object} Status properties + */ +export function getDownloadStatus(status) { + let colour + switch (status) { + case DownloadStatus.Ready: + colour = 'green' + break + default: + colour = 'white' + } + + return { + colour, + text: status + } +} + /** * Get consent outcome status properties * diff --git a/app/views/_layouts/default.njk b/app/views/_layouts/default.njk index 152f7d682..6a1c709bc 100644 --- a/app/views/_layouts/default.njk +++ b/app/views/_layouts/default.njk @@ -67,9 +67,9 @@ }, { classes: 'app-header__navigation-item--with-count', - href: "/uploads", - html: __("upload.list.label"), - active: navigation.activeSection == "uploads" + href: "/interchange", + html: __("interchange.list.label"), + active: navigation.activeSection in ["uploads", "downloads"] }, { href: "/reports", diff --git a/app/views/_layouts/form.njk b/app/views/_layouts/form.njk index e241ef5c7..053489306 100644 --- a/app/views/_layouts/form.njk +++ b/app/views/_layouts/form.njk @@ -5,7 +5,7 @@ {% block content %} {{ super() }} -
+
{% block form %} {% endblock %} diff --git a/app/views/dashboard.njk b/app/views/dashboard.njk index 9f630978f..edc6153c0 100644 --- a/app/views/dashboard.njk +++ b/app/views/dashboard.njk @@ -65,30 +65,30 @@ {{ card({ clickable: true, secondary: true, - heading: __("move.list.title"), + heading: __("consent.list.title"), headingSize: "xs", - href: "/moves", - description: __("move.list.description") + href: "/consents", + description: __("consent.list.description") }) }}
  • {{ card({ clickable: true, secondary: true, - heading: __("consent.list.title"), + heading: __("move.list.title"), headingSize: "xs", - href: "/consents", - description: __("consent.list.description") + href: "/moves", + description: __("move.list.description") }) }}
  • {{ card({ clickable: true, secondary: true, - heading: __("upload.list.title"), + heading: __("interchange.list.title"), headingSize: "xs", - href: "/uploads", - description: __("upload.list.description") + href: "/interchange", + description: __("interchange.list.description") }) }}
  • diff --git a/app/views/download/form.njk b/app/views/download/form.njk index b0a508a47..0f58285f8 100644 --- a/app/views/download/form.njk +++ b/app/views/download/form.njk @@ -1,10 +1,14 @@ {% extends "_layouts/form.njk" %} + +{% set formAction = "/downloads/new?referrer=/reports" %} {% set confirmButtonText = __("download.new.confirm") %} {% set title = __("download.new.label") %} + {% block form %} {{ appHeading({ title: title }) }} + {{ radios({ formGroup: { classes: "nhsuk-u-margin-bottom-8" @@ -18,6 +22,7 @@ items: academicYearItems, decorate: "download.year" }) }} + {{ radios({ formGroup: { classes: "nhsuk-u-margin-bottom-8" @@ -29,8 +34,9 @@ } }, items: programmeTypeItems, - decorate: "download.type" + decorate: "download.programmeType" }) }} + {{ dateInput({ formGroup: { classes: "nhsuk-u-margin-bottom-8" @@ -43,6 +49,7 @@ }, decorate: "download.startAt_" }) }} + {{ dateInput({ formGroup: { classes: "nhsuk-u-margin-bottom-8" @@ -55,21 +62,21 @@ }, decorate: "download.endAt_" }) }} - {% if account.role == UserRole.DataConsumer %} - {{ checkboxes({ - formGroup: { - classes: "nhsuk-u-margin-bottom-8" - }, - fieldset: { - legend: { - classes: "nhsuk-fieldset__legend--m", - text: __("download.teams.title") - } - }, - items: teamItems, - decorate: "download.team_ids" - }) }} - {% endif %} + + {{ checkboxes({ + formGroup: { + classes: "nhsuk-u-margin-bottom-8" + }, + fieldset: { + legend: { + classes: "nhsuk-fieldset__legend--m", + text: __("download.teams.title") + } + }, + items: teamItems, + decorate: "download.team_ids" + }) if account.role == UserRole.DataConsumer }} + {{ radios({ formGroup: { classes: "nhsuk-u-margin-bottom-8" diff --git a/app/views/download/list.njk b/app/views/download/list.njk new file mode 100644 index 000000000..ad4e87aae --- /dev/null +++ b/app/views/download/list.njk @@ -0,0 +1,94 @@ +{% from "interchange/_navigation.njk" import interchangeNavigation with context %} + +{% extends "_layouts/form.njk" %} + +{% set gridColumns = "full" %} +{% set hideConfirmButton = true %} +{% set title = __("download.list.title") %} + +{% block form %} + {{ super() }} + + {{ interchangeNavigation({ + view: "downloads" + }) }} + +
    + + {% call card({ + classes: "app-filters", + feature: true, + heading: __("download.search.label"), + headingLevel: 3 + }) %} + {{ radios({ + classes: "nhsuk-radios--small", + fieldset: { + legend: { + classes: "nhsuk-fieldset__legend--s", + text: __("download.type.label") + } + }, + items: radioFilterItems(DownloadType), + decorate: "type" + }) }} + + {{ appButtonGroup({ + buttons: [{ + classes: "nhsuk-button--secondary nhsuk-button--small", + text: __("search.confirm"), + attributes: { + formaction: params.formaction, + formmethod: "post", + role: "search" + } + }, { + classes: "nhsuk-button--secondary nhsuk-button--small", + text: __("search.clear"), + href: "/downloads" + } if data.type] + }) }} + {% endcall %} + + +
    + {% if data.type and data.type != "none" %} + {% set title = data.type | replace("records", "record exports") %} + {% else %} + {% set title = "All exports" %} + {% endif %} + + {{ appHeading({ + level: 3, + size: "m", + title: title, + summary: __mf("download.results", { + from: results.from, + to: results.to, + count: results.count + }) | safe + }) }} + + {% for download in results.page %} + {{ summaryList({ + card: { + classes: "app-card--compact", + heading: download.name, + headingSize: "s", + headingLevel: 4, + href: download.uri + "/download" if download.status == DownloadStatus.Ready, + clickable: true if download.status == DownloadStatus.Ready + }, + rows: summaryRows(download, { + createdAt: {}, + createdBy: {}, + type: {}, + status: {} + }) + }) }} + {% endfor %} + + {{ pagination(pages) }} +
    +
    +{% endblock %} diff --git a/app/views/interchange/_navigation.njk b/app/views/interchange/_navigation.njk new file mode 100644 index 000000000..adf7575c8 --- /dev/null +++ b/app/views/interchange/_navigation.njk @@ -0,0 +1,27 @@ +{% from "_macros/heading.njk" import appHeading %} +{% from "_macros/secondary-navigation.njk" import appSecondaryNavigation %} + +{% macro interchangeNavigation(params) %} + {{ appHeading({ + title: __("interchange.list.title") + }) }} + + {{ appSecondaryNavigation({ + items: [ + { + text: __("upload.list.label"), + href: "/uploads", + current: params.view == "uploads" + }, + { + text: __("download.list.label"), + href: "/downloads", + current: params.view == "downloads" + } + ] + }) }} + +

    + {{ __(params.view + "list.title") }} +

    +{% endmacro %} diff --git a/app/views/move/list.njk b/app/views/move/list.njk index b37adc4d1..2e5c69d80 100644 --- a/app/views/move/list.njk +++ b/app/views/move/list.njk @@ -1,8 +1,10 @@ -{% extends "_layouts/default.njk" %} +{% extends "_layouts/form.njk" %} +{% set gridColumns = "full" %} +{% set hideConfirmButton = true %} {% set title = __("move.list.title") %} -{% block content %} +{% block form %} {{ super() }} {{ appHeading({ @@ -16,7 +18,12 @@ {{ button({ classes: "nhsuk-button--secondary", text: __("move.download.label"), - href: "#" + value: DownloadType.Moves, + decorate: "download.type", + attributes: { + formaction: "/downloads/new?referrer=/moves", + formmethod: "post" + } }) }}
  • diff --git a/app/views/school/show.njk b/app/views/school/show.njk index 676ef88ac..8d27e266a 100644 --- a/app/views/school/show.njk +++ b/app/views/school/show.njk @@ -64,6 +64,17 @@ }) | safe }) }} + {{ button({ + classes: "nhsuk-button--secondary nhsuk-button--small", + text: __("session.offline.confirm"), + value: DownloadType.Session, + decorate: "download.type", + attributes: { + formaction: "/downloads/new?referrer=" + school.uri, + formmethod: "post" + } + }) if data.invitedToClinic and results.count > 0 }} + {% for patient in results.page %} {% set reportStatusHtml -%} {%- if data.programme_id %} diff --git a/app/views/session/offline.njk b/app/views/session/offline.njk index 45a990a56..39c957668 100644 --- a/app/views/session/offline.njk +++ b/app/views/session/offline.njk @@ -2,6 +2,7 @@ {% set title = __("session.offline.title") %} {% set confirmButtonText = __("session.offline.confirm") %} +{% set formAction = "/downloads/new?referrer=" + session.uri %} {% block beforeContent %} {{ breadcrumb({ @@ -46,5 +47,17 @@ decorate: "lastName", value: account.lastName }) }} + + {{ input({ + type: "hidden", + decorate: "download.session_id", + value: session.id + }) }} + + {{ input({ + type: "hidden", + decorate: "download.type", + value: DownloadType.Session + }) }} {% endcall %} {% endblock %} diff --git a/app/views/upload/list.njk b/app/views/upload/list.njk index 4363bf88e..2858a3889 100644 --- a/app/views/upload/list.njk +++ b/app/views/upload/list.njk @@ -1,3 +1,5 @@ +{% from "interchange/_navigation.njk" import interchangeNavigation with context %} + {% extends "_layouts/form.njk" %} {% set gridColumns = "full" %} @@ -7,9 +9,8 @@ {% block form %} {{ super() }} - {{ appHeading({ - size: "xl", - title: __("upload.list.title") + {{ interchangeNavigation({ + view: "uploads" }) }}