Compare commits

...

23 Commits

Author SHA1 Message Date
Shivank Kacker
c44651b104 Updated to work for edge cases 2025-01-08 04:28:17 +05:30
Shivank Kacker
8fa21e0651 finished up 2025-01-07 17:34:36 +05:30
Shivank Kacker
e1b61c7ba7 major refactor, moving away from dom injects 2025-01-07 15:34:20 +05:30
Shivank Kacker
bb9aa58880 hide test button 2025-01-03 02:19:11 +05:30
Shivank Kacker
73b4345f5a fixed datetime input 2025-01-03 02:15:28 +05:30
Shivank Kacker
a8127b91b5 cleanup 2025-01-03 01:47:28 +05:30
Shivank Kacker
09ea4c8f7f revert changes in package lock 2025-01-03 01:45:05 +05:30
Shivank Kacker
23cc76d474 Shifted to updating form state for structured components 2025-01-03 01:41:49 +05:30
Shivank Kacker
cd0a983934 merge conflicts 2025-01-02 20:47:46 +05:30
Shivank Kacker
5dc08bdd34 add support for prompt overriders 2025-01-02 17:53:18 +05:30
Shivank Kacker
fb6f4b72bf removed font awesome 2025-01-02 14:51:08 +05:30
Shivank Kacker
a47ae9d370 remove .env from gitignore 2025-01-02 13:07:40 +05:30
Shivank Kacker
247a81bf50 Added missing packages 2025-01-02 12:24:17 +05:30
Shivank Kacker
322dbc16b5 updates 2025-01-02 12:09:01 +05:30
Shivank Kacker
ea67333f3c Moved structured inputs to scribe 2025-01-01 22:32:12 +05:30
Shivank Kacker
ac0eea4d70 enhancements 2024-12-31 14:41:11 +05:30
Shivank Kacker
47c4b7465d support for other fields 2024-12-31 05:12:38 +05:30
Shivank Kacker
099515df47 structured inputs 2024-12-29 05:21:41 +05:30
Shivank Kacker
0fc4c1ab06 toasts and more translations 2024-12-28 16:21:16 +05:30
Shivank Kacker
67fdf2d27e added translations and temporary font awesome shift 2024-12-28 15:58:58 +05:30
Shivank Kacker
34a1df5037 figured out running on build 2024-12-27 05:27:07 +05:30
Shivank Kacker
23b4f6e464 update file structure 2024-12-26 18:32:26 +05:30
Shivank Kacker
c50d0ea163 init 2024-12-21 17:06:13 +05:30
41 changed files with 6379 additions and 679 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
VITE_CARE_API_URL="https://care-api.do.ohc.network"

1
.gitignore vendored
View File

@ -32,7 +32,6 @@ dist-ssr
*.sw?
# Environment variables
.env
.env.local
.env.development.local
.env.test.local

View File

@ -0,0 +1,8 @@
# Care Scribe
Run with
```
npm run build:watch
npm run dev
```

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/style/index.css",
"baseColor": "neutral",
"cssVariables": false,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "radix"
}

28
eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

