mirror of
https://github.com/temetro/temetro.git
synced 2026-08-21 15:36:47 +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,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>
|
||||
|
||||
Reference in New Issue
Block a user