import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { UploadCloud, AlertTriangle, CheckCircle2, Info } from 'lucide-react'; import { AppHeader, AppSpinner, ScrollableTab } from '../../../components'; import InvitePatientsPanel from './InvitePatientsPanel.tsx'; import { usePatientImportService, type ColumnMapping, type DryRunResult, type ImportDecision, type CommitResult, type PlannedImportRow, type ImportOperationSummary, type ImportStatus, } from '../../../services/PatientImportService.tsx'; import { commitReadiness, rowsNeedingDecision, reRunWarning, commitOutcomeSummary, fieldLabel, choiceFits, decisionList, matchChoices, reviewCounts, ANSWERS_PER_STEP, canContinue, canReviewAgain, narrowToRows, remainingWork, stillQueued, withRevisions, type ImportStep, } from './patientImportWizard'; import { onViewerChange, viewerEpoch } from '../../../utils/writeThroughHold'; import { mayHaveBeenWritten, relayServerFailure } from '../../../utils/fetchFailureMessage'; import { importButtonLabel } from './importButtonLabel'; // - bringing a practice's existing patients in. // // THE REVIEW STEP IS THE FEATURE, NOT A CONFIRMATION SCREEN. Patient identity in Akashic is GLOBAL, // so an import that just writes rows silently creates duplicates of people who already exist - usually // as patients of another practice - and those duplicates stay invisible until the patient turns up // somewhere else with half a history. This page exists to put that reconciliation in front of a human // while it is still cheap. // // ZERO WordPress: Radix `ui/*` + Tailwind + lucide only. No Cancel button - Back navigates steps. const STEP_LABELS: Array<[ImportStep, string]> = [ ['upload', 'Choose a file'], ['mapping', 'Match the columns'], ['review', 'Review'], ['done', 'Done'], ]; /** Thrown by a request whose opening has moved on; its caller publishes nothing. */ class ImportRetired extends Error {} /** A commit answered by our own refusal wrote nothing new; anything else may have been made. */ const refusedBy = (error: unknown): { status: number; code?: string; batchId?: string } | undefined => { const failure = error as { status?: unknown; body?: { code?: unknown; batchId?: unknown } } | null; if (typeof failure?.status !== 'number' || failure.status >= 500 || mayHaveBeenWritten(error)) return undefined; const code = typeof failure.body?.code === 'string' ? failure.body.code : undefined; const batchId = typeof failure.body?.batchId === 'string' ? failure.body.batchId : undefined; return { status: failure.status, code, batchId }; }; const PatientImportPage: React.FC = () => { const { requestUploadPolicy, uploadFile, dryRun, commit, getStatus, listOperations } = usePatientImportService(); const [step, setStep] = useState('upload'); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [batchId, setBatchId] = useState(null); const [result, setResult] = useState(); const [mapping, setMapping] = useState({}); const [decisions, setDecisions] = useState>(new Map()); const [outcome, setOutcome] = useState(null); // Earlier imports of this practice, read from the server; nothing about them is kept in the browser. const [operations, setOperations] = useState(null); const [listFailed, setListFailed] = useState(false); const [olderAfter, setOlderAfter] = useState(); const [olderFailed, setOlderFailed] = useState(false); // Answers a step has not recorded yet, held in memory only and sent again with the next continue. const [queued, setQueued] = useState<{ batchId: string; answers: ImportDecision[] } | null>(null); const [status, setStatus] = useState(null); const [unconfirmed, setUnconfirmed] = useState(false); // A review resumed from an import already under way: its answers name the revision of each row. const [revisions, setRevisions] = useState | null>(null); // Every upload, preview and commit belongs to this opening, this viewer (whose practice it includes) and the // batch and mapping it began on. A later one, a new viewer or leaving the page retires it before it is sent // or published; a commit already sent may still have been made, and retiring it undoes nothing. const opening = useRef({}); const generation = useRef(0); const inFlight = useRef(undefined); const working = useRef(false); const retire = () => { generation.current += 1; inFlight.current?.abort(); inFlight.current = undefined; }; const begin = () => { retire(); const controller = new AbortController(); inFlight.current = controller; const owner = { opening: opening.current, generation: generation.current, viewer: viewerEpoch() }; const current = () => owner.opening !== undefined && opening.current === owner.opening && generation.current === owner.generation && viewerEpoch() === owner.viewer; const admit = () => { if (!current()) throw new ImportRetired(); }; return { current, transport: { admit, signal: controller.signal } }; }; // Read on opening and again for each new viewer; an answer for a retired opening or viewer is dropped. const loadOperations = useRef<() => void>(() => {}); loadOperations.current = () => { const op = begin(); setListFailed(false); setOlderFailed(false); listOperations(undefined, op.transport).then((page) => { if (op.current()) { setOperations(page.operations); setOlderAfter(page.next); } }, () => { // A refused or failed list is said, never shown as no earlier imports. if (op.current()) { setOperations(null); setOlderAfter(undefined); setListFailed(true); } }); }; // One older page per press, under the same fence; a failed page is said and can be asked for again. const loadOlder = useCallback(() => { const after = olderAfter; if (!after || working.current) return; const op = begin(); setOlderFailed(false); listOperations(after, op.transport).then((page) => { if (!op.current()) return; setOperations((shown) => { const known = new Set((shown ?? []).map((item) => item.batchId)); return [...(shown ?? []), ...page.operations.filter((item) => !known.has(item.batchId))]; }); setOlderAfter(page.next); }, () => { if (op.current()) setOlderFailed(true); }); }, [olderAfter, listOperations]); useEffect(() => { opening.current = {}; const stop = onViewerChange(() => { retire(); working.current = false; setBusy(false); setStep('upload'); setError(null); setBatchId(null); setResult(undefined); setMapping({}); setDecisions(new Map()); setOutcome(null); setOperations(null); setListFailed(false); setStatus(null); setUnconfirmed(false); setRevisions(null); setOlderAfter(undefined); setOlderFailed(false); setQueued(null); loadOperations.current(); }); loadOperations.current(); return () => { stop(); retire(); opening.current = undefined; }; }, []); const readiness = useMemo(() => commitReadiness(result, decisions), [result, decisions]); const counts = useMemo(() => (result ? reviewCounts(result, decisions) : { creates: 0, links: 0, skips: 0 }), [result, decisions]); const matches = useMemo(() => (result ? rowsNeedingDecision(result) : []), [result]); const reRun = useMemo(() => (result ? reRunWarning(result.summary) : undefined), [result]); const handleFile = useCallback(async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; // Cleared BEFORE the guard, so re-picking the SAME file after an error still fires `onChange`. // Without this a practice who picks a bad file has to pick a different one to try again. event.target.value = ''; if (!file || working.current) return; const op = begin(); working.current = true; setBusy(true); setError(null); // A new file is a new review: nothing chosen for the last one carries over. setBatchId(null); setResult(undefined); setDecisions(new Map()); setOutcome(null); setStatus(null); setUnconfirmed(false); setRevisions(null); try { const policy = await requestUploadPolicy(op.transport); const sent = await uploadFile(policy, file, op.transport); if (!op.current()) return; // THREE OUTCOMES, AND THE THIRD IS NOT A FAILURE. A FormData POST is a CORS-simple // request, so a missing bucket rule withholds the RESPONSE while S3 stores the object // anyway. Telling the practice it failed would send them round the loop uploading their // whole patient list again. We do not know - so we go and look. if (sent === 'failed') { setError('That file could not be uploaded. Please check the file and try again.'); return; } setBatchId(policy.batchId); const plan = await dryRun(policy.batchId, undefined, op.transport); if (!op.current()) return; setResult(plan); setMapping(plan.mapping); setStep('mapping'); } catch (uploadError) { if (!op.current()) return; setError(uploadError instanceof Error ? uploadError.message : 'That file could not be read.'); } finally { if (op.current()) { working.current = false; setBusy(false); } } }, [requestUploadPolicy, uploadFile, dryRun]); /** Re-plans under the mapping the practice just confirmed. Still writes nothing. */ const replan = useCallback(async (next: ColumnMapping) => { if (!batchId || working.current) return; const op = begin(); working.current = true; setBusy(true); setError(null); setDecisions(new Map()); try { const plan = await dryRun(batchId, next, op.transport); if (!op.current()) return; setResult(plan); // Decisions are cleared: they were made against the previous plan's candidates, and a row // number now means something else. Carrying them forward would attach a human's answer to a // row they never looked at. setDecisions(new Map()); setStep('review'); } catch (planError) { if (!op.current()) return; setError(planError instanceof Error ? planError.message : 'That file could not be read.'); } finally { if (op.current()) { working.current = false; setBusy(false); } } }, [batchId, dryRun]); /** Reads one import's state and does nothing else. */ const checkStatus = useCallback(async (target: string) => { if (working.current) return; const op = begin(); working.current = true; setBusy(true); setError(null); try { const read = await getStatus(target, op.transport); if (!op.current()) return; setBatchId(target); setStatus(read); setUnconfirmed(false); setStep('status'); } catch (statusError) { if (!op.current()) return; setError(relayServerFailure(statusError, 'That import could not be read.')); } finally { if (op.current()) { working.current = false; setBusy(false); } } }, [getStatus]); // One bounded step per press. Answers it did not record stay queued for the next explicit continue; a // repeat continues the same operation and never starts another or writes a row twice. const runStep = async (op: ReturnType, target: string, first: ColumnMapping | undefined, answers: ImportDecision[]) => { setQueued(answers.length ? { batchId: target, answers } : null); const answer = await commit(target, first, answers.slice(0, ANSWERS_PER_STEP), op.transport); if (op.current()) { const left = stillQueued(answers, answer); setQueued(left.length ? { batchId: target, answers: left } : null); } return answer; }; // A sent commit with no answer may have been made: the page says so and offers a check, never a resend. const settleFailure = (commitError: unknown, onRefused: () => void): string | undefined => { const refused = refusedBy(commitError); if (!refused) { setError(null); setStatus(null); setUnconfirmed(true); setStep('status'); return undefined; } // A refusal recorded nothing: its answers are not sent again, and the rows are reviewed afresh. setQueued(null); if (refused.code === 'IMPORT_ALREADY_RUNNING' && refused.batchId) return refused.batchId; setError(relayServerFailure(commitError, 'The import could not be completed.')); onRefused(); return undefined; }; const handleCommit = useCallback(async () => { if (!batchId || !result || !readiness.ready || working.current) return; const op = begin(); working.current = true; setStep('committing'); setError(null); let elsewhere: string | undefined; try { const chosen = decisionList(result, decisions); const answers = revisions ? withRevisions(chosen, revisions) : chosen; const committed = await runStep(op, batchId, revisions ? undefined : mapping, answers); if (!op.current()) return; setOutcome(committed); setRevisions(null); setStep('done'); } catch (commitError) { if (!op.current()) return; elsewhere = settleFailure(commitError, () => setStep('review')); } finally { if (op.current()) working.current = false; } // The same file is already being imported: that import is opened, read only. if (elsewhere && op.current()) void checkStatus(elsewhere); }, [batchId, result, mapping, decisions, revisions, readiness.ready, commit, checkStatus]); /** An explicit continue of an import already under way, under the mapping it began with. */ const continueOperation = useCallback(async (target: string) => { if (working.current) return; const op = begin(); working.current = true; setBusy(true); setError(null); try { const answer = await runStep(op, target, undefined, queued?.batchId === target ? queued.answers : []); if (!op.current()) return; setOutcome(answer); setStep('done'); } catch (continueError) { if (!op.current()) return; settleFailure(continueError, () => setStep('status')); } finally { if (op.current()) { working.current = false; setBusy(false); } } }, [commit, queued]); /** Shows again only the rows still waiting on an answer, from the upload while it is still here. */ const reviewAgain = useCallback(async (read: ImportStatus) => { if (working.current || !canReviewAgain(read)) return; const op = begin(); working.current = true; setBusy(true); setError(null); try { const plan = await dryRun(read.batchId, read.mapping, op.transport); if (!op.current()) return; setBatchId(read.batchId); setResult(narrowToRows(plan, new Set(read.undecided))); setMapping(plan.mapping); setDecisions(new Map()); setRevisions(new Map(read.rows.map((row) => [row.rowNumber, row.revision]))); setStep('review'); } catch (planError) { if (!op.current()) return; setError(relayServerFailure(planError, 'The rows could not be shown again.')); } finally { if (op.current()) { working.current = false; setBusy(false); } } }, [dryRun]); const decide = (rowNumber: number, action: ImportDecision['action'], patientId?: string) => { setDecisions((current) => new Map(current).set(rowNumber, action === 'link' ? { rowNumber, action, patientId } : { rowNumber, action })); }; // A changed mapping is a different plan: the preview it replaces is retired and its choices go with it. // Never while a commit is out: it was sent, may have been made, and settles where it was sent. const changeMapping = (next: ColumnMapping) => { if (working.current) return; retire(); setDecisions(new Map()); setMapping(next); }; return (

