Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/billing/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import ERAList from './pages/ERAList';
import PatientDetail from './pages/PatientDetail';
import PatientsList from './pages/PatientsList';
import { RenderingProviderDetail, RenderingProvidersList } from './pages/RenderingProviders';
import RuleDetail from './pages/RuleDetail';
import Rules from './pages/Rules';
import { ServiceFacilitiesList, ServiceFacilityDetail } from './pages/ServiceFacilities';
import Tags from './pages/Tags';
import { theme } from './themes/ottehr';
Expand Down Expand Up @@ -88,6 +90,8 @@ export default function App(): ReactElement {
<Route path="/eras" element={<ERAList />} />
<Route path="/eras/:id" element={<ERADetail />} />
<Route path="/tags" element={<Tags />} />
<Route path="/rules" element={<Rules />} />
<Route path="/rules/:id" element={<RuleDetail />} />
<Route path="*" element={<Navigate to="/" />} />
</Route>
)}
Expand Down
19 changes: 19 additions & 0 deletions apps/billing/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
apiErrorToThrow,
BillingChargeItemDefinition,
BillingCodeOption,
BillingRulesResponse,
chooseJson,
ClaimDetailResponse,
CreateBillingClaimInputSchema,
Expand Down Expand Up @@ -31,6 +32,9 @@ import {
GetPatientDetailInputSchema,
ImportEraInputSchema,
PatientDetailResponse,
RunBillingRulesEngineInputSchema,
RunBillingRulesEngineResponse,
SaveBillingRulesInputSchema,
SaveBillingTagInputSchema,
SavedResourceResponse,
SaveServiceFacilityInputSchema,
Expand Down Expand Up @@ -78,6 +82,21 @@ async function executeBillingZambda<T>(oystehr: Oystehr, id: string, parameters?
}
}

// --- Pre-submission rules engine ---

export const getBillingRules = (oystehr: Oystehr): Promise<BillingRulesResponse> =>
executeBillingZambda(oystehr, 'get-billing-rules');

export const saveBillingRules = (
oystehr: Oystehr,
parameters: z.input<typeof SaveBillingRulesInputSchema>
): Promise<BillingRulesResponse> => executeBillingZambda(oystehr, 'save-billing-rules', parameters);

export const runBillingRulesEngine = (
oystehr: Oystehr,
parameters: z.input<typeof RunBillingRulesEngineInputSchema>
): Promise<RunBillingRulesEngineResponse> => executeBillingZambda(oystehr, 'run-billing-rules-engine', parameters);

// --- Patients ---

export const createBillingPatient = (
Expand Down
2 changes: 2 additions & 0 deletions apps/billing/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
MedicalServices as MedicalServicesIcon,
People as PeopleIcon,
Receipt as ReceiptIcon,
Rule as RuleIcon,
} from '@mui/icons-material';
import { Box, Drawer, List, ListItemButton, ListItemIcon, ListItemText, Typography } from '@mui/material';
import { FC } from 'react';
Expand All @@ -33,6 +34,7 @@ const navItems = [
},
{ label: 'ERAs', path: '/eras', icon: <ReceiptIcon sx={{ fontSize: 18 }} /> },
{ label: 'Tags', path: '/tags', icon: <LabelIcon sx={{ fontSize: 18 }} /> },
{ label: 'Rules', path: '/rules', icon: <RuleIcon sx={{ fontSize: 18 }} /> },
];

