From 29f879c5d19ab6706a60a7c57534d2554f5eb9b3 Mon Sep 17 00:00:00 2001 From: Amey Bhagwat Date: Mon, 19 Oct 2026 12:13:59 +0530 Subject: [PATCH] # Resolve 2 Signatory image issue in the generated Agreement PDF. # Step status cards are now in line and status color badges. # Added save button in survey and a automatically dissapearing toast message for success and error. # Check the numeric validations for survey fields and added field specific error messages instead of showing all in topside. --- backend/src/pdf/agreementHtml.js | 32 +- backend/test/agreementHtml.test.js | 4 +- frontend/src/admin.css | 14 + frontend/src/admin/SettingsPage.jsx | 90 +++- frontend/src/index.css | 104 ++++- frontend/src/pages/SurveyPage.jsx | 515 ++++++++++++++--------- frontend/src/pages/SurveyPage.test.jsx | 31 +- frontend/src/pages/UserDashboardPage.jsx | 47 ++- 8 files changed, 571 insertions(+), 266 deletions(-) diff --git a/backend/src/pdf/agreementHtml.js b/backend/src/pdf/agreementHtml.js index 46a611c..d8f2eb6 100644 --- a/backend/src/pdf/agreementHtml.js +++ b/backend/src/pdf/agreementHtml.js @@ -2,20 +2,13 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -const ROOT = path.dirname(fileURLToPath(import.meta.url)); -const AGREEMENT_PATH = path.resolve(ROOT, '../../../files/docs/agreement.md'); -const LOGO_PATH = path.resolve(ROOT, '../../../frontend/src/images/CREST_logo.jpg'); +const AGREEMENT_PATH = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../files/docs/agreement.md', +); const sourceText = readFileSync(AGREEMENT_PATH, 'utf8'); -/** @type {string} */ -let crestLogoDataUrl = ''; -try { - crestLogoDataUrl = `data:image/jpeg;base64,${readFileSync(LOGO_PATH).toString('base64')}`; -} catch { - crestLogoDataUrl = ''; -} - const MONTHS = [ 'January', 'February', @@ -87,18 +80,6 @@ function physicianSignatureHtml(signature) { return '___________________'; } -/** - * @param {string} signatureMarkup - * @param {{ withLogo?: boolean }} [options] - */ -function signatureRow(signatureMarkup, options = {}) { - const logo = - options.withLogo && crestLogoDataUrl - ? `` - : ''; - return `Signature: ${signatureMarkup}${logo}`; -} - /** * @param {{ name: string, designation: string, signatureDataUrl?: string } | null} signatory * @param {string} dateLabel @@ -115,7 +96,7 @@ function crestBlock(signatory, dateLabel) { return [ `Name: ${filled(signatory.name)}`, `Designation: ${filled(signatory.designation)}`, - signatureRow(image, { withLogo: true }), + `Signature: ${image}`, `Date: ${filled(dateLabel)}`, ].join('\n\n'); } @@ -306,10 +287,7 @@ export function agreementHtml(options) { .filled { font-weight: 700; text-decoration: underline; text-underline-offset: 0.15em; } .watermark { position: fixed; top: 45%; left: -10%; width: 120%; text-align: center; font-size: 42pt; font-weight: 700; letter-spacing: 0.04em; color: rgba(185, 28, 28, 0.18); transform: rotate(-32deg); z-index: 10; } .typed-signature { font-family: "Segoe Script", "Apple Chancery", cursive; font-size: 20pt; } - .signature-row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; width: 100%; } - .signature-field { display: inline-flex; align-items: center; gap: 0.5rem; min-height: 64px; } img.signature { height: 64px; max-width: 240px; width: auto; vertical-align: middle; background: #fff; } - img.crest-logo { height: 52px; width: auto; flex: none; } diff --git a/backend/test/agreementHtml.test.js b/backend/test/agreementHtml.test.js index 85d9bf0..5705ab0 100644 --- a/backend/test/agreementHtml.test.js +++ b/backend/test/agreementHtml.test.js @@ -70,8 +70,8 @@ describe('agreement HTML', () => { expect(html).toContain('Dr Sudeep Salvi'); expect(html).toContain('Medical Director'); expect(html).toContain('data:image/png;base64,AAAA'); - expect(html).toContain('class="signature-row"'); - expect(html).toContain('alt="CREST"'); + expect(html).toContain('alt="CREST signature"'); + expect(html).not.toContain('alt="CREST"'); expect(html).not.toContain('Pending approval'); expect(html).not.toContain('DRAFT - NOT SIGNED'); }); diff --git a/frontend/src/admin.css b/frontend/src/admin.css index 4f0dd97..2b208e6 100644 --- a/frontend/src/admin.css +++ b/frontend/src/admin.css @@ -91,6 +91,20 @@ box-shadow: 0 1px 2px rgb(15 118 110 / 0.06), 0 18px 40px rgb(19 78 74 / 0.08); } +.admin-card-status { + margin: 0.75rem 0 0; + font-size: 0.9rem; + font-weight: 600; +} + +.admin-card-status-success { + color: #14532d; +} + +.admin-card-status-error { + color: #b91c1c; +} + .admin-stat-card { display: flex; flex-direction: column; diff --git a/frontend/src/admin/SettingsPage.jsx b/frontend/src/admin/SettingsPage.jsx index b241641..af1dfce 100644 --- a/frontend/src/admin/SettingsPage.jsx +++ b/frontend/src/admin/SettingsPage.jsx @@ -1,25 +1,62 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { adminApi } from './adminApi.js'; +/** + * @typedef {'signatory' | 'signature' | 'reminders'} SettingsCard + * @typedef {{ message: string, tone: 'success' | 'error' }} CardMessage + */ + export default function SettingsPage() { const [settings, setSettings] = useState(null); const [reminders, setReminders] = useState(null); - const [error, setError] = useState(''); - const [status, setStatus] = useState(''); + const [loadError, setLoadError] = useState(''); + /** @type {[Partial>, Function]} */ + const [cardMessages, setCardMessages] = useState({}); + const timersRef = useRef( + /** @type {Partial>>} */ ({}), + ); useEffect(() => { adminApi('/api/admin/settings') .then(setSettings) - .catch((loadError) => setError(loadError.message)); + .catch((error) => setLoadError(error.message)); adminApi('/api/admin/settings/reminders') .then(setReminders) - .catch((loadError) => setError(loadError.message)); + .catch((error) => setLoadError(error.message)); + + return () => { + for (const timer of Object.values(timersRef.current)) { + if (timer) { + clearTimeout(timer); + } + } + }; }, []); + /** + * @param {SettingsCard} card + * @param {string} message + * @param {'success' | 'error'} [tone] + */ + function showCardMessage(card, message, tone = 'success') { + const existing = timersRef.current[card]; + if (existing) { + clearTimeout(existing); + } + + setCardMessages((current) => ({ ...current, [card]: { message, tone } })); + timersRef.current[card] = setTimeout(() => { + setCardMessages((current) => { + const next = { ...current }; + delete next[card]; + return next; + }); + delete timersRef.current[card]; + }, 5000); + } + async function save(event) { event.preventDefault(); - setError(''); - setStatus(''); try { setSettings( @@ -28,9 +65,9 @@ export default function SettingsPage() { body: { name: settings.name, designation: settings.designation }, }), ); - setStatus('Settings saved'); + showCardMessage('signatory', 'Settings saved'); } catch (saveError) { - setError(saveError.message); + showCardMessage('signatory', saveError.message, 'error'); } } @@ -43,9 +80,6 @@ export default function SettingsPage() { const reader = new FileReader(); reader.onload = async () => { - setError(''); - setStatus(''); - try { setSettings( await adminApi('/api/admin/settings/signature', { @@ -53,9 +87,9 @@ export default function SettingsPage() { body: { image: String(reader.result) }, }), ); - setStatus('Signature saved'); + showCardMessage('signature', 'Signature saved'); } catch (uploadError) { - setError(uploadError.message); + showCardMessage('signature', uploadError.message, 'error'); } }; reader.readAsDataURL(file); @@ -63,8 +97,6 @@ export default function SettingsPage() { async function saveReminders(event) { event.preventDefault(); - setError(''); - setStatus(''); try { setReminders( @@ -77,14 +109,14 @@ export default function SettingsPage() { }, }), ); - setStatus('Reminder settings saved'); + showCardMessage('reminders', 'Reminder settings saved'); } catch (saveError) { - setError(saveError.message); + showCardMessage('reminders', saveError.message, 'error'); } } if (!settings || !reminders) { - return error ?