4235
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,14 @@
{
"name": "care-scribe",
"version": "0.0.1",
"main": "index.js",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"dev": "vite preview",
"build:watch": "tsc -b && vite build --watch",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"repository": {
"type": "git",
@ -16,15 +21,42 @@
},
"homepage": "https://github.com/ohcnetwork/care_scribe_fe#readme",
"description": "",
"peerDependencies": {
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
"@eslint/js": "^9.17.0",
"@originjs/vite-plugin-federation": "^1.3.6",
"@types/node": "^22.10.3",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.13.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.1",
"vite": "^6.0.3"
},
"dependencies": {
"lottie-react": "^2.4.0"
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-toast": "^1.2.4",
"@tanstack/react-query": "^5.62.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"i18next": "^24.2.0",
"jotai": "^2.10.4",
"lottie-react": "^2.4.0",
"lucide-react": "^0.469.0",
"raviger": "^4.1.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.4.0",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"use-keyboard-shortcut": "^1.1.6"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

57
src/App.tsx Normal file
View File

@ -0,0 +1,57 @@
import { useEffect } from "react";
import { Controller } from "./components/Controller";
import { usePath } from "raviger";
import { useFeatureFlags } from "./hooks/useFeatureFlags";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import translation from "./locale/en.json";
import { Toaster } from "./components/ui/toaster";
export default function App(props: {
formState: unknown;
setFormState: unknown;
}) {
const path = usePath();
const facilityId = path?.includes("/facility/")
? path.split("/facility/")[1].split("/")[0]
: undefined;
const featureFlags = useFeatureFlags(facilityId);
const SCRIBE_ENABLED = featureFlags.includes("SCRIBE_ENABLED");
i18n.use(initReactI18next).init({
resources: {
en: {
translation,
},
},
lng: localStorage.getItem("i18nextLng") || "en",
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
saveMissing: false,
});
useEffect(() => {
if (!SCRIBE_ENABLED) return;
const pageElement = document.querySelector(
'[data-cui-page="true"]',
) as HTMLElement;
if (pageElement) {
pageElement.style.setProperty("padding-bottom", "100px", "important");
}
return () => {
if (pageElement) {
pageElement.style.paddingBottom = "";
}
};
}, []);
return (
<div>
<Toaster />
{SCRIBE_ENABLED && <Controller {...props} />}
</div>
);
}

18
src/Providers.tsx Normal file
View File

@ -0,0 +1,18 @@
import App from "@/App";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { FeatureFlagsProvider } from "./hooks/useFeatureFlags";
const queryClient = new QueryClient();
export default function Providers(props: {
formState: unknown;
setFormState: unknown;
}) {
return (
<QueryClientProvider client={queryClient}>
<FeatureFlagsProvider>
<App {...props} />
</FeatureFlagsProvider>
</QueryClientProvider>
);
}

View File

@ -1,41 +0,0 @@
import {
CreateFileRequest,
CreateFileResponse,
FileUploadModel,
} from "@/components/Patient/models";
import { ScribeModel } from "../types";
import { Type } from "@/Utils/request/api";
const routes = {
createScribe: {
path: "/api/care_scribe/scribe/",
method: "POST",
TReq: Type<ScribeModel>(),
TRes: Type<ScribeModel>(),
},
getScribe: {
path: "/api/care_scribe/scribe/{external_id}/",
method: "GET",
TRes: Type<ScribeModel>(),
},
updateScribe: {
path: "/api/care_scribe/scribe/{external_id}/",
method: "PUT",
TReq: Type<ScribeModel>(),
TRes: Type<ScribeModel>(),
},
createScribeFileUpload: {
path: "/api/care_scribe/scribe_file/",
method: "POST",
TBody: Type<CreateFileRequest>(),
TRes: Type<CreateFileResponse>(),
},
editScribeFileUpload: {
path: "/api/care_scribe/scribe_file/{id}/?file_type={fileType}&associating_id={associatingId}",
method: "PATCH",
TBody: Type<Partial<FileUploadModel>>(),
TRes: Type<FileUploadModel>(),
},
} as const;
export default routes;

View File

@ -1,22 +1,29 @@
import { useState } from "react";
import { ScribeField, ScribeFieldSuggestion, ScribeStatus } from "../types";
import { useTranslation } from "react-i18next";
import useSegmentedRecording from "@/Utils/useSegmentedRecorder";
import request from "@/Utils/request/request";
import routes from "../api/api";
import uploadFile from "@/Utils/request/uploadFile";
import TextAreaFormField from "@/components/Form/FormFields/TextAreaFormField";
import CareIcon from "@/CAREUI/icons/CareIcon";
import { getFieldsToReview, scrapeFields, SCRIBE_PROMPT_MAP } from "../utils";
import * as Notify from "@/Utils/Notifications";
import { getFieldsToReview, getQuestionInputs } from "../utils/utils";
import ScribeButton from "./ScribeButton";
import animationData from "../assets/animation.json";
import Lottie from "lottie-react";
import ScribeReview from "./Review";
import useSegmentedRecording from "@/hooks/useSegmentedRecorder";
import { useTimer } from "@/hooks/useTimer";
import ButtonV2 from "@/components/Common/ButtonV2";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
import { API } from "@/utils/api";
import uploadFile from "@/utils/uploadFile";
import { useToast } from "@/hooks/use-toast";
import { SCRIBE_PROMPT_MAP, STRUCTURED_INPUT_PROMPTS } from "@/utils/prompts";
import {
ChevronUpIcon,
Cross1Icon,
CrossCircledIcon,
} from "@radix-ui/react-icons";
export function Controller() {
export function Controller(props: {
formState: unknown;
setFormState: unknown;
}) {
const [status, setStatus] = useState<ScribeStatus>("IDLE");
const { t } = useTranslation();
const [transcript, setTranscript] = useState<string>();
@ -27,11 +34,15 @@ export function Controller() {
const [openEditTranscript, setOpenEditTranscript] = useState(false);
//Use this to test scribe
const SCRIBE_TEST_INPUT = `The patients category is mild. The physical examination info of the patient is that the patient is wounded.
Blood pressure is 10 systolic and 14 diastolic. Pulse is 40 bpm. Temperature is 98.
Respiratory rate is 43. Spo2 is 14 percent. Heartbeat is irregular.
The rhythm description is dhuk dhuk dhuk dhuk.
Level of consciousness is alert. The action to be taken is plan for home care. Please review after 15 minutes.`;
const SCRIBE_TEST_INPUT = `The patient's encounter status is currently on hold, classified as an emergency with a priority of “as needed,”
under hospital identifier 245. The patient was admitted from a nursing home with a diet preference of vegetarian. The care team consists of physical therapists,
and the encounter started yesterday at 12 a.m., ending today at 5 p.m. The care plan focuses on stabilizing the patient's blood pressure,
with a follow-up frequency of two times weekly. The next visit is scheduled for January 3, 2025.
The patient's current vital signs indicate a systolic blood pressure of 20, diastolic blood pressure of 40, pulse of 84, SpO2 at 78%,
and a blood sugar level of 59. Pain is reported as mild, and the patient is bed-bound, unable to move.
An acute symptom of left-sided ulcerative colitis has been added, with differential verification, moderate severity, beginning yesterday.
Update the existing symptom's verification to confirmed, and all existing diagnoses should be removed. Nurse John Doe is filling the allergy intolerance form.
A resolved allergy to isomaltose has been detected.`;
const {
startRecording: startSegmentedRecording,
@ -40,6 +51,8 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
audioBlobs,
} = useSegmentedRecording();
const { toast } = useToast();
// Keeps polling the scribe endpoint to check if transcript or ai response has been generated
const poller = async (
scribeInstanceId: string,
@ -48,38 +61,34 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
return new Promise((resolve, reject) => {
const interval = setInterval(async () => {
try {
const res = await request(routes.getScribe, {
pathParams: {
external_id: scribeInstanceId,
},
});
const res = await API.scribe.get(scribeInstanceId);
const { status, transcript, ai_response } = res;
if (!res.data || res.error)
throw new Error("Error getting scribe instance");
const { status, transcript, ai_response } = res.data;
if (status === "FAILED") {
toast({ title: "Transcription failed", variant: "destructive" });
clearInterval(interval);
return reject(new Error("Transcription failed"));
}
if (
status === "GENERATING_AI_RESPONSE" ||
status === "COMPLETED" ||
status === "FAILED"
type === "transcript" &&
["GENERATING_AI_RESPONSE", "COMPLETED"].includes(status) &&
transcript !== null
) {
clearInterval(interval);
if (status === "FAILED") {
Notify.Error({ msg: "Transcription failed" });
return reject(new Error("Transcription failed"));
}
if (type === "transcript" && transcript !== null) {
return resolve(transcript);
}
if (type === "ai_response" && ai_response !== null) {
return resolve(ai_response);
}
return reject(new Error(`Failed to resolve response`));
return resolve(transcript);
}
if (
type === "ai_response" &&
status === "COMPLETED" &&
ai_response !== null
) {
clearInterval(interval);
return resolve(ai_response);
}
// return reject(new Error(`Failed to resolve response`));
} catch (error) {
clearInterval(interval);
reject(error);
@ -112,30 +121,23 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
.reduce((acc, curr) => ({ ...acc, ...curr }), {});
return changedData;
} catch (e) {
Notify.Error({ msg: t("scribe_error") });
toast({ title: t("scribe_error"), variant: "destructive" });
setStatus("FAILED");
}
};
// gets the audio transcription
const getTranscript = async (scribeInstanceId: string) => {
const res = await request(routes.updateScribe, {
body: {
status: "READY",
},
pathParams: {
external_id: scribeInstanceId,
},
});
if (res.error || !res.data) throw Error("Error updating scribe instance");
try {
await API.scribe.update(scribeInstanceId, {
status: "READY",
});
const transcript = await poller(scribeInstanceId, "transcript");
setLastTranscript(transcript);
setTranscript(transcript);
return transcript;
} catch (error) {
Notify.Error({ msg: t("scribe_error") });
toast({ title: t("scribe_error"), variant: "destructive" });
setStatus("FAILED");
}
};
@ -146,20 +148,18 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
const name = "audio.mp3";
const filename = Date.now().toString();
const response = await request(routes.createScribeFileUpload, {
body: {
original_name: name,
file_type: 1,
name: filename,
associating_id: scribeInstanceId,
file_category: category,
mime_type: audioBlob?.type?.split(";")?.[0],
},
const data = await API.scribe.createFileUpload({
original_name: name,
file_type: 1,
name: filename,
associating_id: scribeInstanceId,
file_category: category,
mime_type: audioBlob?.type?.split(";")?.[0],
});
await new Promise<void>((resolve, reject) => {
const url = response.data?.signed_url;
const internal_name = response.data?.internal_name;
const url = data?.signed_url;
const internal_name = data?.internal_name;
const f = audioBlob;
if (f === undefined) {
reject(Error("No file to upload"));
@ -182,57 +182,67 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
);
});
const res = request(routes.editScribeFileUpload, {
body: { upload_completed: true },
pathParams: {
id: response.data?.id || "",
fileType: "SCRIBE",
associatingId: scribeInstanceId,
},
});
return res;
return await API.scribe.editFileUpload(
data.id,
"SCRIBE",
scribeInstanceId,
{ upload_completed: true },
);
};
// Sets up a scribe instance with the available recordings. Returns the instance ID.
const createScribeInstance = async (fields: ScribeField[]) => {
const hfields = await getHydratedFields(fields);
const response = await request(routes.createScribe, {
body: {
status: "CREATED",
form_data: hfields,
},
const data = await API.scribe.create({
status: "CREATED",
form_data: hfields as any,
// system_prompt: "...",
// json_prompt: "...",
});
if (response.error) throw Error("Error creating scribe instance");
if (!response.data) throw Error("Response did not return any data");
await Promise.all(
audioBlobs.map((blob) =>
uploadAudio(blob, response.data?.external_id ?? ""),
),
audioBlobs.map((blob) => uploadAudio(blob, data?.external_id ?? "")),
);
return response.data.external_id;
return data.external_id;
};
const getHydratedFields = async (fields: ScribeField[]) => {
return fields.map((field, i) => ({
friendlyName: field.label || "Unlabled Field",
current: field.value,
id: `${i}`,
description:
field.customPrompt ||
SCRIBE_PROMPT_MAP[field.type]?.prompt ||
SCRIBE_PROMPT_MAP["default"]?.prompt,
type: "string",
example:
field.customExample ||
SCRIBE_PROMPT_MAP[field.type]?.example ||
SCRIBE_PROMPT_MAP["default"]?.example,
options: field.options?.map((opt) => ({
id: opt.value || "NONE",
text: opt.text,
})),
}));
return fields.map((field, i) => {
const structuredType = field.question.structured_type;
const structuredPrompt =
structuredType &&
Object.keys(STRUCTURED_INPUT_PROMPTS).includes(structuredType)
? STRUCTURED_INPUT_PROMPTS[
structuredType as keyof typeof STRUCTURED_INPUT_PROMPTS
]
: undefined;
return {
friendlyName: field.question.text || "Unlabled Field",
current: field.value,
id: `${i}`,
description:
structuredPrompt?.prompt ||
SCRIBE_PROMPT_MAP[field.question.type]?.prompt ||
SCRIBE_PROMPT_MAP["default"]?.prompt,
type: typeof (
structuredPrompt?.example ||
SCRIBE_PROMPT_MAP[field.question.type]?.example ||
SCRIBE_PROMPT_MAP["default"]?.example
),
example: JSON.stringify(
structuredPrompt?.example ||
SCRIBE_PROMPT_MAP[field.question.type]?.example ||
SCRIBE_PROMPT_MAP["default"]?.example,
),
options: field.question.answer_option?.map((opt) => ({
id: opt.value,
text: opt.value,
})),
};
});
};
// updates the transcript and fetches a new AI response
@ -241,19 +251,17 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
if (!instanceId) throw Error("Cannot find scribe instance");
setToReview(undefined);
setLastTranscript(updatedTranscript);
const res = await request(routes.updateScribe, {
body: {
try {
await API.scribe.update(instanceId, {
status: "READY",
transcript: updatedTranscript,
ai_response: null,
},
pathParams: {
external_id: instanceId,
},
});
if (res.error || !res.data) throw Error("Error updating scribe instance");
//ai_response: null,
});
} catch (error) {
throw Error("Error updating Scribe Instance");
}
setStatus("THINKING");
const fields = scrapeFields(null, false);
const fields = getQuestionInputs(props.formState);
const aiResponse = await getAIResponse(instanceId, fields);
if (!aiResponse) return;
setStatus("REVIEWING");
@ -268,7 +276,7 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
timer.start();
setStatus("RECORDING");
} catch (error) {
Notify.Error({ msg: t("audio__permission_message") });
toast({ title: t("audio__permission_message") });
setStatus("IDLE");
}
};
@ -278,7 +286,7 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
timer.reset();
setStatus("UPLOADING");
stopSegmentedRecording();
const fields = scrapeFields(null, false);
const fields = getQuestionInputs(props.formState);
const instanceId = await createScribeInstance(fields);
setInstanceId(instanceId);
setStatus("TRANSCRIBING");
@ -301,7 +309,7 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
className={`fixed bottom-5 right-5 z-40 flex flex-col items-end gap-4 transition-all`}
>
<div
className={`${status === "IDLE" ? "max-h-0 opacity-0" : "max-h-[400px]"} w-full overflow-hidden rounded-2xl ${status === "REVIEWING" && !(openEditTranscript || (toReview && !toReview.length)) ? "" : "border border-secondary-400"} bg-white transition-all delay-100`}
className={`${status === "IDLE" ? "max-h-0 opacity-0" : "max-h-[400px]"} w-full overflow-hidden rounded-2xl ${status === "REVIEWING" && !(openEditTranscript || (toReview && !toReview.length)) ? "" : "border-secondary-400 border"} bg-white transition-all delay-100`}
>
{status === "RECORDING" && (
<div className="flex items-center justify-center p-4 py-10">
@ -318,7 +326,7 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
<div className="w-32">
<Lottie animationData={animationData} loop autoPlay />
</div>
<div className="-translate-y-4 text-sm text-secondary-700">
<div className="text-secondary-700 -translate-y-4 text-sm">
{t("copilot_thinking")}
</div>
</div>
@ -334,7 +342,7 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
)}
{audioBlobs.length > 0 && (
<div className="mb-4">
<div className="rounded border border-secondary-400 bg-secondary-200">
<div className="border-secondary-400 bg-secondary-200 rounded border">
<audio controls className="plain-audio w-full">
{audioBlobs.map((blob, index) => (
<source
@ -345,15 +353,12 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
))}
</audio>
</div>
<ButtonV2
variant="primary"
ghost
border
<Button
className="mt-2 w-full"
onClick={handleStopRecording}
>
{t("transcribe_again")}
</ButtonV2>
</Button>
</div>
)}
<div className="text-base font-semibold">
@ -369,16 +374,17 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
>
Test
</button>
<TextAreaFormField
<Textarea
name="transcript"
disabled={status !== "REVIEWING"}
value={transcript}
onChange={(e) => setTranscript(e.value)}
errorClassName="hidden"
onChange={(e) => setTranscript(e.target.value)}
className="h-20 resize-none"
// errorClassName="hidden"
placeholder="Transcript"
/>
<ButtonV2
loading={status !== "REVIEWING"}
<Button
// loading={status !== "REVIEWING"}
disabled={transcript === lastTranscript}
className="mt-4 w-full"
onClick={() =>
@ -386,7 +392,7 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
}
>
{t("process_transcript")}
</ButtonV2>
</Button>
{!(toReview && !toReview.length) && (
<button
className="absolute -top-6 right-4 text-xs text-gray-100 hover:text-gray-200"
@ -399,7 +405,7 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
)}
{status === "FAILED" && (
<div className="flex flex-col items-center justify-center gap-4 px-4 py-10 text-red-500">
<CareIcon icon="l-times-circle" className="text-4xl" />
<CrossCircledIcon className="text-4xl" />
{t("scribe_error")}
</div>
)}
@ -412,17 +418,17 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
className="flex max-h-[50px] w-40 items-center gap-2 overflow-hidden rounded-lg bg-black/20 p-2 text-left text-xs text-white transition-all hover:bg-black/40 md:max-h-[100px]"
>
<div>{transcript}</div>
<CareIcon icon="l-angle-up" className="text-xl" />
<ChevronUpIcon className="text-xl" />
</button>
)}
<div className="flex items-center gap-2">
{status === "REVIEWING" && (
<button
onClick={handleCancel}
className="flex aspect-square h-full items-center justify-center rounded-full border border-secondary-400 bg-secondary-300 p-4 text-xl transition-all hover:bg-secondary-400"
className="border-secondary-400 bg-secondary-300 hover:bg-secondary-400 flex aspect-square h-full items-center justify-center rounded-full border p-4 text-xl transition-all"
title={t("cancel")}
>
<CareIcon icon="l-times" />
<Cross1Icon />
</button>
)}
<ScribeButton
@ -437,10 +443,14 @@ Level of consciousness is alert. The action to be taken is plan for home care. P
</div>
{!!toReview && !!toReview.length && (
<ScribeReview
{...props}
toReview={toReview}
onReviewComplete={async (approvedFields) => {
const approved = approvedFields.filter((a) => a.approved);
approved && Notify.Success({ msg: t("autofilled_fields") });
approved &&
toast({
title: t("autofilled_fields"),
});
setToReview(undefined);
setStatus("IDLE");
}}

View File

@ -1,21 +1,18 @@
import { useEffect, useState } from "react";
import { ScribeFieldReviewedSuggestion, ScribeFieldSuggestion } from "../types";
import CareIcon from "@/CAREUI/icons/CareIcon";
import {
previewFieldUpdate,
renderFieldValue,
sleep,
updateFieldValue,
} from "../utils";
import { renderFieldValue, sleep, updateFieldValue } from "../utils/utils";
import useKeyboardShortcut from "use-keyboard-shortcut";
import { KeyboardShortcutKey } from "@/CAREUI/interactive/KeyboardShortcut";
import { useTranslation } from "react-i18next";
import { KeyboardShortcutKey } from "./ui/keyboard-shortcut";
import { ChevronLeftIcon } from "@radix-ui/react-icons";
export default function ScribeReview(props: {
formState: unknown;
setFormState: unknown;
toReview: ScribeFieldSuggestion[];
onReviewComplete: (accepted: ScribeFieldReviewedSuggestion[]) => void;
}) {
const { toReview, onReviewComplete } = props;
const { toReview, onReviewComplete, formState, setFormState } = props;
const initialReviewIndex = toReview.length > 1 ? -1 : 0;
const [reviewIndex, setReviewIndex] = useState(initialReviewIndex);
const [acceptedSuggestions, setAcceptedSuggestions] = useState<
@ -77,7 +74,8 @@ export default function ScribeReview(props: {
suggestionIndex: reviewIndex,
},
];
if (!approved && reviewingField) updateFieldValue(reviewingField);
if (!approved && reviewingField)
updateFieldValue(reviewingField, false, formState, setFormState);
await sleep(150);
setAcceptedSuggestions(accepted);
handleForward(accepted);
@ -91,7 +89,7 @@ export default function ScribeReview(props: {
}));
for (const f of toReview) {
await sleep(100);
updateFieldValue(f, true);
updateFieldValue(f, true, formState, setFormState);
}
setAcceptedSuggestions(accepted);
handleReviewComplete(accepted);
@ -99,8 +97,7 @@ export default function ScribeReview(props: {
useEffect(() => {
if (reviewingField) {
updateFieldValue(reviewingField, true);
previewFieldUpdate(reviewingField);
updateFieldValue(reviewingField, true, formState, setFormState);
}
}, [reviewingField]);
@ -124,7 +121,9 @@ export default function ScribeReview(props: {
key={index}
className="flex flex-col items-start rounded-lg bg-black/20 px-4 py-2"
>
<div className="text-xs text-secondary-400">{field.label}</div>
<div className="text-secondary-400 text-xs">
{field.question.text}
</div>
<div className="font-bold">{renderFieldValue(field, true)}</div>
</div>
))}
@ -133,14 +132,14 @@ export default function ScribeReview(props: {
<div className="flex flex-col gap-2 md:flex-row">
<button
onClick={handleAcceptAll}
className="flex w-full items-center gap-2 rounded-full bg-primary-500 px-4 py-2 text-lg font-semibold transition-all hover:bg-primary-600 md:w-auto"
className="bg-primary-500 hover:bg-primary-600 flex w-full items-center gap-2 rounded-full px-4 py-2 text-lg font-semibold transition-all md:w-auto"
>
<KeyboardShortcutKey shortcut={["E"]} />
{t("accept_all")}
</button>
<button
onClick={() => handleForward()}
className="flex w-full items-center gap-2 rounded-full bg-white px-4 py-2 text-lg font-semibold text-black transition-all hover:bg-secondary-100 md:w-auto"
className="hover:bg-secondary-100 flex w-full items-center gap-2 rounded-full bg-white px-4 py-2 text-lg font-semibold text-black transition-all md:w-auto"
>
<KeyboardShortcutKey shortcut={["A"]} />
{t("start_review")}
@ -179,18 +178,18 @@ export default function ScribeReview(props: {
onClick={handleBack}
className="flex aspect-square items-center justify-center rounded-full border border-white p-2 text-2xl font-semibold text-white"
>
<CareIcon icon="l-angle-left" />
<ChevronLeftIcon />
</button>
<button
onClick={() => handleVerdict(false)}
className="flex items-center gap-2 rounded-full bg-white px-4 py-2 text-lg font-semibold text-black transition-all hover:bg-secondary-100"
className="hover:bg-secondary-100 flex items-center gap-2 rounded-full bg-white px-4 py-2 text-lg font-semibold text-black transition-all"
>
<KeyboardShortcutKey shortcut={["R"]} />
{t("reject")}
</button>
<button
onClick={() => handleVerdict(true)}
className="flex items-center gap-2 rounded-full bg-primary-500 px-4 py-2 text-lg font-semibold transition-all hover:bg-primary-600"
className="bg-primary-500 hover:bg-primary-600 flex items-center gap-2 rounded-full px-4 py-2 text-lg font-semibold transition-all"
>
<KeyboardShortcutKey shortcut={["A"]} />
{t("accept")}
@ -203,7 +202,7 @@ export default function ScribeReview(props: {
})}
</div>
<div className="flex items-center gap-4">
{toReview.map((r, i) => (
{toReview.map((_, i) => (
<button
key={i}
className={`aspect-square w-4 rounded-full ${acceptedSuggestions.find((s) => s.suggestionIndex === i)?.approved === true ? "bg-primary-500" : acceptedSuggestions.find((s) => s.suggestionIndex === i)?.approved === false ? "bg-red-500" : "bg-white"} ${reviewIndex === i ? "opacity-100" : "opacity-50"} transition-all`}

View File

@ -1,7 +1,8 @@
import CareIcon from "@/CAREUI/icons/CareIcon";
import { ReloadIcon } from "@radix-ui/react-icons";
import { ScribeStatus } from "../types";
import { useTranslation } from "react-i18next";
import useKeyboardShortcut from "use-keyboard-shortcut";
import { MicrophoneIcon, MicrophoneSlashIcon } from "@/utils/icons";
export default function ScribeButton(props: {
status: ScribeStatus;
@ -15,21 +16,19 @@ export default function ScribeButton(props: {
return (
<button
onClick={onClick}
className={`group z-10 flex items-center rounded-full ${status === "IDLE" ? "bg-primary-500 text-white hover:bg-primary-600" : "border border-secondary-400 bg-secondary-200 hover:bg-secondary-300"} transition-all disabled:bg-secondary-300`}
className={`group z-10 flex items-center rounded-full ${status === "IDLE" ? "bg-primary-500 hover:bg-primary-600 text-white" : "border-secondary-400 bg-secondary-200 hover:bg-secondary-300 border"} disabled:bg-secondary-300 transition-all`}
disabled={["TRANSCRIBING", "THINKING"].includes(status)}
>
<div
className={`flex aspect-square h-full items-center justify-center rounded-full ${status === "IDLE" ? "bg-primary-600 group-hover:bg-primary-700" : "bg-secondary-300 group-hover:bg-secondary-400"} p-4 text-xl`}
>
<CareIcon
icon={
status === "IDLE"
? "l-microphone"
: status === "RECORDING"
? "l-microphone-slash"
: "l-redo"
}
/>
{status === "IDLE" ? (
<MicrophoneIcon className="w-4 invert" />
) : status === "RECORDING" ? (
<MicrophoneSlashIcon className="w-5" />
) : (
<ReloadIcon />
)}
</div>
<div className="pl-2 pr-6 font-semibold">
{status === "IDLE"

View File

@ -0,0 +1,57 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-neutral-300",
{
variants: {
variant: {
default:
"bg-neutral-900 text-neutral-50 shadow hover:bg-neutral-900/90 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90",
destructive:
"bg-red-500 text-neutral-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-neutral-50 dark:hover:bg-red-900/90",
outline:
"border border-neutral-200 bg-white shadow-sm hover:bg-neutral-100 hover:text-neutral-900 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
secondary:
"bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-100/80 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80",
ghost:
"hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
link: "text-neutral-900 underline-offset-4 hover:underline dark:text-neutral-50",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,38 @@
import { Fragment } from "react/jsx-runtime";
const SHORTCUT_KEY_MAP = {
Meta: "⌘",
Shift: "⇧Shift",
Alt: "⌥Alt",
Control: "Ctrl",
ArrowUp: "↑",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
} as Record<string, string>;
export function KeyboardShortcutKey(props: {
shortcut: string[];
useShortKeys?: boolean;
}) {
const { shortcut, useShortKeys } = props;
return (
<div className="hidden shrink-0 items-center md:flex">
{shortcut.map((key, idx, keys) => (
<Fragment key={idx}>
<kbd className="border-secondary-400 bg-secondary-100 relative z-10 flex h-6 min-w-6 shrink-0 items-center justify-center rounded-md border border-b-4 px-2 text-xs text-black">
{SHORTCUT_KEY_MAP[key]
? useShortKeys
? SHORTCUT_KEY_MAP[key][0]
: SHORTCUT_KEY_MAP[key]
: key}
</kbd>
{idx !== keys.length - 1 && (
<span className="px-1 text-zinc-300/60"> + </span>
)}
</Fragment>
))}
</div>
);
}

View File

@ -0,0 +1,21 @@
import { cn } from "@/utils/utils";
import * as React from "react";
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-neutral-200 bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-neutral-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-800 dark:placeholder:text-neutral-400 dark:focus-visible:ring-neutral-300 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";
export { Textarea };

127
src/components/ui/toast.tsx Normal file
View File

@ -0,0 +1,127 @@
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { Cross2Icon } from "@radix-ui/react-icons";
import { cn } from "@/utils/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border border-neutral-200 p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-neutral-800",
{
variants: {
variant: {
default:
"border bg-white text-neutral-950 dark:bg-neutral-950 dark:text-neutral-50",
destructive:
"destructive group border-red-500 bg-red-500 text-neutral-50 dark:border-red-900 dark:bg-red-900 dark:text-neutral-50",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-neutral-200 bg-transparent px-3 text-sm font-medium transition-colors hover:bg-neutral-100 focus:outline-none focus:ring-1 focus:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-neutral-100/40 group-[.destructive]:hover:border-red-500/30 group-[.destructive]:hover:bg-red-500 group-[.destructive]:hover:text-neutral-50 group-[.destructive]:focus:ring-red-500 dark:border-neutral-800 dark:hover:bg-neutral-800 dark:focus:ring-neutral-300 dark:group-[.destructive]:border-neutral-800/40 dark:group-[.destructive]:hover:border-red-900/30 dark:group-[.destructive]:hover:bg-red-900 dark:group-[.destructive]:hover:text-neutral-50 dark:group-[.destructive]:focus:ring-red-900",
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-neutral-950/50 opacity-0 transition-opacity hover:text-neutral-950 focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:text-neutral-50/50 dark:hover:text-neutral-50",
className,
)}
toast-close=""
{...props}
>
<Cross2Icon className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@ -0,0 +1,33 @@
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

194
src/hooks/use-toast.ts Normal file
View File

@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@ -0,0 +1,78 @@
import { createContext, useContext, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { API } from "../utils/api";
import { FacilityModel } from "../types";
export type FeatureFlag = "SCRIBE_ENABLED";
export interface FeatureFlagsResponse {
user_flags: FeatureFlag[];
facility_flags: {
facility: string;
features: FeatureFlag[];
}[];
}
const defaultFlags: FeatureFlag[] = [];
const FeatureFlagsContext = createContext<FeatureFlagsResponse>({
user_flags: defaultFlags,
facility_flags: [],
});
export const FeatureFlagsProvider = (props: { children: React.ReactNode }) => {
const [featureFlags, setFeatureFlags] = useState<FeatureFlagsResponse>({
user_flags: defaultFlags,
facility_flags: [],
});
const { data: user } = useQuery({
queryKey: ["authuser"],
queryFn: API.users.current,
});
useEffect(() => {
if (user?.user_flags) {
setFeatureFlags((ff) => ({
...ff,
user_flags: [...defaultFlags, ...(user.user_flags || [])],
}));
}
}, [user]);
return (
<FeatureFlagsContext.Provider value={featureFlags}>
{props.children}
</FeatureFlagsContext.Provider>
);
};
export const useFeatureFlags = (facility?: FacilityModel | string) => {
const [facilityObject] = useState<FacilityModel | undefined>(
typeof facility === "string" ? undefined : facility,
);
const context = useContext(FeatureFlagsContext);
if (context === undefined) {
throw new Error(
"useFeatureFlags must be used within a FeatureFlagsProvider",
);
}
// const facilityQuery = useQuery({
// queryKey: ["facility", facility],
// queryFn: () =>
// API.facilities.getPermitted(typeof facility === "string" ? facility : ""),
// });
// useEffect(() => {
// facilityQuery.data && setFacilityObject(facilityQuery.data);
// }, [facilityQuery.data]);
const facilityFlags = facilityObject?.facility_flags || [];
// useEffect(() => {
// facilityQuery.refetch();
// }, [facility]);
return [...context.user_flags, ...facilityFlags];
};

View File

@ -0,0 +1,148 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
const useSegmentedRecording = () => {
const [isRecording, setIsRecording] = useState(false);
const [recorder, setRecorder] = useState<MediaRecorder | null>(null);
const [audioBlobs, setAudioBlobs] = useState<Blob[]>([]);
const [restart, setRestart] = useState(false);
const [microphoneAccess, setMicrophoneAccess] = useState(false); // New state
const { t } = useTranslation();
const bufferInterval = 1 * 1000;
const splitSizeLimit = 20 * 1000000; // 20MB
useEffect(() => {
if (!isRecording && recorder && audioBlobs.length > 0) {
setRecorder(null);
}
}, [isRecording, recorder, audioBlobs]);
useEffect(() => {
if (recorder === null) {
if (isRecording || restart) {
requestRecorder().then(
(newRecorder) => {
setRecorder(newRecorder);
setMicrophoneAccess(true); // Set access to true on success
if (restart) {
setIsRecording(true);
}
},
() => {
window.alert({
msg: t("audio__permission_message"),
});
setIsRecording(false);
setMicrophoneAccess(false); // Set access to false on failure
},
);
}
return;
}
if (isRecording) {
recorder.state === "inactive" && recorder.start(bufferInterval);
} else {
if (restart) {
setIsRecording(true);
} else {
recorder?.stream?.getTracks()?.forEach((i) => i?.stop());
recorder.stop();
}
recorder.state === "recording" && recorder.stop();
}
// Obtain the audio when ready.
const handleData = (e: { data: Blob }) => {
const newChunk = e.data;
let currentBlob: Blob | undefined = audioBlobs[audioBlobs.length - 1];
if (restart) {
currentBlob = undefined;
}
if ((currentBlob?.size || 0) + newChunk.size < splitSizeLimit) {
// Audio size is less than 20MB, appending to current blob
if (!currentBlob) {
// Current blob is null, setting new blob
if (restart) {
setAudioBlobs((prev) => [
...prev,
new Blob([newChunk], { type: recorder.mimeType }),
]);
setRestart(false);
return;
}
setAudioBlobs([new Blob([newChunk], { type: recorder.mimeType })]);
return;
}
// Appending new chunk to current blob
const newBlob = new Blob([currentBlob, newChunk], {
type: recorder.mimeType,
});
setAudioBlobs((prev) => [...prev.slice(0, prev.length - 1), newBlob]);
} else {
// Audio size exceeded 20MB, starting new recording
if (currentBlob)
setAudioBlobs((prev) => [
...prev.slice(0, prev.length - 1),
new Blob([currentBlob ?? new Blob([]), newChunk], {
type: recorder.mimeType,
}),
]);
recorder.stop();
setRecorder(null);
setRestart(true);
setIsRecording(false);
}
};
recorder.addEventListener("dataavailable", handleData);
return () => recorder.removeEventListener("dataavailable", handleData);
}, [recorder, isRecording, bufferInterval, audioBlobs, restart]);
const startRecording = async () => {
try {
const newRecorder = await requestRecorder();
setRecorder(newRecorder);
setMicrophoneAccess(true);
setIsRecording(true);
} catch (error) {
setMicrophoneAccess(false);
throw new Error("Microphone access denied");
}
};
const stopRecording = () => {
setIsRecording(false);
};
const resetRecording = () => {
setAudioBlobs([]);
};
return {
isRecording,
startRecording,
stopRecording,
resetRecording,
audioBlobs,
microphoneAccess, // Return microphoneAccess
};
};
async function requestRecorder() {
return new Promise<MediaRecorder>((resolve, reject) => {
navigator.mediaDevices
.getUserMedia({ audio: true })
.then((stream) => {
const recorder = new MediaRecorder(stream, {
audioBitsPerSecond: 128000,
});
resolve(recorder);
})
.catch((error) => {
reject(error);
});
});
}
export default useSegmentedRecording;

53
src/hooks/useTimer.tsx Normal file
View File

@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
/**
* Custom hook to manage a timer in MM:SS format. This can be useful for tracking time during recording sessions, user actions, or any other timed event.
*
* @returns {Object} A set of properties and methods to control and display the timer:
*
* @property {number} seconds - The total elapsed time in seconds.
* @property {JSX.Element} time - A JSX element displaying the current time in MM:SS format.
* @property {function} start - Function to start the timer.
* @property {function} stop - Function to stop the timer.
*
* @example
* const { time, start, stop } = useTimer();
*
* // To start the timer:
* start();
*
* // To stop the timer:
* stop();
*
* // To display the timer in your component:
* <div>{time}</div>
*/
export const useTimer = (autoStart = false) => {
const [running, setRunning] = useState(autoStart);
const [time, setTime] = useState(0);
useEffect(() => {
let interval: ReturnType<typeof setInterval>;
if (running) {
interval = setInterval(() => {
setTime((prevTime) => prevTime + 1);
}, 10);
} else {
setTime(0);
}
return () => clearInterval(interval);
}, [running]);
return {
seconds: time / 100,
time: (
<span>
{("0" + Math.floor((time / 6000) % 60)).slice(-2)}:
{("0" + Math.floor((time / 100) % 60)).slice(-2)}
</span>
),
start: () => setRunning(true),
stop: () => setRunning(false),
reset: () => setTime(0),
};
};

View File

@ -1,50 +0,0 @@
import { useEffect, useState } from "react";
import { Controller } from "./components/Controller";
import { useFeatureFlags } from "@/Utils/featureFlags";
import { usePath } from "raviger";
export { default as manifest } from "./manifest";
export default function Entry() {
const path = usePath();
const facilityId = path?.includes("/facility/")
? path.split("/facility/")[1].split("/")[0]
: undefined;
const [forms, setForms] = useState<NodeListOf<Element>>();
const featureFlags = useFeatureFlags(facilityId);
const SCRIBE_ENABLED = featureFlags.includes("SCRIBE_ENABLED");
useEffect(() => {
if (!SCRIBE_ENABLED) return;
const observer = new MutationObserver((mutationsList, observer) => {
for (const mutation of mutationsList) {
if (mutation.type === "childList") {
const forms = document.querySelectorAll('[data-scribe-form="true"]');
setForms(forms);
}
}
});
const config = { childList: true, subtree: true };
observer.observe(document.body, config);
return () => observer.disconnect();
}, [SCRIBE_ENABLED]);
useEffect(() => {
if (!forms || forms.length === 0) return;
const pageElement = document.querySelector(
'[data-cui-page="true"]',
) as HTMLElement;
if (pageElement) {
pageElement.style.setProperty("padding-bottom", "100px", "important");
}
return () => {
if (pageElement) {
pageElement.style.paddingBottom = "";
}
};
}, [forms]);
return <div>{forms?.length && <Controller />}</div>;
}

19
src/locale/en.json Normal file
View File

@ -0,0 +1,19 @@
{
"voice_autofill": "Voice Autofill",
"hearing": "We are hearing you...",
"copilot_thinking": "Copilot is thinking...",
"could_not_autofill": "We could not autofill any fields from what you said",
"transcribe_again": "Transcribe Again",
"transcript_edit_info": "You can update this if we made an error",
"transcript_information": "This is what we heard",
"process_transcript": "Process Again",
"retake_recording": "Retake Recording",
"scribe__reviewing_field": "Reviewing field {{currentField}} / {{totalFields}}",
"scribe_error": "Could not autofill fields",
"accept": "Accept",
"accept_all": "Accept All",
"reject": "Reject",
"stop_recording": "Stop Recording",
"autofilled_fields": "Autofilled Fields",
"start_review": "Start Review"
}

1
src/main.tsx Normal file
View File

@ -0,0 +1 @@
export { default as manifest } from "./manifest";

View File

@ -1,13 +1,12 @@
import { PluginManifest } from "@/pluginTypes";
import { lazy } from "react";
const manifest: PluginManifest = {
const manifest = {
plugin: "care-scribe",
routes: {},
extends: [],
components: {
Scribe: lazy(
() => import("./index"),
() => import("./Providers"),
)
},
navItems: [],

8
src/style/index.css Normal file
View File

@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--radius: 0.5rem
}
}

View File

@ -1,4 +1,39 @@
import { UserModel } from "@/components/Users/models";
export type FeatureFlag = "SCRIBE_ENABLED"; // "HCX_ENABLED" | "ABDM_ENABLED" |
export type UserBareMinimum = {
id: number;
username: string;
first_name: string;
last_name: string;
email: string;
user_type: unknown;
last_login: string | undefined;
read_profile_picture_url?: string;
external_id: string;
};
export type GenderType = "Male" | "Female" | "Transgender";
export type UserModel = UserBareMinimum & {
external_id: string;
local_body?: number;
district?: number;
state?: number;
video_connect_link: string;
phone_number?: string;
alt_phone_number?: string;
gender?: GenderType;
read_profile_picture_url?: string;
date_of_birth: Date | null | string;
is_superuser?: boolean;
verified?: boolean;
home_facility?: string;
qualification?: string;
doctor_experience_commenced_on?: string;
doctor_medical_council_registration?: string;
weekly_working_hours?: string | null;
user_flags?: FeatureFlag[];
};
export type ScribeModel = {
external_id: string;
@ -21,6 +56,8 @@ export type ScribeModel = {
| "GENERATING_AI_RESPONSE"
| "COMPLETED"
| "FAILED";
system_prompt?: string;
json_prompt?: string;
};
export type ScribeStatus =
@ -38,22 +75,108 @@ export type ScribeFieldOption = {
text: string
}
export type ScribeFieldTypes = "string" | "number" | "date" | "datetime-local" | "select" | "cui-select" | "cui-multi-select" | "cui-date" | "radio" | "checkbox" | "sub-form"
export type ScribeField = {
type: ScribeFieldTypes
question: FormQuestion,
fieldElement: Element,
label: string;
options?: ScribeFieldOption[];
value: string | null;
customPrompt?: string,
customExample?: string
}
export type ScribeAIResponse = {
[field_number: number]: unknown
}
export type ScribePromptMap = {
[key in QuestionType | "default"]?: { prompt: string; example: unknown };
}
export type ScribeFieldSuggestion = ScribeField & { newValue: unknown }
export type ScribeFieldReviewedSuggestion = ScribeFieldSuggestion & { suggestionIndex: number, approved?: boolean }
export type ScribeFieldReviewedSuggestion = ScribeFieldSuggestion & { suggestionIndex: number, approved?: boolean }
export type FileCategory = "UNSPECIFIED" | "XRAY" | "AUDIO" | "IDENTITY_PROOF";
export interface CreateFileRequest {
file_type: string | number;
file_category: FileCategory;
name: string;
associating_id: string;
original_name: string;
mime_type: string;
}
export interface CreateFileResponse {
id: string;
file_type: string;
file_category: FileCategory;
signed_url: string;
internal_name: string;
}
export interface FileUploadModel {
id?: string;
name?: string;
associating_id?: string;
created_date?: string;
upload_completed?: boolean;
uploaded_by?: UserBareMinimum;
file_category?: FileCategory;
read_signed_url?: string;
is_archived?: boolean;
archive_reason?: string;
extension?: string;
archived_by?: UserBareMinimum;
archived_datetime?: string;
}
export interface FacilityModel {
id?: string;
name?: string;
read_cover_image_url?: string;
facility_type?: string;
address?: string;
features?: number[];
location?: {
latitude: number;
longitude: number;
};
phone_number?: string;
middleware_address?: string;
modified_date?: string;
created_date?: string;
state?: number;
district?: number;
local_body?: number;
ward?: number;
pincode?: string;
facility_flags?: FeatureFlag[];
latitude?: string;
longitude?: string;
kasp_empanelled?: boolean;
patient_count?: number;
bed_count?: number;
}
export type QuestionType =
| "group"
| "display"
| "boolean"
| "decimal"
| "integer"
| "date"
| "dateTime"
| "time"
| "string"
| "text"
| "url"
| "choice"
| "quantity"
| "structured";
export interface FormQuestion {
id: string;
structured_type?: string;
answer_option?: { value: string }[];
text: string;
required?: boolean;
type: QuestionType
}

View File

@ -1,401 +0,0 @@
import dayjs from "dayjs";
import {
ScribeAIResponse,
ScribeField,
ScribeFieldSuggestion,
ScribeFieldTypes,
} from "./types";
const isVisible = (elem: HTMLElement, allowSubform: boolean) => {
// Ignore fields that are hidden in the viewport
return (
!!(
elem.offsetWidth ||
elem.offsetHeight ||
elem.getClientRects().length ||
window.getComputedStyle(elem).visibility !== "hidden"
) &&
// Intentionally ignored fields
!elem.closest('[data-scribe-ignore="true"]') &&
// Check if field is not in a subform
(allowSubform ? true : !elem.closest("[data-scribe-subform]"))
);
};
export const scrapeFields = (
initFormElement: HTMLElement | null,
isSubform: boolean,
) => {
const formElement =
initFormElement ||
(document.querySelector(`[data-scribe-form="true"]`) as HTMLElement);
if (!formElement || !isVisible(formElement, isSubform))
throw Error(
'Cannot find a scribeable form. Make sure to mark forms with the "data-scribe-form" attribute',
);
const inputElements = [
...formElement.querySelectorAll(
'input:not([type="submit"]):not([role="combobox"])',
),
].filter((ele) =>
isVisible(ele as HTMLElement, isSubform),
) as HTMLInputElement[];
const textAreaElements = [...formElement.querySelectorAll("textarea")].filter(
(ele) => isVisible(ele as HTMLElement, isSubform),
) as HTMLTextAreaElement[];
const selectElements = [...formElement.querySelectorAll(`select`)].filter(
(ele) => isVisible(ele as HTMLElement, isSubform),
) as HTMLSelectElement[];
// Care UI (Headless UI) does not use the traditional <select> field for dropdowns.
const careUISelectElements = [
...formElement.querySelectorAll(`[data-cui-listbox]`),
].filter((ele) => isVisible(ele as HTMLElement, isSubform));
const careUIDateElements = [
...formElement.querySelectorAll(`[data-cui-dateinput]`),
].filter((ele) => isVisible(ele as HTMLElement, isSubform));
// temp disable subforms
// const subFormElements = scrapeSubForms(formElement);
const getInputType: (t: string | null) => ScribeField["type"] = (
type: string | null,
) =>
type &&
[
"string",
"number",
"date",
"datetime-local",
"radio",
"checkbox",
].includes(type)
? (type as ScribeField["type"])
: "string";
const inputs: ScribeField[] = inputElements
.filter(
(ele) => !["radio", "checkbox"].includes(ele.getAttribute("type") || ""),
)
.map((ele) => ({
type: getInputType(ele.getAttribute("type")),
fieldElement: ele,
label: ele.labels?.[0]?.innerText || ele.name || "",
value: ele.value,
}));
const checkBoxesAndRadios: ScribeField[] = Array.from(
new Map(
inputElements
.filter((ele) =>
["radio", "checkbox"].includes(ele.getAttribute("type") || ""),
)
.map((ele) => [
ele.getAttribute("name"), // use the `name` attribute as the key
{
type: getInputType(ele.getAttribute("type")),
fieldElement: ele.parentElement?.parentElement || ele,
label:
(
document.querySelector(
`label[for=${ele.getAttribute("name")}]`,
) as HTMLLabelElement
)?.innerText || "",
options: [
...(document.querySelectorAll(
`input[name=${ele.getAttribute("name")}]`,
) as NodeListOf<HTMLInputElement>),
].map((inp) => ({
text: (
document.querySelector(
`label[for="${inp.id}"]`,
) as HTMLLabelElement
).innerText,
value: inp.value,
})),
value:
[
...(document.querySelectorAll(
`input[name=${ele.getAttribute("name")}]`,
) as NodeListOf<HTMLInputElement>),
].find((radio) => radio.checked)?.value || null,
},
]),
).values(),
);
const textareas: ScribeField[] = textAreaElements.map((ele) => ({
type: "string",
fieldElement: ele,
label: ele.labels?.[0]?.innerText || "",
value: ele.value,
customPrompt: ele.getAttribute("data-scribe-prompt") || undefined,
customExample: ele.getAttribute("data-scribe-example") || undefined,
}));
const selects: ScribeField[] = selectElements.map((ele) => ({
type: "select",
fieldElement: ele,
label: ele.labels?.[0]?.innerText || "",
options: [...ele.querySelectorAll("option")].map((option) => ({
value: option?.value || "",
text: option?.innerText,
})),
value: ele.value,
customPrompt: ele.getAttribute("data-scribe-prompt") || undefined,
customExample: ele.getAttribute("data-scribe-example") || undefined,
}));
const cuiSelects: ScribeField[] = careUISelectElements.map((ele) => ({
type: Array.isArray(
JSON.parse(ele.getAttribute("data-cui-listbox-value") || `""`),
)
? "cui-multi-select"
: "cui-select",
fieldElement: ele,
label:
(
ele.parentElement?.parentElement?.querySelector(
"label:not([data-headlessui-state])",
) as HTMLLabelElement
)?.innerText || ele.id,
options: (
JSON.parse(ele.getAttribute("data-cui-listbox-options") || "[]") as [
string,
string,
][]
).map(([value, text]) => ({ text, value })),
value: JSON.parse(ele.getAttribute("data-cui-listbox-value") || `""`),
customPrompt: ele.getAttribute("data-scribe-prompt") || undefined,
customExample: ele.getAttribute("data-scribe-example") || undefined,
}));
const cuiDateInput: ScribeField[] = careUIDateElements.map((ele) => ({
type: "cui-date",
fieldElement: ele,
label:
(
ele.parentElement?.parentElement?.parentElement?.querySelector(
"label",
) as HTMLLabelElement
)?.innerText ||
ele.querySelector("input[readonly]")?.id ||
"",
value: JSON.parse(ele.getAttribute("data-cui-dateinput-value") || `""`),
customPrompt: ele.getAttribute("data-scribe-prompt") || undefined,
customExample: ele.getAttribute("data-scribe-example") || undefined,
}));
// const subForms: ScribeField[] = subFormElements.map((form) => ({
// type: "sub-form",
// fieldElement: form.element,
// label: form.label,
// value: JSON.stringify(
// form.entries.map((row, id) => ({
// id,
// action: "NONE",
// fields: row.map((field) => ({ [field.label]: field.value })),
// })),
// ),
// customPrompt: `A complex array of objects.
// If there are any additions to the field, please add to the array in the following example format:
// ${JSON.stringify({ id: null, action: "ADD", fields: form.creator.map((field) => ({ [field.label]: field.options ? (field.type === "cui-multi-select" ? [field.options[0].value, field.options[1].value] : field.options[0].value) : SCRIBE_PROMPT_MAP[field.type]?.example })) })}.
// ${
// form.creator.filter((f) => f.options).length
// ? `
// NOTE : Refer to the following as option values for the fields. Make sure you select only the value of the option for the corresponding field.
// ${JSON.stringify(form.creator.filter((f) => f.options).map((f) => ({ [f.label]: f.options })))}
// `
// : ``
// }
// If a row is being added, action should be "ADD". If an existing row is being updated, action should be "UPDATE", and if the row is being deleted or removed, action should be "DELETE". If there is no action, the action should be "NONE". No other action value is allowed.`,
// customExample: `${JSON.stringify({ id: null, action: "ADD", fields: form.creator.map((field) => ({ [field.label]: field.options ? (field.type === "cui-multi-select" ? [field.options[0].value, field.options[1].value] : field.options[0].value) : SCRIBE_PROMPT_MAP[field.type]?.example })) })}.`,
// }));
//console.log(subForms[0]?.customPrompt);
const fields = [
...inputs,
...textareas,
...selects,
...cuiSelects,
...checkBoxesAndRadios,
...cuiDateInput,
//...subForms,
];
return fields;
};
export const scrapeSubForms = (formElement: HTMLElement) => {
const subforms = [
...formElement.querySelectorAll("[data-scribe-subform]"),
] as HTMLElement[];
const subformsData = subforms.map((form) => ({
element: form,
label: form.getAttribute("data-scribe-subform") || "Sub Form",
entries: (
[
...form.querySelectorAll(`[data-scribe-subform-entry="true"]`),
] as HTMLElement[]
).map((entry) => scrapeFields(entry, true)),
creator: scrapeFields(
form.querySelector(`[data-scribe-subform-creator="true"]`) as HTMLElement,
true,
),
}));
return subformsData;
};
export const getFieldsToReview = (
aiResponse: ScribeAIResponse,
scrapedFields: ScribeField[],
) => {
return scrapedFields
.map((f, i) => ({ ...f, newValue: aiResponse[i] }))
.filter((f) => f.newValue);
};
export const renderFieldValue = (
field: ScribeFieldSuggestion,
useNewValue?: boolean,
) => {
const val = useNewValue ? field.newValue : field.value;
if (field.type === "sub-form") {
return <div className="italic text-gray-200">Multiple Updates</div>;
}
if (!["string", "number"].includes(typeof field.value)) return "N/A";
return field.options
? field.options.find((o) => String(o.value) === String(val))?.text
: (val as string | number);
};
export const sleep = async (seconds: number) => {
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, seconds);
});
};
export const updateFieldValue = (
field: ScribeFieldSuggestion,
useNewValue?: boolean,
) => {
const val = (useNewValue ? field.newValue : field.value) as string;
const element = field.fieldElement as HTMLElement;
switch (field.type) {
case "cui-select":
case "cui-multi-select":
element.setAttribute("data-cui-listbox-value", JSON.stringify(val || ""));
break;
case "cui-date":
element.setAttribute(
"data-cui-dateinput-value",
JSON.stringify(val || ""),
);
break;
case "radio":
case "checkbox":
const toCheck = element.querySelector(
`input[value=${val || "__NULL__"}]`,
) as HTMLInputElement;
element.querySelectorAll(`input`).forEach((e) => {
const descriptor = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"checked",
);
if (descriptor?.set) {
descriptor.set.call(e, toCheck.value === e.value);
e.dispatchEvent(new Event("change", { bubbles: true }));
toCheck.value === e.value && e.click();
}
});
break;
case "sub-form":
//console.log("Subform", getSubFormValues(val));
break;
default:
const input = field.fieldElement as
| HTMLInputElement
| HTMLTextAreaElement;
// input.value = x won't do the trick as it will just update the DOM value, and not trigger the onChange for the state to update.
const descriptor = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(element),
"value",
);
if (descriptor?.set) {
descriptor.set.call(input, val as string);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
}
};
const getSubFormValues = (value?: string) => {
try {
const values: {
id: null | number;
action: "ADD" | "UPDATE" | "DELETE" | "NONE";
fields: unknown;
}[] = JSON.parse(value || "[]");
return {
updated: values.filter((v) => v.action === "UPDATE"),
deleted: values.filter((v) => v.action === "DELETE"),
added: values.filter((v) => v.action === "ADD"),
all: values,
};
} catch (error) {
console.error("Could not parse sub form data from scribe response");
}
};
export const previewFieldUpdate = (field: ScribeFieldSuggestion) => {
switch (field.type) {
case "sub-form":
//console.log(field.newValue);
// const newFields = getSubFormValues(field.newValue as string);
//console.log(newFields);
// TODO: Update styling to reflect changes
// entries.forEach((entry, i) => {
// const updatedEntry = value.find(v => v.id === i);
// entry.style.background = updatedEntry?.action === 'DELETE' ? "red" : updatedEntry?.action === "UPDATE" ? "yellow" : ""
// })
// console.log(updatedRows, deletedRows, addedRows);
break;
}
};
export const SCRIBE_PROMPT_MAP: {
[key in ScribeFieldTypes | "default"]?: { prompt: string; example: string };
} = {
default: {
prompt: "A normal string value",
example: "A value",
},
date: {
prompt: "A date value",
example: "2003-12-21",
},
"datetime-local": {
prompt: `A date time value in ISO format. Current timestamp is ${dayjs(new Date()).format("YYYY-MM-DDTHH:mm")}`,
example: "2003-12-21T23:10",
},
"cui-date": {
prompt: `A date time value in ISO format. Current timestamp is ${dayjs(new Date()).format("YYYY-MM-DDTHH:mm")}`,
example: "2003-12-21T23:10",
},
"cui-multi-select": {
prompt: `An array of normal string values`,
example: `["an","example"]`,
},
number: {
prompt: "An integer value",
example: "42",
},
};

112
src/utils/api.ts Normal file
View File

@ -0,0 +1,112 @@
import {
CreateFileRequest,
CreateFileResponse,
FacilityModel,
FileUploadModel,
ScribeModel,
UserModel,
} from "../types";
type methods = "POST" | "GET" | "PATCH" | "DELETE" | "PUT";
type options = {
formdata?: boolean;
external?: boolean;
headers?: any;
auth?: boolean;
};
const CARE_BASE_URL = import.meta.env.VITE_CARE_API_URL || "";
const CARE_ACCESS_TOKEN_LOCAL_STORAGE_KEY = "care_access_token";
const request = async <T extends unknown>(
endpoint: string,
method: methods = "GET",
data: any = {},
options: options = {},
): Promise<T> => {
const { formdata, external, headers, auth: isAuth } = options;
let url = external ? endpoint : CARE_BASE_URL + endpoint;
let payload: null | string = formdata ? data : JSON.stringify(data);
if (method === "GET") {
const requestParams = data
? `?${Object.keys(data)
.filter((key) => data[key] !== null && data[key] !== undefined)
.map((key) => `${key}=${data[key]}`)
.join("&")}`
: "";
url += requestParams;
payload = null;
}
const localToken = localStorage.getItem(CARE_ACCESS_TOKEN_LOCAL_STORAGE_KEY);
const auth =
isAuth === false || typeof localToken === "undefined" || localToken === null
? ""
: "Bearer " + localToken;
const response = await fetch(url, {
method: method,
headers: external
? { ...headers }
: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: auth,
...headers,
},
body: payload,
});
try {
const txt = await response.clone().text();
if (txt === "") {
return {} as any;
}
const json = await response.clone().json();
if (json && response.ok) {
return json;
} else {
throw json;
}
} catch (error) {
throw { error };
}
};
export const API = {
scribe: {
create: (req: Partial<ScribeModel>) =>
request<ScribeModel>("/api/care_scribe/scribe/", "POST", req),
get: (scribeId: string) =>
request<ScribeModel>(`/api/care_scribe/scribe/${scribeId}/`),
update: (scribeId: string, data: Partial<ScribeModel>) =>
request<ScribeModel>(`/api/care_scribe/scribe/${scribeId}/`, "PUT", data),
createFileUpload: (data: CreateFileRequest) =>
request<CreateFileResponse>(
"/api/care_scribe/scribe_file/",
"POST",
data,
),
editFileUpload: (
id: string,
fileType: string,
associatingId: string,
data: Partial<FileUploadModel>,
) =>
request<FileUploadModel>(
`/api/care_scribe/scribe_file/${id}/?file_type=${fileType}&associating_id=${associatingId}`,
"PATCH",
data,
),
},
facilities: {
getPermitted: (facilityId: string) =>
request<FacilityModel>(`/api/v1/facility/${facilityId}/`),
},
users: {
current: () => request<UserModel>(`/api/v1/users/getcurrentuser/`)
}
};

17
src/utils/icons.tsx Normal file
View File

@ -0,0 +1,17 @@
import { SVGProps } from "react";
export function MicrophoneSlashIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" {...props}>
<path d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L472.1 344.7c15.2-26 23.9-56.3 23.9-88.7V216c0-13.3-10.7-24-24-24s-24 10.7-24 24v40c0 21.2-5.1 41.1-14.2 58.7L416 300.8V96c0-53-43-96-96-96s-96 43-96 96v54.3L38.8 5.1zm362.5 407l-43.1-33.9C346.1 382 333.3 384 320 384c-70.7 0-128-57.3-128-128v-8.7L144.7 210c-.5 1.9-.7 3.9-.7 6v40c0 89.1 66.2 162.7 152 174.4V464H248c-13.3 0-24 10.7-24 24s10.7 24 24 24h72 72c13.3 0 24-10.7 24-24s-10.7-24-24-24H344V430.4c20.4-2.8 39.7-9.1 57.3-18.2z" />
</svg>
);
}
export function MicrophoneIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" {...props}>
<path d="M192 0C139 0 96 43 96 96V256c0 53 43 96 96 96s96-43 96-96V96c0-53-43-96-96-96zM64 216c0-13.3-10.7-24-24-24s-24 10.7-24 24v40c0 89.1 66.2 162.7 152 174.4V464H120c-13.3 0-24 10.7-24 24s10.7 24 24 24h72 72c13.3 0 24-10.7 24-24s-10.7-24-24-24H216V430.4c85.8-11.7 152-85.3 152-174.4V216c0-13.3-10.7-24-24-24s-24 10.7-24 24v40c0 70.7-57.3 128-128 128s-128-57.3-128-128V216z" />
</svg>
);
}

348
src/utils/prompts.ts Normal file
View File

@ -0,0 +1,348 @@
import { ScribePromptMap } from "@/types";
import dayjs from "dayjs";
const MEDICATION_STATEMENT_STATUS = [
"active",
"on_hold",
"completed",
"stopped",
"unknown",
"entered_in_error",
"not_taken",
"intended",
] as const;
const ARBITRARY_INPUT_PROMPTS: ScribePromptMap = {
default: {
prompt: "A normal string value JSON encoded",
example: "A value"
},
integer: {
prompt: "An integer value JSON encoded",
example: 42,
},
date: {
prompt: `A date value JSON encoded. Current date : ${dayjs(new Date()).format("YYYY-MM-DD")}`,
example: "2003-12-21",
},
boolean: {
prompt: "A true or false value JSON encoded",
example: true,
},
dateTime: {
prompt: `A date time value in ISO format. Current timestamp is ${dayjs(new Date()).format("YYYY-MM-DDTHH:mm")}`,
example: "2003-12-21T23:10",
},
}
export const STRUCTURED_INPUT_PROMPTS = {
"encounter": {
prompt: `An array of only one object of the following schema. Everything in brackets is for your information and is not part of the schema. : {
status?: "planned" | "in_progress" | "on_hold" | "discharged" | "completed" | "cancelled" | "discontinued" | "entered_in_error" | "unknown",
encounter_class? : "imp" (Inpatient (IP)) | "amb" (Ambulatory (OP)) | "obsenc" (Observation Room) | "emer" (Emergency) | "vr" (Virtual) | "hh" (Home Health),'
priority?: "ASAP" | "callback_results" | "callback_for_scheduling" | "elective" | "emergency" | "preop" | "as_needed" | "routine" | "rush_reporting" | "stat" | "timing_critical" | "use_as_directed" | "urgent";
external_identifier (ip/op/obs/emr number)?: string;
(This will only be applicable if encounter_class is "imp", "absenc", or "emer")
hospitalization?: {
re_admission?: boolean;
admit_source?: "hosp_trans" (Hospital Transfer) | "emd" (Emergency Department) | "outp" (Outpatient Department) | "born" (Born) | "gp" (General Practitioner) | "mp" (Medical Practitioner) | "nursing" (Nursing Home) | "psych" (Psychiatric Hospital) | "rehab" (Rehabilitation Facility) | "other" (Other);
diet_preference?: "vegetarian" (Vegetarian) | "diary_free" (Dairy Free) | "nut_free" (Nut Free) | "gluten_free" (Gluten Free) | "vegan" (Vegan) | "halal" (Halal) | "kosher" (Kosher) | "none" (None);
(only applicable if status is "completed")
discharge_disposition?: "home" (Home) | "alt_home" (Alternate Home) | "other_hcf" (Other Healthcare Facility) | "hosp" (Hospice) | "long" (Long Term Care) | "aadvice" (Left Against Advice) | "exp" (Expired) | "psy" (Psychiatric Hospital) | "rehab" (Rehabilitation) | "snf" (Skilled Nursing Facility) | "oth" (Other);
};
...other data that is READ ONLY
}. Make sure to only update the existing data of the user and not remove or update any data that was not explicitly told to be updated. Return ONLY the original data with requested updates.`,
example: [{
status: "in_progress",
encounter_class: "imp",
priority: "callback_for_scheduling",
external_identifier: "1212",
hospitalization: {
re_admission: true,
admit_source: "outp",
discharge_disposition: "home",
diet_preference: "nut_free"
},
"...other_data": "keep other data as is"
}],
},
"medication_request": {
prompt: `An array of objects of the following type based on the SNOMED CT Code for the applicable diagnoses: {
status?: "active" | "on-hold" | "ended" | "stopped" | "completed" | "cancelled" | "entered-in-error" | "draft" | "unknown",
intent?: "proposal" | "plan" | "order" | "original_order" | "reflex_order" | "filler_order" | "instance_order",
category?: "inpatient" | "outpatient" | "community" | "discharge",
priority?: "stat" | "urgent" | "asap" | "routine"
do_not_perform?: boolean;
medication? : CodeType;
authored_on?: ISO time string,
dosage_instruction: {
sequence?: number;
text?: string;
additional_instruction?: {
system: string;
code: string;
display?: string;
}[];
patient_instruction?: string;
timing?: {
repeat?: {
frequency?: number;
period: number; // number of units (ex. 12 days would mean 12 with unit "d")
period_unit: "s" | "min" | "h" | "d" | "wk" | "mo" | "a";
};
};
/**
* True if it is a PRN medication
*/
as_needed_boolean?: boolean;
/**
* If it is a PRN medication (as_needed_boolean is true), the indicator.
*/
as_needed_for?: CodeType;
site?: CodeType;
route?: CodeType;
method?: CodeType;
/**
* One of \`dose_quantity\` or \`dose_range\` must be present.
* \`type\` is optional and defaults to \`ordered\`.
*
* - If \`type\` is \`ordered\`, \`dose_quantity\` must be present.
* - If \`type\` is \`calculated\`, \`dose_range\` must be present. This is used for titrated medications.
*/
dose_and_rate?: (
| {
type?: "ordered";
dose_quantity?: DosageQuantity;
dose_range?: undefined;
}
| {
type: "calculated";
dose_range?: {
low: DosageQuantity;
high: DosageQuantity;
};
dose_quantity?: undefined;
}
)[];
max_dose_per_period?: {
low: DosageQuantity;
high: DosageQuantity;
};
}[];
note?: string
}.
DosageQuantity {
value?: number;
unit?: "mg" | "g" | "ml" | "drop(s)" | "ampule(s)" | "tsp" | "mcg" | "unit(s)"
}
CodeType {
code: string,
display: string,
system: "http://snomed.info/sct"
}
Update existing data, delete existing data or append to the existing list as per the will of the user. Current date is ${new Date().toLocaleDateString()}`,
example: [
{
status: "active",
intent: "original_order",
category: "inpatient",
priority: "urgent",
do_not_perform: false,
medication: {
code: "1214771000202109",
display:
"Ciprofloxacin and fluocinolone only product in otic dose form",
system: "http://snomed.info/sct",
},
authored_on: "2024-12-29T22:16:45.404Z",
dosage_instruction: [
{
dose_and_rate: [
{
type: "ordered",
dose_quantity: {
unit: "g",
value: 11,
},
},
],
route: {
code: "58831000052108",
display: "Subretinal route",
system: "http://snomed.info/sct",
},
method: {
code: "1231460007",
display: "Dialysis system",
system: "http://snomed.info/sct",
},
site: {
code: "16217661000119109",
display: "Structure of right deltoid muscle",
system: "http://snomed.info/sct",
},
as_needed_boolean: true,
timing: {
repeat: {
frequency: 1,
period: 1,
period_unit: "d",
},
},
additional_instruction: [
{
code: "421984009",
display: "Until finished",
system: "http://snomed.info/sct",
},
],
as_needed_for: {
code: "972604701000119104",
display:
"Acquired arteriovenous malformation of vascular structure of gastrointestinal tract",
system: "http://snomed.info/sct",
},
},
],
},
]
},
"medication_statement": {
prompt: `An array of objects of the following type, based on the SNOMED CT Code for the applicable diagnoses {
status?: ${MEDICATION_STATEMENT_STATUS.join(" | ")},
dosage_text?: string,
information_source?: "patient" | "user" | "related_person"
medication?: {
code: string,
display: string,
system: "http://snomed.info/sct"
},
note?: string,
reason?: string,
effective_period?: {
start: ISO date string,
end: ISO date string
}
}. Update existing data, delete existing data or append to the existing list as per the will of the user. Current date is ${new Date().toLocaleDateString()}`,
example: [
{
status: "completed",
dosage_text: "10 ml",
information_source: "patient",
medication: {
code: "1213681000202103",
display: "Cabotegravir only product in oral dose form",
system: "http://snomed.info/sct",
},
note: "a note",
reason: "patient was feeling dizzy",
effective_period: {
start: "2024-12-12T18:30:00.000Z",
end: "2025-01-07T18:30:00.000Z",
},
},
]
},
"symptom": {
prompt: `An array of objects of the following type, based on the SNOMED CT Code for the applicable symptoms: {
code: {"code" : string, "display" : string, "system" : "http://snomed.info/sct"},
clinical_status: "active" | "recurrence" | "relapse" | "inactive" | "remission" | "resolved",
verification_status: "unconfirmed" | "provisional" | "differential" | "confirmed" | "refuted" | "entered-in-error",
severity?: "severe" | "moderate" | "mild",
onset?: {
onset_datetime: YYYY-MM-DD string
},
note?: string
}. Update existing data, delete existing data or append to the existing list as per the will of the user. Current date is ${new Date().toLocaleDateString()} Default onset_datetime to today unless otherwise specified`,
example: [
{
code: {
code: "972900701000119109",
display: "Venous ulcer of toe of left foot",
system: "http://snomed.info/sct",
},
clinical_status: "recurrence",
verification_status: "provisional",
severity: "severe",
onset: {
onset_datetime: "2024-12-03",
},
note: "Note here",
},
]
},
"diagnosis": {
prompt: `An array of objects of the following type, based on the SNOMED CT Code for the applicable diagnoses: {
code: {"code" : string, "display" : string, "system" : "http://snomed.info/sct"},
clinical_status: "active" | "recurrence" | "relapse" | "inactive" | "remission" | "resolved",
verification_status: "unconfirmed" | "provisional" | "differential" | "confirmed" | "refuted" | "entered-in-error",
onset: {
onset_datetime: YYYY-MM-DD string
},
note?: string
}. Update existing data, delete existing data or append to the existing list as per the will of the user. Current date is ${new Date().toLocaleDateString()} Default onset_datetime to today unless otherwise specified`,
example: [
{
code: {
code: "972900701000119109",
display: "Venous ulcer of toe of left foot",
system: "http://snomed.info/sct",
},
clinical_status: "recurrence",
verification_status: "provisional",
onset: {
onset_datetime: "2024-12-03",
},
note: "Note here",
},
]
},
"allergy_intolerance": {
prompt: `An array of objects of the following type based on the SNOMED CT Code for the applicable diagnoses: {
code: {
code: string,
display: string,
system: "http://snomed.info/sct"
},
clinical_status?: "active" | "inactive" | "resolved",
category?: "food" | "medication" | "environment" | "biologic",
criticality?: "low" | "high" | "unable-to-assess",
verification?: "unconfirmed" | "presumed" | "confirmed" | "refuted" | "entered-in-error"
last_occurrence?: YYYY-MM-DD string,
note?: string
}. Update existing data, delete existing data or append to the existing list as per the will of the user. Current date is ${new Date().toLocaleDateString()}`,
example: [
{
code: {
code: "842825221000119100",
display: "Anifrolumab",
system: "http://snomed.info/sct",
},
clinical_status: "inactive",
category: "environment",
criticality: "high",
last_occurrence: "2024-12-11",
note: "212",
},
]
},
"follow_up_appointment": {
prompt: `An object of the following type : {
reason_for_visit: string
}. Update the existing data on the will of the user.`,
example: {
reason_for_visit: "No change in condition"
}
}
}
export const SCRIBE_PROMPT_MAP: ScribePromptMap = {
...ARBITRARY_INPUT_PROMPTS,
};

57
src/utils/uploadFile.ts Normal file
View File

@ -0,0 +1,57 @@
import { Dispatch, SetStateAction } from "react";
export function handleUploadPercentage(
event: ProgressEvent,
setUploadPercent: Dispatch<SetStateAction<number>>,
) {
if (event.lengthComputable) {
const percentComplete = Math.round((event.loaded / event.total) * 100);
setUploadPercent(percentComplete);
}
}
const uploadFile = (
url: string,
file: File | FormData,
reqMethod: string,
headers: object,
onLoad: (xhr: XMLHttpRequest) => void,
setUploadPercent: Dispatch<SetStateAction<number>> | null,
onError: () => void,
) => {
const xhr = new XMLHttpRequest();
xhr.open(reqMethod, url);
Object.entries(headers).forEach(([key, value]) => {
xhr.setRequestHeader(key, value);
});
xhr.onload = () => {
onLoad(xhr);
if (400 <= xhr.status && xhr.status <= 499) {
const error = JSON.parse(xhr.responseText);
if (typeof error === "object" && !Array.isArray(error)) {
Object.values(error).forEach((msg) => {
window.alert(msg || "Something went wrong!");
});
} else {
window.alert(error || "Something went wrong!");
}
}
};
if (setUploadPercent != null) {
xhr.upload.onprogress = (event: ProgressEvent) => {
handleUploadPercentage(event, setUploadPercent);
};
}
xhr.onerror = () => {
window.alert("Network Failure. Please check your internet connectivity.");
onError();
};
xhr.send(file);
};
export default uploadFile;

217
src/utils/utils.tsx Normal file
View File

@ -0,0 +1,217 @@
import dayjs from "dayjs";
import {
FormQuestion,
ScribeAIResponse,
ScribeField,
ScribeFieldSuggestion,
} from "../types";
import clsx, { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const getQuestionInputs: (formState: any) => ScribeField[] = (
formState: any,
) => {
const formElement = document;
const questions = [
...formElement.querySelectorAll("[data-question-id]"),
] as HTMLInputElement[];
return questions
.map((ele) => {
const questionId = ele.getAttribute("data-question-id");
const question = findQuestion(formState, questionId || "");
if (!question) throw Error("No Question Found");
const currentValue = formState
.find((qn: any) =>
qn.responses.some(
(response: any) => response.question_id === questionId,
),
)
?.responses.find((response: any) => response.question_id === questionId)
?.values?.[0]?.value;
return {
question,
fieldElement: ele,
value: JSON.stringify(currentValue || null),
} as ScribeField;
})
.filter((i) => !!i);
};
export const getFieldsToReview = (
aiResponse: ScribeAIResponse,
scrapedFields: ScribeField[],
) => {
return scrapedFields
.map((f, i) => ({ ...f, newValue: aiResponse[i] }))
.filter((f) => f.newValue);
};
export const renderFieldValue = (
field: ScribeFieldSuggestion,
useNewValue?: boolean,
) => {
const val = useNewValue ? field.newValue : field.value;
const parsedValue = JSON.parse(val as string);
if (Array.isArray(parsedValue)) {
return (
<ul className="list-disc pl-5">
{parsedValue.map((item, index) => (
<li key={index}>
{typeof item === "object" && item !== null ? (
<ul className="list-disc pl-5">
{Object.entries(item).map(([key, value]) => (
<li key={key}>
<span className="font-semibold">{key}:</span>{" "}
{typeof value === "string" || typeof value === "number"
? String(value)
: "..."}
</li>
))}
</ul>
) : (
String(item)
)}
</li>
))}
</ul>
);
} else if (typeof parsedValue === "object" && parsedValue !== null) {
return (
<ul className="list-disc pl-5">
{Object.entries(parsedValue).map(([key, value]) => (
<li key={key}>
<span className="font-semibold">{key}:</span>{" "}
{typeof value === "string" || typeof value === "number"
? String(value)
: "..."}
</li>
))}
</ul>
);
}
return (
<span>
{typeof parsedValue === "string" &&
dayjs(parsedValue.replace(/^"(.*)"$/, "$1")).isValid()
? dayjs(parsedValue.replace(/^"(.*)"$/, "$1")).format(
"MMMM D, YYYY h:mm A",
)
: String(parsedValue)}
</span>
);
};
export const sleep = async (seconds: number) => {
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, seconds);
});
};
export const updateFieldValue = (
field: ScribeFieldSuggestion,
useNewValue?: boolean,
formState?: any,
setFormState?: any,
) => {
let val = (useNewValue ? field.newValue : field.value) as any;
try {
val = JSON.parse(val);
} catch (error) {}
const element = field.fieldElement as HTMLElement;
const qId = element.getAttribute("data-question-id");
// just incase scribe does not include previous data
if (qId === "encounter") {
val = [
{
...JSON.parse(field.value as any)[0],
...val[0],
},
];
}
const formQuestionnaire = formState.map((qn: any) => ({
...qn,
responses: qn.responses.map((response: any) =>
response.question_id === qId
? {
...response,
values: response.values.length
? response.values.map((v: any, i: number) =>
i === 0
? {
...v,
value: val,
}
: v,
)
: [
{
type: field.question.structured_type || typeof val,
value: val,
},
],
}
: response,
),
}));
setFormState(formQuestionnaire);
};
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
function isFormQuestion(value: unknown): value is FormQuestion {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"text" in value &&
typeof (value as { id: unknown }).id === "string" &&
typeof (value as { text: unknown }).text === "string"
);
}
export function findQuestion(
form: unknown,
questionId: string,
): FormQuestion | undefined {
// If array, search each element
if (Array.isArray(form)) {
for (const element of form) {
const result = findQuestion(element, questionId);
if (result !== undefined) {
return result;
}
}
return undefined;
}
if (form !== null && typeof form === "object") {
if (isFormQuestion(form) && form.id === questionId) {
return form;
}
// Otherwise, search all properties of this object
for (const key in form) {
// Must ensure it's an own property
if (Object.prototype.hasOwnProperty.call(form, key)) {
const value = (form as Record<string, unknown>)[key];
const result = findQuestion(value, questionId);
if (result !== undefined) {
return result;
}
}
}
}
return undefined;
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

17
tailwind.config.js Normal file
View File

@ -0,0 +1,17 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
theme: {
extend: {
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
colors: {}
}
},
plugins: [require("tailwindcss-animate")],
}

32
tsconfig.app.json Normal file
View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": ["src"]
}

13
tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

24
tsconfig.node.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

42
vite.config.ts Normal file
View File

@ -0,0 +1,42 @@
import path from "path"
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import federation from "@originjs/vite-plugin-federation";
// https://vite.dev/config/
export default defineConfig({
plugins: [federation({
name: "care_scribe",
filename: "remoteEntry.js",
exposes: {
"./manifest": "./src/manifest.ts",
},
shared: ["react", "react-dom"],
}),
react(),],
build: {
target: "esnext",
minify: false,
cssCodeSplit: false,
modulePreload: {
polyfill: false,
},
rollupOptions: {
external: [],
input: {
main: "./src/main.tsx",
},
output: {
format: "esm",
entryFileNames: "assets/[name].js",
chunkFileNames: "assets/[name].js",
},
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
base: "./"
})