feat: add missing backend route and frontend utility/config files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
94
apps/Backend/src/routes/insuranceStatusUnitedDHClaim.ts
Normal file
94
apps/Backend/src/routes/insuranceStatusUnitedDHClaim.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { storage } from "../storage";
|
||||||
|
import { enqueueSeleniumJob } from "../queue/jobRunner";
|
||||||
|
import { forwardOtpToSeleniumUnitedDHClaimAgent } from "../services/seleniumUnitedDHClaimClient";
|
||||||
|
import { io } from "../socket";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /uniteddh-claim
|
||||||
|
*
|
||||||
|
* Enqueues a United/DentalHub claim submission job.
|
||||||
|
*
|
||||||
|
* Body fields (JSON):
|
||||||
|
* data — claim payload (memberId, dateOfBirth, serviceDate, serviceLines, patientName, etc.)
|
||||||
|
* socketId — socket.io client id
|
||||||
|
* claimId — existing claim DB id (optional)
|
||||||
|
*
|
||||||
|
* Response: { status: "queued", jobId: "…" }
|
||||||
|
*/
|
||||||
|
router.post("/uniteddh-claim", async (req: Request, res: Response): Promise<any> => {
|
||||||
|
if (!req.user?.id) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized: user info missing" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const claimData =
|
||||||
|
typeof req.body.data === "string"
|
||||||
|
? JSON.parse(req.body.data)
|
||||||
|
: req.body.data ?? req.body ?? {};
|
||||||
|
|
||||||
|
// Fetch United/DentalHub credentials — same portal as UnitedSCO
|
||||||
|
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
||||||
|
req.user.id,
|
||||||
|
"UNITED_SCO"
|
||||||
|
);
|
||||||
|
if (!credentials) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: "No United/DentalHub credentials found. Please add them on the Settings page.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrichedPayload = {
|
||||||
|
claim: {
|
||||||
|
...claimData,
|
||||||
|
uniteddhUsername: credentials.username,
|
||||||
|
uniteddhPassword: credentials.password,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const socketId: string | undefined = req.body.socketId;
|
||||||
|
const claimId: number | undefined = claimData.claimId
|
||||||
|
? Number(claimData.claimId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const jobId = enqueueSeleniumJob({
|
||||||
|
jobType: "uniteddh-claim-submit",
|
||||||
|
userId: req.user.id,
|
||||||
|
socketId,
|
||||||
|
enrichedPayload,
|
||||||
|
claimId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ status: "queued", jobId });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[uniteddh-claim route] error:", err);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: err.message || "Failed to enqueue United/DentalHub claim job",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /claims/uniteddh-claim/selenium/submit-otp
|
||||||
|
* Body: { session_id, otp, socketId? }
|
||||||
|
*/
|
||||||
|
router.post("/uniteddh-claim/selenium/submit-otp", async (req: Request, res: Response): Promise<any> => {
|
||||||
|
const { session_id: sessionId, otp, socketId } = req.body;
|
||||||
|
if (!sessionId || !otp) {
|
||||||
|
return res.status(400).json({ error: "session_id and otp are required" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await forwardOtpToSeleniumUnitedDHClaimAgent(sessionId, otp);
|
||||||
|
if (socketId && io) {
|
||||||
|
io.to(socketId).emit("selenium:otp_submitted", { session_id: sessionId });
|
||||||
|
}
|
||||||
|
return res.json(r);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[uniteddh-claim] submit-otp failed:", err?.message);
|
||||||
|
return res.status(500).json({ error: err?.message || "Failed to forward OTP" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
23
apps/Frontend/src/hooks/use-extractPdfData.js
Normal file
23
apps/Frontend/src/hooks/use-extractPdfData.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
export default function useExtractPdfData() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (pdfFile) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("pdf", pdfFile);
|
||||||
|
const res = await apiRequest("POST", "/api/patientDataExtraction/patientdataextract", formData);
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error("Failed to extract PDF");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: `Failed to extract PDF: ${error.message}`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
55
apps/Frontend/src/hooks/use-job-status.js
Normal file
55
apps/Frontend/src/hooks/use-job-status.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* useJobStatus — tracks a BullMQ job via WebSocket `job:update` events.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const { status, result, error } = useJobStatus(jobId);
|
||||||
|
*
|
||||||
|
* The hook listens for `job:update` events emitted by the backend workers.
|
||||||
|
* When the jobId changes, the previous listener is removed and a fresh one
|
||||||
|
* is registered for the new job.
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { socket } from "@/lib/socket";
|
||||||
|
export function useJobStatus(jobId) {
|
||||||
|
const [status, setStatus] = useState(jobId ? "queued" : null);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [socketId, setSocketId] = useState(socket.id ?? null);
|
||||||
|
// Keep socketId in sync with the socket connection
|
||||||
|
useEffect(() => {
|
||||||
|
const onConnect = () => setSocketId(socket.id ?? null);
|
||||||
|
socket.on("connect", onConnect);
|
||||||
|
if (socket.connected)
|
||||||
|
setSocketId(socket.id ?? null);
|
||||||
|
return () => { socket.off("connect", onConnect); };
|
||||||
|
}, []);
|
||||||
|
// Reset state when the jobId changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jobId) {
|
||||||
|
setStatus(null);
|
||||||
|
setMessage("");
|
||||||
|
setResult(null);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("queued");
|
||||||
|
setMessage("");
|
||||||
|
setResult(null);
|
||||||
|
setError(null);
|
||||||
|
const handler = (payload) => {
|
||||||
|
if (payload.jobId !== jobId)
|
||||||
|
return;
|
||||||
|
setStatus(payload.status);
|
||||||
|
if (payload.message)
|
||||||
|
setMessage(payload.message);
|
||||||
|
if (payload.result !== undefined)
|
||||||
|
setResult(payload.result);
|
||||||
|
if (payload.error)
|
||||||
|
setError(payload.error);
|
||||||
|
};
|
||||||
|
socket.on("job:update", handler);
|
||||||
|
return () => { socket.off("job:update", handler); };
|
||||||
|
}, [jobId]);
|
||||||
|
return { status, message, result, error, socketId };
|
||||||
|
}
|
||||||
144
apps/Frontend/src/hooks/use-toast.js
Normal file
144
apps/Frontend/src/hooks/use-toast.js
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
const TOAST_LIMIT = 10;
|
||||||
|
const TOAST_REMOVE_DELAY = 10000;
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
};
|
||||||
|
let count = 0;
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
const toastTimeouts = new Map();
|
||||||
|
const addToRemoveQueue = (toastId) => {
|
||||||
|
if (toastTimeouts.has(toastId))
|
||||||
|
return;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId);
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId: toastId,
|
||||||
|
});
|
||||||
|
// Show next toast in the queue
|
||||||
|
const next = memoryState.toasts[1]; // [0] was just removed
|
||||||
|
if (next) {
|
||||||
|
addToRemoveQueue(next.id);
|
||||||
|
}
|
||||||
|
}, TOAST_REMOVE_DELAY);
|
||||||
|
toastTimeouts.set(toastId, timeout);
|
||||||
|
};
|
||||||
|
export const reducer = (state, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
const newToasts = [...state.toasts, action.toast].slice(0, TOAST_LIMIT);
|
||||||
|
addToRemoveQueue(action.toast.id);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: newToasts,
|
||||||
|
};
|
||||||
|
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 = [];
|
||||||
|
let memoryState = { toasts: [] };
|
||||||
|
function dispatch(action) {
|
||||||
|
memoryState = reducer(memoryState, action);
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const shownMessages = new Set();
|
||||||
|
function toast({ ...props }) {
|
||||||
|
const id = genId();
|
||||||
|
// Prevent same message from being shown repeatedly
|
||||||
|
const messageKey = `${props.title}-${props.description}`;
|
||||||
|
if (shownMessages.has(messageKey)) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
dismiss: () => { },
|
||||||
|
update: () => { },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
shownMessages.add(messageKey);
|
||||||
|
setTimeout(() => shownMessages.delete(messageKey), 3000); // allow to re-show after 3s
|
||||||
|
const update = (props) => 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(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) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export { useToast, toast };
|
||||||
119
apps/Frontend/src/lib/queryClient.js
Normal file
119
apps/Frontend/src/lib/queryClient.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL_BACKEND ?? "";
|
||||||
|
async function throwIfResNotOk(res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
if (!window.location.pathname.startsWith("/auth")) {
|
||||||
|
window.location.href = "/auth";
|
||||||
|
throw new Error(`${res.status}: Unauthorized`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Try to parse the response as JSON for a more meaningful error message
|
||||||
|
let message = `${res.status}: ${res.statusText}`;
|
||||||
|
try {
|
||||||
|
const errorBody = await res.clone().json();
|
||||||
|
if (errorBody?.error) {
|
||||||
|
message = errorBody.error;
|
||||||
|
}
|
||||||
|
else if (errorBody?.message) {
|
||||||
|
message = errorBody.message;
|
||||||
|
}
|
||||||
|
else if (errorBody?.detail) {
|
||||||
|
message = errorBody.detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// fallback to reading raw text so no error is lost
|
||||||
|
try {
|
||||||
|
const text = await res.clone().text();
|
||||||
|
if (text?.trim()) {
|
||||||
|
message = text.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function apiRequest(method, url, data) {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
const isFormData = typeof FormData !== "undefined" && data instanceof FormData;
|
||||||
|
const isFileLike = (typeof File !== "undefined" && data instanceof File) ||
|
||||||
|
(typeof Blob !== "undefined" && data instanceof Blob);
|
||||||
|
const isArrayBufferLike = (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) ||
|
||||||
|
(typeof Uint8Array !== "undefined" && data instanceof Uint8Array) ||
|
||||||
|
(data != null && data?.constructor?.name === "Buffer"); // Node Buffer
|
||||||
|
// Decide Content-Type header appropriately:
|
||||||
|
const headers = {
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
if (!isFormData) {
|
||||||
|
if (isFileLike) {
|
||||||
|
// File/Blob: use its own MIME type if present, otherwise fallback
|
||||||
|
const mime = data.type || "application/octet-stream";
|
||||||
|
headers["Content-Type"] = mime;
|
||||||
|
}
|
||||||
|
else if (isArrayBufferLike) {
|
||||||
|
// ArrayBuffer / Buffer / Uint8Array: use generic octet-stream
|
||||||
|
headers["Content-Type"] = "application/octet-stream";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Normal JSON body
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If FormData, we must NOT set Content-Type (browser will set multipart boundary)
|
||||||
|
// Build final body
|
||||||
|
const finalBody = isFormData
|
||||||
|
? data
|
||||||
|
: isFileLike
|
||||||
|
? // File/Blob can be passed directly as BodyInit
|
||||||
|
data
|
||||||
|
: isArrayBufferLike
|
||||||
|
? // ArrayBuffer / Uint8Array / Buffer -> convert to Uint8Array if needed
|
||||||
|
data
|
||||||
|
: data !== undefined
|
||||||
|
? JSON.stringify(data)
|
||||||
|
: undefined;
|
||||||
|
const res = await fetch(`${API_BASE_URL}${url}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: finalBody,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
await throwIfResNotOk(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
export const getQueryFn = ({ on401: unauthorizedBehavior }) => async ({ queryKey }) => {
|
||||||
|
const url = `${API_BASE_URL}${queryKey[0]}`;
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (unauthorizedBehavior === "returnNull" &&
|
||||||
|
(res.status === 401 || res.status === 403)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await throwIfResNotOk(res);
|
||||||
|
return await res.json();
|
||||||
|
};
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
queryFn: getQueryFn({ on401: "throw" }),
|
||||||
|
refetchInterval: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnMount: true,
|
||||||
|
staleTime: 0,
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
28
apps/Frontend/src/lib/socket.js
Normal file
28
apps/Frontend/src/lib/socket.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Shared Socket.IO client singleton.
|
||||||
|
*
|
||||||
|
* Import `socket` anywhere in the frontend to use the shared connection.
|
||||||
|
* The socket connects lazily — the first import triggers the connection.
|
||||||
|
*/
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
// Connect directly to backend to avoid Vite's WS proxy failing on upgrade,
|
||||||
|
// which causes an unhandled AggregateError from engine.io's Promise.any() probe.
|
||||||
|
// Use the env var when set; otherwise derive the backend URL from the current
|
||||||
|
// page's hostname so remote browsers (non-localhost) reach the server correctly.
|
||||||
|
const SOCKET_URL = import.meta.env.VITE_API_BASE_URL_BACKEND ||
|
||||||
|
`${window.location.protocol}//${window.location.hostname}:5000`;
|
||||||
|
export const socket = io(SOCKET_URL, {
|
||||||
|
withCredentials: true,
|
||||||
|
autoConnect: true,
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
reconnectionDelay: 2000,
|
||||||
|
});
|
||||||
|
socket.on("connect", () => {
|
||||||
|
console.log("[socket] connected:", socket.id);
|
||||||
|
});
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
console.log("[socket] disconnected");
|
||||||
|
});
|
||||||
|
socket.on("connect_error", (err) => {
|
||||||
|
console.warn("[socket] connection error:", err.message);
|
||||||
|
});
|
||||||
5
apps/Frontend/src/lib/utils.js
Normal file
5
apps/Frontend/src/lib/utils.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
export function cn(...inputs) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
3
apps/Frontend/src/redux/hooks.js
Normal file
3
apps/Frontend/src/redux/hooks.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
export const useAppDispatch = useDispatch;
|
||||||
|
export const useAppSelector = useSelector;
|
||||||
23
apps/Frontend/src/redux/slices/seleniumTaskSlice.js
Normal file
23
apps/Frontend/src/redux/slices/seleniumTaskSlice.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { createSlice } from "@reduxjs/toolkit";
|
||||||
|
const emptyTask = { status: "idle", message: "", show: false };
|
||||||
|
const initialState = {
|
||||||
|
claimSubmit: { ...emptyTask },
|
||||||
|
eligibilityCheck: { ...emptyTask },
|
||||||
|
eligibilityBatchCheck: { ...emptyTask },
|
||||||
|
claimBatchCheck: { ...emptyTask },
|
||||||
|
};
|
||||||
|
const seleniumTaskSlice = createSlice({
|
||||||
|
name: "seleniumTasks",
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setTaskStatus: (state, action) => {
|
||||||
|
const { key, ...partial } = action.payload;
|
||||||
|
state[key] = { ...state[key], ...partial, show: true };
|
||||||
|
},
|
||||||
|
clearTaskStatus: (state, action) => {
|
||||||
|
state[action.payload] = { ...emptyTask };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
export const { setTaskStatus, clearTaskStatus } = seleniumTaskSlice.actions;
|
||||||
|
export default seleniumTaskSlice.reducer;
|
||||||
7
apps/Frontend/src/redux/store.js
Normal file
7
apps/Frontend/src/redux/store.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { configureStore } from "@reduxjs/toolkit";
|
||||||
|
import seleniumTasksReducer from "./slices/seleniumTaskSlice";
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
seleniumTasks: seleniumTasksReducer,
|
||||||
|
},
|
||||||
|
});
|
||||||
116
apps/Frontend/src/theme-init.js
Normal file
116
apps/Frontend/src/theme-init.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// Type-safe theme initializer that writes CSS variables expected by your Tailwind/index.css.
|
||||||
|
import theme from "../theme.json";
|
||||||
|
const t = theme;
|
||||||
|
/**
|
||||||
|
* Convert inputs into the token form "H S% L%" that your CSS expects.
|
||||||
|
* Accepts:
|
||||||
|
* - token form "210 79% 46%"
|
||||||
|
* - "hsl(210, 79%, 46%)"
|
||||||
|
* - hex: "#aabbcc" or "abc"
|
||||||
|
*/
|
||||||
|
function hslStringToToken(input) {
|
||||||
|
if (!input)
|
||||||
|
return null;
|
||||||
|
const str = input.trim();
|
||||||
|
// Already tokenized like "210 79% 46%"
|
||||||
|
if (/^\d+\s+\d+%?\s+\d+%?$/.test(str))
|
||||||
|
return str;
|
||||||
|
// hsl(...) form -> extract contents then validate parts
|
||||||
|
const hslMatch = str.match(/hsl\(\s*([^)]+)\s*\)/i);
|
||||||
|
if (hslMatch && typeof hslMatch[1] === "string") {
|
||||||
|
const raw = hslMatch[1];
|
||||||
|
const parts = raw.split(",").map((p) => p.trim());
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const rawH = parts[0] ?? "";
|
||||||
|
const rawS = parts[1] ?? "";
|
||||||
|
const rawL = parts[2] ?? "";
|
||||||
|
const h = rawH.replace(/deg$/i, "").trim() || "0";
|
||||||
|
const s = rawS.endsWith("%") ? rawS : rawS ? `${rawS}%` : "0%";
|
||||||
|
const l = rawL.endsWith("%") ? rawL : rawL ? `${rawL}%` : "0%";
|
||||||
|
return `${h} ${s} ${l}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// hex -> convert to hsl token
|
||||||
|
const hexMatch = str.match(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i);
|
||||||
|
const hex = hexMatch?.[1];
|
||||||
|
if (hex && typeof hex === "string") {
|
||||||
|
const normalizedHex = hex.length === 3 ? hex.split("").map((c) => c + c).join("") : hex;
|
||||||
|
// parse safely
|
||||||
|
const r = parseInt(normalizedHex.substring(0, 2), 16) / 255;
|
||||||
|
const g = parseInt(normalizedHex.substring(2, 4), 16) / 255;
|
||||||
|
const b = parseInt(normalizedHex.substring(4, 6), 16) / 255;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
let h = 0;
|
||||||
|
let s = 0;
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
if (max !== min) {
|
||||||
|
const d = max - min;
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
switch (max) {
|
||||||
|
case r:
|
||||||
|
h = (g - b) / d + (g < b ? 6 : 0);
|
||||||
|
break;
|
||||||
|
case g:
|
||||||
|
h = (b - r) / d + 2;
|
||||||
|
break;
|
||||||
|
case b:
|
||||||
|
h = (r - g) / d + 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
h = Math.round(h * 60);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
h = 0;
|
||||||
|
s = 0;
|
||||||
|
}
|
||||||
|
const sPct = `${Math.round(s * 100)}%`;
|
||||||
|
const lPct = `${Math.round(l * 100)}%`;
|
||||||
|
return `${h} ${sPct} ${lPct}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function applyThemeObject(themeObj) {
|
||||||
|
if (!themeObj)
|
||||||
|
return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
// primary (maps to --primary used by tailwind.config)
|
||||||
|
if (themeObj.primary) {
|
||||||
|
const token = hslStringToToken(String(themeObj.primary));
|
||||||
|
if (token)
|
||||||
|
root.style.setProperty("--primary", token);
|
||||||
|
}
|
||||||
|
// optional background/foreground/card if present
|
||||||
|
if (themeObj.background) {
|
||||||
|
const token = hslStringToToken(String(themeObj.background));
|
||||||
|
if (token)
|
||||||
|
root.style.setProperty("--background", token);
|
||||||
|
}
|
||||||
|
if (themeObj.foreground) {
|
||||||
|
const token = hslStringToToken(String(themeObj.foreground));
|
||||||
|
if (token)
|
||||||
|
root.style.setProperty("--foreground", token);
|
||||||
|
}
|
||||||
|
// radius (index.css expects --radius)
|
||||||
|
if (typeof themeObj.radius !== "undefined") {
|
||||||
|
const radiusVal = typeof themeObj.radius === "number"
|
||||||
|
? `${themeObj.radius}rem`
|
||||||
|
: String(themeObj.radius);
|
||||||
|
root.style.setProperty("--radius", radiusVal);
|
||||||
|
}
|
||||||
|
// data attributes
|
||||||
|
if (themeObj.appearance)
|
||||||
|
root.setAttribute("data-appearance", String(themeObj.appearance));
|
||||||
|
if (themeObj.variant)
|
||||||
|
root.setAttribute("data-variant", String(themeObj.variant));
|
||||||
|
}
|
||||||
|
// apply as early as possible
|
||||||
|
try {
|
||||||
|
applyThemeObject(t);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
// don't break runtime if theme parsing fails
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn("theme-init failed to apply theme:", err);
|
||||||
|
}
|
||||||
|
export default theme;
|
||||||
245
apps/Frontend/src/utils/dateUtils.js
Normal file
245
apps/Frontend/src/utils/dateUtils.js
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* Use parseLocalDate when you need a Date object at local midnight
|
||||||
|
* (for calendars, date pickers, Date math in the browser).
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Parse a date string in yyyy-MM-dd format (assumed local) into a JS Date object.
|
||||||
|
* No timezone conversion is applied. Returns a Date at midnight local time.
|
||||||
|
*
|
||||||
|
* * Accepts:
|
||||||
|
* - "YYYY-MM-DD"
|
||||||
|
* - ISO/timestamp string (will take left-of-'T' date portion)
|
||||||
|
* - Date object (will return a new Date set to that local calendar day at midnight)
|
||||||
|
*/
|
||||||
|
export function parseLocalDate(input) {
|
||||||
|
if (input instanceof Date) {
|
||||||
|
return new Date(input.getFullYear(), input.getMonth(), input.getDate());
|
||||||
|
}
|
||||||
|
if (typeof input === "string") {
|
||||||
|
const dateString = input?.split("T")[0] ?? "";
|
||||||
|
const parts = dateString.split("-");
|
||||||
|
const [yearStr, monthStr, dayStr] = parts;
|
||||||
|
// Validate all parts are defined and valid strings
|
||||||
|
if (!yearStr || !monthStr || !dayStr) {
|
||||||
|
throw new Error("Invalid date string format. Expected yyyy-MM-dd.");
|
||||||
|
}
|
||||||
|
const year = parseInt(yearStr, 10);
|
||||||
|
const month = parseInt(monthStr, 10) - 1; // JS Date months are 0-based
|
||||||
|
const day = parseInt(dayStr, 10);
|
||||||
|
if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) {
|
||||||
|
throw new Error("Invalid numeric values in date string.");
|
||||||
|
}
|
||||||
|
return new Date(year, month, day);
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported input to parseLocalDate. Expected string or Date.");
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Use formatLocalDate when you need a date-only string "YYYY-MM-DD" (for displaying stable date values in UI lists,
|
||||||
|
* sending to APIs, storing in sessionStorage/DB where date-only is required).
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Format a date value into a "YYYY-MM-DD" string with **no timezone shifts**.
|
||||||
|
*
|
||||||
|
* Handles all common input cases:
|
||||||
|
* - "YYYY-MM-DD" string → returned as-is.
|
||||||
|
* - ISO/timestamp string → takes the date portion before "T" (safe, no TZ math).
|
||||||
|
* - Date object:
|
||||||
|
* - If created via `new Date("2025-07-15T00:00:00Z")` (ISO instant),
|
||||||
|
* UTC vs local calendar components may differ. In this case, use UTC
|
||||||
|
* fields so the original calendar day (15th) is preserved across timezones.
|
||||||
|
* - If created via `parseLocalDate("2025-07-15")` or `new Date(2025, 6, 15)`
|
||||||
|
* (local-midnight Date), UTC and local calendar components match,
|
||||||
|
* so local fields are safe to use.
|
||||||
|
*
|
||||||
|
* This hybrid logic ensures:
|
||||||
|
* - DOBs and other date-only values will never appear off by one day
|
||||||
|
* due to timezone differences.
|
||||||
|
* - Works with both string and Date inputs without requiring code changes elsewhere.
|
||||||
|
*/
|
||||||
|
export function formatLocalDate(input) {
|
||||||
|
if (!input)
|
||||||
|
return "";
|
||||||
|
// Case 1: already "YYYY-MM-DD" string
|
||||||
|
if (typeof input === "string" && /^\d{4}-\d{2}-\d{2}$/.test(input)) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
// Case 2: ISO/timestamp string -> take the left-of-T portion
|
||||||
|
if (typeof input === "string") {
|
||||||
|
const dateString = input.split("T")[0] ?? "";
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
// Case 3: Date object
|
||||||
|
if (input instanceof Date) {
|
||||||
|
if (isNaN(input.getTime()))
|
||||||
|
return "";
|
||||||
|
// HYBRID LOGIC:
|
||||||
|
// - If this Date was likely created from an ISO instant at UTC midnight
|
||||||
|
// (e.g. "2025-10-15T00:00:00Z"), then getUTCHours() === 0 but getHours()
|
||||||
|
// will be non-zero in most non-UTC timezones. In that case use UTC date
|
||||||
|
// parts to preserve the original calendar day.
|
||||||
|
// - Otherwise use the local calendar fields (safe for local-midnight Dates).
|
||||||
|
const utcHours = input.getUTCHours();
|
||||||
|
const localHours = input.getHours();
|
||||||
|
const useUTC = utcHours === 0 && localHours !== 0;
|
||||||
|
const year = useUTC ? input.getUTCFullYear() : input.getFullYear();
|
||||||
|
const month = useUTC ? input.getUTCMonth() + 1 : input.getMonth() + 1;
|
||||||
|
const day = useUTC ? input.getUTCDate() : input.getDate();
|
||||||
|
const m = `${month}`.padStart(2, "0");
|
||||||
|
const d = `${day}`.padStart(2, "0");
|
||||||
|
return `${year}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
// ---------- helpers ----------
|
||||||
|
const MONTH_SHORT = [
|
||||||
|
"Jan",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"May",
|
||||||
|
"Jun",
|
||||||
|
"Jul",
|
||||||
|
"Aug",
|
||||||
|
"Sep",
|
||||||
|
"Oct",
|
||||||
|
"Nov",
|
||||||
|
"Dec",
|
||||||
|
];
|
||||||
|
function isDateOnlyString(s) {
|
||||||
|
return /^\d{4}-\d{2}-\d{2}$/.test(s);
|
||||||
|
}
|
||||||
|
// ---------- formatDateToHumanReadable ----------
|
||||||
|
/**
|
||||||
|
* Frontend-safe human readable formatter.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - If input is a date-only string "YYYY-MM-DD", format it directly (no TZ math).
|
||||||
|
* - If input is a Date object, use its local calendar fields (getFullYear/getMonth/getDate).
|
||||||
|
* - If input is any other string (ISO/timestamp), DO NOT call new Date(isoString) directly
|
||||||
|
* for display. Instead, use parseLocalDate(dateInput) to extract the local calendar day
|
||||||
|
* (strip time portion) and render that. This prevents off-by-one day drift.
|
||||||
|
*
|
||||||
|
* Output example: "Oct 7, 2025"
|
||||||
|
*/
|
||||||
|
export function formatDateToHumanReadable(dateInput) {
|
||||||
|
if (!dateInput)
|
||||||
|
return "N/A";
|
||||||
|
// date-only string -> show as-is using MONTH_SHORT
|
||||||
|
if (typeof dateInput === "string" && isDateOnlyString(dateInput)) {
|
||||||
|
const [y, m, d] = dateInput.split("-");
|
||||||
|
if (!y || !m || !d)
|
||||||
|
return "Invalid Date";
|
||||||
|
return `${MONTH_SHORT[parseInt(m, 10) - 1]} ${d}, ${y}`;
|
||||||
|
}
|
||||||
|
// Date object -> use local calendar fields
|
||||||
|
if (dateInput instanceof Date) {
|
||||||
|
if (isNaN(dateInput.getTime()))
|
||||||
|
return "Invalid Date";
|
||||||
|
const dd = String(dateInput.getDate());
|
||||||
|
const mm = MONTH_SHORT[dateInput.getMonth()];
|
||||||
|
const yy = dateInput.getFullYear();
|
||||||
|
return `${mm} ${dd}, ${yy}`;
|
||||||
|
}
|
||||||
|
// Other string (likely ISO/timestamp) -> normalize via parseLocalDate
|
||||||
|
// This preserves the calendar day the user expects (no timezone drift).
|
||||||
|
if (typeof dateInput === "string") {
|
||||||
|
try {
|
||||||
|
const localDate = parseLocalDate(dateInput);
|
||||||
|
const dd = String(localDate.getDate());
|
||||||
|
const mm = MONTH_SHORT[localDate.getMonth()];
|
||||||
|
const yy = localDate.getFullYear();
|
||||||
|
return `${mm} ${dd}, ${yy}`;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Invalid date input provided:", dateInput, err);
|
||||||
|
return "Invalid Date";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Invalid Date";
|
||||||
|
}
|
||||||
|
// ---------------- OCR Date helper --------------------------
|
||||||
|
/**
|
||||||
|
* Convert any OCR numeric-ish value into a number.
|
||||||
|
* Handles string | number | null | undefined gracefully.
|
||||||
|
*/
|
||||||
|
export function toNum(val) {
|
||||||
|
if (val == null || val === "")
|
||||||
|
return 0;
|
||||||
|
if (typeof val === "number")
|
||||||
|
return val;
|
||||||
|
const parsed = Number(val);
|
||||||
|
return isNaN(parsed) ? 0 : parsed;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert any OCR string-like value into a safe string.
|
||||||
|
*/
|
||||||
|
export function toStr(val) {
|
||||||
|
if (val == null)
|
||||||
|
return "";
|
||||||
|
return String(val).trim();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert OCR date strings like "070825" (MMDDYY) into a JS Date object.
|
||||||
|
* Example: "070825" → 2025-08-07.
|
||||||
|
*/
|
||||||
|
export function convertOCRDate(input) {
|
||||||
|
const raw = toStr(input);
|
||||||
|
if (!/^\d{6}$/.test(raw)) {
|
||||||
|
throw new Error(`Invalid OCR date format: ${raw}`);
|
||||||
|
}
|
||||||
|
const month = parseInt(raw.slice(0, 2), 10) - 1;
|
||||||
|
const day = parseInt(raw.slice(2, 4), 10);
|
||||||
|
const year2 = parseInt(raw.slice(4, 6), 10);
|
||||||
|
const year = year2 < 50 ? 2000 + year2 : 1900 + year2;
|
||||||
|
return new Date(year, month, day);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Format a Date or date string into "HH:mm" (24-hour) string.
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* - By default, hours/minutes are taken in local time.
|
||||||
|
* - Pass { asUTC: true } to format using UTC hours/minutes.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* formatLocalTime(new Date(2025, 6, 15, 9, 5)) → "09:05"
|
||||||
|
* formatLocalTime("2025-07-15") → "00:00"
|
||||||
|
* formatLocalTime("2025-07-15T14:30:00Z") → "20:30" (in +06:00)
|
||||||
|
* formatLocalTime("2025-07-15T14:30:00Z", { asUTC:true }) → "14:30"
|
||||||
|
*/
|
||||||
|
export function formatLocalTime(d, opts = {}) {
|
||||||
|
if (!d)
|
||||||
|
return "";
|
||||||
|
const { asUTC = false } = opts;
|
||||||
|
const pad2 = (n) => n.toString().padStart(2, "0");
|
||||||
|
let dateObj;
|
||||||
|
if (d instanceof Date) {
|
||||||
|
if (isNaN(d.getTime()))
|
||||||
|
return "";
|
||||||
|
dateObj = d;
|
||||||
|
}
|
||||||
|
else if (typeof d === "string") {
|
||||||
|
const raw = d.trim();
|
||||||
|
const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(raw);
|
||||||
|
if (isDateOnly) {
|
||||||
|
// Parse yyyy-MM-dd safely as local midnight
|
||||||
|
try {
|
||||||
|
dateObj = parseLocalDate(raw);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
dateObj = new Date(raw); // fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// For full ISO/timestamp strings, let Date handle TZ
|
||||||
|
dateObj = new Date(raw);
|
||||||
|
}
|
||||||
|
if (isNaN(dateObj.getTime()))
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const hours = asUTC ? dateObj.getUTCHours() : dateObj.getHours();
|
||||||
|
const minutes = asUTC ? dateObj.getUTCMinutes() : dateObj.getMinutes();
|
||||||
|
return `${pad2(hours)}:${pad2(minutes)}`;
|
||||||
|
}
|
||||||
21
apps/Frontend/src/utils/pageNumberGenerator.js
Normal file
21
apps/Frontend/src/utils/pageNumberGenerator.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export function getPageNumbers(current, total, maxButtons = 7) {
|
||||||
|
const pages = [];
|
||||||
|
if (total <= maxButtons) {
|
||||||
|
for (let i = 1; i <= total; i++)
|
||||||
|
pages.push(i);
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
const delta = 2;
|
||||||
|
const start = Math.max(2, current - delta);
|
||||||
|
const end = Math.min(total - 1, current + delta);
|
||||||
|
pages.push(1);
|
||||||
|
if (start > 2)
|
||||||
|
pages.push("...");
|
||||||
|
for (let i = start; i <= end; i++)
|
||||||
|
pages.push(i);
|
||||||
|
if (end < total - 1)
|
||||||
|
pages.push("...");
|
||||||
|
if (total > 1)
|
||||||
|
pages.push(total);
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
351
apps/Frontend/src/utils/procedureCombos.js
Normal file
351
apps/Frontend/src/utils/procedureCombos.js
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
export const PROCEDURE_COMBOS = {
|
||||||
|
childRecall: {
|
||||||
|
id: "childRecall",
|
||||||
|
label: "Child Recall",
|
||||||
|
codes: ["D0120", "D1120", "D0272", "D1208"],
|
||||||
|
},
|
||||||
|
childRecallDirect: {
|
||||||
|
id: "childRecallDirect",
|
||||||
|
label: "Child Recall Direct(no x-ray)",
|
||||||
|
codes: ["D0120", "D1120", "D1208"],
|
||||||
|
},
|
||||||
|
childRecallDirect2BW: {
|
||||||
|
id: "childRecallDirect2BW",
|
||||||
|
label: "Child Recall Direct 2BW",
|
||||||
|
codes: ["D0120", "D1120", "D1208", "D0272"],
|
||||||
|
},
|
||||||
|
childRecallDirect4BW: {
|
||||||
|
id: "childRecallDirect4BW",
|
||||||
|
label: "Child Recall Direct 4BW",
|
||||||
|
codes: ["D0120", "D1120", "D1208", "D0274"],
|
||||||
|
},
|
||||||
|
childRecallDirect2PA2BW: {
|
||||||
|
id: "childRecallDirect2PA2BW",
|
||||||
|
label: "Child Recall Direct 2PA 2BW",
|
||||||
|
codes: ["D0120", "D1120", "D1208", "D0220", "D0230", "D0272"],
|
||||||
|
toothNumbers: [null, null, null, "9", "24", null], // only these two need values
|
||||||
|
},
|
||||||
|
childRecallDirect2PA4BW: {
|
||||||
|
id: "childRecallDirect2PA4BW",
|
||||||
|
label: "Child Recall Direct 2PA 4BW",
|
||||||
|
codes: ["D0120", "D1120", "D1208", "D0220", "D0230", "D0274"],
|
||||||
|
toothNumbers: [null, null, null, "9", "24", null], // only these two need values
|
||||||
|
},
|
||||||
|
childRecallDirect3PA2BW: {
|
||||||
|
id: "childRecallDirect3PA2BW",
|
||||||
|
label: "Child Recall Direct 3PA 2BW",
|
||||||
|
codes: [
|
||||||
|
"D0120", // exam
|
||||||
|
"D1120", // prophy
|
||||||
|
"D1208", // fluoride
|
||||||
|
"D0220",
|
||||||
|
"D0230",
|
||||||
|
"D0230", // extra PA
|
||||||
|
"D0272", // 2BW
|
||||||
|
],
|
||||||
|
},
|
||||||
|
childRecallDirect4PA: {
|
||||||
|
id: "childRecallDirect4PA",
|
||||||
|
label: "Child Recall Direct 4PA",
|
||||||
|
codes: ["D0120", "D1120", "D1208", "D0220", "D0230", "D0230", "D0230"],
|
||||||
|
},
|
||||||
|
childRecallDirect3PA: {
|
||||||
|
id: "childRecallDirect3PA",
|
||||||
|
label: "Child Recall Direct 3PA",
|
||||||
|
codes: ["D0120", "D1120", "D1208", "D0220", "D0230", "D0230"],
|
||||||
|
},
|
||||||
|
childRecallDirectPANO: {
|
||||||
|
id: "childRecallDirectPANO",
|
||||||
|
label: "Child Recall Direct PANO",
|
||||||
|
codes: ["D0120", "D1120", "D1208", "D0330"],
|
||||||
|
},
|
||||||
|
adultRecall: {
|
||||||
|
id: "adultRecall",
|
||||||
|
label: "Adult Recall",
|
||||||
|
codes: ["D0120", "D0220", "D0230", "D0274", "D1110"],
|
||||||
|
toothNumbers: [null, "9", "24", null, null], // only these two need values
|
||||||
|
},
|
||||||
|
adultRecallDirect: {
|
||||||
|
id: "adultRecallDirect",
|
||||||
|
label: "Adult Recall Direct(no x-ray)",
|
||||||
|
codes: ["D0120", "D1110"],
|
||||||
|
},
|
||||||
|
adultRecallDirect2BW: {
|
||||||
|
id: "adultRecallDirect2BW",
|
||||||
|
label: "Adult Recall Direct - 2bw (no x-ray)",
|
||||||
|
codes: ["D0120", "D1110", "D0272"],
|
||||||
|
},
|
||||||
|
adultRecallDirect4BW: {
|
||||||
|
id: "adultRecallDirect4BW",
|
||||||
|
label: "Adult Recall Direct - 4bw (no x-ray)",
|
||||||
|
codes: ["D0120", "D1110", "D0274"],
|
||||||
|
},
|
||||||
|
adultRecallDirect2PA2BW: {
|
||||||
|
id: "adultRecallDirect2PA2BW",
|
||||||
|
label: "Adult Recall Direct - 2PA 2BW",
|
||||||
|
codes: ["D0120", "D0220", "D0230", "D0272", "D1110"],
|
||||||
|
toothNumbers: [null, "9", "24", null, null], // only these two need values
|
||||||
|
},
|
||||||
|
adultRecallDirect2PA4BW: {
|
||||||
|
id: "adultRecallDirect2PA4BW",
|
||||||
|
label: "Adult Recall Direct - 2PA 4BW",
|
||||||
|
codes: ["D0120", "D0220", "D0230", "D0274", "D1110"],
|
||||||
|
toothNumbers: [null, "9", "24", null, null], // only these two need values
|
||||||
|
},
|
||||||
|
adultRecallDirect4PA: {
|
||||||
|
id: "adultRecallDirect4PA",
|
||||||
|
label: "Adult Recall Direct 4PA",
|
||||||
|
codes: ["D0120", "D1110", "D0220", "D0230", "D0230", "D0230"],
|
||||||
|
},
|
||||||
|
adultRecallDirectPano: {
|
||||||
|
id: "adultRecallDirectPano",
|
||||||
|
label: "Adult Recall Direct - PANO",
|
||||||
|
codes: ["D0120", "D1110", "D0330"],
|
||||||
|
},
|
||||||
|
newChildPatient: {
|
||||||
|
id: "newChildPatient",
|
||||||
|
label: "New Child Patient",
|
||||||
|
codes: ["D0150", "D1120", "D1208"],
|
||||||
|
},
|
||||||
|
newAdultPatientPano: {
|
||||||
|
id: "newAdultPatientPano",
|
||||||
|
label: "New Adult Patient - PANO",
|
||||||
|
codes: ["D0150", "D0330", "D1110"],
|
||||||
|
},
|
||||||
|
newAdultPatientFMX: {
|
||||||
|
id: "newAdultPatientFMX",
|
||||||
|
label: "New Adult Patient (FMX)",
|
||||||
|
codes: ["D0150", "D0210", "D1110"],
|
||||||
|
},
|
||||||
|
newPatientLimitedPano: {
|
||||||
|
id: "newPatientLimitedPano",
|
||||||
|
label: "Patient (Limited exam+Pano)",
|
||||||
|
codes: ["D0140", "D0330"],
|
||||||
|
},
|
||||||
|
newAdultPatientLimited1PA: {
|
||||||
|
id: "newAdultPatientLimited1PA",
|
||||||
|
label: "Patient (Limited exam+1PA)",
|
||||||
|
codes: ["D0140", "D0220"],
|
||||||
|
},
|
||||||
|
patientD9110_1PA: {
|
||||||
|
id: "patientD9110_1PA",
|
||||||
|
label: "Patient (D9110+1PA)",
|
||||||
|
codes: ["D9110", "D0220"],
|
||||||
|
},
|
||||||
|
//Compostie
|
||||||
|
oneSurfCompFront: {
|
||||||
|
id: "oneSurfCompFront",
|
||||||
|
label: "One Surface Composite (Front)",
|
||||||
|
codes: ["D2330"],
|
||||||
|
},
|
||||||
|
oneSurfCompBack: {
|
||||||
|
id: "oneSurfCompBack",
|
||||||
|
label: "One Surface Composite (Back)",
|
||||||
|
codes: ["D2391"],
|
||||||
|
},
|
||||||
|
twoSurfCompFront: {
|
||||||
|
id: "twoSurfCompFront",
|
||||||
|
label: "Two Surface Composite (Front)",
|
||||||
|
codes: ["D2331"],
|
||||||
|
},
|
||||||
|
twoSurfCompBack: {
|
||||||
|
id: "twoSurfCompBack",
|
||||||
|
label: "Two Surface Composite (Back)",
|
||||||
|
codes: ["D2392"],
|
||||||
|
},
|
||||||
|
threeSurfCompFront: {
|
||||||
|
id: "threeSurfCompFront",
|
||||||
|
label: "Three Surface Composite (Front)",
|
||||||
|
codes: ["D2332"],
|
||||||
|
},
|
||||||
|
threeSurfCompBack: {
|
||||||
|
id: "threeSurfCompBack",
|
||||||
|
label: "Three Surface Composite (Back)",
|
||||||
|
codes: ["D2393"],
|
||||||
|
},
|
||||||
|
fourSurfCompFront: {
|
||||||
|
id: "fourSurfCompFront",
|
||||||
|
label: "Four Surface Composite (Front)",
|
||||||
|
codes: ["D2335"],
|
||||||
|
},
|
||||||
|
fourSurfCompBack: {
|
||||||
|
id: "fourSurfCompBack",
|
||||||
|
label: "Four Surface Composite (Back)",
|
||||||
|
codes: ["D2394"],
|
||||||
|
},
|
||||||
|
// Pedo
|
||||||
|
pedoSealants: {
|
||||||
|
id: "pedoSealants",
|
||||||
|
label: "Sealants",
|
||||||
|
codes: ["D1351"],
|
||||||
|
},
|
||||||
|
pedoPulpotomy: {
|
||||||
|
id: "pedoPulpotomy",
|
||||||
|
label: "Pulpotomy",
|
||||||
|
codes: ["D3220"],
|
||||||
|
},
|
||||||
|
pedoSSCrown: {
|
||||||
|
id: "pedoSSCrown",
|
||||||
|
label: "Stainless Steel Crown",
|
||||||
|
codes: ["D2930"],
|
||||||
|
},
|
||||||
|
// Dentures / Partials
|
||||||
|
fu: {
|
||||||
|
id: "fu",
|
||||||
|
label: "FU",
|
||||||
|
codes: ["D5110"],
|
||||||
|
},
|
||||||
|
fl: {
|
||||||
|
id: "fl",
|
||||||
|
label: "FL",
|
||||||
|
codes: ["D5120"],
|
||||||
|
},
|
||||||
|
puResin: {
|
||||||
|
id: "puResin",
|
||||||
|
label: "PU (Resin)",
|
||||||
|
codes: ["D5211"],
|
||||||
|
},
|
||||||
|
puCast: {
|
||||||
|
id: "puCast",
|
||||||
|
label: "PU (Cast)",
|
||||||
|
codes: ["D5213"],
|
||||||
|
},
|
||||||
|
plResin: {
|
||||||
|
id: "plResin",
|
||||||
|
label: "PL (Resin)",
|
||||||
|
codes: ["D5212"],
|
||||||
|
},
|
||||||
|
plCast: {
|
||||||
|
id: "plCast",
|
||||||
|
label: "PL (Cast)",
|
||||||
|
codes: ["D5214"],
|
||||||
|
},
|
||||||
|
// Endodontics
|
||||||
|
rctAnterior: {
|
||||||
|
id: "rctAnterior",
|
||||||
|
label: "RCT Anterior",
|
||||||
|
codes: ["D3310"],
|
||||||
|
},
|
||||||
|
rctPremolar: {
|
||||||
|
id: "rctPremolar",
|
||||||
|
label: "RCT PreM",
|
||||||
|
codes: ["D3320"],
|
||||||
|
},
|
||||||
|
rctMolar: {
|
||||||
|
id: "rctMolar",
|
||||||
|
label: "RCT Molar",
|
||||||
|
codes: ["D3330"],
|
||||||
|
},
|
||||||
|
postCore: {
|
||||||
|
id: "postCore",
|
||||||
|
label: "Post/Core",
|
||||||
|
codes: ["D2954"],
|
||||||
|
},
|
||||||
|
coreBU: {
|
||||||
|
id: "coreBU",
|
||||||
|
label: "Core BU",
|
||||||
|
codes: ["D2950"],
|
||||||
|
},
|
||||||
|
// Prostho / Perio / Oral Surgery
|
||||||
|
crown: {
|
||||||
|
id: "crown",
|
||||||
|
label: "Crown",
|
||||||
|
codes: ["D2740"],
|
||||||
|
},
|
||||||
|
deepCleaning: {
|
||||||
|
id: "deepCleaning",
|
||||||
|
label: "Deep Cleaning",
|
||||||
|
codes: ["D4341"],
|
||||||
|
},
|
||||||
|
simpleExtraction: {
|
||||||
|
id: "simpleExtraction",
|
||||||
|
label: "Simple EXT",
|
||||||
|
codes: ["D7140"],
|
||||||
|
},
|
||||||
|
surgicalExtraction: {
|
||||||
|
id: "surgicalExtraction",
|
||||||
|
label: "Surg EXT",
|
||||||
|
codes: ["D7210"],
|
||||||
|
},
|
||||||
|
babyTeethExtraction: {
|
||||||
|
id: "babyTeethExtraction",
|
||||||
|
label: "Baby Teeth EXT",
|
||||||
|
codes: ["D7111"],
|
||||||
|
},
|
||||||
|
fullBonyExtraction: {
|
||||||
|
id: "fullBonyExtraction",
|
||||||
|
label: "Full Bony EXT",
|
||||||
|
codes: ["D7240"],
|
||||||
|
},
|
||||||
|
// Orthodontics
|
||||||
|
orthPreExamDirect: {
|
||||||
|
id: "orthPreExamDirect",
|
||||||
|
label: "Direct Pre-Orth Exam",
|
||||||
|
codes: ["D9310"],
|
||||||
|
},
|
||||||
|
orthRecordDirect: {
|
||||||
|
id: "orthRecordDirect",
|
||||||
|
label: "Direct Orth Record",
|
||||||
|
codes: ["D8660"],
|
||||||
|
},
|
||||||
|
orthPerioVisitDirect: {
|
||||||
|
id: "orthPerioVisitDirect",
|
||||||
|
label: "Direct Perio Orth Visit ",
|
||||||
|
codes: ["D8670"],
|
||||||
|
},
|
||||||
|
orthRetentionDirect: {
|
||||||
|
id: "orthRetentionDirect",
|
||||||
|
label: "Direct Orth Retention",
|
||||||
|
codes: ["D8680"],
|
||||||
|
},
|
||||||
|
orthPA: {
|
||||||
|
id: "orthPA",
|
||||||
|
label: "Orth PA",
|
||||||
|
codes: ["D8080", "D8670", "D8660"],
|
||||||
|
},
|
||||||
|
// add more…
|
||||||
|
};
|
||||||
|
// Which combos appear under which heading
|
||||||
|
export const COMBO_CATEGORIES = {
|
||||||
|
"Recalls & New Patients": [
|
||||||
|
"childRecall",
|
||||||
|
"adultRecall",
|
||||||
|
"newChildPatient",
|
||||||
|
"newAdultPatientPano",
|
||||||
|
"newAdultPatientFMX",
|
||||||
|
"newPatientLimitedPano",
|
||||||
|
"newAdultPatientLimited1PA",
|
||||||
|
"patientD9110_1PA",
|
||||||
|
],
|
||||||
|
"Composite Fillings (Front)": [
|
||||||
|
"oneSurfCompFront",
|
||||||
|
"twoSurfCompFront",
|
||||||
|
"threeSurfCompFront",
|
||||||
|
"fourSurfCompFront",
|
||||||
|
],
|
||||||
|
"Composite Fillings (Back)": [
|
||||||
|
"oneSurfCompBack",
|
||||||
|
"twoSurfCompBack",
|
||||||
|
"threeSurfCompBack",
|
||||||
|
"fourSurfCompBack",
|
||||||
|
],
|
||||||
|
Pedo: ["pedoSealants", "pedoPulpotomy", "pedoSSCrown"],
|
||||||
|
"Dentures / Partials (>21 price)": [
|
||||||
|
"fu",
|
||||||
|
"fl",
|
||||||
|
"puResin",
|
||||||
|
"puCast",
|
||||||
|
"plResin",
|
||||||
|
"plCast",
|
||||||
|
],
|
||||||
|
Endodontics: ["rctAnterior", "rctPremolar", "rctMolar", "postCore", "coreBU"],
|
||||||
|
Prosthodontics: ["crown"],
|
||||||
|
Periodontics: ["deepCleaning"],
|
||||||
|
Extractions: [
|
||||||
|
"simpleExtraction",
|
||||||
|
"surgicalExtraction",
|
||||||
|
"babyTeethExtraction",
|
||||||
|
"fullBonyExtraction",
|
||||||
|
],
|
||||||
|
Orthodontics: ["orthPA"],
|
||||||
|
};
|
||||||
89
apps/Frontend/tailwind.config.js
Normal file
89
apps/Frontend/tailwind.config.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// this is being used as global styling, and looks for :root in index.css
|
||||||
|
export default {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
chart: {
|
||||||
|
"1": "hsl(var(--chart-1))",
|
||||||
|
"2": "hsl(var(--chart-2))",
|
||||||
|
"3": "hsl(var(--chart-3))",
|
||||||
|
"4": "hsl(var(--chart-4))",
|
||||||
|
"5": "hsl(var(--chart-5))",
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
DEFAULT: "hsl(var(--sidebar-background))",
|
||||||
|
foreground: "hsl(var(--sidebar-foreground))",
|
||||||
|
primary: "hsl(var(--sidebar-primary))",
|
||||||
|
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||||
|
accent: "hsl(var(--sidebar-accent))",
|
||||||
|
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||||
|
border: "hsl(var(--sidebar-border))",
|
||||||
|
ring: "hsl(var(--sidebar-ring))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: {
|
||||||
|
height: "0",
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
height: "var(--radix-accordion-content-height)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: {
|
||||||
|
height: "var(--radix-accordion-content-height)",
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
height: "0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||||
|
};
|
||||||
47
apps/Frontend/vite.config.js
Normal file
47
apps/Frontend/vite.config.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { defineConfig, loadEnv } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
|
return {
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: env.HOST,
|
||||||
|
port: Number(env.PORT),
|
||||||
|
fs: {
|
||||||
|
allow: [".."],
|
||||||
|
},
|
||||||
|
allowedHosts: ["communitydentistsoflowell.mydentalofficemanagement.com"],
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: env.VITE_API_BASE_URL_BACKEND || "http://localhost:5000",
|
||||||
|
changeOrigin: true,
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on("proxyReq", (proxyReq, req) => {
|
||||||
|
const auth = req.headers["authorization"];
|
||||||
|
if (auth)
|
||||||
|
proxyReq.setHeader("Authorization", auth);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/socket.io": {
|
||||||
|
target: env.VITE_API_BASE_URL_BACKEND || "http://localhost:5000",
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
extensions: [".mts", ".ts", ".tsx", ".mjs", ".js", ".jsx", ".json"],
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "src"),
|
||||||
|
"@repo/db/usedSchemas": path.resolve(__dirname, "../../packages/db/usedSchemas/browser.ts"),
|
||||||
|
"@repo/db/types": path.resolve(__dirname, "../../packages/db/types/index.ts"),
|
||||||
|
"@repo/db": path.resolve(__dirname, "../../packages/db"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ["@repo/db"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user