diff --git a/app/assets/images/nhsuk-icon-arrow-down.svg b/app/assets/images/nhsuk-icon-arrow-down.svg new file mode 100644 index 00000000..619714f5 --- /dev/null +++ b/app/assets/images/nhsuk-icon-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/app/assets/images/nhsuk-icon-arrow-up-down.svg b/app/assets/images/nhsuk-icon-arrow-up-down.svg new file mode 100644 index 00000000..ab69b3c7 --- /dev/null +++ b/app/assets/images/nhsuk-icon-arrow-up-down.svg @@ -0,0 +1,5 @@ + diff --git a/app/assets/images/nhsuk-icon-arrow-up.svg b/app/assets/images/nhsuk-icon-arrow-up.svg new file mode 100644 index 00000000..ed28b96a --- /dev/null +++ b/app/assets/images/nhsuk-icon-arrow-up.svg @@ -0,0 +1,3 @@ + diff --git a/app/assets/sass/main.scss b/app/assets/sass/main.scss index 5b6e65b4..edeba8dc 100755 --- a/app/assets/sass/main.scss +++ b/app/assets/sass/main.scss @@ -192,6 +192,10 @@ a { } } +.app-search-criteria dd { + margin-inline-start: 0; +} + @include nhsuk-media-query($from: tablet) { .app-participants-sort-form { flex-shrink: 0; diff --git a/app/data/participants.js b/app/data/participants.js index 32d46d07..55721619 100644 --- a/app/data/participants.js +++ b/app/data/participants.js @@ -180,7 +180,8 @@ const createParticipant = (index, today) => { return { participantId: `p${pad(index + 1)}`, - full_name: `${surname.toUpperCase()}, ${firstName}${middleName}`, + full_name: `${firstName} ${middleName} ${surname}`, + display_name: `${surname.toUpperCase()}, ${firstName}`, surname_sort_value: surname.toUpperCase(), nhs_number: `999 ${pad((index * 37) % 1000, 3)} ${pad((index * 97 + 1) % 10000, 4)}`, date_of_birth: formatDate(dateOfBirth), diff --git a/app/routes.js b/app/routes.js index 2a98cbc1..c6b389bb 100644 --- a/app/routes.js +++ b/app/routes.js @@ -27,6 +27,7 @@ const participantDateFromToday = (today, days) => { } const formatParticipantDate = (date) => `${String(date.getDate()).padStart(2, '0')} ${participantMonths[date.getMonth()]} ${date.getFullYear()}` +const formatParticipantIsoDate = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` const formatParticipantFullDate = (date) => `${String(date.getDate()).padStart(2, '0')} ${participantFullMonths[date.getMonth()]} ${date.getFullYear()}` const formatParticipantRelativeDate = (date, today, daysThreshold = 7) => { @@ -59,9 +60,11 @@ const refreshParticipantDates = (participant, today, dateFormatter = formatParti last_screened_date: lastScreenedDate ? dateFormatter(lastScreenedDate) : 'Never screened', next_test_due_date: dateFormatter(nextTestDueDate), next_test_due_date_value: nextTestDueDate.getTime(), + next_test_due_date_iso: formatParticipantIsoDate(nextTestDueDate), next_test_due_date_relative: formatParticipantRelativeDate(nextTestDueDate, today, 28), next_appointment_date: nextAppointmentDate ? dateFormatter(nextAppointmentDate) : 'Not known', next_appointment_date_value: nextAppointmentDate ? nextAppointmentDate.getTime() : null, + next_appointment_date_iso: nextAppointmentDate ? formatParticipantIsoDate(nextAppointmentDate) : '9999-12-31', next_appointment_date_relative: nextAppointmentDate ? formatParticipantRelativeDate(nextAppointmentDate, today) : 'Not known' } } @@ -914,7 +917,65 @@ router.get(['/participants', '/batches/participants'], function (req, res) { }) }) -router.get(['/participants/:participantId', '/batches/participants/:participantId'], function (req, res) { +const participantSearchFields = ['firstName', 'lastName', 'nhsNumber', 'sxNumber', 'dobDay', 'dobMonth', 'dobYear', 'postcode', 'emailAddress'] + +const participantSearchCriteria = query => Object.fromEntries(participantSearchFields + .map(field => [field, String(query[field] || '').trim()])) + +const normaliseSearchValue = value => String(value || '').toLowerCase().replace(/\s+/g, '') + +const matchesParticipantSearch = (participant, criteria) => { + const includes = (value, term) => !term || normaliseSearchValue(value).includes(normaliseSearchValue(term)) + const [dobDay, dobMonthName, dobYear] = participant.date_of_birth.split(' ') + const dobMonth = participantFullMonths.indexOf(dobMonthName) + 1 + const matchesDatePart = (actual, term) => !term || parseInt(term, 10) === parseInt(actual, 10) + const matchesYear = !criteria.dobYear || (criteria.dobYear.length === 2 + ? dobYear.endsWith(criteria.dobYear) + : parseInt(criteria.dobYear, 10) === parseInt(dobYear, 10)) + + return includes(participant.full_name.split(', ')[1], criteria.firstName) && + includes(participant.surname_sort_value, criteria.lastName) && + includes(participant.nhs_number, criteria.nhsNumber) && + includes(participant.sx_number, criteria.sxNumber) && + includes(participant.address.postcode, criteria.postcode) && + includes(participant.email, criteria.emailAddress) && + matchesDatePart(dobDay, criteria.dobDay) && + matchesDatePart(dobMonth, criteria.dobMonth) && + matchesYear +} + +router.get('/participants/search', function (req, res) { + res.render('participants/search', { + participantsPath: '/participants', + criteria: participantSearchCriteria(req.query) + }) +}) + +router.get('/participants/search-results', function (req, res) { + const criteria = participantSearchCriteria(req.query) + const today = new Date() + today.setHours(0, 0, 0, 0) + const participants = ((req.session.data.participants && req.session.data.participants.default) || []) + .filter(participant => matchesParticipantSearch(participant, criteria)) + .map(participant => refreshParticipantDates(participant, today)) + .sort((a, b) => a.full_name.localeCompare(b.full_name)) + const searchQuery = new URLSearchParams(Object.entries(criteria).filter(([, value]) => value)).toString() + + res.render('participants/search-results', { + participants, + participantsPath: '/participants', + criteria, + hasCriteria: Boolean(searchQuery), + searchQuery + }) +}) + +router.get(['/participants/:participantId', '/batches/participants/:participantId'], function (req, res, next) { + // Let static pages such as /participants/search fall through to auto-routing + if (!/^p\d+$/.test(req.params.participantId)) { + return next() + } + const participantsPath = req.path.startsWith('/batches/') ? '/batches/participants' : '/participants' const participants = (req.session.data.participants && req.session.data.participants.default) || [] const today = new Date() diff --git a/app/views/batches/participants/detail.html b/app/views/batches/participants/detail.html index 5af807a9..56403b7b 100644 --- a/app/views/batches/participants/detail.html +++ b/app/views/batches/participants/detail.html @@ -20,7 +20,7 @@
Participant details -

{{ participant.full_name }}

+

{{ participant.display_name }}

diff --git a/app/views/batches/participants/index.html b/app/views/batches/participants/index.html index 1ee942bd..94db53c9 100644 --- a/app/views/batches/participants/index.html +++ b/app/views/batches/participants/index.html @@ -99,45 +99,47 @@

Search and filter

-
+

Showing {{ firstRecord }} to {{ lastRecord }} of {{ totalParticipants }} participants

-
- - {% for filter in selectedNextTestDue %}{% endfor %} - {% for filter in selectedNextScreening %}{% endfor %} - {% for filter in selectedLastScreened %}{% endfor %} - {% for filter in selectedBreachRisk %}{% endfor %} - - -
- +
- - - - - + + + + + {% for participant in participants %} - + - - + + {% endfor %} @@ -145,7 +147,12 @@

Search and filter

{% if totalPages > 1 %} {% endif %} diff --git a/app/views/index.html b/app/views/index.html index 1bea3ea5..362da9a4 100755 --- a/app/views/index.html +++ b/app/views/index.html @@ -54,6 +54,9 @@

September 2026

  • View participant details within a batch
  • +
  • + Search for a participant +
  • August – September 2026

    diff --git a/app/views/participants/detail.html b/app/views/participants/detail.html index 0d053758..d81b7dcc 100644 --- a/app/views/participants/detail.html +++ b/app/views/participants/detail.html @@ -4,7 +4,7 @@ {% from "_includes/primary-navigation.html" import primaryNavigation %} {% block header %} -{{ primaryNavigation("Batches" if participantsPath == "/batches/participants" else "Participants", serviceName) }} +{{ primaryNavigation("Batches" if participantsPath == "/batches/participants" else "Participants", serviceName, bsoName) }} {% endblock %} {% block content %} @@ -24,7 +24,7 @@
    Participant details -

    {{ participant.full_name }}

    +

    {{ participant.display_name }}

    diff --git a/app/views/participants/index.html b/app/views/participants/index.html index d840ef11..3535f7d9 100644 --- a/app/views/participants/index.html +++ b/app/views/participants/index.html @@ -4,7 +4,7 @@ {% from "_includes/primary-navigation.html" import primaryNavigation %} {% block header %} -{{ primaryNavigation("Batches" if participantsPath == "/batches/participants" else "Participants", serviceName) }} +{{ primaryNavigation("Participants", serviceName, bsoName) }} {% endblock %} {% block beforeContent %}{% endblock %} @@ -195,50 +195,30 @@

    -
    +

    Showing {{ firstRecord }} to {{ lastRecord }} of {{ totalParticipants }} participants

    -
    - - {% for filter in selectedNextTestDue %}{% endfor %} - {% for filter in selectedNextScreening %}{% endfor %} - {% for filter in selectedLastScreened %}{% endfor %} - {% for filter in selectedBreachRisk %}{% endfor %} - - -
    -

    NamePostcodeNHS numberNext test due dateNext screening + Name + + Postcode + + NHS number + + Next test due date + + Next screening +
    {{ participant.full_name }}
    {{ participant.age }} years old
    +

    {{ participant.display_name }}

    +

    {{ participant.age }} years old

    +
    {{ participant.address.postcode }} {{ participant.nhs_number }}{{ participant.next_test_due_date }}
    {{ participant.next_test_due_date_relative }}
    {% if participant.next_appointment_date != 'Not known' %}{{ participant.next_appointment_date }}
    {% endif %}{{ participant.next_appointment_date_relative }}
    + {{ participant.next_test_due_date }}
    + {{ participant.next_test_due_date_relative }} +
    + {% if participant.next_appointment_date != 'Not known' %}{{ participant.next_appointment_date }}
    {% endif + %} + {{ participant.next_appointment_date_relative }} +
    + + +
    - - - - - @@ -247,16 +227,16 @@

    {% for participant in participants %}

    - -
    + Name + Postcode + NHS number + Next test due date + Next screening
    - {{ participant.full_name }}
    - {{ participant.age }} years old +

    {{ participant.display_name }}

    +

    {{ participant.age }} years old

    {{ participant.address.postcode }} {{ participant.nhs_number }} + {{ participant.next_test_due_date }}
    {{ participant.next_test_due_date_relative }}
    + {% if participant.next_appointment_date != 'Not known' %}{{ participant.next_appointment_date }}
    {% endif %} {{ participant.next_appointment_date_relative }} diff --git a/app/views/participants/search-results.html b/app/views/participants/search-results.html new file mode 100644 index 00000000..b2294c79 --- /dev/null +++ b/app/views/participants/search-results.html @@ -0,0 +1,108 @@ +{% extends 'layout.html' %} + +{% set pageName = "Search results" %} + +{% from "_includes/primary-navigation.html" import primaryNavigation %} +{% block header %} +{{ primaryNavigation("Participants", serviceName, bsoName) }} +{% endblock %} + +{% block beforeContent %}{% endblock %} + +{% block content %} + +
    +
    +
    +

    {{ pageName }}

    +
    +
    +
    + +
    +
    +
    +
    +

    + You searched for +

    + {% if hasCriteria %} +
    + {% if criteria.firstName %}
    First name
    {{ criteria.firstName }}
    {% endif %} + {% if criteria.lastName %}
    Last name
    {{ criteria.lastName }}
    {% endif %} + {% if criteria.nhsNumber %}
    NHS number
    {{ criteria.nhsNumber }}
    {% endif %} + {% if criteria.sxNumber %}
    SX number
    {{ criteria.sxNumber }}
    {% endif %} + {% if criteria.dobDay or criteria.dobMonth or criteria.dobYear %} +
    Date of birth
    +
    {{ criteria.dobDay or "–" }} / {{ criteria.dobMonth or "–" }} / {{ criteria.dobYear or "–" }}
    + {% endif %} + {% if criteria.postcode %}
    Postcode
    {{ criteria.postcode }}
    {% endif %} + {% if criteria.emailAddress %}
    Email address
    {{ criteria.emailAddress }}
    {% endif %} +
    + {% else %} +

    No search terms entered

    + {% endif %} +

    Change search

    +

    New search

    +
    +
    +
    +
    + +
    +

    + {{ participants | length }} participant{% if participants | length != 1 %}s{% endif %} found +

    +
    + + {% if participants | length %} + + + + + + + + + + + + {% for participant in participants %} + + + + + + + + {% endfor %} + +
    + Name + + Postcode + + NHS number + + Next test due date + + Next screening +
    +

    {{ participant.display_name }}

    +

    {{ participant.age }} years old

    +
    {{ participant.address.postcode }}{{ participant.nhs_number }} + {{ participant.next_test_due_date }}
    + {{ participant.next_test_due_date_relative }} +
    + {% if participant.next_appointment_date != 'Not known' %}{{ participant.next_appointment_date }}
    {% endif + %} + {{ participant.next_appointment_date_relative }} +
    + {% else %} +

    No participants match your search. Check the details you entered or change your search.

    + {% endif %} + +
    +
    + +{% endblock %} diff --git a/app/views/participants/search.html b/app/views/participants/search.html new file mode 100644 index 00000000..da0cc415 --- /dev/null +++ b/app/views/participants/search.html @@ -0,0 +1,121 @@ +{% extends 'layout.html' %} + +{% set pageName = "Participants" %} + +{% from "_includes/primary-navigation.html" import primaryNavigation %} +{% block header %} +{{ primaryNavigation("Participants", serviceName, bsoName) }} +{% endblock %} + +{% block beforeContent %}{% endblock %} + +{% block content %} + +
    +
    +
    +

    Search for a participant

    +
    +
    +
    + +
    + +
    + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    +
    + + + +
    + For example, 18 9 1960 +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + +
    + + +
    + +
    + + +
    + + + +
    + +
    +
    + +{% endblock %} \ No newline at end of file