Import your patients

Upload a patient list from your old system. Nothing is created until you review it and import.

    {STEP_LABELS.map(([value, label], index) => { const current = step === value || (step === 'committing' && value === 'review'); return (
  1. {index + 1}. {label}
  2. ); })}
{error && (

{error}

)} {/* A real export takes several seconds, on an otherwise empty page. */} {busy && ( )} {!busy && step === 'upload' && ( void checkStatus(target)} /> )} {!busy && step === 'status' && ( void checkStatus(target)} onContinue={(target) => void continueOperation(target)} onReview={(read) => void reviewAgain(read)} /> )} {!busy && step === 'mapping' && result && ( void replan(mapping)} /> )} {!busy && (step === 'review' || step === 'committing') && result && ( { if (!working.current) setStep(revisions ? 'status' : 'mapping'); }} onCommit={() => void handleCommit()} /> )} {!busy && step === 'done' && outcome && ( void checkStatus(target)} onContinue={(target) => void continueOperation(target)} /> )}
); }; const UploadStep: React.FC<{ onFile: (event: React.ChangeEvent) => void; operations: ImportOperationSummary[] | null; listFailed: boolean; hasOlder: boolean; olderFailed: boolean; onOlder: () => void; onCheck: (batchId: string) => void; }> = ({ onFile, operations, listFailed, hasOlder, olderFailed, onOlder, onCheck }) => (
{/* ⭐ Said BEFORE they upload, not after they commit. A practice that assumes an import creates portal accounts will tell a patient their portal is ready and be wrong. AND THE FIRST LINE USED TO PROMISE SOMETHING THE PRODUCT REFUSES. It read "Your staff can book and chart these patients straight away", and booking one is refused the moment it is tried: the Book button is disabled over "Agreements outstanding - has not answered Consent to Treat / HIPAA Authorization / HIPAA Privacy." That gate is correct and is the 2026-09-02 ruling working - no booking without an answer on record, agreed or declined, either way. So the sentence was the wrong half of the pair: a panel whose whole job is to say what an import does NOT do was promising the one thing it does not do, on the screen a practice reads before uploading its whole patient list. It now says what has to happen instead, and names the door - the same ruling's other half, that the front desk can attest all three for somebody who agreed on paper, which is what keeps the gate from ever preventing care. */}

What importing does and does not do

  • Imported patients cannot sign in β€” they have no account until they are invited and register themselves.
  • Importing does not create clinical authorization. Each patient still has to authorize your practice to hold their record.
  • You cannot book one until the three agreements have an answer. The patient can answer them in the portal, or your front desk can record their answer for them at check-in.
  • Including your old system’s patient ID lets you safely upload the same file again.
); const MappingStep: React.FC<{ result: DryRunResult; mapping: ColumnMapping; onChange: (mapping: ColumnMapping) => void; onContinue: () => void; }> = ({ result, mapping, onChange, onContinue }) => { const suggestions = result.inferred?.suggestions ?? []; const fields = useMemo(() => { const known = Object.keys(mapping); const suggested = suggestions.map((s) => s.field); return [...new Set([...suggested, ...known, ...Object.keys(FIELD_ORDER_FALLBACK)])]; }, [mapping, suggestions]); const confidenceOf = (field: string) => suggestions.find((s) => s.field === field); return (

Match your columns to ours

We have matched what we could. Check anything marked we guessed this.

{/* 🚨 Two columns of the same kind is a QUESTION, never a guess β€” home phone and mobile phone hold the same values, and getting it wrong is the number the practice rings. */} {(result.inferred?.ambiguous ?? []).map((choice) => (

{choice.reason} ({choice.headers.join(' or ')})

))} {result.validation.errors.map((message) => (

{message}

))} {result.validation.warnings.map((message) => (

{message}

))}
{fields.map((field) => { const suggestion = confidenceOf(field); // Drawn twice: as its own column on a laptop, and under the select on a // phone, where a third column squeezes the select to two letters. const reason = suggestion?.confidence === 'likely' ? We guessed this β€” {suggestion.reason} : suggestion?.reason; return ( ); })}
Our field Your column How we matched it
{fieldLabel(field)} {reason ?

{reason}

: null}
{reason}
); }; const ReviewStep: React.FC<{ result: DryRunResult; matches: PlannedImportRow[]; decisions: Map; counts: { creates: number; links: number; skips: number }; onDecide: (rowNumber: number, action: ImportDecision['action'], patientId?: string) => void; reRun: string | undefined; blockers: string[]; ready: boolean; committing: boolean; onBack: () => void; onCommit: () => void; }> = ({ result, matches, decisions, counts, onDecide, reRun, blockers, ready, committing, onBack, onCommit }) => (

What will happen

{([ ['New patients', result.summary.create], ['Need your decision', result.summary.match], ['Already imported', result.summary.alreadyImported], ['Cannot be imported', result.summary.error], ] as Array<[string, number]>).map(([label, value]) => (
{label}
{value}
))}
{/* 🚨 The sentence that has to be read BEFORE the button, not discovered afterwards. */} {reRun && (

{reRun}

)} {matches.length > 0 && (

Is this someone we already have?

These people may already exist β€” often as a patient of another practice. Adding them again creates a second record that nobody can see is a duplicate.

    {matches.map((row) => { const remembered = decisions.get(row.rowNumber); const decision = choiceFits(row, remembered) ? remembered : undefined; const choices = matchChoices(row); return (
  • Row {row.rowNumber} {row.sourceId ? ({row.sourceId}) : null}

    {/* Said once, however many: who they are, and how many, belong to another practice. */} {choices.hasPrivate && (

    This row may match someone whose record this import cannot show you. Leave the row out, or add them as new only if you are sure they are a different person.

    )} {!choices.readable && (

    The possible matches for this row could not be read. Leave it out, then check the file again.

    )}
    {choices.visible.map((candidate) => ( ))} {choices.readable && ( )}
  • ); })}
)} {result.summary.error > 0 && (

These rows will be left out

    {result.rows.filter((row) => row.outcome === 'error').map((row) => (
  • Row {row.rowNumber}: {row.reason}
  • ))}
)} {/* πŸ”΄ CARD 413 β€” THIS LIST NEEDED A HEADING OF ITS OWN, because without one it is adopted by whatever heading came before it. Landing under "These rows will be left out" it read as three more exclusions: "Imported patients cannot sign in", "Importing does not create clinical authorization" and "Nothing here is written until you commit" are standing facts about the feature, not reasons a row was dropped. ⭐ Structurally it was always a separate list; it just had nothing saying so. */} {result.limitations.length > 0 && (

What importing does and does not do

    {result.limitations.map((limitation) =>
  • {limitation}
  • )}
)} {blockers.map((blocker) => (

{blocker}

))}
); const DoneStep: React.FC<{ outcome: CommitResult; waiting: number; onCheck: (batchId: string) => void; onContinue: (batchId: string) => void; }> = ({ outcome, waiting, onCheck, onContinue }) => (

{commitOutcomeSummary(outcome)}

{waiting > 0 && (

{waiting} {waiting === 1 ? 'answer is' : 'answers are'} still to be sent; Continue sends them.

)} {(remainingWork(outcome) > 0 || outcome.undecided.length > 0 || waiting > 0) && (
{(canContinue(outcome) || waiting > 0) && ( )}
)} {/* β›” Failures are shown, never swallowed. Once they are hidden, "the import succeeded" stops being evidence that anybody was actually created. */} {outcome.failed.length > 0 && (

These rows could not be added

    {outcome.failed.map((row) => (
  • Row {row.rowNumber}: {row.reason}
  • ))}
)} {/* Left out by choice, and not answered, are different things, and neither was imported. */} {(outcome.skipped ?? []).length > 0 && (

Left out by your choice

{(outcome.skipped ?? []).map((row) => `Row ${row}`).join(', ')}

)} {outcome.undecided.length > 0 && (

Not answered, so not imported

{outcome.undecided.map((row) => `Row ${row}`).join(', ')}

)} {/* ⭐ The chart exists; these are the cells we could not read. Shown with the practice's own words, because they know what they meant. */} {outcome.questions.length > 0 && (

Worth checking

    {outcome.questions.map((question) => (
  • Row {question.rowNumber}: we could not read “{question.value}” as a {fieldLabel(question.field).toLowerCase()} {question.ambiguous ? ' β€” it could be read two ways, so we left it blank' : ''}.
  • ))}
)} {/* 🚨 CARD 331 β€” THE IMPORT DOES NOT END HERE, AND IT MUST NOT END IN "INVITE THEM ALL" EITHER. Spencer's sequence: import (useful on its own β€” the front desk can already find, book and bill these people) β‡’ "we can invite these patients once your email domain is set up" β‡’ invite, staged and throttled. ⭐ The panel renders the domain gate as a NEXT STEP rather than a failure: a practice that has just imported 2,000 patients and is shown an error has been told its import went wrong, which is false. */}
); const STATE_LABELS: Record = { registered: 'Uploaded, not started', initializing: 'Started', sealed: 'Under way', settled: 'Finished', }; /** This practice's imports, so one whose answer was lost can be found again after a reload. */ const EarlierImports: React.FC<{ operations: ImportOperationSummary[] | null; listFailed: boolean; hasOlder: boolean; olderFailed: boolean; onOlder: () => void; onCheck: (batchId: string) => void; }> = ({ operations, listFailed, hasOlder, olderFailed, onOlder, onCheck }) => { if (listFailed) { return

Your earlier imports could not be listed just now.

; } const shown = (operations ?? []).filter((op) => op.state !== 'registered'); if (!shown.length && !hasOlder) return null; return (

Your recent imports

    {shown.map((op) => (
  • {STATE_LABELS[op.state]} {new Date(op.registeredAt).toLocaleString()}{op.rows !== null ? ` Β· ${op.rows} rows` : ''}
  • ))}
{olderFailed && (

Older imports could not be listed just now.

)} {hasOlder && ( )}
); }; /** Rows not yet done, each kind in its own words; none of them was imported, and none is resent here. */ const OutstandingRows: React.FC<{ result: CommitResult }> = ({ result }) => { const groups: Array<[string, string, number[]]> = [ ['import-unconfirmed', 'Not confirmed yet: these may have been added. Check again before doing anything else.', result.unknown ?? []], ['import-unchecked', 'Could not be checked against existing patients this time', result.unavailable ?? []], ['import-elsewhere', 'Waiting on another import of the same patient ID', result.pendingElsewhere ?? []], ['import-conflict', 'Another record already holds the place for these; ask support', result.conflict ?? []], ['import-expired', 'The upload expired before these were added', result.expired ?? []], ['import-evidence-owed', 'Added; the record of how they were added is still being saved', result.evidenceOwed ?? []], ]; return ( <> {groups.filter(([, , rows]) => rows.length > 0).map(([testId, label, rows]) => (

{label}

{rows.map((row) => `Row ${row}`).join(', ')}

))} ); }; /** One import as the server holds it. Checking reads; only Continue writes, and only the work already admitted. */ const StatusStep: React.FC<{ batchId: string | null; status: ImportStatus | null; unconfirmed: boolean; waiting: number; onCheck: (batchId: string) => void; onContinue: (batchId: string) => void; onReview: (status: ImportStatus) => void; }> = ({ batchId, status, unconfirmed, waiting, onCheck, onContinue, onReview }) => (
{unconfirmed && (

We could not confirm this import. It may have been made. Check what happened before importing anything again.

)} {status && ( <>

{commitOutcomeSummary({ ...status, questions: [] })}

{status.undecided.length > 0 && (

Waiting on your answer

{status.undecided.map((row) => `Row ${row}`).join(', ')}

)} )}
{batchId && ( )} {status && canReviewAgain(status) && ( )} {status && (canContinue(status) || waiting > 0) && ( )}
); /** The order the mapping table lists fields in when the file suggested nothing for them. */ const FIELD_ORDER_FALLBACK: Record = { sourceId: true, mrn: true, ssn: true, prefix: true, firstname: true, middlename: true, lastname: true, suffix: true, birthDate: true, gender: true, maritalStatus: true, language: true, race: true, ethnicity: true, phone: true, phoneHome: true, phoneMobile: true, phoneWork: true, email: true, addressLine1: true, addressLine2: true, city: true, state: true, postalCode: true, country: true, emergencyContactName: true, emergencyContactPhone: true, emergencyContactRelationship: true, }; export default PatientImportPage;