# 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.
This commit is contained in:
Amey Bhagwat 2026-10-19 12:13:59 +05:30
parent bb3f8b37a0
commit 29f879c5d1
8 changed files with 571 additions and 266 deletions

View File

@ -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
? `<img class="crest-logo" alt="CREST" src="${crestLogoDataUrl}" />`
: '';
return `<span class="signature-row"><span class="signature-field">Signature: ${signatureMarkup}</span>${logo}</span>`;
}
/**
* @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; }
</style>
</head>
<body>

View File

@ -70,8 +70,8 @@ describe('agreement HTML', () => {
expect(html).toContain('<span class="filled">Dr Sudeep Salvi</span>');
expect(html).toContain('<span class="filled">Medical Director</span>');
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');
});

View File

@ -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;

View File

@ -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<Record<SettingsCard, CardMessage>>, Function]} */
const [cardMessages, setCardMessages] = useState({});
const timersRef = useRef(
/** @type {Partial<Record<SettingsCard, ReturnType<typeof setTimeout>>>} */ ({}),
);
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 ? <p className="text-red-700">{error}</p> : <p>Loading settings…</p>;
return loadError ? <p className="text-red-700">{loadError}</p> : <p>Loading settings…</p>;
}
return (
@ -115,6 +147,7 @@ export default function SettingsPage() {
<button type="submit" className="admin-btn admin-btn-primary">
Save
</button>
<CardStatus message={cardMessages.signatory} />
</form>
<div className="admin-card mt-4">
<h2 className="mb-2 font-semibold text-[#134e4a]">Signature image</h2>
@ -137,6 +170,7 @@ export default function SettingsPage() {
className="admin-file"
onChange={upload}
/>
<CardStatus message={cardMessages.signature} />
</div>
<form className="admin-card mt-4 grid gap-3" onSubmit={saveReminders}>
<h2 className="font-semibold text-[#134e4a]">Reminders</h2>
@ -181,9 +215,23 @@ export default function SettingsPage() {
<button type="submit" className="admin-btn admin-btn-primary">
Save reminders
</button>
<CardStatus message={cardMessages.reminders} />
</form>
{error ? <p className="mt-3 text-red-700">{error}</p> : null}
{status ? <p className="mt-3">{status}</p> : null}
</div>
);
}
/**
* @param {{ message?: CardMessage }} props
*/
function CardStatus({ message }) {
if (!message) {
return null;
}
return (
<p className={`admin-card-status admin-card-status-${message.tone}`} role="status">
{message.message}
</p>
);
}

View File

@ -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 {

View File

@ -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<string, unknown>, 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 (
<div className="survey-field-errors" role="alert">
{messages.map((message) => (
<p key={message} className="field-error">
* {message}
</p>
))}
</div>
);
}
/**
* @param {{
* question: Record<string, unknown>,
* 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 = <FieldErrors messages={errorMessages} />;
if (question.type === 'help') {
return <p className="survey-help">{question.text}</p>;
@ -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 (
<label>
{question.label}
<Input
value={typeof value === 'string' ? value : ''}
onChange={(event) => onChange(event.target.value)}
/>
</label>
<div className="survey-field">
<label>
{question.label}
<Input
aria-invalid={invalid || undefined}
value={typeof value === 'string' ? value : ''}
onChange={(event) => onChange(event.target.value)}
/>
</label>
{errors}
</div>
);
}
if (question.type === 'number' || question.type === 'percentage') {
return (
<label>
{question.label}
<input
type="number"
inputMode={question.integer ? 'numeric' : 'decimal'}
min={question.min}
max={question.max}
aria-invalid={invalid || undefined}
value={value === undefined || value === null ? '' : value}
onChange={(event) => {
const raw = event.target.value;
onChange(raw === '' ? '' : Number(raw));
}}
/>
</label>
<div className="survey-field">
<label>
{question.label}
<input
type="number"
inputMode={question.integer ? 'numeric' : 'decimal'}
min={question.min}
max={question.max}
aria-invalid={invalid || undefined}
value={value === undefined || value === null ? '' : value}
onChange={(event) => {
const raw = event.target.value;
onChange(raw === '' ? '' : Number(raw));
}}
/>
</label>
{errors}
</div>
);
}
if (question.type === 'single_choice' || question.type === 'multi_choice') {
const current = value && typeof value === 'object' ? value : {};
return (
<fieldset>
<legend className={legendClass}>{question.label}</legend>
{question.options.map((option) => (
<label key={option.id} className="survey-choice">
<div className="survey-field">
<fieldset aria-invalid={invalid || undefined}>
<legend className={legendClass}>{question.label}</legend>
{question.options.map((option) => (
<label key={option.id} className="survey-choice">
<input
type={question.type === 'single_choice' ? 'radio' : 'checkbox'}
name={question.id}
checked={
question.type === 'single_choice'
? current.choice === option.id
: (current.choices ?? []).includes(option.id)
}
onChange={() => {
if (question.type === 'single_choice') {
onChange({ choice: option.id, other: current.other ?? '' });
return;
}
const choices = new Set(current.choices ?? []);
if (choices.has(option.id)) {
choices.delete(option.id);
} else {
choices.add(option.id);
}
onChange({ choices: [...choices], other: current.other ?? '' });
}}
/>
{option.label}
</label>
))}
{question.other &&
(current.choice === 'other' || (current.choices ?? []).includes('other')) ? (
<input
type={question.type === 'single_choice' ? 'radio' : 'checkbox'}
name={question.id}
checked={
question.type === 'single_choice'
? current.choice === option.id
: (current.choices ?? []).includes(option.id)
}
onChange={() => {
if (question.type === 'single_choice') {
onChange({ choice: option.id, other: current.other ?? '' });
return;
}
const choices = new Set(current.choices ?? []);
if (choices.has(option.id)) {
choices.delete(option.id);
} else {
choices.add(option.id);
}
onChange({ choices: [...choices], other: current.other ?? '' });
}}
aria-label={`${question.label} other`}
value={current.other ?? ''}
onChange={(event) => onChange({ ...current, other: event.target.value })}
/>
{option.label}
</label>
))}
{question.other &&
(current.choice === 'other' || (current.choices ?? []).includes('other')) ? (
<input
aria-label={`${question.label} other`}
value={current.other ?? ''}
onChange={(event) => onChange({ ...current, other: event.target.value })}
/>
) : null}
</fieldset>
) : null}
</fieldset>
{errors}
</div>
);
}
if (question.type === 'grid_percentage' || question.type === 'grid_number') {
const grid = value && typeof value === 'object' ? value : {};
return (
<fieldset>
<legend className={legendClass}>{question.label}</legend>
<table className="survey-grid">
<thead>
<tr>
<th scope="col">Pack type</th>
{question.columns.map((column) => (
<th key={column.id} scope="col">
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{question.rows.map((row) => (
<tr key={row.id}>
<th scope="row">{row.label}</th>
<div className="survey-field">
<fieldset aria-invalid={invalid || undefined}>
<legend className={legendClass}>{question.label}</legend>
<table className="survey-grid">
<thead>
<tr>
<th scope="col">Pack type</th>
{question.columns.map((column) => (
<td key={column.id}>
<span className="cell-label">{column.label}</span>
<div className="survey-grid-input">
<input
type="number"
inputMode={question.integer ? 'numeric' : 'decimal'}
min={question.min}
max={question.max}
aria-invalid={invalid || undefined}
aria-label={`${row.label}, ${column.label}`}
value={grid[row.id]?.[column.id] ?? ''}
onChange={(event) => {
const raw = event.target.value;
onChange({
...grid,
[row.id]: {
...(grid[row.id] ?? {}),
[column.id]: raw === '' ? '' : Number(raw),
},
});
}}
/>
{question.suffix ? (
<span className="survey-grid-suffix">{question.suffix}</span>
) : null}
</div>
</td>
<th key={column.id} scope="col">
{column.label}
</th>
))}
</tr>
))}
</tbody>
{question.type === 'grid_percentage' ? (
<tfoot>
<tr>
<th scope="row">Total</th>
{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 (
</thead>
<tbody>
{question.rows.map((row) => (
<tr key={row.id}>
<th scope="row">{row.label}</th>
{question.columns.map((column) => (
<td key={column.id}>
<span className={exact || !ready ? 'survey-total' : 'survey-total warn'}>
{ready
? exact
? `Total: ${total}%`
: `Total: ${total}% — must equal ${question.sumTo}%`
: 'Total: —'}
</span>
<span className="cell-label">{column.label}</span>
<div className="survey-grid-input">
<input
type="number"
inputMode={question.integer ? 'numeric' : 'decimal'}
min={question.min}
max={question.max}
aria-invalid={invalid || undefined}
aria-label={`${row.label}, ${column.label}`}
value={grid[row.id]?.[column.id] ?? ''}
onChange={(event) => {
const raw = event.target.value;
onChange({
...grid,
[row.id]: {
...(grid[row.id] ?? {}),
[column.id]: raw === '' ? '' : Number(raw),
},
});
}}
/>
{question.suffix ? (
<span className="survey-grid-suffix">{question.suffix}</span>
) : null}
</div>
</td>
);
})}
</tr>
</tfoot>
) : null}
</table>
</fieldset>
))}
</tr>
))}
</tbody>
{question.type === 'grid_percentage' ? (
<tfoot>
<tr>
<th scope="row">Total</th>
{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 (
<td key={column.id}>
<span className={exact || !ready ? 'survey-total' : 'survey-total warn'}>
{ready
? exact
? `Total: ${total}%`
: `Total: ${total}% — must equal ${question.sumTo}%`
: 'Total: —'}
</span>
</td>
);
})}
</tr>
</tfoot>
) : null}
</table>
</fieldset>
{errors}
</div>
);
}
if (question.type === 'likert_matrix') {
const selected = value && typeof value === 'object' ? value : {};
return (
<fieldset>
<legend className={legendClass}>{question.label}</legend>
<table className="survey-grid">
<thead>
<tr>
<th scope="col">Attribute</th>
{question.scale.map((point) => (
<th key={point.id} scope="col">
{point.label}
</th>
))}
</tr>
</thead>
<tbody>
{question.rows.map((row) => (
<tr key={row.id}>
<th scope="row">{row.label}</th>
<div className="survey-field">
<fieldset aria-invalid={invalid || undefined}>
<legend className={legendClass}>{question.label}</legend>
<table className="survey-grid">
<thead>
<tr>
<th scope="col">Attribute</th>
{question.scale.map((point) => (
<td key={point.id} className="survey-grid-choice">
<span className="cell-label">{point.label}</span>
<label className="survey-choice">
<input
type="radio"
name={`${question.id}-${row.id}`}
checked={selected[row.id] === point.id}
onChange={() => onChange({ ...selected, [row.id]: point.id })}
/>
<span className="visually-hidden">{point.label}</span>
</label>
</td>
<th key={point.id} scope="col">
{point.label}
</th>
))}
</tr>
))}
</tbody>
</table>
</fieldset>
</thead>
<tbody>
{question.rows.map((row) => (
<tr key={row.id}>
<th scope="row">{row.label}</th>
{question.scale.map((point) => (
<td key={point.id} className="survey-grid-choice">
<span className="cell-label">{point.label}</span>
<label className="survey-choice">
<input
type="radio"
name={`${question.id}-${row.id}`}
checked={selected[row.id] === point.id}
onChange={() => onChange({ ...selected, [row.id]: point.id })}
/>
<span className="visually-hidden">{point.label}</span>
</label>
</td>
))}
</tr>
))}
</tbody>
</table>
</fieldset>
{errors}
</div>
);
}
@ -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<typeof setTimeout> | 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<string, unknown>[] }} section
*/
@ -547,29 +646,26 @@ export default function SurveyPage() {
) : (
<section>
<h2>{section.title}</h2>
{stepErrors.length > 0 ? (
<div ref={stepAlertRef} role="alert">
{stepErrors.map((item) => (
<p key={`${item.id}:${item.message}`} className="error">
{item.message}
</p>
))}
</div>
) : null}
<div className="survey-fields">
{section.questions.map((question, index) => (
<QuestionField
key={question.id}
question={question}
hideLabel={
section.questions[index - 1]?.type === 'section_header' &&
section.questions[index - 1].label === question.label
}
invalid={stepErrors.some((item) => 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 (
<QuestionField
key={question.id}
question={question}
hideLabel={
section.questions[index - 1]?.type === 'section_header' &&
section.questions[index - 1].label === question.label
}
invalid={fieldErrors.length > 0}
errorMessages={fieldErrors}
value={answers[question.id]}
onChange={(value) => updateAnswer(question.id, value)}
/>
);
})}
</div>
</section>
)}
@ -591,16 +687,31 @@ export default function SurveyPage() {
Back
</button>
) : null}
<button type="button" className="secondary" disabled={busy || saving} onClick={saveProgress}>
{saving ? 'Saving…' : 'Save'}
</button>
{review ? (
<button type="button" disabled={busy} onClick={openConfirm}>
<button type="button" disabled={busy || saving} onClick={openConfirm}>
Submit
</button>
) : (
<button type="button" disabled={busy} onClick={goNext}>
<button type="button" disabled={busy || saving} onClick={goNext}>
Next
</button>
)}
</div>
{toast
? createPortal(
<div
className={`survey-toast survey-toast-${toast.tone}`}
role="status"
aria-live="polite"
>
{toast.message}
</div>,
document.body,
)
: null}
{confirming ? (
<div className="survey-dialog">
<div role="dialog" aria-modal="true" aria-labelledby="submit-title">

View File

@ -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' }),
);
});
});

