# 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:
parent
bb3f8b37a0
commit
29f879c5d1
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue