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