export const Sidebar: FC = () => {
Expand Down
112 changes: 112 additions & 0 deletions apps/billing/src/components/rules/PayerSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { Autocomplete, AutocompleteInputChangeReason, AutocompleteRenderInputParams, TextField } from '@mui/material';
import { HTMLAttributes, ReactElement, SyntheticEvent, useState } from 'react';
import { BillingPayerOption } from 'utils';
import { searchBillingPayers } from '../../api/api';
import { useApiClients } from '../../hooks/useAppClients';
import { useDebounce } from '../../hooks/useDebounce';

// Searchable payer picker backed by the Oystehr payer list (search-billing-payers). It displays the
// human-friendly payer name + clearinghouse payer id, but stores the RCM payer `id` — the same token
// coverage.payor / Claim.insurer use (see getPayerUrl/extractPayerIdFromUrl), so it round-trips with
// the rules engine's payerId reader/writer. Selecting only valid payers (no free text) is the point.

interface PayerSelectProps {
multiple: boolean;
value: string | string[] | null | undefined;
onChange: (value: string | string[]) => void;
label?: string;
}

const optionLabel = (o: BillingPayerOption): string => (o.name ? `${o.name} (${o.payerId})` : o.id);

// Debounced server-side search plus a memory of payers we've seen, so a selected payer keeps its
// label even after the option list changes (or on edit, once it shows up in a search).
function usePayerSearch(): {
options: BillingPayerOption[];
known: Record<string, BillingPayerOption>;
search: (query?: string) => void;
} {
const { oystehrZambda } = useApiClients();
const { debounce } = useDebounce(300);
const [options, setOptions] = useState<BillingPayerOption[]>([]);
const [known, setKnown] = useState<Record<string, BillingPayerOption>>({});

const runSearch = async (query?: string): Promise<void> => {
if (!oystehrZambda) return;
try {
const res = await searchBillingPayers(oystehrZambda, query ? { name: query } : {});
const payers = res.payers ?? [];
setOptions(payers);
setKnown((prev) => {
const next = { ...prev };
payers.forEach((p) => (next[p.id] = p));
return next;
});
} catch {
setOptions([]);
}
};

return { options, known, search: (query?: string) => debounce(() => void runSearch(query)) };
}

// Resolve a stored id to a display option, falling back to a synthetic option showing the raw id.
const resolve = (
id: string,
known: Record<string, BillingPayerOption>,
options: BillingPayerOption[]
): BillingPayerOption => known[id] ?? options.find((o) => o.id === id) ?? { id, name: '', payerId: id };

export function PayerSelect({ multiple, value, onChange, label = 'Payer' }: PayerSelectProps): ReactElement {
const { options, known, search } = usePayerSearch();

// Props shared by the single- and multi-select variants. Callbacks are typed with their own
// (narrower) signatures so the object is assignable to both Autocomplete generic instantiations.
const shared = {
size: 'small' as const,
filterOptions: (x: BillingPayerOption[]): BillingPayerOption[] => x,
isOptionEqualToValue: (o: BillingPayerOption, v: BillingPayerOption): boolean => o.id === v.id,
getOptionLabel: optionLabel,
renderOption: (props: HTMLAttributes<HTMLLIElement>, o: BillingPayerOption): ReactElement => (
<li {...props} key={o.id}>
{optionLabel(o)}
</li>
),
onOpen: () => search(),
onInputChange: (_: SyntheticEvent, v: string, reason: AutocompleteInputChangeReason): void => {
if (reason === 'input') search(v || undefined);
},
renderInput: (params: AutocompleteRenderInputParams): ReactElement => (
<TextField {...params} label={label} placeholder="Search payers…" />
),
};

if (multiple) {
const ids = Array.isArray(value) ? value : value ? [value] : [];
const selected = ids.map((id) => resolve(id, known, options));
const merged = [...selected.filter((s) => !options.some((o) => o.id === s.id)), ...options];
return (
<Autocomplete<BillingPayerOption, true, false, false>
{...shared}
multiple
options={merged}
value={selected}
onChange={(_, opts) => onChange(opts.map((o) => o.id))}
sx={{ minWidth: 260 }}
/>
);
}

const id = typeof value === 'string' ? value : '';
const selected = id ? resolve(id, known, options) : null;
const merged = selected && !options.some((o) => o.id === selected.id) ? [selected, ...options] : options;
return (
<Autocomplete<BillingPayerOption, false, false, false>
{...shared}
options={merged}
value={selected}
onChange={(_, o) => onChange(o?.id ?? '')}
sx={{ minWidth: 240 }}
/>
);
}
Loading
Loading