From fcb049273a9eabdcc5655338990d17e1befc4871 Mon Sep 17 00:00:00 2001 From: Gitead Date: Mon, 25 May 2026 22:26:58 -0400 Subject: [PATCH] feat: add missing backend route and frontend utility/config files Co-Authored-By: Claude Sonnet 4.6 --- .../routes/insuranceStatusUnitedDHClaim.ts | 94 +++++ apps/Frontend/src/hooks/use-extractPdfData.js | 23 ++ apps/Frontend/src/hooks/use-job-status.js | 55 +++ apps/Frontend/src/hooks/use-toast.js | 144 +++++++ apps/Frontend/src/lib/queryClient.js | 119 ++++++ apps/Frontend/src/lib/socket.js | 28 ++ apps/Frontend/src/lib/utils.js | 5 + apps/Frontend/src/redux/hooks.js | 3 + .../src/redux/slices/seleniumTaskSlice.js | 23 ++ apps/Frontend/src/redux/store.js | 7 + apps/Frontend/src/theme-init.js | 116 ++++++ apps/Frontend/src/utils/dateUtils.js | 245 ++++++++++++ .../Frontend/src/utils/pageNumberGenerator.js | 21 ++ apps/Frontend/src/utils/procedureCombos.js | 351 ++++++++++++++++++ apps/Frontend/tailwind.config.js | 89 +++++ apps/Frontend/vite.config.js | 47 +++ 16 files changed, 1370 insertions(+) create mode 100644 apps/Backend/src/routes/insuranceStatusUnitedDHClaim.ts create mode 100644 apps/Frontend/src/hooks/use-extractPdfData.js create mode 100644 apps/Frontend/src/hooks/use-job-status.js create mode 100644 apps/Frontend/src/hooks/use-toast.js create mode 100644 apps/Frontend/src/lib/queryClient.js create mode 100644 apps/Frontend/src/lib/socket.js create mode 100644 apps/Frontend/src/lib/utils.js create mode 100644 apps/Frontend/src/redux/hooks.js create mode 100644 apps/Frontend/src/redux/slices/seleniumTaskSlice.js create mode 100644 apps/Frontend/src/redux/store.js create mode 100644 apps/Frontend/src/theme-init.js create mode 100644 apps/Frontend/src/utils/dateUtils.js create mode 100644 apps/Frontend/src/utils/pageNumberGenerator.js create mode 100644 apps/Frontend/src/utils/procedureCombos.js create mode 100644 apps/Frontend/tailwind.config.js create mode 100644 apps/Frontend/vite.config.js diff --git a/apps/Backend/src/routes/insuranceStatusUnitedDHClaim.ts b/apps/Backend/src/routes/insuranceStatusUnitedDHClaim.ts new file mode 100644 index 00000000..c5fba983 --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusUnitedDHClaim.ts @@ -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 => { + 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 => { + 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; diff --git a/apps/Frontend/src/hooks/use-extractPdfData.js b/apps/Frontend/src/hooks/use-extractPdfData.js new file mode 100644 index 00000000..769d72e7 --- /dev/null +++ b/apps/Frontend/src/hooks/use-extractPdfData.js @@ -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", + }); + }, + }); +} diff --git a/apps/Frontend/src/hooks/use-job-status.js b/apps/Frontend/src/hooks/use-job-status.js new file mode 100644 index 00000000..c1f73c71 --- /dev/null +++ b/apps/Frontend/src/hooks/use-job-status.js @@ -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 }; +} diff --git a/apps/Frontend/src/hooks/use-toast.js b/apps/Frontend/src/hooks/use-toast.js new file mode 100644 index 00000000..1e9fd10f --- /dev/null +++ b/apps/Frontend/src/hooks/use-toast.js @@ -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 }; diff --git a/apps/Frontend/src/lib/queryClient.js b/apps/Frontend/src/lib/queryClient.js new file mode 100644 index 00000000..86930d87 --- /dev/null +++ b/apps/Frontend/src/lib/queryClient.js @@ -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, + }, + }, +}); diff --git a/apps/Frontend/src/lib/socket.js b/apps/Frontend/src/lib/socket.js new file mode 100644 index 00000000..b3ed17cd --- /dev/null +++ b/apps/Frontend/src/lib/socket.js @@ -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); +}); diff --git a/apps/Frontend/src/lib/utils.js b/apps/Frontend/src/lib/utils.js new file mode 100644 index 00000000..c3725b1c --- /dev/null +++ b/apps/Frontend/src/lib/utils.js @@ -0,0 +1,5 @@ +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +export function cn(...inputs) { + return twMerge(clsx(inputs)); +} diff --git a/apps/Frontend/src/redux/hooks.js b/apps/Frontend/src/redux/hooks.js new file mode 100644 index 00000000..1251c645 --- /dev/null +++ b/apps/Frontend/src/redux/hooks.js @@ -0,0 +1,3 @@ +import { useDispatch, useSelector } from "react-redux"; +export const useAppDispatch = useDispatch; +export const useAppSelector = useSelector; diff --git a/apps/Frontend/src/redux/slices/seleniumTaskSlice.js b/apps/Frontend/src/redux/slices/seleniumTaskSlice.js new file mode 100644 index 00000000..31db9e13 --- /dev/null +++ b/apps/Frontend/src/redux/slices/seleniumTaskSlice.js @@ -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; diff --git a/apps/Frontend/src/redux/store.js b/apps/Frontend/src/redux/store.js new file mode 100644 index 00000000..6f33e1b5 --- /dev/null +++ b/apps/Frontend/src/redux/store.js @@ -0,0 +1,7 @@ +import { configureStore } from "@reduxjs/toolkit"; +import seleniumTasksReducer from "./slices/seleniumTaskSlice"; +export const store = configureStore({ + reducer: { + seleniumTasks: seleniumTasksReducer, + }, +}); diff --git a/apps/Frontend/src/theme-init.js b/apps/Frontend/src/theme-init.js new file mode 100644 index 00000000..f906fba1 --- /dev/null +++ b/apps/Frontend/src/theme-init.js @@ -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; diff --git a/apps/Frontend/src/utils/dateUtils.js b/apps/Frontend/src/utils/dateUtils.js new file mode 100644 index 00000000..bfb79694 --- /dev/null +++ b/apps/Frontend/src/utils/dateUtils.js @@ -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)}`; +} diff --git a/apps/Frontend/src/utils/pageNumberGenerator.js b/apps/Frontend/src/utils/pageNumberGenerator.js new file mode 100644 index 00000000..678abed4 --- /dev/null +++ b/apps/Frontend/src/utils/pageNumberGenerator.js @@ -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; +} diff --git a/apps/Frontend/src/utils/procedureCombos.js b/apps/Frontend/src/utils/procedureCombos.js new file mode 100644 index 00000000..385e3f9d --- /dev/null +++ b/apps/Frontend/src/utils/procedureCombos.js @@ -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"], +}; diff --git a/apps/Frontend/tailwind.config.js b/apps/Frontend/tailwind.config.js new file mode 100644 index 00000000..983ad579 --- /dev/null +++ b/apps/Frontend/tailwind.config.js @@ -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")], +}; diff --git a/apps/Frontend/vite.config.js b/apps/Frontend/vite.config.js new file mode 100644 index 00000000..0eff6699 --- /dev/null +++ b/apps/Frontend/vite.config.js @@ -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"], + }, + }; +});