{error}

:

Loading settings…

; + return loadError ?

{loadError}

:

Loading settings…

; } return ( @@ -115,6 +147,7 @@ export default function SettingsPage() { +

Signature image

@@ -137,6 +170,7 @@ export default function SettingsPage() { className="admin-file" onChange={upload} /> +

Reminders

@@ -181,9 +215,23 @@ export default function SettingsPage() { + - {error ?

{error}

: null} - {status ?

{status}

: null} ); } + +/** + * @param {{ message?: CardMessage }} props + */ +function CardStatus({ message }) { + if (!message) { + return null; + } + + return ( +

+ {message.message} +

+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index d177817..78b1d1c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -773,6 +773,56 @@ a:focus-visible { color: #1c1917; } +.survey-field { + display: grid; + gap: 0; +} + +.survey-field-errors { + margin: 0.15rem 0 0.35rem; +} + +.survey-field-errors .field-error { + margin: 0.15rem 0 0; +} + +.survey-toast { + position: fixed; + right: 1.25rem; + /* Sit above the site footer */ + bottom: 5.5rem; + z-index: 1000; + max-width: min(22rem, calc(100vw - 2rem)); + padding: 0.65rem 0.9rem; + border-radius: 0.55rem; + box-shadow: var(--shadow); + font-size: 0.9rem; + font-weight: 500; + animation: survey-toast-in 0.2s ease-out; +} + +.survey-toast-success { + background: rgba(15, 118, 110, 0.9); + color: #f0fdfa; +} + +.survey-toast-error { + background: rgb(185, 28, 28, 0.9); + color: #fff; +} + +@keyframes survey-toast-in { + from { + opacity: 0; + transform: translateY(0.4rem); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + .survey-dialog { position: fixed; inset: 0; @@ -1018,19 +1068,33 @@ a:focus-visible { .user-step-list { display: grid; + grid-template-columns: 1fr; gap: 0.75rem; margin: 1.25rem 0; } +@media (min-width: 48rem) { + .user-step-list { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + .user-step { display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: 0.5rem; - padding: 0.85rem 1rem; + flex-direction: column; + align-items: flex-start; + gap: 0.55rem; + padding: 0.95rem 1rem; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-muted); + min-width: 0; +} + +.user-step strong { + font-size: 0.98rem; + line-height: 1.3; + color: var(--text); } .user-step.done { @@ -1038,9 +1102,35 @@ a:focus-visible { background: #f0fdfa; } -.user-step span { - color: var(--text-muted); - font-size: 0.9rem; +.user-step-status { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.user-step-status-completed { + background: #dcfce7; + color: #14532d; +} + +.user-step-status-in-process { + background: #fef3c7; + color: #92400e; +} + +.user-step-status-not-started { + background: #fecaca; + color: #9f1233; +} + +.user-step-status-rejected { + background: #fee2e2; + color: #991b1b; } .user-next-hint { diff --git a/frontend/src/pages/SurveyPage.jsx b/frontend/src/pages/SurveyPage.jsx index 1cec5c9..56e6533 100644 --- a/frontend/src/pages/SurveyPage.jsx +++ b/frontend/src/pages/SurveyPage.jsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { Link, useParams } from 'react-router-dom'; import { validateAnswers } from '../../../backend/src/survey/validate.js'; import { surveyApi } from '../surveyApi.js'; @@ -70,10 +71,44 @@ function formatAnswer(question, value) { } /** - * @param {{ question: Record, value: unknown, invalid?: boolean, hideLabel?: boolean, onChange: (value: unknown) => void }} props + * @param {{ messages: string[] }} props */ -function QuestionField({ question, value, invalid = false, hideLabel = false, onChange }) { +function FieldErrors({ messages }) { + if (!messages.length) { + return null; + } + + return ( +
+ {messages.map((message) => ( +

+ * {message} +

+ ))} +
+ ); +} + +/** + * @param {{ + * question: Record, + * value: unknown, + * invalid?: boolean, + * errorMessages?: string[], + * hideLabel?: boolean, + * onChange: (value: unknown) => void, + * }} props + */ +function QuestionField({ + question, + value, + invalid = false, + errorMessages = [], + hideLabel = false, + onChange, +}) { const legendClass = hideLabel ? 'visually-hidden' : undefined; + const errors = ; if (question.type === 'help') { return

{question.text}

; @@ -86,204 +121,220 @@ function QuestionField({ question, value, invalid = false, hideLabel = false, on if (question.type === 'short_text' || question.type === 'long_text') { const Input = question.type === 'long_text' ? 'textarea' : 'input'; return ( - +
+ + {errors} +
); } if (question.type === 'number' || question.type === 'percentage') { return ( - +
+ + {errors} +
); } if (question.type === 'single_choice' || question.type === 'multi_choice') { const current = value && typeof value === 'object' ? value : {}; return ( -
- {question.label} - {question.options.map((option) => ( -
+ {errors} + ); } if (question.type === 'grid_percentage' || question.type === 'grid_number') { const grid = value && typeof value === 'object' ? value : {}; return ( -
- {question.label} - - - - - {question.columns.map((column) => ( - - ))} - - - - {question.rows.map((row) => ( - - +
+
+ {question.label} +
Pack type - {column.label} -
{row.label}
+ + + {question.columns.map((column) => ( - + ))} - ))} - - {question.type === 'grid_percentage' ? ( - - - - {question.columns.map((column) => { - const numbers = question.rows.map((row) => grid[row.id]?.[column.id]); - const filled = numbers.filter((cell) => typeof cell === 'number'); - const total = filled.reduce((sum, cell) => sum + cell, 0); - const ready = filled.length > 0; - const exact = ready && total === question.sumTo; - return ( + + + {question.rows.map((row) => ( + + + {question.columns.map((column) => ( - ); - })} - - - ) : null} -
Pack type - {column.label} -
- { - const raw = event.target.value; - onChange({ - ...grid, - [row.id]: { - ...(grid[row.id] ?? {}), - [column.id]: raw === '' ? '' : Number(raw), - }, - }); - }} - /> - {question.suffix ? ( - {question.suffix} - ) : null} -
-
+ {column.label} +
Total
{row.label} - - {ready - ? exact - ? `Total: ${total}%` - : `Total: ${total}% — must equal ${question.sumTo}%` - : 'Total: —'} - + {column.label} +
+ { + const raw = event.target.value; + onChange({ + ...grid, + [row.id]: { + ...(grid[row.id] ?? {}), + [column.id]: raw === '' ? '' : Number(raw), + }, + }); + }} + /> + {question.suffix ? ( + {question.suffix} + ) : null} +
-
+ ))} + + ))} + + {question.type === 'grid_percentage' ? ( + + + Total + {question.columns.map((column) => { + const numbers = question.rows.map((row) => grid[row.id]?.[column.id]); + const filled = numbers.filter((cell) => typeof cell === 'number'); + const total = filled.reduce((sum, cell) => sum + cell, 0); + const ready = filled.length > 0; + const exact = ready && total === question.sumTo; + return ( + + + {ready + ? exact + ? `Total: ${total}%` + : `Total: ${total}% — must equal ${question.sumTo}%` + : 'Total: —'} + + + ); + })} + + + ) : null} + + + {errors} + ); } if (question.type === 'likert_matrix') { const selected = value && typeof value === 'object' ? value : {}; return ( -
- {question.label} - - - - - {question.scale.map((point) => ( - - ))} - - - - {question.rows.map((row) => ( - - +
+
+ {question.label} +
Attribute - {point.label} -
{row.label}
+ + + {question.scale.map((point) => ( - + ))} - ))} - -
Attribute - {point.label} - - + {point.label} +
-
+ + + {question.rows.map((row) => ( + + {row.label} + {question.scale.map((point) => ( + + {point.label} + + + ))} + + ))} + + + + {errors} + ); } @@ -301,11 +352,14 @@ export default function SurveyPage() { const [stepErrors, setStepErrors] = useState([]); const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); + const [saving, setSaving] = useState(false); + /** @type {[{ message: string, tone: 'success' | 'error' } | null, Function]} */ + const [toast, setToast] = useState(null); const suppressOpen = useRef(false); const answersRef = useRef(answers); const dirtyRef = useRef(false); const persistRef = useRef(async () => {}); - const stepAlertRef = useRef(null); + const toastTimerRef = useRef(/** @type {ReturnType | null} */ (null)); answersRef.current = answers; useEffect(() => { @@ -333,8 +387,33 @@ export default function SurveyPage() { }; }, [token]); - persistRef.current = async () => { - if (!dirtyRef.current) { + /** + * @param {string} message + * @param {'success' | 'error'} [tone] + */ + function showToast(message, tone = 'success') { + if (toastTimerRef.current) { + clearTimeout(toastTimerRef.current); + } + + setToast({ message, tone }); + toastTimerRef.current = setTimeout(() => { + setToast(null); + toastTimerRef.current = null; + }, 5000); + } + + /** + * @param {{ force?: boolean, announce?: boolean }} [options] + */ + persistRef.current = async (options = {}) => { + const force = Boolean(options.force); + const announce = Boolean(options.announce); + + if (!force && !dirtyRef.current) { + if (announce) { + showToast('Progress saved'); + } return; } @@ -345,9 +424,16 @@ export default function SurveyPage() { method: 'PUT', body: { answers: answersRef.current }, }); + if (announce) { + showToast('Progress saved'); + } } catch (saveError) { dirtyRef.current = true; - setError(saveError.message); + if (announce) { + showToast(saveError.message, 'error'); + } else { + setError(saveError.message); + } } }; @@ -362,6 +448,15 @@ export default function SurveyPage() { return () => clearInterval(timer); }, [screen, token]); + useEffect( + () => () => { + if (toastTimerRef.current) { + clearTimeout(toastTimerRef.current); + } + }, + [], + ); + const shownSection = useRef(sectionIndex); useEffect(() => { @@ -374,18 +469,22 @@ export default function SurveyPage() { page.scrollTo?.({ top: 0, behavior: 'smooth' }); }, [sectionIndex]); - useEffect(() => { - if (stepErrors.length > 0) { - stepAlertRef.current?.scrollIntoView?.({ block: 'nearest' }); - } - }, [stepErrors]); - function updateAnswer(id, value) { dirtyRef.current = true; - setStepErrors([]); + setStepErrors((current) => current.filter((item) => item.id !== id)); setAnswers((current) => ({ ...current, [id]: value })); } + async function saveProgress() { + setSaving(true); + setError(''); + try { + await persistRef.current({ force: true, announce: true }); + } finally { + setSaving(false); + } + } + /** * @param {{ questions: Record[] }} section */ @@ -547,29 +646,26 @@ export default function SurveyPage() { ) : (

{section.title}

- {stepErrors.length > 0 ? ( -
- {stepErrors.map((item) => ( -

- {item.message} -

- ))} -
- ) : null}
- {section.questions.map((question, index) => ( - item.id === question.id)} - value={answers[question.id]} - onChange={(value) => updateAnswer(question.id, value)} - /> - ))} + {section.questions.map((question, index) => { + const fieldErrors = stepErrors + .filter((item) => item.id === question.id) + .map((item) => item.message); + return ( + 0} + errorMessages={fieldErrors} + value={answers[question.id]} + onChange={(value) => updateAnswer(question.id, value)} + /> + ); + })}
)} @@ -591,16 +687,31 @@ export default function SurveyPage() { Back ) : null} + {review ? ( - ) : ( - )} + {toast + ? createPortal( +
+ {toast.message} +
, + document.body, + ) + : null} {confirming ? (
diff --git a/frontend/src/pages/SurveyPage.test.jsx b/frontend/src/pages/SurveyPage.test.jsx index 23763cb..fb948d1 100644 --- a/frontend/src/pages/SurveyPage.test.jsx +++ b/frontend/src/pages/SurveyPage.test.jsx @@ -68,7 +68,7 @@ describe('survey steps', () => { fireEvent.click(screen.getByRole('button', { name: 'Next' })); expect( - await screen.findByText(`${PROPORTION} must be at most 100`), + await screen.findByText((text) => text.includes(`${PROPORTION} must be at most 100`)), ).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Foracort prescribing pattern' })).toBeInTheDocument(); expect(screen.queryByRole('heading', { name: 'Notes' })).not.toBeInTheDocument(); @@ -80,7 +80,9 @@ describe('survey steps', () => { await screen.findByLabelText(PROPORTION); fireEvent.click(screen.getByRole('button', { name: 'Next' })); - expect(await screen.findByText(`${PROPORTION} is required`)).toBeInTheDocument(); + expect( + await screen.findByText((text) => text.includes(`${PROPORTION} is required`)), + ).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Foracort prescribing pattern' })).toBeInTheDocument(); expect(screen.queryByRole('heading', { name: 'Review' })).not.toBeInTheDocument(); }); @@ -92,6 +94,29 @@ describe('survey steps', () => { fireEvent.click(screen.getByRole('button', { name: 'Next' })); expect(await screen.findByRole('heading', { name: 'Notes' })).toBeInTheDocument(); - expect(screen.queryByText(`${PROPORTION} must be at most 100`)).not.toBeInTheDocument(); + expect( + screen.queryByText((text) => text.includes(`${PROPORTION} must be at most 100`)), + ).not.toBeInTheDocument(); + }); + + it('saves progress from the Save button and shows a toast', async () => { + const fetchMock = vi.fn(async (path, options = {}) => { + if (String(path).endsWith('/progress') && options.method === 'PUT') { + return { ok: true, json: async () => survey }; + } + + return { ok: true, json: async () => survey }; + }); + vi.stubGlobal('fetch', fetchMock); + + renderSurvey(); + await screen.findByLabelText(PROPORTION); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByText('Progress saved')).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith( + '/api/survey/token/progress', + expect.objectContaining({ method: 'PUT' }), + ); }); }); diff --git a/frontend/src/pages/UserDashboardPage.jsx b/frontend/src/pages/UserDashboardPage.jsx index f59b720..3865ba5 100644 --- a/frontend/src/pages/UserDashboardPage.jsx +++ b/frontend/src/pages/UserDashboardPage.jsx @@ -49,6 +49,43 @@ function surveyStatusLabel(surveyStep, survey) { return 'Not started'; } +/** + * @param {string} label + * @returns {'completed' | 'in-process' | 'not-started' | 'rejected'} + */ +function stepStatusTone(label) { + const normalized = label.toLowerCase(); + + if (normalized === 'completed' || normalized === 'approved') { + return 'completed'; + } + + if (normalized === 'rejected') { + return 'rejected'; + } + + if ( + normalized === 'not started' || + normalized === 'not-started' || + normalized === '' + ) { + return 'not-started'; + } + + return 'in-process'; +} + +/** + * @param {{ label: string }} props + */ +function StepStatusBadge({ label }) { + return ( + + {label} + + ); +} + /** * @param {{ status: string }} props */ @@ -147,7 +184,9 @@ function SurveyDetails({ survey, surveyStep }) {
Status
-
{status}
+
+ +
{started ? (
@@ -240,15 +279,15 @@ export default function UserDashboardPage() {
1. Consent (PIS / ICF) - {me.steps.consent.done ? 'Completed' : 'Not started'} +
2. Physician agreement - {agreementLabel} +
3. Study questionnaire - {surveyLabel} +