From 8bd9a720707bdeef08f8c61c60082ee77c233532 Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Wed, 2 Sep 2026 13:54:03 -0400 Subject: [PATCH 01/38] feat(web): add bulk remote assignment wizard Web tier for #1500, on top of the all-or-nothing backend. - Source step offers a subject picker (custom identifiers only, inferred by excluding 64-char hash ids), file upload and paste. - CSV, TSV and XLSX. xlsx is dynamically imported so only a user who selects a workbook pays for it. - Timepoints step replaces InstrumentShowcase: add (instrument, expiry) pairs, each applying to every selected subject. - PII never leaves the browser. Ids are derived with generateSubjectHash and raw rows are dropped once resolved; no error message can contain a value from the file. - Group links become a "Group Actions" group whose children are gated independently. Route file added but route-tree.ts is NOT regenerated, per the repo rule that the user does that manually. Co-Authored-By: Claude Opus 5 --- .../BulkRemoteAssignmentWizard.stories.tsx | 58 ++++ .../BulkRemoteAssignmentWizard.tsx | 165 +++++++++ .../BulkRemoteAssignmentWizard/ErrorList.tsx | 40 +++ .../BulkRemoteAssignmentWizard/MapStep.tsx | 84 +++++ .../BulkRemoteAssignmentWizard/ReviewStep.tsx | 138 ++++++++ .../BulkRemoteAssignmentWizard/SourceStep.tsx | 184 ++++++++++ .../TimepointsStep.tsx | 148 ++++++++ .../BulkRemoteAssignmentWizard/index.ts | 1 + .../BulkRemoteAssignmentWizard/types.ts | 20 ++ .../__tests__/useBulkAssignments.test.ts | 136 ++++++++ .../src/hooks/__tests__/useNavItems.test.ts | 46 ++- apps/web/src/hooks/useBulkAssignments.ts | 73 ++++ apps/web/src/hooks/useNavItems.ts | 28 +- .../_app/group/bulk-remote-assignments.tsx | 53 +++ .../utils/__tests__/bulk-assignments.test.ts | 207 +++++++++++ apps/web/src/utils/bulk-assignments.ts | 321 ++++++++++++++++++ 16 files changed, 1698 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.stories.tsx create mode 100644 apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx create mode 100644 apps/web/src/components/BulkRemoteAssignmentWizard/ErrorList.tsx create mode 100644 apps/web/src/components/BulkRemoteAssignmentWizard/MapStep.tsx create mode 100644 apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx create mode 100644 apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx create mode 100644 apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.tsx create mode 100644 apps/web/src/components/BulkRemoteAssignmentWizard/index.ts create mode 100644 apps/web/src/components/BulkRemoteAssignmentWizard/types.ts create mode 100644 apps/web/src/hooks/__tests__/useBulkAssignments.test.ts create mode 100644 apps/web/src/hooks/useBulkAssignments.ts create mode 100644 apps/web/src/routes/_app/group/bulk-remote-assignments.tsx create mode 100644 apps/web/src/utils/__tests__/bulk-assignments.test.ts create mode 100644 apps/web/src/utils/bulk-assignments.ts diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.stories.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.stories.tsx new file mode 100644 index 000000000..fc2088c76 --- /dev/null +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.stories.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import type { Subject } from '@opendatacapture/schemas/subject'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import { BulkRemoteAssignmentWizard } from './BulkRemoteAssignmentWizard'; + +type Story = StoryObj; + +const subject = (id: string): Subject => ({ + createdAt: new Date(), + dateOfBirth: null, + firstName: null, + groupIds: ['group-1'], + id, + lastName: null, + sex: null, + updatedAt: new Date() +}); + +export default { + args: { + defaultExpiresAt: new Date(Date.now() + 30 * 86_400_000).toISOString().split('T')[0]!, + groupId: 'group-1', + instruments: [ + { id: 'instrument-1', title: 'Happiness Questionnaire' }, + { id: 'instrument-2', title: 'General Consent Form' } + ], + subjects: [subject('Depression_Clinic$001'), subject('Depression_Clinic$002')] + }, + component: BulkRemoteAssignmentWizard, + // The wizard submits through a mutation, so it needs a client even in the states that never submit. + decorators: [ + (Story: React.ComponentType) => ( + + + + ) + ] +} as Meta; + +/** The entry state: pick existing subjects, upload a file, or paste delimited data. */ +export const Source: Story = {}; + +/** Every subject uses a generated identifier, so only the file and paste paths are offered. */ +export const NoSelectableSubjects: Story = { + args: { + subjects: [subject('a'.repeat(64)), subject('b'.repeat(64))] + } +}; + +/** The group has opted into no instruments, so no timepoint can be added. */ +export const NoAccessibleInstruments: Story = { + args: { + instruments: [] + } +}; diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx new file mode 100644 index 000000000..6425b7517 --- /dev/null +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx @@ -0,0 +1,165 @@ +import React, { useState } from 'react'; + +import { Button, Heading } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { BulkAssignmentFailure } from '@opendatacapture/schemas/assignment'; +import type { Subject } from '@opendatacapture/schemas/subject'; + +import { toBulkAssignmentFailure, useCreateBulkAssignmentsMutation } from '@/hooks/useBulkAssignments'; +import { toResultCsv } from '@/utils/bulk-assignments'; + +import { MapStep } from './MapStep'; +import { ReviewStep } from './ReviewStep'; +import { SourceStep } from './SourceStep'; +import { TimepointsStep } from './TimepointsStep'; + +import type { WizardState } from './types'; + +type InstrumentOption = { id: string; title: string }; + +const downloadCsv = (csv: string) => { + const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' })); + const anchor = document.createElement('a'); + anchor.download = 'bulk-remote-assignments.csv'; + anchor.href = url; + anchor.click(); + URL.revokeObjectURL(url); +}; + +export type BulkRemoteAssignmentWizardProps = { + /** Prefilled expiry for a new timepoint, as `YYYY-MM-DD`. */ + defaultExpiresAt: string; + groupId: string; + instruments: InstrumentOption[]; + subjects: Subject[]; +}; + +/** + * Bulk remote assignment wizard. + * + * The operation is all-or-nothing end to end: the API refuses a batch it cannot create in full, so + * there is no partial-result state to render. A refusal returns the user to review with what to fix, + * and nothing has been created. + * + * Raw uploaded rows live only inside this component's state and are dropped as soon as subject ids + * are resolved, so a reset or an unmount discards them. + */ +export const BulkRemoteAssignmentWizard = ({ + defaultExpiresAt, + groupId, + instruments, + subjects +}: BulkRemoteAssignmentWizardProps) => { + const { t } = useTranslation(); + const [state, setState] = useState({ step: 'SOURCE' }); + const [failure, setFailure] = useState(null); + const [transportError, setTransportError] = useState(false); + const createMutation = useCreateBulkAssignmentsMutation(); + + const reset = () => { + setFailure(null); + setTransportError(false); + setState({ step: 'SOURCE' }); + }; + + const submit = ({ allowDuplicates }: { allowDuplicates: boolean }) => { + if (state.step !== 'REVIEW') { + return; + } + setFailure(null); + setTransportError(false); + createMutation.mutate( + { + allowDuplicates, + groupId, + subjectIds: state.subjectIds, + timepoints: state.timepoints.map(({ expiresAt, instrumentId }) => ({ + expiresAt: new Date(`${expiresAt}T23:59:59.999Z`), + instrumentId + })) + }, + { + onError: (error) => { + const refusal = toBulkAssignmentFailure(error); + if (refusal) { + setFailure(refusal); + return; + } + setTransportError(true); + }, + onSuccess: (assignments) => { + setState({ createdCount: assignments.length, step: 'DONE', subjectIds: state.subjectIds }); + } + } + ); + }; + + return ( +
+ {state.step === 'SOURCE' && ( + setState({ parsed, step: 'MAP' })} + onSubjectsSelected={(subjectIds) => setState({ step: 'TIMEPOINTS', subjectIds })} + /> + )} + + {state.step === 'MAP' && ( + setState({ step: 'TIMEPOINTS', subjectIds })} + /> + )} + + {state.step === 'TIMEPOINTS' && ( + setState({ step: 'REVIEW', subjectIds: state.subjectIds, timepoints })} + /> + )} + + {state.step === 'REVIEW' && ( + setState({ step: 'TIMEPOINTS', subjectIds: state.subjectIds })} + onSubmit={submit} + /> + )} + + {state.step === 'DONE' && ( +
+ {t({ en: 'Assignments created', fr: 'Tâches créées' })} +

+ {t({ + en: `${state.createdCount} assignments were created.`, + fr: `${state.createdCount} tâches ont été créées.` + })} +

+
+ + +
+
+ )} +
+ ); +}; diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/ErrorList.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/ErrorList.tsx new file mode 100644 index 000000000..a6ce0eccf --- /dev/null +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/ErrorList.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; + +import type { BulkParseError } from '@/utils/bulk-assignments'; + +/** + * Every failure the wizard shows goes through here. Messages arrive already sanitized — the parser + * never puts a value from the user's file into one — so this only has to render them. + */ +export const ErrorList = ({ errors }: { errors: BulkParseError[] }) => { + const { t } = useTranslation(); + if (errors.length === 0) { + return null; + } + return ( +
+

+ {t({ + en: 'Nothing has been created. Fix the following and try again:', + fr: 'Rien n’a été créé. Corrigez ce qui suit et réessayez :' + })} +

+
    + {errors.map((error, index) => ( +
  • + {error.row !== undefined && ( + {t({ en: `Row ${error.row}: `, fr: `Ligne ${error.row} : ` })} + )} + {error.message} +
  • + ))} +
+
+ ); +}; diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/MapStep.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/MapStep.tsx new file mode 100644 index 000000000..8d80089a0 --- /dev/null +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/MapStep.tsx @@ -0,0 +1,84 @@ +import React, { useState } from 'react'; + +import { Badge, Button, ClientTable } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { BULK_ASSIGNMENT_MAX_SUBJECTS } from '@opendatacapture/schemas/assignment'; + +import { BulkParseFailure, resolveSubjectIds } from '@/utils/bulk-assignments'; +import type { BulkParseError, BulkParseResult } from '@/utils/bulk-assignments'; + +import { ErrorList } from './ErrorList'; + +type MapStepProps = { + onBack: () => void; + onResolved: (subjectIds: string[]) => void; + parsed: BulkParseResult; +}; + +export const MapStep = ({ onBack, onResolved, parsed }: MapStepProps) => { + const { t } = useTranslation(); + const [errors, setErrors] = useState([]); + + const resolve = async () => { + setErrors([]); + try { + onResolved(await resolveSubjectIds(parsed, { maxSubjects: BULK_ASSIGNMENT_MAX_SUBJECTS })); + } catch (err) { + if (err instanceof BulkParseFailure) { + setErrors(err.errors); + return; + } + throw err; + } + }; + + return ( +
+ +
+ {t({ en: 'Detected mode', fr: 'Mode détecté' })}: + + {parsed.mode === 'ID' + ? t({ en: 'Subject ID', fr: 'Identifiant du sujet' }) + : t({ en: 'Personal information', fr: 'Renseignements personnels' })} + + + {t({ en: `${parsed.rows.length} rows`, fr: `${parsed.rows.length} lignes` })} + +
+ + {parsed.mode === 'PII' && ( +

+ {t({ + en: 'Subject identifiers are derived in your browser. The personal information in this file is never sent.', + fr: 'Les identifiants sont dérivés dans votre navigateur. Les renseignements personnels de ce fichier ne sont jamais envoyés.' + })} +

+ )} + +
+ {parsed.headers.map((header) => ( + + {header} + {parsed.mapping[header] ? ` → ${parsed.mapping[header]}` : ` (${t({ en: 'ignored', fr: 'ignorée' })})`} + + ))} +
+ + ({ field: header, label: header }))} + data={parsed.preview} + data-testid="bulk-preview-table" + /> + +
+ + +
+
+ ); +}; diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx new file mode 100644 index 000000000..0adc2e5cf --- /dev/null +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx @@ -0,0 +1,138 @@ +import React, { useState } from 'react'; + +import { Button, Checkbox } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { BulkAssignmentFailure, BulkAssignmentIssue } from '@opendatacapture/schemas/assignment'; + +import type { BulkParseError } from '@/utils/bulk-assignments'; + +import { ErrorList } from './ErrorList'; + +import type { DraftTimepoint } from './types'; + +type ReviewStepProps = { + failure: BulkAssignmentFailure | null; + isSubmitting: boolean; + onBack: () => void; + onSubmit: (options: { allowDuplicates: boolean }) => void; + subjectCount: number; + timepoints: DraftTimepoint[]; + transportError: boolean; +}; + +/** + * Render a refusal as sentences a clinician can act on. The API reports ids rather than names — it + * has never been told the names — so a count plus the ids is all there is to show, and all that + * should be shown. + */ +const useIssueMessages = () => { + const { t } = useTranslation(); + return (issues: BulkAssignmentIssue[]): BulkParseError[] => + issues.map((issue) => { + switch (issue.kind) { + case 'CONFLICT': + return { + message: t({ + en: `${issue.conflicts.length} subject(s) already have an outstanding assignment for one of these instruments.`, + fr: `${issue.conflicts.length} sujet(s) ont déjà une tâche en cours pour l’un de ces instruments.` + }) + }; + case 'INSTRUMENT_UNAVAILABLE': + return { + message: t({ + en: `This group cannot assign ${issue.instrumentIds.length} of the selected instrument(s).`, + fr: `Ce groupe ne peut pas attribuer ${issue.instrumentIds.length} des instruments sélectionnés.` + }) + }; + case 'SUBJECT_UNAVAILABLE': + return { + message: t({ + en: `${issue.subjectIds.length} subject(s) are not available in this group.`, + fr: `${issue.subjectIds.length} sujet(s) ne sont pas disponibles dans ce groupe.` + }) + }; + } + }); +}; + +export const ReviewStep = ({ + failure, + isSubmitting, + onBack, + onSubmit, + subjectCount, + timepoints, + transportError +}: ReviewStepProps) => { + const { t } = useTranslation(); + const [allowDuplicates, setAllowDuplicates] = useState(false); + const toMessages = useIssueMessages(); + + const hasConflict = failure?.issues.some(({ kind }) => kind === 'CONFLICT') ?? false; + const messages = failure ? toMessages(failure.issues) : []; + if (transportError) { + messages.push({ + message: t({ + en: 'The request could not be completed. Nothing has been created.', + fr: 'La requête n’a pas pu être complétée. Rien n’a été créé.' + }) + }); + } + + return ( +
+ + +
+

+ {t({ + en: `${subjectCount} subjects × ${timepoints.length} instruments = ${subjectCount * timepoints.length} assignments`, + fr: `${subjectCount} sujets × ${timepoints.length} instruments = ${subjectCount * timepoints.length} tâches` + })} +

+
    + {timepoints.map((timepoint) => ( +
  • + {timepoint.instrumentTitle} — {timepoint.expiresAt} +
  • + ))} +
+
+ +

+ {t({ + en: 'All assignments are created together. If any of them cannot be created, none are.', + fr: 'Toutes les tâches sont créées ensemble. Si l’une d’elles échoue, aucune n’est créée.' + })} +

+ + {hasConflict && ( + + )} + +
+ + +
+
+ ); +}; diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx new file mode 100644 index 000000000..b94cba411 --- /dev/null +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx @@ -0,0 +1,184 @@ +import React, { useState } from 'react'; + +import { Button, Card, ClientTable, FileDropzone, TextArea } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { Subject } from '@opendatacapture/schemas/subject'; + +import { + ACCEPTED_FILE_EXTENSIONS, + assertFileSize, + BulkParseFailure, + isWorkbookFile, + parseDelimitedText, + parseWorkbook +} from '@/utils/bulk-assignments'; +import type { BulkParseError, BulkParseResult } from '@/utils/bulk-assignments'; + +import { ErrorList } from './ErrorList'; + +type SourceStepProps = { + onParsed: (parsed: BulkParseResult) => void; + onSubjectsSelected: (subjectIds: string[]) => void; + subjects: Subject[]; +}; + +/** + * A subject id that was generated from personal information is a 64-character hex digest; anything + * else was chosen by a person. Only the latter is meaningful to pick from a list, so the picker + * offers those and leaves hash-identified subjects to the file/paste path. + */ +const GENERATED_ID = /^[0-9a-f]{64}$/i; + +export const isCustomIdentifier = (id: string) => !GENERATED_ID.test(id); + +export const SourceStep = ({ onParsed, onSubjectsSelected, subjects }: SourceStepProps) => { + const { t } = useTranslation(); + const [errors, setErrors] = useState([]); + const [pasted, setPasted] = useState(''); + const [selected, setSelected] = useState>(() => new Set()); + + const selectable = subjects.filter(({ id }) => isCustomIdentifier(id)); + + const run = async (parse: () => BulkParseResult | Promise) => { + setErrors([]); + try { + onParsed(await parse()); + } catch (err) { + if (err instanceof BulkParseFailure) { + setErrors(err.errors); + return; + } + throw err; + } + }; + + const handleFile = (file: File) => + void run(async () => { + assertFileSize(file); + // The workbook parser is the only path that pulls in `xlsx`, and it does so dynamically. + return isWorkbookFile(file) ? parseWorkbook(file) : parseDelimitedText(await file.text()); + }); + + const toggle = (id: string) => + setSelected((previous) => { + const next = new Set(previous); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + + return ( +
+ + + + {t({ en: 'Select subjects', fr: 'Sélectionner des sujets' })} + + {t({ + en: 'Choose existing subjects in this group that use a custom identifier.', + fr: 'Choisissez des sujets existants de ce groupe qui utilisent un identifiant personnalisé.' + })} + + + + {selectable.length === 0 ? ( +

+ {t({ + en: 'No subjects with a custom identifier are available in this group.', + fr: 'Aucun sujet avec un identifiant personnalisé n’est disponible dans ce groupe.' + })} +

+ ) : ( + ({ + id, + selected: selected.has(id) ? '✓' : '' + }))} + data-testid="bulk-subject-picker" + onEntryClick={({ id }) => toggle(id)} + /> + )} +
+ + + +
+ + + + {t({ en: 'Upload a file', fr: 'Téléverser un fichier' })} + + {t({ + en: 'CSV, TSV or Excel. Include a subject ID column, or first name, last name, date of birth and sex.', + fr: 'CSV, TSV ou Excel. Incluez une colonne d’identifiant, ou prénom, nom, date de naissance et sexe.' + })} + + + + + + + + + + {t({ en: 'Or paste data', fr: 'Ou coller des données' })} + + {t({ + en: 'Comma, tab or semicolon separated, with a header row.', + fr: 'Séparé par des virgules, tabulations ou points-virgules, avec une ligne d’en-tête.' + })} + + + +