import type { DryRunResult, ImportDecision, PlannedImportRow, ColumnMapping, CommitResult, VisibleImportCandidate, ImportStatus, } from '../../../services/PatientImportService.tsx'; // - the wizard's decisions, kept out of the component so they can be tested. // // The pattern the inventory importer set: parse/map/resolve logic lives in a plain module and the // component only renders it. What is here is the set of judgements that decide whether a practice can // press the button - and getting any of them wrong is a wrong patient record, not a wrong pixel. /** The step the practice is on. `review` is the one the whole feature exists for. */ export type ImportStep = 'upload' | 'mapping' | 'review' | 'committing' | 'done' | 'status'; /** Fields whose column MUST be chosen. Everything else is optional - a partial import beats none. */ export const REQUIRED_FIELDS = ['firstname', 'lastname'] as const; /** Plain-English names, because `postalCode` is our schema and not what a receptionist reads. */ export const FIELD_LABELS: Record = { sourceId: 'Patient ID in your old system', mrn: 'Medical record number', ssn: 'Social Security Number', prefix: 'Title', firstname: 'First name', middlename: 'Middle name', lastname: 'Last name', suffix: 'Suffix', birthDate: 'Date of birth', gender: 'Sex', maritalStatus: 'Marital status', language: 'Language', race: 'Race', ethnicity: 'Ethnicity', phone: 'Phone', phoneHome: 'Home phone', phoneMobile: 'Mobile phone', phoneWork: 'Work phone', email: 'Email', addressLine1: 'Address', addressLine2: 'Address line 2', city: 'City', state: 'State', postalCode: 'ZIP code', country: 'Country', emergencyContactName: 'Emergency contact name', emergencyContactPhone: 'Emergency contact phone', emergencyContactRelationship: 'Emergency contact relationship', }; export const fieldLabel = (field: string): string => FIELD_LABELS[field] ?? field; /** Rows a human has to adjudicate. Everything else is already settled by the dry run. */ export const rowsNeedingDecision = (result: DryRunResult): PlannedImportRow[] => result.rows.filter((row) => row.outcome === 'match'); /** * THE GATE ON THE COMMIT BUTTON, and it is deliberately strict in one direction only. * * An undecided match must block: committing it would either create a duplicate of somebody who * already exists or silently skip a patient the practice believes they imported. Both are invisible * until much later. * * But error rows must NOT block. A file where 3 of 4,000 rows have no last name is a normal file, and * refusing the whole import over them would send the practice back to a system they are leaving to fix * three cells. Those rows are reported and left out. */ /** A candidate this practice may link to: its own patient, carrying an id. Nothing else is ever a link. */ export const isVisibleCandidate = (candidate: unknown): candidate is VisibleImportCandidate => { if (!candidate || typeof candidate !== 'object') return false; const { sameOrganization, patientId, name, birthDate } = candidate as Record; return sameOrganization === true && typeof patientId === 'string' && patientId.length > 0 && (name === undefined || typeof name === 'string') && (birthDate === undefined || typeof birthDate === 'string'); }; const isPrivateMarker = (candidate: unknown): boolean => Boolean(candidate) && typeof candidate === 'object' && (candidate as Record).sameOrganization === false; /** * What a match row may be answered with. A row whose candidates cannot all be read may only be left out: * an unreadable candidate is neither a person to link nor proof that nobody matches. */ export interface MatchChoices { readable: boolean; visible: VisibleImportCandidate[]; /** Someone this import cannot identify may already have a record. Said once, never counted. */ hasPrivate: boolean; } export function matchChoices(row: PlannedImportRow): MatchChoices { const candidates: unknown[] = Array.isArray(row.candidates) ? row.candidates : []; const readable = candidates.length > 0 && candidates.every((c) => isVisibleCandidate(c) || isPrivateMarker(c)); return { readable, visible: readable ? candidates.filter(isVisibleCandidate) : [], hasPrivate: readable && candidates.some(isPrivateMarker), }; } /** Whether this choice is one this row offers now. A remembered choice is checked, never just looked up. */ export function choiceFits(row: PlannedImportRow, decision: ImportDecision | undefined): boolean { if (!decision || decision.rowNumber !== row.rowNumber || row.outcome !== 'match') return false; const choices = matchChoices(row); if (decision.action === 'skip') return decision.patientId === undefined; if (!choices.readable) return false; if (decision.action === 'create') return decision.patientId === undefined; if (decision.action === 'link') return choices.visible.some((candidate) => candidate.patientId === decision.patientId); return false; } export interface CommitReadiness { ready: boolean; /** Why not, in the words the button's helper text uses. Empty when ready. */ blockers: string[]; } export function commitReadiness( result: DryRunResult | undefined, decisions: Map, ): CommitReadiness { if (!result) return { ready: false, blockers: ['Upload a file first.'] }; const blockers: string[] = []; if (result.validation.errors.length > 0) { blockers.push('Finish matching the columns.'); } const undecided = rowsNeedingDecision(result).filter((row) => !choiceFits(row, decisions.get(row.rowNumber))); if (undecided.length > 0) { blockers.push( undecided.length === 1 ? '1 row needs you to say whether it is someone we already have.' : `${undecided.length} rows need you to say whether they are someone we already have.`, ); } // Nothing to do is a refusal, not a no-op. A button that "succeeds" having written nothing is how // a practice concludes the import worked and waits for patients that will never appear. if (result.summary.create === 0 && rowsNeedingDecision(result).length === 0) { blockers.push('There is nothing in this file left to import.'); } return { ready: blockers.length === 0, blockers }; } /** The decisions the server expects: only choices this plan's match rows offer, a patient named on a link alone. */ export const decisionList = (result: DryRunResult, decisions: Map): ImportDecision[] => rowsNeedingDecision(result).flatMap((row): ImportDecision[] => { const decision = decisions.get(row.rowNumber); if (!decision || !choiceFits(row, decision)) return []; const { rowNumber, action, patientId } = decision; const chosen: ImportDecision = action === 'link' ? { rowNumber, action, patientId } : { rowNumber, action }; return [row.offer ? { ...chosen, offer: row.offer } : chosen]; }); /** Answers one step sends; the server refuses more, and the rest wait for the next explicit continue. */ export const ANSWERS_PER_STEP = 100; /** Answers a step did not record, kept for the next continue; a finished import needs none. */ export function stillQueued(answers: ImportDecision[], result: Pick): ImportDecision[] { if (!result.state || result.state === 'settled') return []; const recorded = new Set(result.recorded ?? []); return answers.filter((answer) => !recorded.has(answer.rowNumber)); } /** What the button will do: people created, people matched, rows left out. */ export function reviewCounts(result: DryRunResult, decisions: Map): { creates: number; links: number; skips: number } { const chosen = decisionList(result, decisions); return { creates: result.summary.create + chosen.filter((decision) => decision.action === 'create').length, links: chosen.filter((decision) => decision.action === 'link').length, skips: chosen.filter((decision) => decision.action === 'skip').length, }; } /** * THE SENTENCE THAT HAS TO BE ON SCREEN BEFORE THEY COMMIT, not discovered afterwards. A practice * WILL upload the same file twice, because the first run looked stuck - and whether that is harmless * depends entirely on whether the file carried their old system's patient id. */ export function reRunWarning(summary: DryRunResult['summary']): string | undefined { if (summary.reRunnable) return undefined; // - MEASURED AGAINST THE IMPORTABLE ROWS, NOT EVERY ROW. `withoutSourceId` counts only // rows that could actually be written (the server stopped counting `error` rows, which are left out // of every run and so cannot be created twice). Comparing it against `total` - which still includes // those refused rows - means one unusable row downgrades the true sentence "this file has no patient // ID column" into a row count, on a file that has no ID column at all. const importable = summary.total - summary.error; return summary.withoutSourceId === importable ? 'This file has no patient ID column, so uploading it again would create everyone a second time.' : `${summary.withoutSourceId} of these rows have no patient ID, so uploading this file again would create those people a second time.`; } /** What actually happened, in one sentence, in the order a practice cares about it. */ export function commitOutcomeSummary(result: CommitResult): string { const parts: string[] = []; if (result.created.length) parts.push(`${result.created.length} added`); if (result.linked.length) parts.push(`${result.linked.length} matched to existing patients`); if (result.failed.length) parts.push(`${result.failed.length} could not be added`); if (result.undecided.length) parts.push(`${result.undecided.length} skipped with no answer`); const leftOut = result.skipped ?? []; if (leftOut.length) parts.push(`${leftOut.length} left out`); const unconfirmed = result.unknown ?? []; if (unconfirmed.length) parts.push(`${unconfirmed.length} not confirmed yet`); const unchecked = result.unavailable ?? []; if (unchecked.length) parts.push(`${unchecked.length} could not be checked`); const waiting = result.pendingElsewhere ?? []; if (waiting.length) parts.push(`${waiting.length} waiting on another import`); const expired = result.expired ?? []; if (expired.length) parts.push(`${expired.length} expired before they were added`); // Leaving rows out is a finished review, and it added nobody. if (leftOut.length && parts.length === 1) { return `${leftOut.length} ${leftOut.length === 1 ? 'row' : 'rows'} left out; nothing imported.`; } return parts.length ? parts.join(', ') : 'Nothing was imported.'; } /** Every field the mapping UI offers, in the order it shows them. */ export const mappedFields = (mapping: ColumnMapping, allFields: string[]): string[] => allFields.filter((field) => Boolean(mapping[field])); /** Work a continue can do now: rows to record or write, unconfirmed rows to settle, evidence owed, an upload to clear. */ export function remainingWork(result: Pick): number { const left = result.remaining; if (!left) return 0; return left.initialize + left.dispatch + left.evidence + (result.state === 'settled' && result.raw === 'cleanup-pending' ? 1 : 0); } /** A continue does something only when there is work it can do without a new answer from the practice. */ export const canContinue = (result: Pick): boolean => remainingWork(result) > 0; /** Rows still waiting on an answer, when the upload they need is still here to show them again. */ export const canReviewAgain = (status: ImportStatus): boolean => status.undecided.length > 0 && status.raw === 'present'; /** A fresh preview narrowed to the rows still waiting on the practice, so nothing already done is asked again. */ export function narrowToRows(result: DryRunResult, rows: ReadonlySet): DryRunResult { const kept = result.rows.filter((row) => rows.has(row.rowNumber)); const count = (outcome: PlannedImportRow['outcome']) => kept.filter((row) => row.outcome === outcome).length; return { ...result, rows: kept, summary: { ...result.summary, total: kept.length, create: count('create'), match: count('match'), alreadyImported: count('already-imported'), error: count('error'), withoutSourceId: kept.filter((row) => !row.sourceId && row.outcome !== 'error').length, reRunnable: kept.every((row) => Boolean(row.sourceId) || row.outcome === 'error'), }, }; } /** Each answer carries the revision of the row it answers, when the import already recorded that row. */ export const withRevisions = (decisions: ImportDecision[], revisions: ReadonlyMap): ImportDecision[] => decisions.map((decision) => (revisions.has(decision.rowNumber) ? { ...decision, revision: revisions.get(decision.rowNumber) } : decision));