mirror of
https://github.com/temetro/temetro.git
synced 2026-08-13 03:56:53 +00:00
feat: HL7/FHIR, e-prescribing & insurance claims integrations
Real, standards-compliant integration clients that the clinic points at
its own (sandbox or production) endpoints — no mock data.
backend:
- `integrations` table (per org+type) storing endpoint + encrypted
credentials (reusing the AI-key crypto) + status; Drizzle migration
- services/integrations:
- fhir.ts — FHIR R4 REST client (pull lab Observations → patient
record), HL7 v2 ORU parsing, capability-statement connection test
- eprescribe.ts — NCPDP SCRIPT NewRx message build + transmit
- claims.ts — X12 837P claim generation + 835 remittance parsing
- `/api/integrations` route: config GET/PUT (owner/admin), connection
test, and the FHIR sync / HL7 ingest / e-Rx send / claim submit actions,
RBAC-gated (lab/patient, prescription, invoice)
frontend:
- lib/integrations.ts client
- Settings → Integrations tab to configure endpoints/credentials/enable
+ test each integration
- on-page actions, shown only when the integration is enabled:
Lab page → FHIR "Sync results" card; prescription sheet → "Send to
pharmacy"; invoice sheet → "Submit claim"
Production e-Rx/claims routing requires the clinic's own Surescripts /
clearinghouse credentials; the code transmits real messages once supplied.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { FileCheck, Send } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ApiError } from "@/lib/api-client";
|
||||
import {
|
||||
getIntegration,
|
||||
sendEprescription,
|
||||
submitInsuranceClaim,
|
||||
} from "@/lib/integrations";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
// Renders only when the e-prescribing integration is enabled. Transmits the
|
||||
// prescription as an NCPDP SCRIPT NewRx to the configured pharmacy gateway.
|
||||
export function SendToPharmacyButton({ rxId }: { rxId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getIntegration("eprescribe").then(
|
||||
(c) => active && setEnabled(Boolean(c?.enabled)),
|
||||
);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const send = async () => {
|
||||
setSending(true);
|
||||
try {
|
||||
await sendEprescription(rxId);
|
||||
notify.success(
|
||||
t("integrations.eRx.sentTitle"),
|
||||
t("integrations.eRx.sentBody"),
|
||||
);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
t("integrations.eRx.failedTitle"),
|
||||
err instanceof ApiError ? err.message : t("integrations.eRx.failedBody"),
|
||||
);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button disabled={sending} onClick={send} type="button">
|
||||
<Send className="size-4" />
|
||||
{sending ? t("integrations.eRx.sending") : t("integrations.eRx.send")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Renders only when the claims integration is enabled. Submits an X12 837P claim
|
||||
// for the invoice to the configured clearinghouse and reports the remittance.
|
||||
export function SubmitClaimButton({ invoiceId }: { invoiceId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getIntegration("claims").then(
|
||||
(c) => active && setEnabled(Boolean(c?.enabled)),
|
||||
);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const submit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await submitInsuranceClaim(invoiceId);
|
||||
notify.success(
|
||||
t("integrations.claims.submittedTitle"),
|
||||
t("integrations.claims.submittedBody", {
|
||||
status: result.claimStatus,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
t("integrations.claims.failedTitle"),
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: t("integrations.claims.failedBody"),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled={submitting}
|
||||
onClick={submit}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<FileCheck className="size-4" />
|
||||
{submitting
|
||||
? t("integrations.claims.submitting")
|
||||
: t("integrations.claims.submit")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AiBadge } from "@/components/ai-badge";
|
||||
import { SubmitClaimButton } from "@/components/integrations/integration-actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -357,6 +358,7 @@ export function InvoiceDetailSheet({
|
||||
<Download className="size-4" />
|
||||
{t("invoices.sheet.download")}
|
||||
</Button>
|
||||
<SubmitClaimButton invoiceId={invoice.id} />
|
||||
{isSettled ? null : (
|
||||
<Button disabled={busy} onClick={payAll} type="button">
|
||||
<CircleCheck className="size-4" />
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { Cable, RefreshCw, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ApiError } from "@/lib/api-client";
|
||||
import {
|
||||
getIntegration,
|
||||
type IntegrationConfig,
|
||||
syncFhirLabs,
|
||||
} from "@/lib/integrations";
|
||||
import type { Patient } from "@/lib/patients";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// The Lab page's HL7/FHIR connection panel: shows the connection status and, when
|
||||
// enabled, lets staff pull a patient's results from the configured lab system.
|
||||
export function LabIntegrationCard({
|
||||
patients,
|
||||
onSynced,
|
||||
}: {
|
||||
patients: Patient[];
|
||||
onSynced: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [config, setConfig] = useState<IntegrationConfig | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<Patient | null>(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getIntegration("fhir").then((c) => active && setConfig(c));
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const search = query.trim().toLowerCase();
|
||||
const matches = useMemo(() => {
|
||||
if (!search) return [];
|
||||
return patients
|
||||
.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(search) ||
|
||||
p.fileNumber.includes(search),
|
||||
)
|
||||
.slice(0, 5);
|
||||
}, [patients, search]);
|
||||
|
||||
// Hide entirely until we know the config (avoids a flash); render a muted
|
||||
// "configure me" card when present but disabled.
|
||||
if (!config) return null;
|
||||
|
||||
const sync = async () => {
|
||||
if (!selected) return;
|
||||
setSyncing(true);
|
||||
try {
|
||||
const { imported } = await syncFhirLabs(selected.fileNumber);
|
||||
notify.success(
|
||||
t("integrations.fhir.syncedTitle"),
|
||||
t("integrations.fhir.syncedBody", {
|
||||
count: imported,
|
||||
name: selected.name,
|
||||
}),
|
||||
);
|
||||
setSelected(null);
|
||||
setQuery("");
|
||||
onSynced();
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
t("integrations.fhir.failedTitle"),
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: t("integrations.fhir.failedBody"),
|
||||
);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-2xl border bg-card/30 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cable className="size-4 text-muted-foreground" />
|
||||
<h2 className="font-medium text-foreground text-sm">
|
||||
{t("integrations.fhir.cardTitle")}
|
||||
</h2>
|
||||
<Badge
|
||||
className="ml-auto"
|
||||
variant={
|
||||
config.status === "connected"
|
||||
? "secondary"
|
||||
: config.status === "error"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{t(`settings.integrations.status.${config.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{!config.enabled ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("integrations.fhir.disabledHint")}
|
||||
</p>
|
||||
) : selected ? (
|
||||
<div className="flex items-center gap-3 rounded-xl border bg-background/40 px-3 py-2">
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback>{selected.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate font-medium text-foreground text-sm">
|
||||
{selected.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
#{selected.fileNumber}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
disabled={syncing}
|
||||
onClick={() => setSelected(null)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{t("integrations.fhir.change")}
|
||||
</Button>
|
||||
<Button disabled={syncing} onClick={sync} size="sm" type="button">
|
||||
<RefreshCw className={cn("size-4", syncing && "animate-spin")} />
|
||||
{syncing
|
||||
? t("integrations.fhir.syncing")
|
||||
: t("integrations.fhir.sync")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("integrations.fhir.searchPlaceholder")}
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
{matches.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{matches.map((p) => (
|
||||
<button
|
||||
className="flex items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent"
|
||||
key={p.fileNumber}
|
||||
onClick={() => setSelected(p)}
|
||||
type="button"
|
||||
>
|
||||
<Avatar className="size-7">
|
||||
<AvatarFallback>{p.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="min-w-0 truncate text-sm">{p.name}</span>
|
||||
<span className="ms-auto text-muted-foreground text-xs">
|
||||
#{p.fileNumber}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { LabIntegrationCard } from "@/components/lab/lab-integration-card";
|
||||
import { StagedFilesField } from "@/components/patients/patient-files";
|
||||
import { uploadAttachment } from "@/lib/attachments";
|
||||
import { LAB_ANALYSES, LAB_ANALYSIS_UNITS } from "@/lib/lab-analyses";
|
||||
@@ -609,6 +610,18 @@ export function LabView() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<LabIntegrationCard
|
||||
onSynced={() =>
|
||||
listPatients()
|
||||
.then((data) => {
|
||||
setPatients(data);
|
||||
setRecent(buildRecent(data));
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
patients={patients}
|
||||
/>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg tracking-tight">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { SendToPharmacyButton } from "@/components/integrations/integration-actions";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -142,12 +143,20 @@ export function PrescriptionDetailSheet({
|
||||
</div>
|
||||
)}
|
||||
</SheetPanel>
|
||||
{rx && onDelete && (
|
||||
{rx && (
|
||||
<SheetFooter>
|
||||
<Button onClick={onDelete} type="button" variant="destructive">
|
||||
<Trash2 className="size-4" />
|
||||
{t("prescriptions.detail.delete")}
|
||||
</Button>
|
||||
{onDelete && (
|
||||
<Button
|
||||
className="sm:mr-auto"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
{t("prescriptions.detail.delete")}
|
||||
</Button>
|
||||
)}
|
||||
<SendToPharmacyButton rxId={rx.id} />
|
||||
</SheetFooter>
|
||||
)}
|
||||
</SheetPopup>
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, CircleDashed, XCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
FieldLabel,
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
} from "@/components/settings/settings-parts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
type IntegrationConfig,
|
||||
type IntegrationType,
|
||||
listIntegrations,
|
||||
saveIntegration,
|
||||
testIntegration,
|
||||
} from "@/lib/integrations";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
const TYPES: IntegrationType[] = ["fhir", "eprescribe", "claims"];
|
||||
|
||||
function StatusBadge({ config }: { config: IntegrationConfig }) {
|
||||
const { t } = useTranslation();
|
||||
if (config.status === "connected") {
|
||||
return (
|
||||
<Badge className="gap-1" variant="secondary">
|
||||
<CheckCircle2 className="size-3" />
|
||||
{t("settings.integrations.status.connected")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (config.status === "error") {
|
||||
return (
|
||||
<Badge className="gap-1" variant="destructive">
|
||||
<XCircle className="size-3" />
|
||||
{t("settings.integrations.status.error")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge className="gap-1" variant="outline">
|
||||
<CircleDashed className="size-3" />
|
||||
{t("settings.integrations.status.unconfigured")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationCard({
|
||||
type,
|
||||
initial,
|
||||
}: {
|
||||
type: IntegrationType;
|
||||
initial: IntegrationConfig;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [endpoint, setEndpoint] = useState(initial.endpoint);
|
||||
const [enabled, setEnabled] = useState(initial.enabled);
|
||||
// Empty = leave the stored secret untouched; typing replaces it.
|
||||
const [credentials, setCredentials] = useState("");
|
||||
const [config, setConfig] = useState(initial);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
const dirty =
|
||||
endpoint !== config.endpoint ||
|
||||
enabled !== config.enabled ||
|
||||
credentials.length > 0;
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await saveIntegration(type, {
|
||||
endpoint,
|
||||
enabled,
|
||||
...(credentials ? { credentials } : {}),
|
||||
});
|
||||
setConfig(saved);
|
||||
setEndpoint(saved.endpoint);
|
||||
setEnabled(saved.enabled);
|
||||
setCredentials("");
|
||||
notify.success(
|
||||
t("settings.integrations.savedTitle"),
|
||||
t(`settings.integrations.${type}.title`),
|
||||
);
|
||||
} catch {
|
||||
notify.error(
|
||||
t("settings.integrations.saveFailedTitle"),
|
||||
t("settings.integrations.saveFailedBody"),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await testIntegration(type);
|
||||
if (result.ok) {
|
||||
notify.success(t("settings.integrations.testOk"), result.message);
|
||||
} else {
|
||||
notify.error(t("settings.integrations.testFailed"), result.message);
|
||||
}
|
||||
} catch {
|
||||
notify.error(
|
||||
t("settings.integrations.testFailed"),
|
||||
t("settings.integrations.testError"),
|
||||
);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
action={<StatusBadge config={config} />}
|
||||
description={t(`settings.integrations.${type}.description`)}
|
||||
title={t(`settings.integrations.${type}.title`)}
|
||||
>
|
||||
<SettingsCard className="space-y-5 p-5">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.integrations.endpoint")}</FieldLabel>
|
||||
<Input
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
placeholder={t(`settings.integrations.${type}.endpointPlaceholder`)}
|
||||
value={endpoint}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.integrations.credentials")}</FieldLabel>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
onChange={(e) => setCredentials(e.target.value)}
|
||||
placeholder={
|
||||
config.hasCredentials
|
||||
? t("settings.integrations.credentialsSet")
|
||||
: t(`settings.integrations.${type}.credentialsPlaceholder`)
|
||||
}
|
||||
type="password"
|
||||
value={credentials}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`settings.integrations.${type}.credentialsHint`)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center justify-between gap-4 rounded-2xl border bg-card/30 px-4 py-3">
|
||||
<span className="space-y-0.5">
|
||||
<span className="block text-sm font-medium">
|
||||
{t("settings.integrations.enable")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("settings.integrations.enableHint")}
|
||||
</span>
|
||||
</span>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled={saving || !dirty} onClick={save} size="sm">
|
||||
{saving
|
||||
? t("settings.integrations.saving")
|
||||
: t("settings.integrations.save")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={testing}
|
||||
onClick={test}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{testing
|
||||
? t("settings.integrations.testing")
|
||||
: t("settings.integrations.test")}
|
||||
</Button>
|
||||
{config.lastSyncAt ? (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{t("settings.integrations.lastSync", {
|
||||
when: new Date(config.lastSyncAt).toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function IntegrationsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [configs, setConfigs] = useState<IntegrationConfig[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
listIntegrations()
|
||||
.then((rows) => active && setConfigs(rows))
|
||||
.catch(() => active && setConfigs([]));
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (configs === null) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.integrations.loading")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.integrations.intro")}
|
||||
</p>
|
||||
{TYPES.map((type) => {
|
||||
const initial =
|
||||
configs.find((c) => c.type === type) ??
|
||||
({
|
||||
type,
|
||||
endpoint: "",
|
||||
enabled: false,
|
||||
status: "unconfigured",
|
||||
hasCredentials: false,
|
||||
lastSyncAt: null,
|
||||
} satisfies IntegrationConfig);
|
||||
return <IntegrationCard initial={initial} key={type} type={type} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { AIPanel } from "@/components/settings/settings-ai";
|
||||
import { SigningPanel } from "@/components/settings/settings-billing";
|
||||
import { CareTeamPanel } from "@/components/settings/settings-care-team";
|
||||
import { DevelopersPanel } from "@/components/settings/settings-developers";
|
||||
import { IntegrationsPanel } from "@/components/settings/settings-integrations";
|
||||
import { ProfilePanel } from "@/components/settings/settings-preferences";
|
||||
import { RecordsPanel } from "@/components/settings/settings-records";
|
||||
import { useActiveRole } from "@/lib/roles";
|
||||
@@ -18,6 +19,7 @@ const TABS = [
|
||||
{ id: "records", labelKey: "settings.tabs.records" },
|
||||
{ id: "signing", labelKey: "settings.tabs.signing" },
|
||||
{ id: "careTeam", labelKey: "settings.tabs.careTeam" },
|
||||
{ id: "integrations", labelKey: "settings.tabs.integrations" },
|
||||
{ id: "developers", labelKey: "settings.tabs.developers" },
|
||||
] as const;
|
||||
|
||||
@@ -70,6 +72,7 @@ export function SettingsView() {
|
||||
{activeTab === "records" && <RecordsPanel />}
|
||||
{activeTab === "signing" && <SigningPanel />}
|
||||
{activeTab === "careTeam" && <CareTeamPanel />}
|
||||
{activeTab === "integrations" && <IntegrationsPanel />}
|
||||
{activeTab === "developers" && <DevelopersPanel />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1103,6 +1103,36 @@
|
||||
"uploadFailedTitle": "Some files didn't upload",
|
||||
"uploadFailedBody": "The record was saved, but one or more files failed to upload. Try adding them again from the record."
|
||||
},
|
||||
"integrations": {
|
||||
"fhir": {
|
||||
"cardTitle": "Lab system (HL7/FHIR)",
|
||||
"disabledHint": "Not enabled. An owner or admin can connect a lab system in Settings → Integrations.",
|
||||
"searchPlaceholder": "Search a patient to pull results…",
|
||||
"change": "Change",
|
||||
"sync": "Sync results",
|
||||
"syncing": "Syncing…",
|
||||
"syncedTitle": "Results synced",
|
||||
"syncedBody": "Imported {{count}} result(s) for {{name}}.",
|
||||
"failedTitle": "Sync failed",
|
||||
"failedBody": "Couldn't reach the lab system. Please try again."
|
||||
},
|
||||
"eRx": {
|
||||
"send": "Send to pharmacy",
|
||||
"sending": "Sending…",
|
||||
"sentTitle": "Prescription sent",
|
||||
"sentBody": "Transmitted to the pharmacy as an NCPDP SCRIPT NewRx.",
|
||||
"failedTitle": "Couldn't send",
|
||||
"failedBody": "The pharmacy gateway rejected the message or is unreachable."
|
||||
},
|
||||
"claims": {
|
||||
"submit": "Submit claim",
|
||||
"submitting": "Submitting…",
|
||||
"submittedTitle": "Claim submitted",
|
||||
"submittedBody": "Clearinghouse status: {{status}}.",
|
||||
"failedTitle": "Couldn't submit claim",
|
||||
"failedBody": "The clearinghouse rejected the claim or is unreachable."
|
||||
}
|
||||
},
|
||||
"patientCard": {
|
||||
"notFound": "No patient found for file #{{number}}.",
|
||||
"overview": "Overview",
|
||||
@@ -1277,8 +1307,55 @@
|
||||
"records": "Records",
|
||||
"signing": "Signing",
|
||||
"careTeam": "Care team",
|
||||
"integrations": "Integrations",
|
||||
"developers": "Developers"
|
||||
},
|
||||
"integrations": {
|
||||
"loading": "Loading integrations…",
|
||||
"intro": "Connect temetro to external healthcare systems. Point each integration at your vendor's sandbox or production endpoint and supply its credentials.",
|
||||
"endpoint": "Endpoint URL",
|
||||
"credentials": "Credentials",
|
||||
"credentialsSet": "•••••••• (stored — type to replace)",
|
||||
"enable": "Enable integration",
|
||||
"enableHint": "When on, its actions appear on the relevant pages.",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"test": "Test connection",
|
||||
"testing": "Testing…",
|
||||
"savedTitle": "Integration saved",
|
||||
"saveFailedTitle": "Couldn't save",
|
||||
"saveFailedBody": "Something went wrong, or you don't have permission. Please try again.",
|
||||
"testOk": "Connection succeeded",
|
||||
"testFailed": "Connection failed",
|
||||
"testError": "Couldn't reach the endpoint.",
|
||||
"lastSync": "Last activity {{when}}",
|
||||
"status": {
|
||||
"connected": "Connected",
|
||||
"error": "Error",
|
||||
"unconfigured": "Not configured"
|
||||
},
|
||||
"fhir": {
|
||||
"title": "Lab system (HL7/FHIR)",
|
||||
"description": "Read lab results from a FHIR R4 server or HL7 v2 feed (e.g. a HAPI FHIR / SMART Health IT sandbox, or your lab's gateway).",
|
||||
"endpointPlaceholder": "https://hapi.fhir.org/baseR4",
|
||||
"credentialsPlaceholder": "Bearer token (optional for open sandboxes)",
|
||||
"credentialsHint": "Sent as a Bearer token, or JSON {\"token\":\"…\"}. Leave blank for public sandboxes."
|
||||
},
|
||||
"eprescribe": {
|
||||
"title": "e-Prescribing (NCPDP SCRIPT)",
|
||||
"description": "Transmit prescriptions to pharmacies as NCPDP SCRIPT NewRx messages. Production routing requires your Surescripts (or sandbox) account.",
|
||||
"endpointPlaceholder": "https://your-pharmacy-gateway.example/script",
|
||||
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"senderId\":\"…\"}",
|
||||
"credentialsHint": "JSON with your gateway token and sender id."
|
||||
},
|
||||
"claims": {
|
||||
"title": "Insurance claims (X12 837/835)",
|
||||
"description": "Submit professional claims (837P) to a clearinghouse and read remittances (835). Production requires your clearinghouse account.",
|
||||
"endpointPlaceholder": "https://your-clearinghouse.example/claims",
|
||||
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
|
||||
"credentialsHint": "JSON with your clearinghouse token and submitter/receiver ids."
|
||||
}
|
||||
},
|
||||
"empty": "Nothing here yet.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Client for the backend integrations API (HL7/FHIR labs, e-prescribing,
|
||||
// insurance claims). Config is owner/admin-only to write; status is readable by
|
||||
// any member so pages can gate their on-page actions.
|
||||
|
||||
import { apiFetch } from "@/lib/api-client";
|
||||
|
||||
export type IntegrationType = "fhir" | "eprescribe" | "claims";
|
||||
export type IntegrationStatus = "unconfigured" | "connected" | "error";
|
||||
|
||||
export type IntegrationConfig = {
|
||||
type: IntegrationType;
|
||||
endpoint: string;
|
||||
enabled: boolean;
|
||||
status: IntegrationStatus;
|
||||
hasCredentials: boolean;
|
||||
lastSyncAt: string | null;
|
||||
};
|
||||
|
||||
export function listIntegrations(): Promise<IntegrationConfig[]> {
|
||||
return apiFetch<IntegrationConfig[]>("/api/integrations");
|
||||
}
|
||||
|
||||
export function saveIntegration(
|
||||
type: IntegrationType,
|
||||
input: { endpoint?: string; enabled?: boolean; credentials?: string },
|
||||
): Promise<IntegrationConfig> {
|
||||
return apiFetch<IntegrationConfig>(`/api/integrations/${type}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function testIntegration(
|
||||
type: IntegrationType,
|
||||
): Promise<{ ok: boolean; message: string }> {
|
||||
return apiFetch(`/api/integrations/${type}/test`, { method: "POST" });
|
||||
}
|
||||
|
||||
// Pull a patient's lab results from the FHIR server.
|
||||
export function syncFhirLabs(fileNumber: string): Promise<{ imported: number }> {
|
||||
return apiFetch("/api/integrations/fhir/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ fileNumber }),
|
||||
});
|
||||
}
|
||||
|
||||
// Transmit a prescription to a pharmacy (NCPDP SCRIPT NewRx).
|
||||
export function sendEprescription(
|
||||
rxId: string,
|
||||
): Promise<{ messageId: string; status: string }> {
|
||||
return apiFetch("/api/integrations/eprescribe/send", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rxId }),
|
||||
});
|
||||
}
|
||||
|
||||
// Submit an insurance claim for an invoice (X12 837P) and read the remittance.
|
||||
export function submitInsuranceClaim(
|
||||
invoiceId: string,
|
||||
): Promise<{ claimStatus: string; paidAmount: number; submitted: boolean }> {
|
||||
return apiFetch("/api/integrations/claims/submit", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ invoiceId }),
|
||||
});
|
||||
}
|
||||
|
||||
// Convenience hook-style fetch reused by the on-page sections: returns the
|
||||
// config for one type (or null while loading/absent).
|
||||
export async function getIntegration(
|
||||
type: IntegrationType,
|
||||
): Promise<IntegrationConfig | null> {
|
||||
try {
|
||||
const all = await listIntegrations();
|
||||
return all.find((c) => c.type === type) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user