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:
Gitead
2026-05-25 22:26:58 -04:00
parent adb5801023
commit fcb049273a
16 changed files with 1370 additions and 0 deletions

View 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;

View 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",
});
},
});
}

View 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 };
}

View 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 };

View 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,
},
},
});

View 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);
});

View File

@@ -0,0 +1,5 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,3 @@
import { useDispatch, useSelector } from "react-redux";
export const useAppDispatch = useDispatch;
export const useAppSelector = useSelector;

View 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;

View File

@@ -0,0 +1,7 @@
import { configureStore } from "@reduxjs/toolkit";
import seleniumTasksReducer from "./slices/seleniumTaskSlice";
export const store = configureStore({
reducer: {
seleniumTasks: seleniumTasksReducer,
},
});

View 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;

View 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)}`;
}

View 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;
}

View 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"],
};

View 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")],
};

View 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"],
},
};
});