View File

@ -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 (
<span className={`user-step-status user-step-status-${stepStatusTone(label)}`}>
{label}
</span>
);
}
/**
* @param {{ status: string }} props
*/
@ -147,7 +184,9 @@ function SurveyDetails({ survey, surveyStep }) {
<dl className="user-detail-grid">
<div>
<dt>Status</dt>
<dd>{status}</dd>
<dd>
<StepStatusBadge label={status} />
</dd>
</div>
{started ? (
<div>
@ -240,15 +279,15 @@ export default function UserDashboardPage() {
<div className="user-step-list">
<div className={`user-step ${me.steps.consent.done ? 'done' : 'pending'}`}>
<strong>1. Consent (PIS / ICF)</strong>
<span>{me.steps.consent.done ? 'Completed' : 'Not started'}</span>
<StepStatusBadge label={me.steps.consent.done ? 'Completed' : 'Not started'} />
</div>
<div className={`user-step ${me.steps.agreement.done ? 'done' : 'pending'}`}>
<strong>2. Physician agreement</strong>
<span>{agreementLabel}</span>
<StepStatusBadge label={agreementLabel} />
</div>
<div className={`user-step ${me.steps.survey.done ? 'done' : 'pending'}`}>
<strong>3. Study questionnaire</strong>
<span>{surveyLabel}</span>
<StepStatusBadge label={surveyLabel} />
</div>
</div>